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: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ 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. 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: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ 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. 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`.

The Classic permission selector offers Auto (YOLO) / Safe choices, stored as literal `auto` / `safe`. Server startup preserves the saved policy; never reintroduce the obsolete safe-to-auto coercion. Existing runtime-specific policy support and settings invalidation still apply.
Expand Down
42 changes: 0 additions & 42 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions src/agent/claude-runtime-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { acquireClaudeRuntime } from './runtime-pool.js';
import { ClaudeAcquireFailure } from './claude-runtime-pool.js';
import { handleAgentExit, type ExitHandlerParams } from './lifecycle-handler.js';
import { handoffRuntimeOutcome } from './runtime/outcome.js';
import { runPinFields } from '../messaging/run-pin.js';
import { RuntimeProjection, type RuntimeEnd } from './runtime/projection.js';
import { recordRuntimeEvent } from './runtime/events.js';
import { reserveClaudeRun } from './runtime/claude-run-controls.js';
Expand Down Expand Up @@ -118,9 +119,10 @@ export function startClaudeNativeRun(input: ClaudeNativeRunOptions): { child: nu
ensureFallbackStarted();
if (!ctx.runtimeTerminalAttempted && !selected) {
ctx.runtimeTerminalAttempted = true;
broadcast('agent_done', { traceRunId, scope: base.scopeKey, sessionId: base.chatSessionId, origin: base.origin,
cli: 'claude', ...(worker ? { isEmployee: true } : {}),
...(base.opts.requestId ? { requestId: base.opts.requestId } : {}),
broadcast('agent_done', { ...runPinFields({ origin: base.origin, requestId: base.opts.requestId,
scope: base.scopeKey, sessionId: base.chatSessionId,
remoteKey: base.opts.remoteKey, target: base.opts.target }),
traceRunId, cli: 'claude', ...(worker ? { isEmployee: true } : {}),
text: final.status === 'stopped' ? '' : `❌ ${diagnostic()}`, error: true,
runtimeStatus: final.status, runtimeFinality: final.finalText === null ? 'absent' : 'present' }, input.audience);
}
Expand Down
36 changes: 23 additions & 13 deletions src/agent/lifecycle-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { RuntimeEventBody, RuntimeTransport, RuntimeTurnOutcome } from '../
import { handoffRuntimeOutcome, lifecycleRuntimeOutcome, runtimeOutcomeExitCode } from './runtime/outcome.js';
import type { ToolEntry } from '../types/agent.js';
import type { RemoteTarget } from '../messaging/types.js';
import { runPinFields } from '../messaging/run-pin.js';
import { resolveSpawnOutputText } from './events/helpers.js';
import { isKiroPlainTextCli, isKiroResumeDegradedOutput } from './kiro-runtime.js';
import {
Expand Down Expand Up @@ -163,6 +164,9 @@ type LifecycleSpawnOptions = {
scopeKey?: string;
chatSessionId?: string;
remoteKey?: string;
/** Destination captured when the run was admitted. Survives `...opts`
* through fallback and retry respawns so the pin cannot be lost. */
target?: RemoteTarget;
cli?: string;
model?: string;
_heartbeatAnchorId?: number;
Expand Down Expand Up @@ -348,6 +352,11 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
const code = runtimeOutcomeExitCode(nativeOutcome, processCode);
const nativeRequestId = ctx.requestId ?? opts.requestId;
if (mainManaged) revokeSlackToolGrant(nativeRequestId);
// This run's identity and destination, carried on every terminal event it
// emits below. See src/messaging/run-pin.ts for why it is carried and not
// looked up (#742/#743).
const donePin = runPinFields({ origin, requestId: nativeRequestId, scope: scopeKey,
sessionId: chatSessionId, remoteKey: opts.remoteKey, target: opts.target });
const nativeTraceRunId = ctx.traceRunId;
const effectiveProvider = params.effectiveProvider;
const runtimeCli = cli;
Expand Down Expand Up @@ -438,7 +447,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
...opts, _isSmokeContinuation: true, _skipInsert: true,
});
contPromise.then((r) => resolve(r)).catch(() => {
broadcast('agent_done', { ...runTag(ctx),
broadcast('agent_done', { ...runTag(ctx), ...donePin,
text: `❌ Smoke continuation failed. Original: ${ctx.fullText.slice(0, 200)}`,
error: true, origin,
...empTag,
Expand Down Expand Up @@ -646,7 +655,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
_skipInsert: true,
});
retryP.then(resolve).catch(() => {
broadcast('agent_done', { ...runTag(ctx),
broadcast('agent_done', { ...runTag(ctx), ...donePin,
text: '❌ kiro stale resume and fresh retry failed',
error: true,
origin,
Expand Down Expand Up @@ -707,6 +716,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
handoffRuntimeOutcome(ctx, { ...nativeOutcome, finalText: finalContent });
ctx.runtimeTerminalAttempted = true;
broadcast('agent_done', {
...donePin,
...(nativeTraceRunId ? { traceRunId: nativeTraceRunId } : {}),
text: runtimeCompatibilityText(finalContent),
runtimeFinality: finalContent === null ? 'absent' : 'present',
Expand Down Expand Up @@ -818,7 +828,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
try { linkTraceRunToMessage(ctx.traceRunId, messageId); }
catch { console.warn('[trace] print link failed'); }
}
broadcast('agent_done', { ...runTag(ctx), text: finalContent, toolLog: sanitizedToolLog, origin, ...empTag, ...(wasSteer ? { steered: true } : {}) });
broadcast('agent_done', { ...runTag(ctx), ...donePin, text: finalContent, toolLog: sanitizedToolLog, origin, ...empTag, ...(wasSteer ? { steered: true } : {}) });

if (opts._heartbeatAnchorId) {
try {
Expand Down Expand Up @@ -868,7 +878,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
// Classified like the retry-exhausted site below: a watchdog kill is
// the one failure the channel MUST show, and the forwarder gate
// drops anything without errorKind (#519 round 2).
{ ...runTag(ctx), text: `❌ ${errMsg}`, error: true, errorKind, cli: runtimeCli, origin, ...empTag },
{ ...runTag(ctx), ...donePin, text: `❌ ${errMsg}`, error: true, errorKind, cli: runtimeCli, origin, ...empTag },
isEmployee ? 'internal' : 'public',
);
finalizeRun('error', errMsg);
Expand Down Expand Up @@ -946,7 +956,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
_skipInsert: true,
}) as { promise: Promise<{ text: string; code: number }> };
retryP.then(resolve).catch(() => {
broadcast('agent_done', { ...runTag(ctx), text: `❌ ${errMsg} (fresh-session retry failed)`, error: true, origin, ...empTag, ...(wasSteer ? { steered: true } : {}) }, isEmployee ? 'internal' : 'public');
broadcast('agent_done', { ...runTag(ctx), ...donePin, text: `❌ ${errMsg} (fresh-session retry failed)`, error: true, origin, ...empTag, ...(wasSteer ? { steered: true } : {}) }, isEmployee ? 'internal' : 'public');
resolve({ text: '', code: 1 });
if (mainManaged && !opts.internal) processQueue(scopeKey);
});
Expand All @@ -969,7 +979,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
}
insertMessage.run('assistant', `⏱️ ${errMsg}`, cli, model, settings["workingDir"] || null, chatSessionId);
}
broadcast('agent_done', { ...runTag(ctx), text: `❌ ${errMsg}`, error: true, errorKind, cli: runtimeCli, origin, ...empTag, ...(wasSteer ? { steered: true } : {}) }, isEmployee ? 'internal' : 'public');
broadcast('agent_done', { ...runTag(ctx), ...donePin, text: `❌ ${errMsg}`, error: true, errorKind, cli: runtimeCli, origin, ...empTag, ...(wasSteer ? { steered: true } : {}) }, isEmployee ? 'internal' : 'public');
finalizeRun('error', errMsg);
resolve({ text: '', code: 1 });
if (mainManaged && !opts.internal) processQueue(scopeKey);
Expand Down Expand Up @@ -1006,7 +1016,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
...opts, _retryAttempt: mainAttempt + 1, _skipInsert: true,
});
retryP.then((r) => resolve(r)).catch(() => {
broadcast('agent_done', { ...runTag(ctx), text: `❌ ${errMsg} (재시도 실패, attempt ${mainAttempt + 1})`, error: true, origin, ...empTag }, isEmployee ? 'internal' : 'public');
broadcast('agent_done', { ...runTag(ctx), ...donePin, text: `❌ ${errMsg} (재시도 실패, attempt ${mainAttempt + 1})`, error: true, origin, ...empTag }, isEmployee ? 'internal' : 'public');
resolve({ text: '', code: 1 });
if (mainManaged && !opts.internal) processQueue(scopeKey);
});
Expand Down Expand Up @@ -1063,7 +1073,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
...opts, cli: fallbackCli, _isFallback: true, _skipInsert: true,
});
retryP.then((r) => resolve(r)).catch(() => {
broadcast('agent_done', { ...runTag(ctx),
broadcast('agent_done', { ...runTag(ctx), ...donePin,
text: `❌ Fallback (${fallbackCli}) failed`, error: true, origin,
...empTag,
}, isEmployee ? 'internal' : 'public');
Expand All @@ -1073,10 +1083,10 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
return;
}
}
// The `{ ...runTag(ctx),` opening stays on this line: RID-001 in
// The `{ ...runTag(ctx), ...donePin,` opening stays on this line: RID-001 in
// tests/unit/web-sse-replay-idempotency.test.ts matches that exact shape
// to prove every agent_done carries its trace run id.
broadcast('agent_done', { ...runTag(ctx),
broadcast('agent_done', { ...runTag(ctx), ...donePin,
text: `❌ ${errMsg}`,
error: true,
// Classified here so a forwarder never re-parses Korean prose to
Expand Down Expand Up @@ -1125,7 +1135,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
retryP.then((r) => resolve(r)).catch((retryErr: Error) => {
const retryMessage = retryErr?.message ? `; retry=${retryErr.message}` : '';
const diagnostic = `${cls.message} (fresh employee session retry failed${retryMessage})`;
broadcast('agent_done', { ...runTag(ctx), text: `❌ ${diagnostic}`, error: true, origin, isEmployee: true }, 'internal');
broadcast('agent_done', { ...runTag(ctx), ...donePin, text: `❌ ${diagnostic}`, error: true, origin, isEmployee: true }, 'internal');
resolve({ text: '', code: 1, diagnostic });
});
return;
Expand Down Expand Up @@ -1154,7 +1164,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
...opts, _retryAttempt: empAttempt + 1, _skipInsert: true, _skipResume: true,
});
retryP.then((r) => resolve(r)).catch(() => {
broadcast('agent_done', { ...runTag(ctx), text: `❌ ${cls.message} (재시도 실패, attempt ${empAttempt + 1})`, error: true, origin, isEmployee: true }, 'internal');
broadcast('agent_done', { ...runTag(ctx), ...donePin, text: `❌ ${cls.message} (재시도 실패, attempt ${empAttempt + 1})`, error: true, origin, isEmployee: true }, 'internal');
resolve({ text: '', code: 1, diagnostic: cls.message });
});
}, empDelayMs));
Expand Down Expand Up @@ -1198,7 +1208,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise<void>
_skipSessionPersist: true,
});
retryP.then(resolve).catch(() => {
broadcast('agent_done', { ...runTag(ctx),
broadcast('agent_done', { ...runTag(ctx), ...donePin,
text: '❌ kiro resume empty and fresh retry failed',
error: true,
origin,
Expand Down
Loading