Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ The check enumerates submodule contents and fails on what it finds there, becaus

### Architecture Docs Sync

- Channel forwarders deliver to the destination captured when a run was admitted, never to a per-channel last-active slot. `src/messaging/run-pin.ts` builds the identity block (origin/requestId/scope/sessionId/remoteKey/target) that every `agent_done` carries, and `resolveForwarderTarget` refuses an event with no destination or one addressed to another channel. Slack, Discord and Telegram forwarders no longer accept a `getLastTarget`/`getLastChatId` option, so web and CLI turns are not mirrored into chat rooms. Heartbeat destinations are complete or held: a Slack destination needs a thread or an explicit `scope: "channel_root"`, an absent destination sends nothing, and `authorizeExplicitTarget` vouches for a send without rewriting its address. Slack progress cards end their live loop on `message_not_found`/`cant_update_message` or three consecutive failures rather than retrying a dead message. See `structure/telegram.md` and `structure/server_api.md`.
- Channel forwarders deliver to the destination captured when a run was admitted, never to a per-channel last-active slot. `src/messaging/run-pin.ts` builds the identity block (origin/requestId/scope/sessionId/remoteKey/target) that every `agent_done` carries, and `resolveForwarderTarget` refuses an event with no destination or one addressed to another channel. Slack, Discord and Telegram forwarders no longer accept a `getLastTarget`/`getLastChatId` option, so web and CLI turns are not mirrored into chat rooms. Heartbeat destinations are complete or held: a Slack destination needs a thread or an explicit `scope: "channel_root"`, an absent destination sends nothing, threaded jobs verify `conversations.replies` before runner work, and a 25-minute server-owned `enforceDestination` grant is injected into print, native, employee and script runtimes before work so omitted targets pin and mismatches fail without process-global locking. Live hold reasons remain visible to GET/UI until recovery. `authorizeExplicitTarget` vouches for a send without rewriting its address. Slack progress cards end their live loop on `message_not_found`/`cant_update_message` or three consecutive failures rather than retrying a dead message. See `structure/telegram.md` and `structure/server_api.md`.

- Auto (`permissions:auto`) grants qualified direct-local Jaw API authority across supported runtimes, independently of per-turn secrets. Keep actual/effective loopback, exact browser origin, proxy provenance, explicit outbound destinations and server-only resource options. Safe/custom keep existing scoped/operator paths; full API authority is instance-wide, distinct from provider Safe and task scope. Preserve no-descendant/read-only assignments, captured worker context and honest capability/receipt evidence. See `docs/slack-tools.md` and `structure/server_api.md`.
- Slack group DMs use `message.mpim` and optional `mpim:history`; exact `channel_type: mpim` mentions retain channel allowlist and thread policy, never the one-to-one DM bypass. An install without `mpim:history` receives no group-DM traffic at all; that gap is reported in `missingCapabilities` and logged as a reception limitation rather than failing credential validation. An absent scope header is unknown and a present empty header is a known empty grant. Keep `structure/telegram.md` and the validation API docs synchronized.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Native Code interruption seals callbacks before persisting accepted buffered con

## Current Runtime Notes

- Channel forwarders deliver to the destination captured when a run was admitted, never to a per-channel last-active slot. `src/messaging/run-pin.ts` builds the identity block (origin/requestId/scope/sessionId/remoteKey/target) that every `agent_done` carries, and `resolveForwarderTarget` refuses an event with no destination or one addressed to another channel. Slack, Discord and Telegram forwarders no longer accept a `getLastTarget`/`getLastChatId` option, so web and CLI turns are not mirrored into chat rooms. Heartbeat destinations are complete or held: a Slack destination needs a thread or an explicit `scope: "channel_root"`, an absent destination sends nothing, and `authorizeExplicitTarget` vouches for a send without rewriting its address. Slack progress cards end their live loop on `message_not_found`/`cant_update_message` or three consecutive failures rather than retrying a dead message. See `structure/telegram.md` and `structure/server_api.md`.
- Channel forwarders deliver to the destination captured when a run was admitted, never to a per-channel last-active slot. `src/messaging/run-pin.ts` builds the identity block (origin/requestId/scope/sessionId/remoteKey/target) that every `agent_done` carries, and `resolveForwarderTarget` refuses an event with no destination or one addressed to another channel. Slack, Discord and Telegram forwarders no longer accept a `getLastTarget`/`getLastChatId` option, so web and CLI turns are not mirrored into chat rooms. Heartbeat destinations are complete or held: a Slack destination needs a thread or an explicit `scope: "channel_root"`, an absent destination sends nothing, threaded jobs verify `conversations.replies` before runner work, and a 25-minute server-owned `enforceDestination` grant is injected into print, native, employee and script runtimes before work so omitted targets pin and mismatches fail without process-global locking. Live hold reasons remain visible to GET/UI until recovery. `authorizeExplicitTarget` vouches for a send without rewriting its address. Slack progress cards end their live loop on `message_not_found`/`cant_update_message` or three consecutive failures rather than retrying a dead message. See `structure/telegram.md` and `structure/server_api.md`.

- File sends across Slack, Telegram and Discord share one confirmation vocabulary. A send the vendor will not name is refused rather than reported as delivered (Slack keeps its `files[]` echo requirement, Telegram requires `message_id` > 0, Discord requires a readable Create Message body): those are `ok:false` with `confirmation: 'unconfirmed'`, replacing the older `ok:true, ambiguous:true` no consumer read. Anything forwarding a file result must preserve `confirmation`, or the caption posts twice. See `structure/infra.md` and `structure/telegram.md`.

Expand Down
5 changes: 4 additions & 1 deletion src/agent/pi-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,9 +710,10 @@ function launchPiRpcExecution(profile: PiProfile, pi: PiSettings, options: {
cwd: string;
sessionId?: string;
root?: string;
env?: NodeJS.ProcessEnv;
}) {
const dir = ensurePiRuntimeConfig(pi, profile.id, options.effort || '', options.root);
const inherited = { ...process.env }, cwd = options.cwd;
const inherited = { ...(options.env ?? process.env) }, cwd = options.cwd;
const cmd = resolvePiCommand(inherited);
const args = [
...cmd.baseArgs,
Expand Down Expand Up @@ -798,6 +799,7 @@ export function spawnPersistentPiRpc(profile: PiProfile, pi: PiSettings, options
cwd: string;
sessionId?: string;
root?: string;
env?: NodeJS.ProcessEnv;
}): PiRpcSession {
const profileId = profile.id, initialEffort = options.effort;
const { cmd, child, owner, startVersionProbe } = launchPiRpcExecution(profile, pi, options);
Expand Down Expand Up @@ -1041,6 +1043,7 @@ export function spawnPiRpc(profile: PiProfile, pi: PiSettings, options: {
onEvent?: (event: PiRuntimeEvent) => void;
onRawRecord?: (record: unknown) => void;
root?: string;
env?: NodeJS.ProcessEnv;
}): { child: ChildProcess; done: Promise<PiPromptResult & { code: number; sessionId?: string | null }>;
cleanup: Promise<PiExecutionCleanupReceipt> } {
const effort = options.effort;
Expand Down
2 changes: 2 additions & 0 deletions src/agent/runtime-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface PiAcquireOptions {
profileFp: string;
};
piSettings: unknown;
env?: NodeJS.ProcessEnv;
storedSessionId?: string | null;
instructions?: string;
forceNew?: boolean;
Expand Down Expand Up @@ -516,6 +517,7 @@ async function createPiEntry(
model: opts.key.model,
effort: opts.key.effort,
cwd: opts.key.cwd,
...(opts.env ? { env: opts.env } : {}),
...(opts.forceNew || !opts.storedSessionId ? {} : { sessionId: opts.storedSessionId }),
});
if (store.entries.get(key) !== creating) {
Expand Down
30 changes: 21 additions & 9 deletions src/agent/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,16 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult {
const envDefaultsCli = cli;
const cliEnv = applyCliEnvDefaults(envDefaultsCli, opts.env);
const spawnEnv = makeCleanEnv(cliEnv);
// Capture a request grant before ANY runtime branch acquires or launches a
// process. Scheduled work also permits employee runtimes: its server-owned
// grant is narrower than their ordinary credentials and is the only way
// they may address Slack during this assignment.
const slackToolGrantEligible = origin === 'heartbeat'
|| (!isEmployee && ['cursor', 'claude', 'codex', 'grok'].includes(cli));
const slackToolGrant = slackToolGrantEligible
? activateSlackToolGrant(opts.requestId, scopeKey, chatSessionId)
: undefined;
if (slackToolGrant) spawnEnv[SLACK_TOOL_GRANT_ENV] = slackToolGrant;
const bucketRow = currentBucket ? getSessionBucket.get(currentBucket) as SessionBucketRow | undefined : null;
const bucketSessionId = bucketRow?.session_id || null;
const bucketModel = typeof bucketRow?.model === 'string' ? bucketRow.model : null;
Expand Down Expand Up @@ -1789,7 +1799,8 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult {
prompt: { text: withSteerContext(withHistoryPrompt(prompt, historyBlock), opts.steerContext), ...(opts.images ? { images: opts.images } : {}) },
audience: traceAudience, liveScope: effectiveLiveScope, parentLiveScope: parentLiveScopeForChild,
...(opts.runtimeParentItemId ? { parentItemId: opts.runtimeParentItemId } : {}),
storedSessionId: resumeSessionId, fresh: forceNew || opts._skipResume === true || isEmployee,
storedSessionId: resumeSessionId,
fresh: forceNew || opts._skipResume === true || isEmployee || Boolean(slackToolGrant),
cleanupUnleased: cleanupClaudeWorker,
isCurrent: ownedRun, isCurrentOwner: token => isCurrentSessionOwner(token, scopeKey), consumeKillReason,
activity: identity => opts.lifecycle?.onActivity?.('native-runtime', identity),
Expand Down Expand Up @@ -1977,7 +1988,10 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult {
key: { scopeKey, cwd: nativeCwd, model: runtimeModel === 'default' ? '' : runtimeModel, effort, permissions },
binary: detected.path || (grok ? 'grok' : 'cursor-agent'), env: spawnEnv, promptTimeoutMs: resolvedAgyPrintTimeoutMs,
persistenceOwner, isCurrentOwner: token => isCurrentSessionOwner(token, scopeKey), canAcquire: ownsRun,
storedSessionId: resumeSessionId, forceNew, signal,
storedSessionId: resumeSessionId,
forceNew: forceNew || Boolean(slackToolGrant),
...(slackToolGrant ? { lifetime: 'request' as const } : {}),
signal,
});
facade = new AcpRuntimeSession(lease.session, { provider: cli, deferTurnEnd: true,
...(grok ? grokMainOptions : { createReplacement: io => new AcpReplacement(io), prepareReplacement }),
Expand Down Expand Up @@ -2746,7 +2760,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult {
execution = spawnPiRpc(profile, pi, {
prompt: piPrompt, model: runtimeModel,
...(piSessionId ? { sessionId: piSessionId } : {}),
effort, cwd: spawnCwd, sysPrompt: piSysPrompt,
effort, cwd: spawnCwd, sysPrompt: piSysPrompt, env: spawnEnv,
onEvent: onPiEvent, onRawRecord: onPiRawRecord,
});
} catch (error) {
Expand Down Expand Up @@ -2777,9 +2791,10 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult {
profileFp,
},
piSettings: pi,
env: spawnEnv,
storedSessionId: piSessionId || null,
instructions: piSysPrompt,
forceNew,
forceNew: forceNew || Boolean(slackToolGrant),
}).then((lease) => {
mainRun!.starting = false;
if (activeMainProcesses.get(scopeKey) !== mainRun || !isCurrentSessionOwner(persistenceOwner, scopeKey)) {
Expand Down Expand Up @@ -3270,7 +3285,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult {
},
storedThreadId: resumeSessionId || null,
instructions: sysPrompt,
forceNew,
forceNew: forceNew || Boolean(slackToolGrant),
});
return { kind: 'lease', lease };
}
Expand Down Expand Up @@ -3332,7 +3347,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult {
bucketKey: currentBucket!,
storedThreadId: resumeSessionId || null,
instructions: sysPrompt,
forceNew,
forceNew: forceNew || Boolean(slackToolGrant),
waitMs: deadlineAt - Date.now(),
}), (lateLease) => { lateLease.release(); });
if (acquireWasCancelled()) {
Expand Down Expand Up @@ -3470,9 +3485,6 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult {
}
// The snapshot has to predate the child; the helper owns that ordering (073 §2.4).
const kiroPlainText = isKiroPlainTextCli(cli, effectiveProvider);
const slackToolGrant = !isEmployee && ['cursor', 'claude', 'codex', 'grok'].includes(cli)
? activateSlackToolGrant(opts.requestId, scopeKey, chatSessionId) : undefined;
if (slackToolGrant) launchEnv[SLACK_TOOL_GRANT_ENV] = slackToolGrant;
const { child, kiroConversationIdsBefore, kiroSpawnStartedAt } = spawnWithKiroSnapshot({
kiroPlainText,
isFreshMainRun: !isResume && !empSid,
Expand Down
70 changes: 69 additions & 1 deletion src/memory/heartbeat-destination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@
import { isHeartbeatDestination, type HeartbeatDestination } from '../core/config.js';
import { targetFromChatId } from '../messaging/send.js';
import type { RemoteTarget } from '../messaging/types.js';
import { fetchSlackReplies } from '../slack/history.js';
import type { SlackFetch } from '../slack/api.js';

export type HeartbeatHoldReason =
| 'unbound_destination'
| 'incomplete_destination'
| 'malformed_destination';
| 'malformed_destination'
| 'thread_channel_mismatch'
| 'stale_thread'
| 'live_lookup_failed'
| 'slack_grant_unavailable';

export type HeartbeatBinding =
| { state: 'bound'; target: RemoteTarget }
Expand Down Expand Up @@ -70,5 +76,67 @@ export function heartbeatHoldMessage(reason: HeartbeatHoldReason): string {
return 'destination names a channel but no thread — add a thread, or set scope:"channel_root" to post to the channel itself';
case 'malformed_destination':
return 'destination is malformed';
case 'thread_channel_mismatch':
return 'the configured Slack thread does not belong to the configured channel';
case 'stale_thread':
return 'the configured Slack thread no longer exists';
case 'live_lookup_failed':
return 'the configured Slack thread could not be verified for this tick';
case 'slack_grant_unavailable':
return 'destination-bound Slack authority could not be reserved for this tick';
}
}

export type HeartbeatThreadVerificationOptions = {
token: string;
fetchImpl?: SlackFetch;
signal?: AbortSignal;
};

const STALE_THREAD_CODES = new Set(['thread_not_found', 'message_not_found']);
const MISMATCH_CODES = new Set(['channel_not_found', 'not_in_channel']);

/**
* Prove a threaded Slack destination still names a parent in that channel.
*
* The check deliberately has no positive cache. A success from the previous
* tick says nothing about a thread that was deleted or a bot removed from its
* channel before this one. A 429 is not retried here either: the heartbeat owns
* a future tick, so waiting and issuing a second read only spends more shared
* Slack budget. Every uncertain result fails this tick closed.
*/
export async function verifyHeartbeatThreadBindingLive(
destination: unknown,
options: HeartbeatThreadVerificationOptions,
): Promise<HeartbeatBinding> {
const binding = resolveHeartbeatBinding(destination);
if (binding.state === 'held') return binding;
const { target } = binding;
if (target.channel !== 'slack' || !target.threadId) return binding;
if (!options.token.trim()) return { state: 'held', reason: 'live_lookup_failed' };

try {
const result = await fetchSlackReplies(options.token, target.targetId, target.threadId, {
limit: 1,
noRetry: true,
noRetryOnRateLimit: true,
sensitiveResponse: true,
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
...(options.signal ? { signal: options.signal } : {}),
});
if (!result.ok) {
if (result.code && STALE_THREAD_CODES.has(result.code)) {
return { state: 'held', reason: 'stale_thread' };
}
if (result.code && MISMATCH_CODES.has(result.code)) {
return { state: 'held', reason: 'thread_channel_mismatch' };
}
return { state: 'held', reason: 'live_lookup_failed' };
}
return result.messages[0]?.ts === target.threadId
? binding
: { state: 'held', reason: 'thread_channel_mismatch' };
} catch {
return { state: 'held', reason: 'live_lookup_failed' };
}
}
Loading