super-dsh 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-agy/bridge/agy_bridge.py +250 -0
- package/agent-agy/bridge/gemini_openai_server.mjs +98 -0
- package/agent-agy/cordis.patch.yml +49 -0
- package/agent-agy/dist/adapter.d.ts +15 -0
- package/agent-agy/dist/adapter.js +42 -0
- package/agent-agy/dist/agent-preset-agy.d.ts +47 -0
- package/agent-agy/dist/agent-preset-agy.js +169 -0
- package/agent-agy/dist/agent-preset-projection.d.ts +31 -0
- package/agent-agy/dist/agent-preset-projection.js +33 -0
- package/agent-agy/dist/agent.d.ts +137 -0
- package/agent-agy/dist/agent.js +493 -0
- package/agent-agy/dist/agy-cli-client.d.ts +137 -0
- package/agent-agy/dist/agy-cli-client.js +301 -0
- package/agent-agy/dist/agy-client.d.ts +160 -0
- package/agent-agy/dist/agy-client.js +252 -0
- package/agent-agy/dist/agy-sessions.d.ts +24 -0
- package/agent-agy/dist/agy-sessions.js +116 -0
- package/agent-agy/dist/agy-store.d.ts +39 -0
- package/agent-agy/dist/agy-store.js +83 -0
- package/agent-agy/dist/inbox.d.ts +26 -0
- package/agent-agy/dist/inbox.js +53 -0
- package/agent-agy/dist/index.d.ts +80 -0
- package/agent-agy/dist/index.js +505 -0
- package/agent-agy/dist/models.d.ts +41 -0
- package/agent-agy/dist/models.js +74 -0
- package/agent-agy/dist/world-plugin.d.ts +9 -0
- package/agent-agy/dist/world-plugin.js +97 -0
- package/agent-agy/package.json +64 -0
- package/agent-claude/cordis.patch.yml +42 -0
- package/agent-claude/dist/adapter.js +62 -0
- package/agent-claude/dist/agent-preset-claude.js +169 -0
- package/agent-claude/dist/agent-preset-projection.js +30 -0
- package/agent-claude/dist/agent.js +1271 -0
- package/agent-claude/dist/claude-client.js +357 -0
- package/agent-claude/dist/claude-events.js +223 -0
- package/agent-claude/dist/claude-home.js +29 -0
- package/agent-claude/dist/commands.js +182 -0
- package/agent-claude/dist/content.js +134 -0
- package/agent-claude/dist/inbox.js +53 -0
- package/agent-claude/dist/index.js +555 -0
- package/agent-claude/dist/input-queue.js +64 -0
- package/agent-claude/dist/interaction.js +271 -0
- package/agent-claude/dist/models.js +158 -0
- package/agent-claude/dist/permission.js +120 -0
- package/agent-claude/dist/session-id.js +20 -0
- package/agent-claude/dist/session-map.js +165 -0
- package/agent-claude/dist/world-plugin.js +14 -0
- package/agent-claude/package.json +74 -0
- package/agent-codex/README.md +74 -0
- package/agent-codex/cordis.patch.yml +77 -0
- package/agent-codex/dist/adapter.js +55 -0
- package/agent-codex/dist/agent-preset-codex.js +171 -0
- package/agent-codex/dist/agent-preset-projection.js +37 -0
- package/agent-codex/dist/agent.js +1056 -0
- package/agent-codex/dist/app-home.js +65 -0
- package/agent-codex/dist/codex-client.js +469 -0
- package/agent-codex/dist/codex-events.js +297 -0
- package/agent-codex/dist/codex-store.js +190 -0
- package/agent-codex/dist/inbox.js +53 -0
- package/agent-codex/dist/index.js +605 -0
- package/agent-codex/dist/models.js +152 -0
- package/agent-codex/dist/permission.js +71 -0
- package/agent-codex/dist/session-map.js +170 -0
- package/agent-codex/dist/spike.js +46 -0
- package/agent-codex/dist/world-plugin.js +96 -0
- package/agent-codex/package.json +78 -0
- package/agent-hermes/README.md +144 -0
- package/agent-hermes/cordis.patch.yml +83 -0
- package/agent-hermes/dist/adapter.js +83 -0
- package/agent-hermes/dist/agent-preset-hermes.js +171 -0
- package/agent-hermes/dist/agent-preset-projection.js +34 -0
- package/agent-hermes/dist/agent.js +1068 -0
- package/agent-hermes/dist/hermes-client.js +953 -0
- package/agent-hermes/dist/hermes-events.js +289 -0
- package/agent-hermes/dist/hermes-store.js +158 -0
- package/agent-hermes/dist/inbox.js +53 -0
- package/agent-hermes/dist/index.js +541 -0
- package/agent-hermes/dist/models.js +324 -0
- package/agent-hermes/dist/permission.js +60 -0
- package/agent-hermes/dist/world-plugin.js +101 -0
- package/agent-hermes/package.json +76 -0
- package/agent-hub/cordis.patch.yml +12 -0
- package/agent-hub/dist/agent-roster.d.ts +34 -0
- package/agent-hub/dist/agent-roster.js +27 -0
- package/agent-hub/dist/carrier.d.ts +43 -0
- package/agent-hub/dist/carrier.js +241 -0
- package/agent-hub/dist/client-shim.d.ts +28 -0
- package/agent-hub/dist/client-shim.js +251 -0
- package/agent-hub/dist/envelope.d.ts +54 -0
- package/agent-hub/dist/envelope.js +92 -0
- package/agent-hub/dist/gateway.d.ts +28 -0
- package/agent-hub/dist/gateway.js +18 -0
- package/agent-hub/dist/index-pass.d.ts +22 -0
- package/agent-hub/dist/index-pass.js +47 -0
- package/agent-hub/dist/index.d.ts +29 -0
- package/agent-hub/dist/index.js +29 -0
- package/agent-hub/dist/labels.d.ts +45 -0
- package/agent-hub/dist/labels.js +83 -0
- package/agent-hub/dist/ownership.d.ts +37 -0
- package/agent-hub/dist/ownership.js +54 -0
- package/agent-hub/dist/roster.d.ts +28 -0
- package/agent-hub/dist/roster.js +38 -0
- package/agent-hub/dist/rpc.d.ts +38 -0
- package/agent-hub/dist/rpc.js +52 -0
- package/agent-hub/dist/spawn-world.d.ts +46 -0
- package/agent-hub/dist/spawn-world.js +72 -0
- package/agent-hub/dist/targets.d.ts +33 -0
- package/agent-hub/dist/targets.js +30 -0
- package/agent-hub/dist/world-entry.d.ts +31 -0
- package/agent-hub/dist/world-entry.js +51 -0
- package/agent-hub/dist/world-host.d.ts +69 -0
- package/agent-hub/dist/world-host.js +44 -0
- package/agent-hub/dist/world-join.d.ts +16 -0
- package/agent-hub/dist/world-join.js +122 -0
- package/agent-hub/dist/world-mount.d.ts +52 -0
- package/agent-hub/dist/world-mount.js +52 -0
- package/agent-hub/dist/world-mux.d.ts +72 -0
- package/agent-hub/dist/world-mux.js +224 -0
- package/agent-hub/dist/world-provision.d.ts +20 -0
- package/agent-hub/dist/world-provision.js +122 -0
- package/agent-hub/dist/world-web-server.d.ts +162 -0
- package/agent-hub/dist/world-web-server.js +248 -0
- package/agent-hub/lib/client/index.js +242 -0
- package/agent-hub/package.json +65 -0
- package/agent-omp/README.md +77 -0
- package/agent-omp/cordis.patch.yml +92 -0
- package/agent-omp/dist/adapter.js +36 -0
- package/agent-omp/dist/agent-preset-omp.js +171 -0
- package/agent-omp/dist/agent-preset-projection.js +36 -0
- package/agent-omp/dist/agent.js +1145 -0
- package/agent-omp/dist/app-home.js +86 -0
- package/agent-omp/dist/discovery.js +116 -0
- package/agent-omp/dist/inbox.js +53 -0
- package/agent-omp/dist/index.js +574 -0
- package/agent-omp/dist/knobs.js +50 -0
- package/agent-omp/dist/lazy-rpc.js +161 -0
- package/agent-omp/dist/mobile/gesture.js +148 -0
- package/agent-omp/dist/mobile/zoom-guard.js +294 -0
- package/agent-omp/dist/mobile-boot.js +38 -0
- package/agent-omp/dist/models.js +299 -0
- package/agent-omp/dist/omp-cli.js +147 -0
- package/agent-omp/dist/omp-disk-discovery.js +178 -0
- package/agent-omp/dist/omp-store.js +518 -0
- package/agent-omp/dist/pairing.js +45 -0
- package/agent-omp/dist/permission.js +96 -0
- package/agent-omp/dist/protocol.js +13 -0
- package/agent-omp/dist/rpc-types.js +1 -0
- package/agent-omp/dist/sdk-client.js +378 -0
- package/agent-omp/dist/sidecar-client.js +195 -0
- package/agent-omp/dist/store/adapter.js +47 -0
- package/agent-omp/dist/store/db.js +210 -0
- package/agent-omp/dist/store/index.js +59 -0
- package/agent-omp/dist/store/reconcile.js +51 -0
- package/agent-omp/dist/store/schema.js +40 -0
- package/agent-omp/dist/world-plugin.js +96 -0
- package/agent-omp/package.json +97 -0
- package/agent-omp/sidecar/main.ts +481 -0
- package/agent-pi/README.md +67 -0
- package/agent-pi/cordis.patch.yml +80 -0
- package/agent-pi/dist/adapter.js +53 -0
- package/agent-pi/dist/agent-preset-pi.js +169 -0
- package/agent-pi/dist/agent-preset-projection.js +29 -0
- package/agent-pi/dist/agent.js +949 -0
- package/agent-pi/dist/inbox.js +53 -0
- package/agent-pi/dist/index.js +595 -0
- package/agent-pi/dist/knobs.js +24 -0
- package/agent-pi/dist/models.js +200 -0
- package/agent-pi/dist/permission.js +66 -0
- package/agent-pi/dist/pi-client.js +224 -0
- package/agent-pi/dist/pi-events.js +155 -0
- package/agent-pi/dist/pi-home.js +55 -0
- package/agent-pi/dist/safe-json.js +25 -0
- package/agent-pi/dist/session-map.js +119 -0
- package/agent-pi/dist/world-plugin.js +97 -0
- package/agent-pi/package.json +77 -0
- package/cordis.patch.yml +22 -19
- package/package.json +88 -25
- package/client.js +0 -4
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeAgent — the Dash `Agent` shim over one `ClaudeSdkClient` (a
|
|
3
|
+
* per-session Claude Agent SDK bridge).
|
|
4
|
+
*
|
|
5
|
+
* Bridges Claude's wire event stream (the neutral vocabulary projected by
|
|
6
|
+
* claude-events.ts) into the Dash `SessionEventMap`:
|
|
7
|
+
*
|
|
8
|
+
* Claude wire → Dash session event
|
|
9
|
+
* ────────────────────────────────────────────────────────────────
|
|
10
|
+
* (prompt delivers) → turn/start + request/context + user/message
|
|
11
|
+
* (first step-scoped event) → step/start + system/message (once per session)
|
|
12
|
+
* assistant_reasoning → live stream chunk + assistant/message {reasoning}
|
|
13
|
+
* assistant_text → live stream chunk + assistant/message {text}
|
|
14
|
+
* tool_start → tool/call (+ todo/write for TodoWrite)
|
|
15
|
+
* tool_end → tool/result
|
|
16
|
+
* compact_boundary → compaction/start + compaction/end bracket
|
|
17
|
+
* result (turn_end) → turn/end (+ usage)
|
|
18
|
+
* permission_denied → log-only trace
|
|
19
|
+
* model_refusal_fallback → retracted uuids traced + content re-projected
|
|
20
|
+
*
|
|
21
|
+
* One Claude turn (user prompt → result) maps to one Dash turn. A Dash step
|
|
22
|
+
* is one model attempt: the SDK delivers COMPLETE assistant messages (no
|
|
23
|
+
* deltas), each of which streams one live bridge chunk and buffers its text;
|
|
24
|
+
* the buffer flushes to an `assistant/message` immediately BEFORE the first
|
|
25
|
+
* `tool/call` it requested and again at `turn_end`. When a new assistant
|
|
26
|
+
* message arrives after the current step's attempt was already flushed, the
|
|
27
|
+
* step closes and the next one opens — the turn-usage fold requires exactly
|
|
28
|
+
* one assistant/message (one usage sample) per step.
|
|
29
|
+
*
|
|
30
|
+
* Usage: Claude reports accounting on every assistant message AND at
|
|
31
|
+
* `result` (`turn_end`). Every flush attaches the sample of the message that
|
|
32
|
+
* opened the attempt (RC-6) — the mid-turn `tool_start` flush included — so
|
|
33
|
+
* every tool turn proves its usage; `totalTokens` is synthesized from
|
|
34
|
+
* Claude's own counters. Nothing stashes across turns.
|
|
35
|
+
*
|
|
36
|
+
* The module is deliberately plain-TS-`private` (never `#`): the agent is
|
|
37
|
+
* handed to `ctx.agents.enter`, whose scope carrier and Cordis tracing proxy
|
|
38
|
+
* break on hard-private receivers.
|
|
39
|
+
*/
|
|
40
|
+
import { randomUUID } from "node:crypto";
|
|
41
|
+
import { agentEvents } from "@deepseek-ai/dsh-agent";
|
|
42
|
+
import { LlmAttemptId, ToolCallId, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, } from "@deepseek-ai/dsh-llm";
|
|
43
|
+
import { createScope } from "@deepseek-ai/dsh-scope";
|
|
44
|
+
import { toClaudeContent } from "./content.js";
|
|
45
|
+
import { Inbox } from "./inbox.js";
|
|
46
|
+
import { resetProjectionState } from "./claude-events.js";
|
|
47
|
+
import { CLAUDE_PROVIDER_ID } from "./adapter.js";
|
|
48
|
+
import { claudePermissionMode, presetFromEvents } from "./permission.js";
|
|
49
|
+
import { catalogFromInit, modelEntryFromSdk, readModelCatalog, setModelCatalog } from "./models.js";
|
|
50
|
+
import { registerClaudeCommands, slashCommandsFromReported, } from "./commands.js";
|
|
51
|
+
/** Diagnostic trace (set CLAUDE_TRACE=1 on the dsh process to enable). */
|
|
52
|
+
const TRACE = process.env.CLAUDE_TRACE === "1";
|
|
53
|
+
const trace = (...parts) => {
|
|
54
|
+
if (TRACE)
|
|
55
|
+
process.stderr.write(`[claude-agent ${Date.now() % 1_000_000}] ${parts.join(" ")}\n`);
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Classify a Claude-side failure into a stable turn-error code (2026-09-18).
|
|
59
|
+
* `CONVERSATION_NOT_FOUND` is terminal for the pairing: the CLI never
|
|
60
|
+
* materialized a conversation under the recorded derived id (e.g. a failed
|
|
61
|
+
* first turn), so every later prompt would fail identically — the session is
|
|
62
|
+
* marked not resumable and ignored (user ruling: never resurrect, never
|
|
63
|
+
* re-wrap prior content).
|
|
64
|
+
*/
|
|
65
|
+
export function classifyClaudeError(error) {
|
|
66
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
67
|
+
return /No conversation found with session ID/i.test(text) ? "CONVERSATION_NOT_FOUND" : "UNKNOWN";
|
|
68
|
+
}
|
|
69
|
+
/** Map Claude's `result.usage` accounting into Dash `TokenUsage`. */
|
|
70
|
+
/** Map Claude's `result.usage` / assistant-message accounting into Dash `TokenUsage`. */
|
|
71
|
+
export function convertUsage(usage) {
|
|
72
|
+
if (usage === null || typeof usage !== "object")
|
|
73
|
+
return undefined;
|
|
74
|
+
const u = usage;
|
|
75
|
+
const inputTokens = typeof u.input_tokens === "number" ? u.input_tokens : 0;
|
|
76
|
+
const outputTokens = typeof u.output_tokens === "number" ? u.output_tokens : 0;
|
|
77
|
+
if (inputTokens === 0 && outputTokens === 0)
|
|
78
|
+
return undefined;
|
|
79
|
+
const cacheReadTokens = typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : 0;
|
|
80
|
+
const cacheWriteTokens = typeof u.cache_creation_input_tokens === "number" ? u.cache_creation_input_tokens : 0;
|
|
81
|
+
// Synthesize the exact total from Claude's own counters (RC-6): the
|
|
82
|
+
// turn-usage fold's `normalizeUsage` needs `totalTokens` (or BOTH cache
|
|
83
|
+
// buckets) to prove an attempt, and prompt = input + cacheRead + cacheWrite
|
|
84
|
+
// is exactly the identity Claude's accounting guarantees.
|
|
85
|
+
const result = {
|
|
86
|
+
inputTokens,
|
|
87
|
+
outputTokens,
|
|
88
|
+
totalTokens: inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens,
|
|
89
|
+
};
|
|
90
|
+
if (cacheReadTokens > 0)
|
|
91
|
+
result.cacheReadTokens = cacheReadTokens;
|
|
92
|
+
if (cacheWriteTokens > 0)
|
|
93
|
+
result.cacheWriteTokens = cacheWriteTokens;
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
/** Text-only preview of a Dash user message for the delivery trace. */
|
|
97
|
+
function userMessagePreview(message) {
|
|
98
|
+
const parts = [];
|
|
99
|
+
for (const block of message.content) {
|
|
100
|
+
if (block.type === "text")
|
|
101
|
+
parts.push(block.text);
|
|
102
|
+
}
|
|
103
|
+
return parts.join("\n");
|
|
104
|
+
}
|
|
105
|
+
/** The verified image reference returned by `readImage`, defensively read. */
|
|
106
|
+
function storedMediaType(stored) {
|
|
107
|
+
const mediaType = stored?.ref?.mediaType;
|
|
108
|
+
return typeof mediaType === "string" && mediaType !== "" ? mediaType : undefined;
|
|
109
|
+
}
|
|
110
|
+
/** A non-empty string field, or undefined when absent/blank/unusable. */
|
|
111
|
+
function stringOf(value) {
|
|
112
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
113
|
+
}
|
|
114
|
+
/** Human-readable detail for a contained failure. */
|
|
115
|
+
function describeError(error) {
|
|
116
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
117
|
+
return message === "" ? "unknown error" : message;
|
|
118
|
+
}
|
|
119
|
+
/** One durable attachment reference projected into the fields the transcoder reads. */
|
|
120
|
+
function attachmentFields(ref) {
|
|
121
|
+
if (ref === null || typeof ref !== "object")
|
|
122
|
+
return {};
|
|
123
|
+
const record = ref;
|
|
124
|
+
return {
|
|
125
|
+
...(typeof record.mediaType === "string" ? { mediaType: record.mediaType } : {}),
|
|
126
|
+
...(typeof record.attachmentId === "string" ? { attachmentId: record.attachmentId } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Project one Dash user message's content blocks into the transcoder's input.
|
|
131
|
+
* Text, image and file blocks are ALL forwarded: an unattachable one must reach
|
|
132
|
+
* the transcoder so it is reported, not passed over. Reasoning and tool blocks
|
|
133
|
+
* are not user-attachable content and carry no wire meaning here.
|
|
134
|
+
*/
|
|
135
|
+
function dshPromptBlocks(content) {
|
|
136
|
+
const blocks = [];
|
|
137
|
+
for (const block of content) {
|
|
138
|
+
switch (block.type) {
|
|
139
|
+
case "text":
|
|
140
|
+
blocks.push({ type: "text", text: block.text });
|
|
141
|
+
break;
|
|
142
|
+
case "image":
|
|
143
|
+
case "file":
|
|
144
|
+
blocks.push({ type: block.type, ...attachmentFields(block.attachment) });
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return blocks;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* How long an agent may sit idle before its Claude client is torn down. The
|
|
154
|
+
* Dash host keeps resumed agents registered forever, and every idle agent pins
|
|
155
|
+
* a live CLI subprocess. `0` disables the exit.
|
|
156
|
+
*/
|
|
157
|
+
const CLAUDE_IDLE_EXIT_MS = parseIdleExit(process.env.CLAUDE_IDLE_EXIT_MS);
|
|
158
|
+
function parseIdleExit(raw) {
|
|
159
|
+
if (raw === undefined || raw.trim() === "")
|
|
160
|
+
return 600_000;
|
|
161
|
+
const value = Number(raw);
|
|
162
|
+
return Number.isFinite(value) && value >= 0 ? value : 600_000;
|
|
163
|
+
}
|
|
164
|
+
/** Text/reasoning blocks for a refusal-fallback `content` payload. */
|
|
165
|
+
function refusalBlocks(content) {
|
|
166
|
+
if (!Array.isArray(content))
|
|
167
|
+
return [];
|
|
168
|
+
const blocks = [];
|
|
169
|
+
for (const raw of content) {
|
|
170
|
+
if (raw === null || typeof raw !== "object")
|
|
171
|
+
continue;
|
|
172
|
+
const block = raw;
|
|
173
|
+
if (block.type === "text" && typeof block.text === "string" && block.text !== "") {
|
|
174
|
+
blocks.push({ type: "text", text: block.text });
|
|
175
|
+
}
|
|
176
|
+
else if (block.type === "reasoning" && typeof block.text === "string" && block.text !== "") {
|
|
177
|
+
blocks.push({ type: "reasoning", text: block.text });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return blocks;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* v2 live assistant-stream publication for the Claude bridge — the same
|
|
184
|
+
* frame protocol the reference loop's AssistantStreamAttempt speaks (start
|
|
185
|
+
* marker, dense zero-based chunks, terminal settlement) emitted over this
|
|
186
|
+
* agent's own dispatch, so the session-controller folds the stream into its
|
|
187
|
+
* reconnect baseline. The SDK delivers COMPLETE assistant messages (no
|
|
188
|
+
* deltas), so each arriving message's text/thinking pushes ONE chunk — that
|
|
189
|
+
* alone makes the text visible before the first `tool_start` flush. Chunks
|
|
190
|
+
* also accumulate as raw durable records embedded in the final
|
|
191
|
+
* assistant/message.
|
|
192
|
+
*/
|
|
193
|
+
class AssistantStreamBridge {
|
|
194
|
+
attemptId;
|
|
195
|
+
records = [];
|
|
196
|
+
nextRevision;
|
|
197
|
+
emitFrame;
|
|
198
|
+
index = 0;
|
|
199
|
+
terminal = false;
|
|
200
|
+
constructor(sessionId, attempt, nextRevision, emitFrame) {
|
|
201
|
+
this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`);
|
|
202
|
+
this.nextRevision = nextRevision;
|
|
203
|
+
this.emitFrame = emitFrame;
|
|
204
|
+
}
|
|
205
|
+
/** Whether the terminal frame already fired. */
|
|
206
|
+
get ended() {
|
|
207
|
+
return this.terminal;
|
|
208
|
+
}
|
|
209
|
+
/** Opening marker before the first delivered chunk. */
|
|
210
|
+
start(turn, step) {
|
|
211
|
+
this.emitFrame({ type: "start", attemptId: this.attemptId, revision: this.nextRevision(), turn, step });
|
|
212
|
+
}
|
|
213
|
+
/** One live chunk: durable record plus dense process-local frame. */
|
|
214
|
+
push(chunk) {
|
|
215
|
+
const time = Date.now();
|
|
216
|
+
this.records.push({ type: "chunk", time, chunk });
|
|
217
|
+
this.emitFrame({ type: "chunk", attemptId: this.attemptId, revision: this.nextRevision(), index: this.index++, time, chunk });
|
|
218
|
+
}
|
|
219
|
+
/** Terminal settlement after the durable assistant/message committed. */
|
|
220
|
+
settle(seq) {
|
|
221
|
+
this.terminal = true;
|
|
222
|
+
this.emitFrame({ type: "end", attemptId: this.attemptId, revision: this.nextRevision(), index: this.index, outcome: { kind: "committed", eventType: "assistant/message", seq } });
|
|
223
|
+
}
|
|
224
|
+
/** No durable attempt event will commit. */
|
|
225
|
+
abandon() {
|
|
226
|
+
this.terminal = true;
|
|
227
|
+
this.emitFrame({ type: "end", attemptId: this.attemptId, revision: this.nextRevision(), index: this.index, outcome: { kind: "abandoned" } });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
export class ClaudeAgent {
|
|
231
|
+
id;
|
|
232
|
+
options;
|
|
233
|
+
session;
|
|
234
|
+
inbox;
|
|
235
|
+
ctx;
|
|
236
|
+
client;
|
|
237
|
+
loopCtx;
|
|
238
|
+
scope;
|
|
239
|
+
dispatch;
|
|
240
|
+
streaming = false;
|
|
241
|
+
activityDone = Promise.resolve();
|
|
242
|
+
resolveActivityDone = () => { };
|
|
243
|
+
dashTurn = 0;
|
|
244
|
+
step = 0;
|
|
245
|
+
lastTurn = 0;
|
|
246
|
+
turnOpen = false;
|
|
247
|
+
cancelCause = null;
|
|
248
|
+
/** Accumulated reasoning text of the in-flight assistant message. */
|
|
249
|
+
reasoningBuffer = "";
|
|
250
|
+
/** Accumulated visible text of the in-flight assistant message. */
|
|
251
|
+
textBuffer = "";
|
|
252
|
+
/** Usage captured from the latest projected assistant message (RC-6). */
|
|
253
|
+
capturedUsage = undefined;
|
|
254
|
+
/** The model the CLI put on the latest assistant message (observed, not requested). */
|
|
255
|
+
observedMessageModel = "";
|
|
256
|
+
/** Whether the current step already committed its `assistant/message` (one attempt per step). */
|
|
257
|
+
stepFlushed = false;
|
|
258
|
+
/** Whether a step is currently OPEN (the counter itself never resets mid-turn). */
|
|
259
|
+
stepOpen = false;
|
|
260
|
+
/** One-shot guard for the `system/message` surface node (never re-emitted). */
|
|
261
|
+
systemMessageDone = false;
|
|
262
|
+
/** Last `request/context` route key (`provider/model`) — dedupes per-turn emits. */
|
|
263
|
+
lastRouteKey = null;
|
|
264
|
+
/** Live v2 assistant-stream publication for the in-flight wire message. */
|
|
265
|
+
streamBridge;
|
|
266
|
+
assistantAttemptCounter = 0;
|
|
267
|
+
assistantStreamRevision = 0;
|
|
268
|
+
/** In-flight nested (subagent) tool calls, tracked from `parent_tool_use_id`. */
|
|
269
|
+
subagentCount = 0;
|
|
270
|
+
/** In-flight approval requests (the interaction bridge is wired in a later task). */
|
|
271
|
+
/** In-flight approval requests, tracked around the interaction bridge. */
|
|
272
|
+
pendingApproval = 0;
|
|
273
|
+
/** The runtime's own default model (from `session_init`), for the catalog. */
|
|
274
|
+
observedDefaultModel = "";
|
|
275
|
+
/** Deliveries parked while idle without a wakeup; flushed at the next prompt. */
|
|
276
|
+
remoteQueue = [];
|
|
277
|
+
/** Serialization chain for async prompt deliveries (attachment prefetch). */
|
|
278
|
+
deliveryChain = Promise.resolve();
|
|
279
|
+
sessionIdentityCommitted = false;
|
|
280
|
+
disposed = false;
|
|
281
|
+
idleExitTimer = undefined;
|
|
282
|
+
onIdleExit;
|
|
283
|
+
runtimeInfo;
|
|
284
|
+
notResumableMarked = false;
|
|
285
|
+
/**
|
|
286
|
+
* Claude's advertised command surface for THIS session, as last observed
|
|
287
|
+
* (`session_init.slash_commands`, then enriched by `supportedCommands()`).
|
|
288
|
+
* Read live by the registration mirror's `listSlashCommands`.
|
|
289
|
+
*/
|
|
290
|
+
observedSlashCommands = [];
|
|
291
|
+
/** The agent-scoped `commands` service, once the registry is available. */
|
|
292
|
+
commandsAccessor = undefined;
|
|
293
|
+
/** Signature of the mirrored list, so an unchanged re-observation is a no-op. */
|
|
294
|
+
commandsSignature = "";
|
|
295
|
+
/** Releases the current mirror before it is rebuilt or the agent is disposed. */
|
|
296
|
+
commandsDisposer = undefined;
|
|
297
|
+
constructor(loopCtx, id, options, session, client, onIdleExit, runtimeInfo) {
|
|
298
|
+
this.loopCtx = loopCtx;
|
|
299
|
+
this.id = id;
|
|
300
|
+
this.options = options;
|
|
301
|
+
this.session = session;
|
|
302
|
+
this.client = client;
|
|
303
|
+
this.onIdleExit = onIdleExit;
|
|
304
|
+
this.runtimeInfo = runtimeInfo;
|
|
305
|
+
this.dispatch = agentEvents(loopCtx, this);
|
|
306
|
+
this.inbox = new Inbox();
|
|
307
|
+
this.scope = createScope(loopCtx, this);
|
|
308
|
+
this.ctx = this.scope.ctx.extend({ agent: this });
|
|
309
|
+
// Claude's slash commands are session state, so the mirror is registered on
|
|
310
|
+
// THIS agent's scope (`this.ctx` carries the agent's scope key): the
|
|
311
|
+
// registry then resolves it as an agent-scoped shadow, which is what keeps
|
|
312
|
+
// sibling sessions from colliding on the same `claude-*` names and what
|
|
313
|
+
// disposes the mirror with the session. `inject` also re-runs if the
|
|
314
|
+
// commands service is reloaded, so the mirror is rebuilt against the new
|
|
315
|
+
// registry instance.
|
|
316
|
+
this.ctx.inject(["commands"], (commandCtx) => {
|
|
317
|
+
this.attachCommandRuntime(commandCtx.commands);
|
|
318
|
+
});
|
|
319
|
+
this.lastTurn = session.snapshotEvents().findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
|
|
320
|
+
client.on((event) => this.handleEvent(event));
|
|
321
|
+
// Session-start projection reset: the module-level tool-name map must never
|
|
322
|
+
// leak another session's tool into this one (Task 4 review finding).
|
|
323
|
+
resetProjectionState();
|
|
324
|
+
// Mid-session permission switching: reconcile the effective Claude mode on
|
|
325
|
+
// every DSH permission event (RC-5). `permission/preset` carries the mode
|
|
326
|
+
// itself; `sandbox/mode` names the skeleton preset directly; an
|
|
327
|
+
// `approval/policy` alone cannot pick a mode, so the log is re-folded.
|
|
328
|
+
this.ctx.on("session/event", (session, event) => {
|
|
329
|
+
if (session !== this.session)
|
|
330
|
+
return;
|
|
331
|
+
if (event.type !== "permission/preset" && event.type !== "sandbox/mode" && event.type !== "approval/policy")
|
|
332
|
+
return;
|
|
333
|
+
let mode;
|
|
334
|
+
if (event.type === "permission/preset") {
|
|
335
|
+
mode = event.data.claudeMode ?? claudePermissionMode(event.data.preset);
|
|
336
|
+
}
|
|
337
|
+
else if (event.type === "sandbox/mode") {
|
|
338
|
+
// The skeleton's sandbox mode names ARE the preset names.
|
|
339
|
+
mode = claudePermissionMode(event.data.mode);
|
|
340
|
+
}
|
|
341
|
+
if (mode === undefined) {
|
|
342
|
+
const folded = presetFromEvents(this.session.snapshotEvents());
|
|
343
|
+
if (folded === undefined)
|
|
344
|
+
return;
|
|
345
|
+
mode = claudePermissionMode(folded);
|
|
346
|
+
}
|
|
347
|
+
trace(`${event.type} observed — re-applying claudeMode ${mode}`);
|
|
348
|
+
void this.client.setPermissionMode(mode).catch((error) => {
|
|
349
|
+
trace(`setPermissionMode ${mode} failed: ${String(error)}`);
|
|
350
|
+
});
|
|
351
|
+
this.runtimeInfo?.onModeChange?.(mode);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
get status() {
|
|
355
|
+
return this.streaming ? "running" : "idle";
|
|
356
|
+
}
|
|
357
|
+
/** Drive one prompt (the primary driving surface). */
|
|
358
|
+
prompt(message) {
|
|
359
|
+
this.deliver(message, "next-turn", true);
|
|
360
|
+
}
|
|
361
|
+
/** Queue a follow-up turn (the Web UI's `session.prompt` "queue" mode). */
|
|
362
|
+
followUp(message) {
|
|
363
|
+
this.deliver(message, "next-turn", true);
|
|
364
|
+
}
|
|
365
|
+
send(message, target, wakeup) {
|
|
366
|
+
this.deliver(message, target, wakeup);
|
|
367
|
+
}
|
|
368
|
+
followup(message) {
|
|
369
|
+
this.deliver(message, "next-turn", true);
|
|
370
|
+
}
|
|
371
|
+
steer(message) {
|
|
372
|
+
this.deliver(message, "next-step", true);
|
|
373
|
+
}
|
|
374
|
+
inject(message) {
|
|
375
|
+
this.deliver(message, "next-step", false);
|
|
376
|
+
}
|
|
377
|
+
interrupt() {
|
|
378
|
+
// Turn-level interrupt: the session survives, only the in-flight turn stops.
|
|
379
|
+
this.cancel({ kind: "user" }, { keepInbox: true });
|
|
380
|
+
}
|
|
381
|
+
cancel(cause, _options) {
|
|
382
|
+
if (this.disposed)
|
|
383
|
+
return;
|
|
384
|
+
if (!this.streaming)
|
|
385
|
+
return;
|
|
386
|
+
if (this.cancelCause === null)
|
|
387
|
+
this.cancelCause = cause;
|
|
388
|
+
void this.client.interrupt().catch(() => { });
|
|
389
|
+
}
|
|
390
|
+
async whenIdle() {
|
|
391
|
+
let activity;
|
|
392
|
+
do {
|
|
393
|
+
activity = this.activityDone;
|
|
394
|
+
await activity;
|
|
395
|
+
} while (activity !== this.activityDone);
|
|
396
|
+
}
|
|
397
|
+
/** Track one in-flight approval so the idle-TTL gate protects a session waiting on a human. */
|
|
398
|
+
async runApproval(task) {
|
|
399
|
+
this.pendingApproval += 1;
|
|
400
|
+
try {
|
|
401
|
+
return await task();
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
this.pendingApproval -= 1;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
runMaintenance(task) {
|
|
408
|
+
return task(new AbortController().signal);
|
|
409
|
+
}
|
|
410
|
+
async dispose() {
|
|
411
|
+
if (this.disposed)
|
|
412
|
+
return;
|
|
413
|
+
this.disposed = true;
|
|
414
|
+
clearTimeout(this.idleExitTimer);
|
|
415
|
+
this.idleExitTimer = undefined;
|
|
416
|
+
if (this.turnOpen) {
|
|
417
|
+
this.abandonStreamBridge();
|
|
418
|
+
this.closeTurn({ kind: "aborted", reason: { kind: "disposed" } });
|
|
419
|
+
}
|
|
420
|
+
this.markIdle();
|
|
421
|
+
this.endActivity();
|
|
422
|
+
this.client.close();
|
|
423
|
+
resetProjectionState();
|
|
424
|
+
// Release the command mirror explicitly (the agent scope would also dispose
|
|
425
|
+
// it): the registry's own duplicate check makes the release observable.
|
|
426
|
+
const releaseCommands = this.commandsDisposer;
|
|
427
|
+
this.commandsDisposer = undefined;
|
|
428
|
+
this.commandsSignature = "";
|
|
429
|
+
// Session-observed data must not outlive the session (a resumed/next
|
|
430
|
+
// session re-observes it from its own `session_init`).
|
|
431
|
+
this.observedSlashCommands = [];
|
|
432
|
+
try {
|
|
433
|
+
releaseCommands?.();
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await this.scope.dispose();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
440
|
+
deliver(message, target, wakeup) {
|
|
441
|
+
if (this.disposed)
|
|
442
|
+
return;
|
|
443
|
+
const busy = this.turnOpen || this.streaming;
|
|
444
|
+
trace(`deliver target=${target} wakeup=${wakeup} busy=${busy}`);
|
|
445
|
+
if (busy) {
|
|
446
|
+
this.appendUserMessage(message);
|
|
447
|
+
this.scheduleDelivery(async () => {
|
|
448
|
+
const payload = await this.promptContent(message);
|
|
449
|
+
if (payload.kind === "empty") {
|
|
450
|
+
// A steer/followUp is not the turn's own content (unlike the opening
|
|
451
|
+
// prompt), so its notice has fired and the delivery is DROPPED here —
|
|
452
|
+
// never pushed empty, and never allowed to abort a turn the user is
|
|
453
|
+
// actively watching because one image failed to resolve.
|
|
454
|
+
trace(`busy ${target} delivery for turn ${this.dashTurn} has no attachable content — not pushed`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const sent = target === "next-turn" ? this.client.followUp(payload.content) : this.client.steer(payload.content);
|
|
458
|
+
await sent;
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (!wakeup) {
|
|
463
|
+
// Idle injection: record it durably and park; a later prompt delivers it.
|
|
464
|
+
this.appendUserMessage(message);
|
|
465
|
+
this.remoteQueue.push({ message, sent: false, transport: "steer" });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
this.deliverPrompt(message);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Transcode one queued user message into the SDK client's prompt content and
|
|
472
|
+
* REPORT every block that could not be attached.
|
|
473
|
+
*
|
|
474
|
+
* Attachment bytes are read asynchronously HERE — DSH's store is async and
|
|
475
|
+
* returns a `Uint8Array` — into a plain id -> {mediaType, base64} map, and the
|
|
476
|
+
* unchanged pure `toClaudeContent` consumes it as a synchronous lookup. The
|
|
477
|
+
* resulting block array is handed to the client AS-IS: there is no "\n"
|
|
478
|
+
* re-join, so the transcoder's one-block-per-input-block shape reaches the
|
|
479
|
+
* wire intact.
|
|
480
|
+
*/
|
|
481
|
+
async promptContent(message) {
|
|
482
|
+
const blocks = dshPromptBlocks(message.content);
|
|
483
|
+
let resolved = new Map();
|
|
484
|
+
let failures = new Map();
|
|
485
|
+
try {
|
|
486
|
+
({ resolved, failures } = await this.resolveAttachments(message.content));
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
// A catastrophic prefetch failure must not abort the turn: report it and
|
|
490
|
+
// continue text-only (the per-block skips below still name every loss).
|
|
491
|
+
this.reportSkippedBlocks([`attachment prefetch failed (${describeError(error)})`]);
|
|
492
|
+
}
|
|
493
|
+
const { content, skipped } = toClaudeContent(blocks, (id) => {
|
|
494
|
+
const hit = resolved.get(id);
|
|
495
|
+
if (hit !== undefined)
|
|
496
|
+
return hit;
|
|
497
|
+
const failure = failures.get(id);
|
|
498
|
+
// The transcoder contains a throwing reader into a reported skip, so the
|
|
499
|
+
// store's own failure detail survives into the notice.
|
|
500
|
+
if (failure !== undefined)
|
|
501
|
+
throw new Error(failure);
|
|
502
|
+
return undefined;
|
|
503
|
+
});
|
|
504
|
+
if (content.length === 0 && skipped.length === 0) {
|
|
505
|
+
// `dshPromptBlocks` filtered everything out (no text/image at all, or no
|
|
506
|
+
// content), so the transcoder had nothing to report. Name the loss HERE:
|
|
507
|
+
// this is the one path where content could otherwise vanish silently.
|
|
508
|
+
skipped.push(`the message carried no attachable content (${message.content.length} source block(s), none of them text or image)`);
|
|
509
|
+
}
|
|
510
|
+
this.reportSkippedBlocks(skipped);
|
|
511
|
+
if (content.length === 0) {
|
|
512
|
+
// Never synthesize the empty-text block the transcoder refuses to emit.
|
|
513
|
+
// The notice above has fired; the caller fails the turn loudly.
|
|
514
|
+
return { kind: "empty", reason: `no attachable content: ${skipped.join("; ")}` };
|
|
515
|
+
}
|
|
516
|
+
return { kind: "content", content };
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Prefetch every durable image attachment one message references, because
|
|
520
|
+
* `toClaudeContent`'s reader is synchronous by contract while the DSH store
|
|
521
|
+
* is async. Every failure mode — a missing service, a malformed ref, a
|
|
522
|
+
* rejecting `readImage`, a non-byte payload, an unusable media type — is
|
|
523
|
+
* recorded with its detail and re-raised through the reader, so each one
|
|
524
|
+
* lands in `skipped` with an actionable reason.
|
|
525
|
+
*/
|
|
526
|
+
async resolveAttachments(content) {
|
|
527
|
+
const resolved = new Map();
|
|
528
|
+
const failures = new Map();
|
|
529
|
+
const attachments = this.loopCtx.get("attachments");
|
|
530
|
+
for (const block of content) {
|
|
531
|
+
if (block.type !== "image")
|
|
532
|
+
continue;
|
|
533
|
+
const ref = block.attachment;
|
|
534
|
+
const id = ref !== null && typeof ref === "object"
|
|
535
|
+
? stringOf(ref.attachmentId)
|
|
536
|
+
: undefined;
|
|
537
|
+
if (id === undefined)
|
|
538
|
+
continue; // malformed ref: the transcoder reports the missing attachmentId
|
|
539
|
+
if (resolved.has(id) || failures.has(id))
|
|
540
|
+
continue;
|
|
541
|
+
if (attachments === undefined || typeof attachments.readImage !== "function") {
|
|
542
|
+
failures.set(id, "no attachment service is mounted on the context");
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
const stored = await attachments.readImage(ref);
|
|
547
|
+
const data = stored?.data;
|
|
548
|
+
if (!(data instanceof Uint8Array)) {
|
|
549
|
+
failures.set(id, "readImage returned no byte payload");
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const mediaType = storedMediaType(stored) ?? stringOf(ref.mediaType);
|
|
553
|
+
if (mediaType === undefined) {
|
|
554
|
+
failures.set(id, "the stored reference carries no mediaType");
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
resolved.set(id, { mediaType, data: Buffer.from(data).toString("base64") });
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
failures.set(id, describeError(error));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return { resolved, failures };
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Serialize async prompt deliveries so a slow attachment read cannot reorder
|
|
567
|
+
* the pushes the client queue preserves FIFO.
|
|
568
|
+
*/
|
|
569
|
+
scheduleDelivery(task) {
|
|
570
|
+
this.deliveryChain = this.deliveryChain.then(task).catch((error) => { this.fail(error); });
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Surface every block the transcoder could not attach. Channel: an always-on
|
|
574
|
+
* stderr warning plus the CLAUDE_TRACE diagnostic. A log-only session event
|
|
575
|
+
* was rejected on purpose — `Session.append` cannot set the envelope's
|
|
576
|
+
* `ignorable` marker, and the persistence read path refuses any event type
|
|
577
|
+
* unknown to this harness that is not ignorable
|
|
578
|
+
* (`@deepseek-ai/dsh-session-persistence` `validateStoredEvents`), so
|
|
579
|
+
* appending a custom notice would make the whole session log unreadable.
|
|
580
|
+
*/
|
|
581
|
+
reportSkippedBlocks(skipped) {
|
|
582
|
+
if (skipped.length === 0)
|
|
583
|
+
return;
|
|
584
|
+
const detail = skipped.join("; ");
|
|
585
|
+
process.stderr.write(`agent-claude: ${skipped.length} content block(s) not attached to the Claude request: ${detail}\n`);
|
|
586
|
+
trace(`content blocks not attached: ${detail}`);
|
|
587
|
+
}
|
|
588
|
+
deliverPrompt(message) {
|
|
589
|
+
if (this.disposed)
|
|
590
|
+
return;
|
|
591
|
+
trace(`deliverPrompt turn=${this.dashTurn + 1} text="${userMessagePreview(message).slice(0, 40)}"`);
|
|
592
|
+
this.reserveTurn();
|
|
593
|
+
clearTimeout(this.idleExitTimer);
|
|
594
|
+
this.idleExitTimer = undefined;
|
|
595
|
+
this.beginActivity();
|
|
596
|
+
this.markRunning();
|
|
597
|
+
// Enter the SAME serialization chain the busy path uses, so a steer that
|
|
598
|
+
// arrives during cold start (after `reserveTurn` flips `turnOpen`) cannot
|
|
599
|
+
// overtake this opening prompt and invert FIFO: the chain slot is taken
|
|
600
|
+
// here, before `startTurn` yields on `ensureStarted`.
|
|
601
|
+
this.scheduleDelivery(() => this.startTurn(message));
|
|
602
|
+
}
|
|
603
|
+
reserveTurn() {
|
|
604
|
+
if (this.turnOpen)
|
|
605
|
+
return;
|
|
606
|
+
this.dashTurn = ++this.lastTurn;
|
|
607
|
+
this.step = 0;
|
|
608
|
+
this.stepOpen = false;
|
|
609
|
+
this.stepFlushed = false;
|
|
610
|
+
this.turnOpen = true;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Cold-start path for one reserved turn: confirm the client is started,
|
|
614
|
+
* commit the session-identity events (agent-preset + permission stamps),
|
|
615
|
+
* then append turn/start + user/message and dispatch. A failed cold start
|
|
616
|
+
* synthesizes the failed turn (`turn/end{reason:{kind:'error'}}`) rather
|
|
617
|
+
* than hanging, then unwinds the reservation.
|
|
618
|
+
*/
|
|
619
|
+
async startTurn(message) {
|
|
620
|
+
try {
|
|
621
|
+
await this.client.ensureStarted();
|
|
622
|
+
await this.bootstrapSessionIdentity();
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
trace(`startTurn cold start failed: ${String(error)}`);
|
|
626
|
+
if (this.turnOpen) {
|
|
627
|
+
this.session.append("turn/start", { turn: this.dashTurn });
|
|
628
|
+
this.appendUserMessage(message);
|
|
629
|
+
this.closeTurn({ kind: "error", error: { message: String(error), code: classifyClaudeError(error) } });
|
|
630
|
+
}
|
|
631
|
+
this.markNotResumableIfDead(error);
|
|
632
|
+
this.markIdle();
|
|
633
|
+
this.endActivity();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (this.disposed)
|
|
637
|
+
return;
|
|
638
|
+
this.session.append("turn/start", { turn: this.dashTurn });
|
|
639
|
+
// Route metadata for the next request (`request/context`) lands before
|
|
640
|
+
// the turn's messages, exactly as the reference loop stamps it.
|
|
641
|
+
this.emitRequestContext();
|
|
642
|
+
this.appendUserMessage(message);
|
|
643
|
+
// Flush any idle-injection that was parked before this turn.
|
|
644
|
+
for (const entry of this.remoteQueue.splice(0)) {
|
|
645
|
+
this.appendUserMessage(entry.message);
|
|
646
|
+
const parked = await this.promptContent(entry.message);
|
|
647
|
+
if (parked.kind === "empty") {
|
|
648
|
+
// A parked injection is context, not the turn's content: report it
|
|
649
|
+
// (already done) and drop it rather than pushing an empty message or
|
|
650
|
+
// aborting the turn the host actually asked for.
|
|
651
|
+
trace(`parked injection for turn ${this.dashTurn} has no attachable content — not pushed`);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
void this.client.steer(parked.content).catch((error) => this.fail(error));
|
|
655
|
+
}
|
|
656
|
+
const payload = await this.promptContent(message);
|
|
657
|
+
if (payload.kind === "empty") {
|
|
658
|
+
// Nothing attachable: never push a blank turn — synthesize the failed
|
|
659
|
+
// turn exactly as a cold-start failure does, after the notice fired.
|
|
660
|
+
this.fail(new Error(payload.reason));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
void this.client.prompt(payload.content).catch((error) => this.fail(error));
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* One-shot first-turn identity commit: the agent-preset stamp plus, when
|
|
667
|
+
* the log fold carries no revivable preset, the `permission/preset` event
|
|
668
|
+
* carrying the single-source `claudeMode` and the live `setPermissionMode`
|
|
669
|
+
* re-application.
|
|
670
|
+
*
|
|
671
|
+
* RC-5: the LOG FOLD is the permission authority at first turn. A preset
|
|
672
|
+
* chosen in the blank window (between create and the first prompt) is
|
|
673
|
+
* already folded into `permission/preset` — re-stamping here would bounce
|
|
674
|
+
* the user's choice back to the create-time preset, so BOTH the stamp and
|
|
675
|
+
* the live call are skipped when the fold already carries one (the resume
|
|
676
|
+
* path re-applied the cached mode at client construction).
|
|
677
|
+
*/
|
|
678
|
+
async bootstrapSessionIdentity() {
|
|
679
|
+
if (this.sessionIdentityCommitted)
|
|
680
|
+
return;
|
|
681
|
+
this.sessionIdentityCommitted = true;
|
|
682
|
+
if (!this.session.snapshotEvents().some((event) => event.type === "agent-preset/selected" && event.data?.agentPreset === "claude")) {
|
|
683
|
+
this.session.append("agent-preset/selected", { agentPreset: "claude" });
|
|
684
|
+
}
|
|
685
|
+
if (presetFromEvents(this.session.snapshotEvents()) !== undefined) {
|
|
686
|
+
trace("permission/preset already folded into the log — skipping the first-turn re-stamp and mode re-application");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const preset = this.runtimeInfo?.preset;
|
|
690
|
+
const claudeMode = this.runtimeInfo?.claudeMode ?? claudePermissionMode(preset);
|
|
691
|
+
if (preset !== undefined) {
|
|
692
|
+
this.session.append("permission/preset", { preset, claudeMode });
|
|
693
|
+
trace(`permission/preset ${preset} -> claudeMode ${claudeMode}`);
|
|
694
|
+
}
|
|
695
|
+
void this.client.setPermissionMode(claudeMode).catch((error) => {
|
|
696
|
+
trace(`setPermissionMode ${claudeMode} failed: ${String(error)}`);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Stamp the session's system prompt once per session as the step's surface
|
|
701
|
+
* node. `system/message` is a step-scoped surface event, so this runs only
|
|
702
|
+
* after `step/start` committed. Skips when the log already carries one
|
|
703
|
+
* (resume) and while no step is open — retried on later steps until the
|
|
704
|
+
* facts are available.
|
|
705
|
+
*
|
|
706
|
+
* Prompt source: the Claude Agent SDK exposes NO system-prompt text to
|
|
707
|
+
* hosts (verified against `SDKSystemMessage` and the initialize response —
|
|
708
|
+
* `systemPrompt` exists only as an outbound Options/request field, and
|
|
709
|
+
* `systemPromptSections` is a /context token report). So the node records
|
|
710
|
+
* clearly-labeled adapter-synthesized FACTS, never a fabricated CLI prompt.
|
|
711
|
+
*/
|
|
712
|
+
emitSystemMessage() {
|
|
713
|
+
if (this.systemMessageDone)
|
|
714
|
+
return;
|
|
715
|
+
if (!this.turnOpen)
|
|
716
|
+
return;
|
|
717
|
+
this.commitStepStart();
|
|
718
|
+
if (!this.stepOpen)
|
|
719
|
+
return;
|
|
720
|
+
if (this.session.snapshotEvents().some((event) => event.type === "system/message")) {
|
|
721
|
+
this.systemMessageDone = true;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const model = this.observedMessageModel || this.observedDefaultModel || this.options.model || "unknown";
|
|
725
|
+
const mode = this.runtimeInfo?.claudeMode ?? claudePermissionMode(presetFromEvents(this.session.snapshotEvents()));
|
|
726
|
+
const text = [
|
|
727
|
+
"(agent-claude adapter) — session facts synthesized by the adapter.",
|
|
728
|
+
"The Claude Agent SDK does not expose the Claude Code system prompt to hosts, so this node records the resolved runtime facts instead:",
|
|
729
|
+
`- model: ${model}`,
|
|
730
|
+
`- cwd: ${this.client.cwd}`,
|
|
731
|
+
`- permission mode: ${mode}`,
|
|
732
|
+
].join("\n");
|
|
733
|
+
this.session.append("system/message", {
|
|
734
|
+
turn: this.dashTurn,
|
|
735
|
+
step: this.step,
|
|
736
|
+
message: createSystemMessage(text, "aw.agent-adapter-claude"),
|
|
737
|
+
}, { surfaceOp: "append" });
|
|
738
|
+
this.systemMessageDone = true;
|
|
739
|
+
trace(`system/message stamped (${text.length} chars)`);
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Stamp route metadata for the next request (`request/context`): the
|
|
743
|
+
* effective provider/model plus the model's context window when genuinely
|
|
744
|
+
* known. Turn-enclosed (no step required) and deduped by route key.
|
|
745
|
+
*/
|
|
746
|
+
emitRequestContext() {
|
|
747
|
+
if (!this.turnOpen)
|
|
748
|
+
return;
|
|
749
|
+
const model = this.observedMessageModel || this.observedDefaultModel || this.options.model || "";
|
|
750
|
+
if (model === "")
|
|
751
|
+
return;
|
|
752
|
+
const key = `${CLAUDE_PROVIDER_ID}/${model}`;
|
|
753
|
+
if (key === this.lastRouteKey)
|
|
754
|
+
return;
|
|
755
|
+
this.lastRouteKey = key;
|
|
756
|
+
const entry = readModelCatalog().models.find((candidate) => candidate.id === model);
|
|
757
|
+
const contextWindow = entry?.contextWindow;
|
|
758
|
+
this.session.append("request/context", {
|
|
759
|
+
provider: CLAUDE_PROVIDER_ID,
|
|
760
|
+
model,
|
|
761
|
+
...(contextWindow !== undefined && contextWindow > 0 ? { contextWindow } : {}),
|
|
762
|
+
});
|
|
763
|
+
trace(`request/context ${key}${contextWindow === undefined ? "" : ` ctx=${contextWindow}`}`);
|
|
764
|
+
}
|
|
765
|
+
/** The open stream bridge for this message, starting one on first use. */
|
|
766
|
+
ensureStreamBridge() {
|
|
767
|
+
if (this.streamBridge === undefined || this.streamBridge.ended) {
|
|
768
|
+
this.streamBridge = new AssistantStreamBridge(this.session.id, ++this.assistantAttemptCounter, () => ++this.assistantStreamRevision, (frame) => this.dispatch.emit("agent/assistant-stream", { frame }));
|
|
769
|
+
this.streamBridge.start(this.dashTurn, this.step);
|
|
770
|
+
}
|
|
771
|
+
return this.streamBridge;
|
|
772
|
+
}
|
|
773
|
+
/** Abandon any open stream bridge — failure paths never settle. */
|
|
774
|
+
abandonStreamBridge() {
|
|
775
|
+
if (this.streamBridge !== undefined && !this.streamBridge.ended)
|
|
776
|
+
this.streamBridge.abandon();
|
|
777
|
+
this.streamBridge = undefined;
|
|
778
|
+
}
|
|
779
|
+
/** Capture the usage/model an assistant wire event reported (RC-6). */
|
|
780
|
+
captureProvenance(event) {
|
|
781
|
+
if (typeof event.model === "string" && event.model !== "")
|
|
782
|
+
this.observedMessageModel = event.model;
|
|
783
|
+
const usage = convertUsage(event.usage);
|
|
784
|
+
if (usage !== undefined)
|
|
785
|
+
this.capturedUsage = usage;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Close the current step after its attempt was already flushed, so the next
|
|
789
|
+
* assistant message opens a fresh attempt step (the turn-usage fold needs
|
|
790
|
+
* exactly one usage sample per step). No-op unless a flushed message is
|
|
791
|
+
* actually open.
|
|
792
|
+
*/
|
|
793
|
+
closeAttemptStep() {
|
|
794
|
+
if (!this.turnOpen || !this.stepOpen || !this.stepFlushed)
|
|
795
|
+
return;
|
|
796
|
+
this.session.append("step/end", { turn: this.dashTurn, step: this.step });
|
|
797
|
+
// Keep the COUNTER: steps within one turn number 1, 2, 3, … (the r1 code
|
|
798
|
+
// reset it to 0 here, so every step of a multi-step turn was numbered 1 —
|
|
799
|
+
// duplicate (turn, step) coordinates corrupted the Web transcript replay,
|
|
800
|
+
// live-found 2026-09-18).
|
|
801
|
+
this.stepOpen = false;
|
|
802
|
+
this.stepFlushed = false;
|
|
803
|
+
}
|
|
804
|
+
appendUserMessage(message) {
|
|
805
|
+
this.session.append("user/message", message, { surfaceOp: "append" });
|
|
806
|
+
}
|
|
807
|
+
/** Open the step lazily before the first step-scoped event of the turn. */
|
|
808
|
+
commitStepStart() {
|
|
809
|
+
if (!this.turnOpen || this.stepOpen)
|
|
810
|
+
return;
|
|
811
|
+
this.step += 1;
|
|
812
|
+
this.stepOpen = true;
|
|
813
|
+
this.stepFlushed = false;
|
|
814
|
+
this.session.append("step/start", { turn: this.dashTurn, step: this.step });
|
|
815
|
+
trace(`step/start turn=${this.dashTurn} step=${this.step}`);
|
|
816
|
+
// The system prompt is stamped inside the first OPEN step of the session
|
|
817
|
+
// (the node precedes the step's messages, mirroring the reference loop).
|
|
818
|
+
this.emitSystemMessage();
|
|
819
|
+
}
|
|
820
|
+
commitStepEnd() {
|
|
821
|
+
if (!this.turnOpen || !this.stepOpen)
|
|
822
|
+
return;
|
|
823
|
+
this.session.append("step/end", { turn: this.dashTurn, step: this.step });
|
|
824
|
+
this.stepOpen = false;
|
|
825
|
+
}
|
|
826
|
+
closeTurn(reason) {
|
|
827
|
+
if (!this.turnOpen)
|
|
828
|
+
return;
|
|
829
|
+
this.turnOpen = false;
|
|
830
|
+
this.session.append("turn/end", { turn: this.dashTurn, reason });
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Flush buffered reasoning/text as one `assistant/message` (the attempt's
|
|
834
|
+
* durable settlement), then reset. The sample rides the SAME event (RC-6):
|
|
835
|
+
* the explicit `usage` wins, else the latest assistant-message usage the
|
|
836
|
+
* projection carried — so the `tool_start` flush lands a same-attempt
|
|
837
|
+
* sample and nothing ever stashes across turns.
|
|
838
|
+
*/
|
|
839
|
+
flushAssistant(usage) {
|
|
840
|
+
if (this.reasoningBuffer === "" && this.textBuffer === "") {
|
|
841
|
+
if (usage !== undefined)
|
|
842
|
+
trace("flush had usage but no buffered content — sample dropped (no message carries it)");
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const blocks = [];
|
|
846
|
+
if (this.reasoningBuffer !== "")
|
|
847
|
+
blocks.push({ type: "reasoning", text: this.reasoningBuffer });
|
|
848
|
+
if (this.textBuffer !== "")
|
|
849
|
+
blocks.push({ type: "text", text: this.textBuffer });
|
|
850
|
+
this.reasoningBuffer = "";
|
|
851
|
+
this.textBuffer = "";
|
|
852
|
+
const effectiveUsage = usage ?? this.capturedUsage;
|
|
853
|
+
this.capturedUsage = undefined;
|
|
854
|
+
this.commitStepStart();
|
|
855
|
+
const bridge = this.streamBridge;
|
|
856
|
+
const assistant = createAssistantMessage({
|
|
857
|
+
content: blocks,
|
|
858
|
+
// The OBSERVED model (what the CLI actually ran), not the requested one.
|
|
859
|
+
source: { provider: CLAUDE_PROVIDER_ID, model: this.observedMessageModel || this.observedDefaultModel || this.options.model || "" },
|
|
860
|
+
});
|
|
861
|
+
const event = this.session.append("assistant/message", {
|
|
862
|
+
turn: this.dashTurn,
|
|
863
|
+
step: this.step,
|
|
864
|
+
message: assistant,
|
|
865
|
+
stream: bridge?.records ?? [],
|
|
866
|
+
...(effectiveUsage === undefined ? {} : { usage: effectiveUsage }),
|
|
867
|
+
}, {
|
|
868
|
+
surfaceOp: "append",
|
|
869
|
+
});
|
|
870
|
+
this.stepFlushed = true;
|
|
871
|
+
if (bridge !== undefined && !bridge.ended)
|
|
872
|
+
bridge.settle(event.seq);
|
|
873
|
+
this.streamBridge = undefined;
|
|
874
|
+
}
|
|
875
|
+
appendToolCall(callId, name, args) {
|
|
876
|
+
this.commitStepStart();
|
|
877
|
+
this.session.append("tool/call", {
|
|
878
|
+
turn: this.dashTurn,
|
|
879
|
+
step: this.step,
|
|
880
|
+
callId: ToolCallId(callId),
|
|
881
|
+
name,
|
|
882
|
+
arguments: JSON.stringify(args ?? {}),
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
appendToolResult(callId, content, isError) {
|
|
886
|
+
this.commitStepStart();
|
|
887
|
+
const message = createToolResultMessage({
|
|
888
|
+
callId: ToolCallId(callId),
|
|
889
|
+
content: content === "" ? [] : [{ type: "text", text: content }],
|
|
890
|
+
isError,
|
|
891
|
+
});
|
|
892
|
+
this.session.append("tool/result", {
|
|
893
|
+
turn: this.dashTurn,
|
|
894
|
+
step: this.step,
|
|
895
|
+
message,
|
|
896
|
+
...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
|
|
897
|
+
}, {
|
|
898
|
+
surfaceOp: "append",
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Feed the observed model into the catalog: the degraded `session_init.model`
|
|
903
|
+
* read immediately, then the full `supportedModels()` list replaces it.
|
|
904
|
+
*/
|
|
905
|
+
observeModelCatalog(init) {
|
|
906
|
+
if (typeof init.model === "string" && init.model !== "")
|
|
907
|
+
this.observedDefaultModel = init.model;
|
|
908
|
+
const entries = catalogFromInit(init);
|
|
909
|
+
if (entries.length > 0)
|
|
910
|
+
setModelCatalog(entries, this.observedDefaultModel);
|
|
911
|
+
void this.client.supportedModels().then((models) => {
|
|
912
|
+
const full = Array.isArray(models) ? models.flatMap((m) => modelEntryFromSdk(m)) : [];
|
|
913
|
+
if (full.length > 0)
|
|
914
|
+
setModelCatalog(full, this.observedDefaultModel);
|
|
915
|
+
}).catch((error) => {
|
|
916
|
+
trace(`supportedModels() failed: ${String(error)}`);
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Learn Claude's command surface for this session: the degraded
|
|
921
|
+
* `session_init.slash_commands` name list immediately (same two-stage shape as
|
|
922
|
+
* the model catalog above), then the authoritative `supportedCommands()` list
|
|
923
|
+
* — which carries each command's description and argument hint — replaces it.
|
|
924
|
+
* Both stages end in {@link syncClaudeCommands}, so a session that reports
|
|
925
|
+
* nothing lands zero registrations rather than throwing.
|
|
926
|
+
*/
|
|
927
|
+
observeSlashCommands(init) {
|
|
928
|
+
this.observedSlashCommands = slashCommandsFromReported(init.slashCommands);
|
|
929
|
+
trace(`observed ${this.observedSlashCommands.length} Claude slash command(s) from session_init`);
|
|
930
|
+
this.syncClaudeCommands();
|
|
931
|
+
const fetchSupported = this.client.supportedCommands;
|
|
932
|
+
if (typeof fetchSupported !== "function")
|
|
933
|
+
return;
|
|
934
|
+
void fetchSupported.call(this.client).then((commands) => {
|
|
935
|
+
const full = slashCommandsFromReported(commands);
|
|
936
|
+
if (full.length === 0)
|
|
937
|
+
return;
|
|
938
|
+
this.observedSlashCommands = full;
|
|
939
|
+
trace(`observed ${full.length} Claude slash command(s) from supportedCommands()`);
|
|
940
|
+
this.syncClaudeCommands();
|
|
941
|
+
}).catch((error) => {
|
|
942
|
+
trace(`supportedCommands() failed: ${String(error)}`);
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
/** Adopt the agent-scoped commands registry and build the mirror against it. */
|
|
946
|
+
attachCommandRuntime(runtime) {
|
|
947
|
+
this.commandsAccessor = { commands: runtime };
|
|
948
|
+
// `inject` re-runs when the service is replaced: the previous mirror lived
|
|
949
|
+
// on the previous registry instance (and was disposed with it), so forgetting
|
|
950
|
+
// the signature forces a rebuild instead of an early return.
|
|
951
|
+
this.commandsSignature = "";
|
|
952
|
+
this.commandsDisposer = undefined;
|
|
953
|
+
this.syncClaudeCommands();
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Keep the agent-scoped command mirror equal to the observed list.
|
|
957
|
+
*
|
|
958
|
+
* Rebuilds only when the observed surface actually changed, and always
|
|
959
|
+
* releases the previous mirror BEFORE registering the new one: the registry
|
|
960
|
+
* rejects a duplicate name inside one scope layer, so a rebuild that
|
|
961
|
+
* registered first would throw on its own names.
|
|
962
|
+
*/
|
|
963
|
+
syncClaudeCommands() {
|
|
964
|
+
const accessor = this.commandsAccessor;
|
|
965
|
+
if (accessor === undefined || this.disposed) {
|
|
966
|
+
// No commands service in this world: the adapter simply has no mirror.
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const signature = this.observedSlashCommands
|
|
970
|
+
.map((command) => `${command.name}\u0000${command.description ?? ""}\u0000${command.argumentHint ?? ""}`)
|
|
971
|
+
.join("\u0001");
|
|
972
|
+
if (signature === this.commandsSignature)
|
|
973
|
+
return;
|
|
974
|
+
this.commandsSignature = signature;
|
|
975
|
+
const previous = this.commandsDisposer;
|
|
976
|
+
this.commandsDisposer = undefined;
|
|
977
|
+
previous?.();
|
|
978
|
+
if (this.observedSlashCommands.length === 0)
|
|
979
|
+
return;
|
|
980
|
+
try {
|
|
981
|
+
this.commandsDisposer = registerClaudeCommands(accessor, {
|
|
982
|
+
listSlashCommands: () => this.observedSlashCommands,
|
|
983
|
+
submit: (agent, line) => { this.submitClaudeLine(agent, line); },
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
// A rejected mirror must not pin the signature (a later observation would
|
|
988
|
+
// then skip the retry); the client's listener containment reports it loud.
|
|
989
|
+
this.commandsSignature = "";
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
trace(`mirrored ${this.observedSlashCommands.length} Claude command(s) onto ctx.commands`);
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Deliver one forwarded Claude slash line to THIS session.
|
|
996
|
+
*
|
|
997
|
+
* The mirror is registered on this agent's scope, so the invocation's agent is
|
|
998
|
+
* always this instance; the identity guard makes a misrouted invocation a
|
|
999
|
+
* loud no-op instead of driving another session's CLI. The line becomes an
|
|
1000
|
+
* ordinary user turn (`prompt`): it queues behind an open turn and opens one
|
|
1001
|
+
* when idle, so Claude's own slash-command handling sees the exact line and
|
|
1002
|
+
* the output lands in the transcript like any other turn.
|
|
1003
|
+
*/
|
|
1004
|
+
submitClaudeLine(agent, line) {
|
|
1005
|
+
if (agent !== this) {
|
|
1006
|
+
trace(`claude command "${line}" arrived for a foreign agent — dropped`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
trace(`forwarding "${line}" to Claude`);
|
|
1010
|
+
this.prompt(createUserMessage({ content: [{ type: "text", text: line }], source: { kind: "user" } }));
|
|
1011
|
+
}
|
|
1012
|
+
handleEvent(event) {
|
|
1013
|
+
switch (event.type) {
|
|
1014
|
+
case "session_init":
|
|
1015
|
+
trace(`session_init model=${String(event.model)}`);
|
|
1016
|
+
this.observeModelCatalog(event);
|
|
1017
|
+
this.observeSlashCommands(event);
|
|
1018
|
+
break;
|
|
1019
|
+
case "assistant_reasoning":
|
|
1020
|
+
case "assistant_text": {
|
|
1021
|
+
// One COMPLETE SDK assistant message (no deltas): capture its
|
|
1022
|
+
// usage/model, make the text visible NOW via one bridge chunk (it
|
|
1023
|
+
// must precede the first tool_start flush), and buffer it for the
|
|
1024
|
+
// durable flush.
|
|
1025
|
+
const text = typeof event.text === "string" ? event.text : "";
|
|
1026
|
+
this.captureProvenance(event);
|
|
1027
|
+
if ((event.parentToolUseId ?? null) === null) {
|
|
1028
|
+
// A new main-thread assistant message after this step's attempt was
|
|
1029
|
+
// already flushed (tool round completed) opens the NEXT attempt's
|
|
1030
|
+
// step — the turn-usage fold needs one assistant/message per step.
|
|
1031
|
+
if (this.stepFlushed)
|
|
1032
|
+
this.closeAttemptStep();
|
|
1033
|
+
const bridge = this.ensureStreamBridge();
|
|
1034
|
+
if (text !== "") {
|
|
1035
|
+
bridge.push(event.type === "assistant_text"
|
|
1036
|
+
? { type: "text-delta", index: 0, text }
|
|
1037
|
+
: { type: "reasoning-delta", index: 0, text });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (event.type === "assistant_reasoning")
|
|
1041
|
+
this.reasoningBuffer += text;
|
|
1042
|
+
else
|
|
1043
|
+
this.textBuffer += text;
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
case "assistant_message":
|
|
1047
|
+
// A raw assistant body with no recognized text/reasoning/tool block.
|
|
1048
|
+
trace("assistant_message (unrecognized body) ignored");
|
|
1049
|
+
break;
|
|
1050
|
+
case "tool_start": {
|
|
1051
|
+
const callId = String(event.callId ?? "");
|
|
1052
|
+
const name = String(event.name ?? "tool");
|
|
1053
|
+
const parent = event.parentToolUseId ?? null;
|
|
1054
|
+
if (parent !== null)
|
|
1055
|
+
this.subagentCount += 1;
|
|
1056
|
+
// RC-6: the mid-turn flush closes the attempt WITH the sample the
|
|
1057
|
+
// message itself reported (captured above / on the assistant events).
|
|
1058
|
+
this.captureProvenance(event);
|
|
1059
|
+
this.flushAssistant();
|
|
1060
|
+
this.appendToolCall(callId, name, event.arguments);
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
case "tool_end": {
|
|
1064
|
+
const callId = String(event.callId ?? "");
|
|
1065
|
+
const parent = event.parentToolUseId ?? null;
|
|
1066
|
+
if (parent !== null)
|
|
1067
|
+
this.subagentCount = Math.max(0, this.subagentCount - 1);
|
|
1068
|
+
const isError = event.isError === true;
|
|
1069
|
+
const content = typeof event.content === "string" ? event.content : "";
|
|
1070
|
+
this.appendToolResult(callId, content, isError);
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
case "turn_end": {
|
|
1074
|
+
trace(`turn_end isError=${String(event.isError)} stopReason=${String(event.stopReason)}`);
|
|
1075
|
+
// Last chance for the system prompt (appended inside the still-open
|
|
1076
|
+
// step, before step/end, so the step-scope invariant holds).
|
|
1077
|
+
this.emitSystemMessage();
|
|
1078
|
+
const usage = convertUsage(event.usage);
|
|
1079
|
+
this.flushAssistant(usage);
|
|
1080
|
+
this.commitStepEnd();
|
|
1081
|
+
const cancelCause = this.cancelCause;
|
|
1082
|
+
this.cancelCause = null;
|
|
1083
|
+
this.closeTurn(cancelCause !== null
|
|
1084
|
+
? { kind: "aborted", reason: cancelCause }
|
|
1085
|
+
: event.isError === true
|
|
1086
|
+
? { kind: "error", error: { message: "Claude turn failed", code: classifyClaudeError(event.error) } }
|
|
1087
|
+
: { kind: "completed" });
|
|
1088
|
+
this.markIdle();
|
|
1089
|
+
this.endActivity();
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
case "todo_write": {
|
|
1093
|
+
// Log-only whole-list snapshot (latest-wins); the tool pair already
|
|
1094
|
+
// rode the same assistant message.
|
|
1095
|
+
const todos = Array.isArray(event.todos) ? event.todos : [];
|
|
1096
|
+
try {
|
|
1097
|
+
this.session.append("todo/write", { todos });
|
|
1098
|
+
trace(`todo/write ${todos.length} item(s)`);
|
|
1099
|
+
}
|
|
1100
|
+
catch (error) {
|
|
1101
|
+
trace(`todo/write failed: ${String(error)}`);
|
|
1102
|
+
}
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
case "compaction": {
|
|
1106
|
+
// The CLI's compact_boundary reports an ALREADY-COMPLETE compaction:
|
|
1107
|
+
// emit the DSH bracket (start+end, standalone owner when no turn is
|
|
1108
|
+
// open) so the vocabulary folds instead of staying trace-only.
|
|
1109
|
+
const compactionId = randomUUID();
|
|
1110
|
+
const ownerTurn = this.turnOpen ? this.dashTurn : null;
|
|
1111
|
+
try {
|
|
1112
|
+
this.session.append("compaction/start", { compactionId, turn: ownerTurn });
|
|
1113
|
+
this.session.append("compaction/end", { compactionId, turn: ownerTurn });
|
|
1114
|
+
trace(`compaction bracket ${compactionId} (turn=${String(ownerTurn)}) metadata=${JSON.stringify(event.metadata)}`);
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
trace(`compaction bracket failed: ${String(error)}`);
|
|
1118
|
+
}
|
|
1119
|
+
break;
|
|
1120
|
+
}
|
|
1121
|
+
case "compaction":
|
|
1122
|
+
// V1 gap: the dsh-compaction event vocabulary is not referenced here.
|
|
1123
|
+
trace(`compaction boundary (log-only): ${JSON.stringify(event.metadata)}`);
|
|
1124
|
+
break;
|
|
1125
|
+
case "permission_denied":
|
|
1126
|
+
trace(`permission_denied (log-only): ${String(event.toolName)} ${String(event.reasonType)}`);
|
|
1127
|
+
break;
|
|
1128
|
+
case "refusal_fallback": {
|
|
1129
|
+
const uuids = Array.isArray(event.retractedMessageUuids) ? event.retractedMessageUuids : [];
|
|
1130
|
+
trace(`refusal_fallback: retracted=${JSON.stringify(uuids)} original=${String(event.originalModel)} fallback=${String(event.fallbackModel)}`);
|
|
1131
|
+
const blocks = refusalBlocks(event.content);
|
|
1132
|
+
if (blocks.length > 0) {
|
|
1133
|
+
this.commitStepStart();
|
|
1134
|
+
const assistant = createAssistantMessage({
|
|
1135
|
+
content: blocks,
|
|
1136
|
+
source: { provider: CLAUDE_PROVIDER_ID, model: String(event.fallbackModel ?? this.options.model ?? "") },
|
|
1137
|
+
});
|
|
1138
|
+
this.session.append("assistant/message", {
|
|
1139
|
+
turn: this.dashTurn,
|
|
1140
|
+
step: this.step,
|
|
1141
|
+
message: assistant,
|
|
1142
|
+
stream: [],
|
|
1143
|
+
}, { surfaceOp: "append" });
|
|
1144
|
+
}
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
1147
|
+
case "refusal_no_fallback":
|
|
1148
|
+
trace(`refusal_no_fallback (log-only): ${String(event.originalModel)} ${String(event.category)}`);
|
|
1149
|
+
break;
|
|
1150
|
+
case "local_command_output":
|
|
1151
|
+
trace(`local_command_output (log-only): ${String(event.content).slice(0, 120)}`);
|
|
1152
|
+
break;
|
|
1153
|
+
case "agent_end": {
|
|
1154
|
+
const isError = event.isError === true;
|
|
1155
|
+
const error = typeof event.error === "string" ? event.error : undefined;
|
|
1156
|
+
if (this.turnOpen) {
|
|
1157
|
+
this.flushAssistant();
|
|
1158
|
+
this.commitStepEnd();
|
|
1159
|
+
const cancelCause = this.cancelCause;
|
|
1160
|
+
this.cancelCause = null;
|
|
1161
|
+
this.closeTurn(cancelCause !== null
|
|
1162
|
+
? { kind: "aborted", reason: cancelCause }
|
|
1163
|
+
: isError
|
|
1164
|
+
? { kind: "error", error: { message: error ?? "Claude session ended with an error", code: classifyClaudeError(error) } }
|
|
1165
|
+
: { kind: "completed" });
|
|
1166
|
+
}
|
|
1167
|
+
this.markIdle();
|
|
1168
|
+
this.endActivity();
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
default:
|
|
1172
|
+
// Unknown wire shapes are ignored, never an error (Task 9 hazard 2).
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
fail(error) {
|
|
1177
|
+
if (this.disposed)
|
|
1178
|
+
return;
|
|
1179
|
+
trace(`fail ${String(error).slice(0, 200)}`);
|
|
1180
|
+
// Failure paths never settle a stream attempt.
|
|
1181
|
+
this.abandonStreamBridge();
|
|
1182
|
+
if (this.turnOpen) {
|
|
1183
|
+
this.flushAssistant();
|
|
1184
|
+
this.commitStepEnd();
|
|
1185
|
+
this.closeTurn({ kind: "error", error: { message: String(error), code: classifyClaudeError(error) } });
|
|
1186
|
+
}
|
|
1187
|
+
this.markNotResumableIfDead(error);
|
|
1188
|
+
this.markIdle();
|
|
1189
|
+
this.endActivity();
|
|
1190
|
+
}
|
|
1191
|
+
/** A conversation-not-found failure is terminal for the pairing (2026-09-18):
|
|
1192
|
+
* mark the map record dead once so later prompts fail fast at resume. */
|
|
1193
|
+
markNotResumableIfDead(error) {
|
|
1194
|
+
if (this.notResumableMarked || classifyClaudeError(error) !== "CONVERSATION_NOT_FOUND")
|
|
1195
|
+
return;
|
|
1196
|
+
this.notResumableMarked = true;
|
|
1197
|
+
try {
|
|
1198
|
+
this.runtimeInfo?.onNotResumable?.();
|
|
1199
|
+
}
|
|
1200
|
+
catch (error) {
|
|
1201
|
+
trace(`mark not resumable failed: ${String(error)}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
beginActivity() {
|
|
1205
|
+
this.resolveActivityDone();
|
|
1206
|
+
this.activityDone = new Promise((resolve) => {
|
|
1207
|
+
this.resolveActivityDone = resolve;
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
endActivity() {
|
|
1211
|
+
this.resolveActivityDone();
|
|
1212
|
+
}
|
|
1213
|
+
markRunning() {
|
|
1214
|
+
clearTimeout(this.idleExitTimer);
|
|
1215
|
+
this.idleExitTimer = undefined;
|
|
1216
|
+
if (this.streaming)
|
|
1217
|
+
return;
|
|
1218
|
+
this.streaming = true;
|
|
1219
|
+
this.dispatch.emit("agent/status", { status: "running" });
|
|
1220
|
+
}
|
|
1221
|
+
markIdle() {
|
|
1222
|
+
if (!this.streaming)
|
|
1223
|
+
return;
|
|
1224
|
+
this.streaming = false;
|
|
1225
|
+
this.dispatch.emit("agent/status", { status: "idle" });
|
|
1226
|
+
this.armIdleExit();
|
|
1227
|
+
}
|
|
1228
|
+
armIdleExit() {
|
|
1229
|
+
if (this.disposed || this.onIdleExit === undefined || CLAUDE_IDLE_EXIT_MS === 0)
|
|
1230
|
+
return;
|
|
1231
|
+
if (this.remoteQueue.some((entry) => !entry.sent))
|
|
1232
|
+
return;
|
|
1233
|
+
clearTimeout(this.idleExitTimer);
|
|
1234
|
+
this.idleExitTimer = setTimeout(() => {
|
|
1235
|
+
this.idleExitTimer = undefined;
|
|
1236
|
+
void this.revalidateIdleExit();
|
|
1237
|
+
}, CLAUDE_IDLE_EXIT_MS);
|
|
1238
|
+
this.idleExitTimer.unref?.();
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Quiescence re-validation at fire time. The idle timer is only a hint:
|
|
1242
|
+
* before teardown, re-confirm no live work via local signals. Any busy
|
|
1243
|
+
* signal re-arms; a never-spawned draft is abandoned outright (no re-arm).
|
|
1244
|
+
*/
|
|
1245
|
+
async revalidateIdleExit() {
|
|
1246
|
+
if (this.disposed || this.streaming)
|
|
1247
|
+
return this.armIdleExit();
|
|
1248
|
+
if (!this.client.spawned) {
|
|
1249
|
+
trace(`idle exit: never-spawned draft ${this.id} — abandoning without re-arm`);
|
|
1250
|
+
this.onIdleExit?.();
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (this.remoteQueue.some((entry) => !entry.sent))
|
|
1254
|
+
return this.armIdleExit();
|
|
1255
|
+
if (this.pendingApproval > 0)
|
|
1256
|
+
return this.armIdleExit();
|
|
1257
|
+
// The client self-reports it is still running (a turn in flight) — re-arm.
|
|
1258
|
+
try {
|
|
1259
|
+
const state = await this.client.getState();
|
|
1260
|
+
if (state.isStreaming)
|
|
1261
|
+
return this.armIdleExit();
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
// Self-attestation unavailable: local signals remain authoritative.
|
|
1265
|
+
}
|
|
1266
|
+
if (this.subagentCount > 0)
|
|
1267
|
+
return this.armIdleExit();
|
|
1268
|
+
trace(`idle exit after ${CLAUDE_IDLE_EXIT_MS}ms — disposing agent ${this.id}`);
|
|
1269
|
+
this.onIdleExit?.();
|
|
1270
|
+
}
|
|
1271
|
+
}
|