@zvada/agent-server 0.2.1 → 0.3.0

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +317 -0
  2. package/README.md +34 -4
  3. package/docs/consuming.md +269 -0
  4. package/docs/deploy.md +80 -0
  5. package/docs/harnesses.md +64 -0
  6. package/docs/rfds/0001-deterministic-echo-ids.md +44 -0
  7. package/package.json +23 -3
  8. package/src/client/client.ts +153 -49
  9. package/src/core/agents/acp/acp-agent.ts +9 -0
  10. package/src/core/agents/acp/mappings.ts +3 -3
  11. package/src/core/agents/base.ts +35 -5
  12. package/src/core/agents/claude-code/adapter.ts +116 -26
  13. package/src/core/agents/claude-code/claude-agent.ts +44 -7
  14. package/src/core/agents/claude-code/generator-session.ts +53 -14
  15. package/src/core/agents/claude-code/options.ts +13 -3
  16. package/src/core/agents/claude-code/session-manager.ts +9 -4
  17. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +25 -6
  18. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +3 -3
  19. package/src/core/agents/types.ts +1 -1
  20. package/src/core/diagnostics.ts +59 -0
  21. package/src/core/index.ts +6 -2
  22. package/src/core/presets.ts +15 -2
  23. package/src/core/provision/pins.ts +5 -1
  24. package/src/core/proxy/anthropic-proxy.ts +76 -3
  25. package/src/core/runtime/agent-runtime.ts +215 -28
  26. package/src/core/runtime/event-processor.ts +51 -26
  27. package/src/core/utils/errors.ts +37 -3
  28. package/src/protocol/config.ts +8 -6
  29. package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
  30. package/src/protocol/factories.ts +106 -10
  31. package/src/protocol/guards.ts +53 -0
  32. package/src/protocol/index.ts +10 -0
  33. package/src/protocol/lifecycle.ts +289 -112
  34. package/src/protocol/meta.ts +14 -0
  35. package/src/protocol/part-input.ts +56 -7
  36. package/src/protocol/parts.ts +125 -10
  37. package/src/protocol/reduce.ts +749 -0
  38. package/src/protocol/selectors.ts +162 -0
  39. package/src/protocol/seq-cursor.ts +87 -0
  40. package/src/protocol/stop-reasons.ts +45 -0
  41. package/src/protocol/time.ts +23 -0
  42. package/src/protocol/tokens.ts +23 -0
  43. package/src/protocol/tool-state.ts +85 -25
  44. package/src/protocol/verify.ts +440 -0
  45. package/src/protocol/vocabulary.ts +18 -0
  46. package/src/protocol/wire.ts +103 -7
  47. package/src/server/acp/binding.ts +23 -2
  48. package/src/server/acp/translate.ts +51 -14
  49. package/src/server/agent-server.ts +109 -6
@@ -14,7 +14,15 @@ import type {
14
14
  * `allow`/`deny`, codex `accept`/`decline`/`cancel`).
15
15
  */
16
16
  export type PermissionDecision =
17
- | { decision: "allow" }
17
+ | {
18
+ decision: "allow";
19
+ /**
20
+ * Allow-with-modified-input (`permission.resolved.outcome.updatedInput`):
21
+ * the harness MUST execute this input, not the original — what was
22
+ * approved is what runs. Absent = the original input was approved as-is.
23
+ */
24
+ updatedInput?: Record<string, unknown>;
25
+ }
18
26
  | { decision: "deny"; reason?: string }
19
27
  /** The turn is being cancelled — abort rather than merely skip the tool. */
20
28
  | { decision: "cancel" };
@@ -69,6 +77,21 @@ export interface AgentExecuteOptions {
69
77
  /** Raw, harness-specific event. Adapters interpret it; the engine never does. */
70
78
  export type RawAgentEvent = unknown;
71
79
 
80
+ /**
81
+ * What `cancel` reports back. `confirmed: true` = the cancelled state holds —
82
+ * either nothing was running (`hadTurn: false`, a clean no-op ack) or the
83
+ * harness acknowledged the interrupt and the turn will end promptly.
84
+ * `confirmed: false` = a turn existed and the cancel was dispatched
85
+ * best-effort but NOT acknowledged (the backend's interrupt timed out) — the
86
+ * underlying agent may still be running, and the turn's `turn.ended` event
87
+ * remains the source of truth.
88
+ */
89
+ export interface CancelResult {
90
+ confirmed: boolean;
91
+ /** Whether an in-flight turn existed when the cancel arrived. */
92
+ hadTurn: boolean;
93
+ }
94
+
72
95
  /**
73
96
  * A harness: a thin wrapper over one agent backend (Claude Code, Codex SDK,
74
97
  * Codex app-server). `execute` yields raw backend events; normalization is the
@@ -78,8 +101,8 @@ export interface Agent {
78
101
  readonly harness: AgentHarness;
79
102
  readonly capabilities: AgentCapabilities;
80
103
  execute(input: AgentInput, options: AgentExecuteOptions): AsyncIterableIterator<RawAgentEvent>;
81
- /** Cancel the in-flight turn for a logical session. */
82
- cancel(sessionId: string): Promise<void>;
104
+ /** Cancel the in-flight turn for a logical session (see `CancelResult`). */
105
+ cancel(sessionId: string): Promise<CancelResult>;
83
106
  /** Release all harness-owned state for one idle logical session. */
84
107
  release?(sessionId: string): Promise<void>;
85
108
  /** Tear down every live session (process shutdown). */
@@ -103,8 +126,15 @@ export abstract class BaseAgent implements Agent {
103
126
  options: AgentExecuteOptions,
104
127
  ): AsyncIterableIterator<RawAgentEvent>;
105
128
 
106
- async cancel(sessionId: string): Promise<void> {
107
- for (const controller of this.inflight.get(sessionId) ?? []) controller.abort();
129
+ async cancel(sessionId: string): Promise<CancelResult> {
130
+ const controllers = this.inflight.get(sessionId);
131
+ const hadTurn = (controllers?.size ?? 0) > 0;
132
+ for (const controller of controllers ?? []) controller.abort();
133
+ // Idle sessions are a clean no-op ack, and the abort signal
134
+ // deterministically ends the engine's execute loop for tracked turns —
135
+ // both confirmed. Harnesses whose backend needs its own interrupt
136
+ // round-trip (claude) refine `confirmed` in their override.
137
+ return { confirmed: true, hadTurn };
108
138
  }
109
139
 
110
140
  async release(sessionId: string): Promise<void> {
@@ -3,9 +3,11 @@ import {
3
3
  type ReasoningPart,
4
4
  type StopReason,
5
5
  type StreamContext,
6
+ type SubagentMetadata,
6
7
  type TextPart,
7
8
  type TokenUsage,
8
9
  type ToolPart,
10
+ type ToolResultContent,
9
11
  appendToolInput,
10
12
  completeToolPart,
11
13
  createPendingToolPart,
@@ -41,6 +43,8 @@ interface StreamEvent {
41
43
  type?: string;
42
44
  text?: string;
43
45
  thinking?: string;
46
+ /** Withheld-thinking placeholder: running size of a thought whose text never streams. */
47
+ estimated_tokens?: number;
44
48
  partial_json?: string;
45
49
  stop_reason?: string;
46
50
  };
@@ -150,6 +154,61 @@ function stringifyToolResult(content: unknown): string {
150
154
  return JSON.stringify(content);
151
155
  }
152
156
 
157
+ /**
158
+ * The display-grade view of a tool_result's content blocks (spec §3.4): the
159
+ * flattened string stays the model-facing record, this preserves what
160
+ * flattening drops — images especially, which contribute "" to the string.
161
+ * Block shapes we can't express are skipped, never guessed.
162
+ */
163
+ function toolResultContent(content: unknown): ToolResultContent[] | undefined {
164
+ if (!Array.isArray(content)) return undefined;
165
+ const out: ToolResultContent[] = [];
166
+ for (const raw of content) {
167
+ if (!raw || typeof raw !== "object") continue;
168
+ const block = raw as {
169
+ type?: unknown;
170
+ text?: unknown;
171
+ source?: { data?: unknown; media_type?: unknown } | null;
172
+ };
173
+ if (block.type === "text" && typeof block.text === "string") {
174
+ out.push({ type: "text", text: block.text });
175
+ continue;
176
+ }
177
+ if (block.type === "image") {
178
+ const { data, media_type } = block.source ?? {};
179
+ // Only the base64 source form is expressible — ToolResultContent has no url variant.
180
+ if (typeof data === "string" && typeof media_type === "string") {
181
+ out.push({ type: "image", data, mimeType: media_type });
182
+ }
183
+ }
184
+ }
185
+ return out.length ? out : undefined;
186
+ }
187
+
188
+ /**
189
+ * SubagentMetadata from a spawning tool's input, shared by the streamed and
190
+ * non-streaming paths. `subagent_type` in the input identifies a spawn on any
191
+ * tool name; `Task` is Claude's own spawn tool and counts even before its
192
+ * input parses into anything useful (presence of `subagent` is what promotes
193
+ * the part to `kind: "task"` — see protocol §3.5).
194
+ */
195
+ function subagentFromInput(
196
+ toolName: string,
197
+ input: Record<string, unknown>,
198
+ ): SubagentMetadata | undefined {
199
+ const str = (key: string): string | undefined =>
200
+ typeof input[key] === "string" ? (input[key] as string) : undefined;
201
+ const type = str("subagent_type");
202
+ if (type === undefined && toolName !== "Task") return undefined;
203
+ const description = str("description");
204
+ const model = str("model");
205
+ return {
206
+ ...(type !== undefined && { type }),
207
+ ...(description !== undefined && { description }),
208
+ ...(model !== undefined && { model }),
209
+ };
210
+ }
211
+
153
212
  export class ClaudeCodeTransformer implements EventTransformer<unknown> {
154
213
  private readonly ctx: StreamContext;
155
214
  /** Anthropic content blocks of the in-flight message, by stream index. */
@@ -237,7 +296,10 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
237
296
  }
238
297
 
239
298
  private ctxFor(): StreamContext {
240
- return { ...this.ctx, ...(this.currentParent ? { parentToolUseId: this.currentParent } : {}) };
299
+ // `parentToolCallId` is the protocol spelling; Claude's own wire field is
300
+ // `parent_tool_use_id`. Spelling this the provider's way silently dropped
301
+ // every subagent linkage (a conditional spread dodges excess-property checks).
302
+ return { ...this.ctx, ...(this.currentParent ? { parentToolCallId: this.currentParent } : {}) };
241
303
  }
242
304
 
243
305
  /**
@@ -264,24 +326,25 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
264
326
  for (const block of content) {
265
327
  if (block.type === "text" && block.text) {
266
328
  events.push({ kind: "part-open", part: createTextPart(this.ctxFor(), block.text, false) });
267
- } else if (
268
- (block.type === "thinking" || block.type === "redacted_thinking") &&
269
- block.thinking
270
- ) {
271
- events.push({
272
- kind: "part-open",
273
- part: createReasoningPart(this.ctxFor(), block.thinking, false),
274
- });
329
+ } else if (block.type === "thinking" || block.type === "redacted_thinking") {
330
+ const redacted = block.type === "redacted_thinking";
331
+ // A redacted block carries no plaintext (its `data` is an encrypted
332
+ // blob), so emit it on the flag alone — the redaction is the signal.
333
+ if (!block.thinking && !redacted) continue;
334
+ // A complete block opens and closes at once: one stamp for both ends.
335
+ const now = Date.now();
336
+ const part = createReasoningPart(this.ctxFor(), block.thinking ?? "", false);
337
+ part.time = { start: now, end: now };
338
+ if (redacted) part.providerMetadata = { redacted: true };
339
+ events.push({ kind: "part-open", part });
275
340
  } else if (block.type === "tool_use" && block.id) {
276
341
  const name = block.name ?? "tool";
277
342
  const input = block.input ?? {};
278
- const part = createToolPart(
279
- this.ctxFor(),
280
- block.id,
281
- name,
282
- input,
283
- claudeToolMeta(name, input),
284
- );
343
+ const subagent = subagentFromInput(name, input);
344
+ const part = createToolPart(this.ctxFor(), block.id, name, input, {
345
+ ...claudeToolMeta(name, input),
346
+ ...(subagent && { subagent }),
347
+ });
285
348
  this.toolPartsById.set(block.id, part);
286
349
  events.push({ kind: "part-open", part });
287
350
  }
@@ -290,17 +353,17 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
290
353
  return [...gauge, ...events];
291
354
  }
292
355
 
293
- private handleStream(event: StreamEvent, parentToolUseId?: string): AdapterEvent[] {
356
+ private handleStream(event: StreamEvent, parentToolCallId?: string): AdapterEvent[] {
294
357
  // Sticky for the whole turn: any stream event means the trailing complete
295
358
  // `assistant` snapshot is a duplicate of what we streamed.
296
359
  this.hasStreamed = true;
297
360
  switch (event.type) {
298
361
  case "message_start":
299
362
  this.blocks.clear();
300
- this.currentParent = parentToolUseId;
363
+ this.currentParent = parentToolCallId;
301
364
  // Each top-level model message becomes a wire message. Sub-agent
302
- // messages don't open one — their parts nest via parentToolUseId.
303
- return parentToolUseId ? [] : [{ kind: "message-start", role: "assistant" }];
365
+ // messages don't open one — their parts nest via parentToolCallId.
366
+ return parentToolCallId ? [] : [{ kind: "message-start", role: "assistant" }];
304
367
  case "content_block_start":
305
368
  return this.openBlock(event);
306
369
  case "content_block_delta":
@@ -313,7 +376,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
313
376
  if (event.usage?.output_tokens) this.usage.output = event.usage.output_tokens;
314
377
  return [];
315
378
  case "message_stop":
316
- return parentToolUseId || this.currentParent ? [] : [{ kind: "message-end" }];
379
+ return parentToolCallId || this.currentParent ? [] : [{ kind: "message-end" }];
317
380
  default:
318
381
  return [];
319
382
  }
@@ -330,6 +393,11 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
330
393
  }
331
394
  if (cb.type === "thinking" || cb.type === "redacted_thinking") {
332
395
  const part = createReasoningPart(this.ctxFor(), "", true);
396
+ // Thought duration starts here; content_block_stop closes it.
397
+ part.time = { start: Date.now() };
398
+ // The plaintext never arrives for a redacted block — keep the flag so a
399
+ // consumer can render "[redacted]" instead of an empty thought.
400
+ if (cb.type === "redacted_thinking") part.providerMetadata = { redacted: true };
333
401
  this.blocks.set(index, { partId: part.id, kind: "reasoning", part });
334
402
  return [{ kind: "part-open", part }];
335
403
  }
@@ -351,8 +419,18 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
351
419
  (entry.part as TextPart).text += d.text;
352
420
  return [{ kind: "text-delta", partId: entry.partId, text: d.text }];
353
421
  }
354
- if (d.type === "thinking_delta" && entry.kind === "reasoning" && d.thinking) {
355
- (entry.part as ReasoningPart).text += d.thinking;
422
+ if (d.type === "thinking_delta" && entry.kind === "reasoning") {
423
+ const reasoning = entry.part as ReasoningPart;
424
+ // Withheld thinking streams placeholder deltas: a token estimate, no
425
+ // text. Keep the estimate so a text-less thought still has a size.
426
+ if (typeof d.estimated_tokens === "number") {
427
+ reasoning.providerMetadata = {
428
+ ...reasoning.providerMetadata,
429
+ estimatedThinkingTokens: d.estimated_tokens,
430
+ };
431
+ }
432
+ if (!d.thinking) return [];
433
+ reasoning.text += d.thinking;
356
434
  return [{ kind: "reasoning-delta", partId: entry.partId, text: d.thinking }];
357
435
  }
358
436
  if (d.type === "input_json_delta" && entry.kind === "tool" && d.partial_json) {
@@ -375,15 +453,25 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
375
453
  const entry = this.blocks.get(event.index ?? 0);
376
454
  if (!entry) return [];
377
455
  if (entry.kind === "text" || entry.kind === "reasoning") {
378
- (entry.part as TextPart | ReasoningPart).state = "done";
379
- return [{ kind: "part-update", part: entry.part }];
456
+ const part = entry.part as TextPart | ReasoningPart;
457
+ part.state = "done";
458
+ if (entry.kind === "reasoning") {
459
+ const reasoning = part as ReasoningPart;
460
+ reasoning.time = { start: reasoning.time?.start ?? Date.now(), end: Date.now() };
461
+ }
462
+ return [{ kind: "part-update", part }];
380
463
  }
381
464
  const tool = entry.part as ToolPart;
382
465
  const partial = tool.state.status === "pending" ? tool.state.partialInput : "";
383
466
  const input = safeParseJson(partial);
384
467
  startToolPart(tool, input);
468
+ // The streamed open carried no input, so subagent metadata can only be
469
+ // read now that the partial JSON has parsed.
385
470
  const locations = toolLocationsFromInput(input);
386
- if (locations) setToolMeta(tool, { locations });
471
+ const subagent = subagentFromInput(tool.toolName, input);
472
+ if (locations || subagent) {
473
+ setToolMeta(tool, { ...(locations && { locations }), ...(subagent && { subagent }) });
474
+ }
387
475
  return [{ kind: "part-update", part: tool }];
388
476
  }
389
477
 
@@ -394,9 +482,11 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
394
482
  if (block.type !== "tool_result" || !block.tool_use_id) continue;
395
483
  const tool = this.toolPartsById.get(block.tool_use_id);
396
484
  if (!tool) continue;
485
+ const content = toolResultContent(block.content);
397
486
  completeToolPart(tool, {
398
487
  output: stringifyToolResult(block.content),
399
488
  isError: block.is_error === true,
489
+ ...(content && { content }),
400
490
  });
401
491
  events.push({ kind: "part-update", part: tool });
402
492
  }
@@ -1,10 +1,12 @@
1
1
  import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { McpSetServersResult } from "@anthropic-ai/claude-agent-sdk";
3
3
  import type { AgentCapabilities, AgentInput, McpServerConfig } from "../../../protocol/index.ts";
4
- import type { AgentExecuteOptions, RawAgentEvent } from "../base.ts";
4
+ import { type DiagnosticHandler, emitDiagnostic } from "../../diagnostics.ts";
5
+ import type { AgentExecuteOptions, CancelResult, RawAgentEvent } from "../base.ts";
5
6
  import { BaseAgent } from "../base.ts";
6
7
  import type {
7
8
  ClaudeHooksFactory,
9
+ ClaudeSessionEndReason,
8
10
  ClaudeSessionExtras,
9
11
  ClaudeToolPolicy,
10
12
  } from "./generator-session.ts";
@@ -30,13 +32,13 @@ function toClaudeContent(input: AgentInput): ClaudeContent {
30
32
  if (part.type === "text") return { type: "text", text: part.text };
31
33
  const source = part.url
32
34
  ? { type: "url", url: part.url }
33
- : { type: "base64", media_type: part.mediaType, data: part.data ?? "" };
35
+ : { type: "base64", media_type: part.mimeType, data: part.data ?? "" };
34
36
  // `file` inputs (PDFs etc.) are document blocks, not images.
35
37
  return { type: part.type === "file" ? "document" : "image", source };
36
38
  });
37
39
  // Kept cast: the SDK's block sources require literal media_type unions
38
40
  // ("image/jpeg" | ... and "application/pdf") while our PartInput carries an
39
- // open string mediaType, so these blocks cannot satisfy the union as typed.
41
+ // open string mimeType, so these blocks cannot satisfy the union as typed.
40
42
  return blocks as unknown as ClaudeContent;
41
43
  }
42
44
 
@@ -96,6 +98,18 @@ export interface ClaudeCodeAgentOptions {
96
98
  toolPolicy?: ClaudeToolPolicy;
97
99
  /** SDK lifecycle hooks factory (decision-capable). Operator embed-tier only. */
98
100
  hooks?: ClaudeHooksFactory;
101
+ /**
102
+ * Called exactly once when a live session's subprocess ends, with why:
103
+ * `idle` (idle-timeout eviction), `replaced` (config change forced a
104
+ * restart), `released` (explicit release/close), `shutdown`
105
+ * (terminateAll). The seam for host-managed per-session resources — BYOK
106
+ * proxy keys, recorders — instead of re-deriving termination from side
107
+ * effects. NOTE: `replaced` sessions usually respawn immediately with
108
+ * context preserved; drop only resources bound to the dead subprocess.
109
+ */
110
+ onSessionEnd?: (sessionId: string, reason: ClaudeSessionEndReason) => void;
111
+ /** Operational diagnostics (interrupt timeouts, resume fallbacks). */
112
+ onDiagnostic?: DiagnosticHandler;
99
113
  /**
100
114
  * Raw SDK option overrides merged over the engine's options at session
101
115
  * spawn (embedder escape hatch for SDK surface the engine does not model:
@@ -113,10 +127,11 @@ export interface ClaudeCodeAgentOptions {
113
127
  export class ClaudeCodeAgent extends BaseAgent {
114
128
  readonly harness = "claude-code" as const;
115
129
  readonly capabilities = CAPABILITIES;
116
- private readonly manager = new ClaudeSessionManager();
130
+ private readonly manager: ClaudeSessionManager;
117
131
 
118
132
  constructor(private readonly agentOptions: ClaudeCodeAgentOptions = {}) {
119
133
  super();
134
+ this.manager = new ClaudeSessionManager(agentOptions.onSessionEnd);
120
135
  }
121
136
 
122
137
  private sessionExtras(): ClaudeSessionExtras {
@@ -193,6 +208,12 @@ export class ClaudeCodeAgent extends BaseAgent {
193
208
  }
194
209
 
195
210
  if (!resumeFailed) break;
211
+ emitDiagnostic(this.agentOptions.onDiagnostic, {
212
+ type: "resumeFallback",
213
+ sessionId: options.sessionId,
214
+ message: `resume of ${options.resumeSessionId} failed; re-running on a fresh session`,
215
+ detail: { resumeSessionId: options.resumeSessionId },
216
+ });
196
217
  await this.manager.terminate(options.sessionId);
197
218
  }
198
219
 
@@ -218,9 +239,25 @@ export class ClaudeCodeAgent extends BaseAgent {
218
239
  return this.manager.setMcpServers(sessionId, servers);
219
240
  }
220
241
 
221
- override async cancel(sessionId: string): Promise<void> {
222
- await this.manager.get(sessionId)?.interruptTurn();
223
- await super.cancel(sessionId);
242
+ override async cancel(sessionId: string): Promise<CancelResult> {
243
+ // Abort FIRST: the controller ends the engine's execute loop, so this
244
+ // turn classifies as cancelled even when the generator drains to a
245
+ // natural end before the SDK interrupt lands (the abort-order race that
246
+ // could surface a clean user cancel as `error`/`end_turn`). The SDK
247
+ // interrupt then stops the underlying turn; its round-trip is what
248
+ // confirms the cancel.
249
+ const session = this.manager.get(sessionId);
250
+ const base = await super.cancel(sessionId);
251
+ if (!session || !base.hadTurn) return base;
252
+ const interrupted = await session.interruptTurn();
253
+ if (!interrupted) {
254
+ emitDiagnostic(this.agentOptions.onDiagnostic, {
255
+ type: "interruptTimeout",
256
+ sessionId,
257
+ message: "cancel dispatched but the SDK interrupt was not acknowledged in time",
258
+ });
259
+ }
260
+ return { confirmed: interrupted, hadTurn: true };
224
261
  }
225
262
 
226
263
  override async release(sessionId: string): Promise<void> {
@@ -4,6 +4,7 @@ import type {
4
4
  CanUseTool,
5
5
  McpSetServersResult,
6
6
  PermissionResult,
7
+ PermissionUpdate,
7
8
  } from "@anthropic-ai/claude-agent-sdk";
8
9
  import { AsyncQueue } from "../../../protocol/index.ts";
9
10
  import type { McpServerConfig } from "../../../protocol/index.ts";
@@ -26,7 +27,19 @@ import {
26
27
  export type ClaudeToolPolicy = (
27
28
  toolName: string,
28
29
  input: Record<string, unknown>,
29
- ctx: { sessionId: string; toolUseId: string; agentId?: string },
30
+ ctx: {
31
+ sessionId: string;
32
+ toolUseId: string;
33
+ agentId?: string;
34
+ /** Aborts when the turn is cancelled — a policy that waits on a remote approval must race this. */
35
+ signal: AbortSignal;
36
+ /** SDK permission-update suggestions; return them as `updatedPermissions` to honor an "always allow". */
37
+ suggestions?: PermissionUpdate[];
38
+ /** File path that triggered the request (e.g. access outside allowed directories). */
39
+ blockedPath?: string;
40
+ /** The SDK's explanation for why this permission request fired, when it gives one. */
41
+ decisionReason?: string;
42
+ },
30
43
  ) => PermissionResult | undefined | Promise<PermissionResult | undefined>;
31
44
 
32
45
  /** SDK lifecycle hooks factory (decision-capable); runs once at session spawn. */
@@ -36,6 +49,9 @@ export type ClaudeHooksFactory = (ctx: {
36
49
  }) => CCOptions["hooks"];
37
50
 
38
51
  /** Embed-tier extras threaded from ClaudeCodeAgentOptions into each session. */
52
+ /** Why a live claude session's subprocess ended (see `onSessionEnd`). */
53
+ export type ClaudeSessionEndReason = "idle" | "replaced" | "released" | "shutdown";
54
+
39
55
  export interface ClaudeSessionExtras {
40
56
  /** Factory for in-process MCP servers; invoked ONCE at session spawn. */
41
57
  sdkMcpServers?: (ctx: {
@@ -102,7 +118,7 @@ export class ClaudeGeneratorSession {
102
118
  constructor(
103
119
  readonly id: string,
104
120
  config: ClaudeSessionConfig,
105
- private readonly onTerminated?: () => void,
121
+ private readonly onTerminated?: (reason: ClaudeSessionEndReason) => void,
106
122
  private readonly extras?: ClaudeSessionExtras,
107
123
  ) {
108
124
  this.config = { ...config };
@@ -212,22 +228,32 @@ export class ClaudeGeneratorSession {
212
228
  };
213
229
  }
214
230
 
215
- /** Stop the in-flight turn without killing the session. */
216
- async interruptTurn(): Promise<void> {
217
- if ((this.state !== "busy" && this.state !== "starting") || !this.query) return;
231
+ /**
232
+ * Stop the in-flight turn without killing the session. Returns whether the
233
+ * interrupt was CONFIRMED: true when there was nothing to interrupt or the
234
+ * SDK acknowledged it; false when the round-trip timed out or failed — the
235
+ * subprocess may still be running its turn and the caller must report that
236
+ * honestly instead of assuming a clean cancel.
237
+ */
238
+ async interruptTurn(timeoutMs = 2000): Promise<boolean> {
239
+ if ((this.state !== "busy" && this.state !== "starting") || !this.query) return true;
240
+ let timer: ReturnType<typeof setTimeout> | undefined;
218
241
  try {
219
242
  await Promise.race([
220
243
  this.query.interrupt(),
221
- new Promise<never>((_, reject) =>
222
- setTimeout(() => reject(new Error("interrupt timeout")), 2000),
223
- ),
244
+ new Promise<never>((_, reject) => {
245
+ timer = setTimeout(() => reject(new Error("interrupt timeout")), timeoutMs);
246
+ }),
224
247
  ]);
248
+ return true;
225
249
  } catch {
226
- // best-effort
250
+ return false; // best-effort dispatched, unconfirmed
251
+ } finally {
252
+ clearTimeout(timer); // the losing racer must not keep the loop alive
227
253
  }
228
254
  }
229
255
 
230
- async terminate(): Promise<void> {
256
+ async terminate(reason: ClaudeSessionEndReason = "released"): Promise<void> {
231
257
  if (this.state === "terminated") return;
232
258
  this.clearIdleTimer();
233
259
  this.state = "terminated";
@@ -242,7 +268,12 @@ export class ClaudeGeneratorSession {
242
268
  // The SDK documents close() as the forceful subprocess/MCP cleanup path.
243
269
  query?.close();
244
270
  } finally {
245
- this.onTerminated?.();
271
+ try {
272
+ this.onTerminated?.(reason);
273
+ } catch {
274
+ // Observer failures (incl. the host's onSessionEnd) must never make
275
+ // terminate() reject — the idle timer calls it unawaited.
276
+ }
246
277
  }
247
278
  }
248
279
 
@@ -291,7 +322,7 @@ export class ClaudeGeneratorSession {
291
322
  private resetIdleTimer(): void {
292
323
  this.clearIdleTimer();
293
324
  if (this.idleTimeoutMs > 0) {
294
- this.idleTimer = setTimeout(() => void this.terminate(), this.idleTimeoutMs);
325
+ this.idleTimer = setTimeout(() => void this.terminate("idle"), this.idleTimeoutMs);
295
326
  this.idleTimer.unref?.();
296
327
  }
297
328
  }
@@ -315,11 +346,16 @@ export function createCanUseTool(deps: {
315
346
  policy?: ClaudeToolPolicy;
316
347
  getBroker: () => PermissionRequestHandler | undefined;
317
348
  }): CanUseTool {
318
- return async (toolName, input, { signal, toolUseID, agentID }) => {
349
+ return async (toolName, input, options) => {
350
+ const { signal, toolUseID, agentID, suggestions, blockedPath, decisionReason } = options;
319
351
  const verdict = await deps.policy?.(toolName, input, {
320
352
  sessionId: deps.sessionId,
321
353
  toolUseId: toolUseID,
322
354
  agentId: agentID,
355
+ signal,
356
+ suggestions,
357
+ blockedPath,
358
+ decisionReason,
323
359
  });
324
360
  if (verdict) return verdict;
325
361
  const handler = deps.getBroker();
@@ -334,7 +370,10 @@ export function createCanUseTool(deps: {
334
370
  { signal },
335
371
  );
336
372
  return decision.decision === "allow"
337
- ? { behavior: "allow", updatedInput: input }
373
+ ? // What was approved is what runs: an edited input from the broker
374
+ // replaces the original (C1 — silent divergence between the approved
375
+ // and executed input is the worst failure mode in an approval path).
376
+ { behavior: "allow", updatedInput: decision.updatedInput ?? input }
338
377
  : {
339
378
  behavior: "deny",
340
379
  message: (decision.decision === "deny" && decision.reason) || "Denied by user",
@@ -68,13 +68,13 @@ export interface ClaudeSessionConfig {
68
68
  */
69
69
  function toSdkPermissionMode(mode: PermissionMode | undefined): CCOptions["permissionMode"] {
70
70
  switch (mode) {
71
- case "bypassPermissions":
71
+ case "bypass_permissions":
72
72
  return "bypassPermissions";
73
73
  case "plan":
74
74
  return "plan";
75
- case "acceptEdits":
75
+ case "accept_edits":
76
76
  return "acceptEdits";
77
- case "dontAsk":
77
+ case "dont_ask":
78
78
  // Native SDK mode: never prompt, deny unapproved. Unlike
79
79
  // bypassPermissions it needs no dangerous flag (and the CLI keeps
80
80
  // extended thinking enabled, which bypass turns off).
@@ -157,5 +157,15 @@ export function buildClaudeOptions(
157
157
  // ClaudeSdkOptionOverrides type excludes them anyway).
158
158
  if (overrides) Object.assign(options, overrides);
159
159
 
160
+ // Fable-class models withhold thinking plaintext by default: blocks stream
161
+ // structure + token estimates only, so every reasoning part arrives with
162
+ // empty text. The hidden `--thinking-display summarized` flag opts into
163
+ // summary text (ignored by CLIs that don't honor it). Applied after operator
164
+ // overrides so an unrelated operator extraArgs can't drop it, while an
165
+ // explicit operator `thinking-display` key still wins.
166
+ if (config.thinkingLevel !== "off") {
167
+ options.extraArgs = { "thinking-display": "summarized", ...options.extraArgs };
168
+ }
169
+
160
170
  return options;
161
171
  }
@@ -2,7 +2,7 @@ import type { McpSetServersResult } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { McpServerConfig } from "../../../protocol/index.ts";
3
3
  import { configFingerprint } from "../config-fingerprint.ts";
4
4
  import { ClaudeGeneratorSession } from "./generator-session.ts";
5
- import type { ClaudeSessionExtras } from "./generator-session.ts";
5
+ import type { ClaudeSessionEndReason, ClaudeSessionExtras } from "./generator-session.ts";
6
6
  import type { ClaudeSessionConfig } from "./options.ts";
7
7
 
8
8
  /** Directory-set identity: order and duplicates don't change the sandbox surface. */
@@ -66,6 +66,10 @@ export function claudeRestartConfig(
66
66
  export class ClaudeSessionManager {
67
67
  private readonly sessions = new Map<string, ClaudeGeneratorSession>();
68
68
 
69
+ constructor(
70
+ private readonly onSessionEnd?: (sessionId: string, reason: ClaudeSessionEndReason) => void,
71
+ ) {}
72
+
69
73
  async getOrCreate(
70
74
  sessionId: string,
71
75
  config: ClaudeSessionConfig,
@@ -80,7 +84,7 @@ export class ClaudeSessionManager {
80
84
  config,
81
85
  existing.currentSessionId,
82
86
  );
83
- await existing.terminate();
87
+ await existing.terminate("replaced");
84
88
  this.sessions.delete(sessionId);
85
89
  } else {
86
90
  await this.hotSwapIfNeeded(existing, config);
@@ -91,8 +95,9 @@ export class ClaudeSessionManager {
91
95
  const session = new ClaudeGeneratorSession(
92
96
  sessionId,
93
97
  startConfig,
94
- () => {
98
+ (reason) => {
95
99
  if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
100
+ this.onSessionEnd?.(sessionId, reason);
96
101
  },
97
102
  extras,
98
103
  );
@@ -119,7 +124,7 @@ export class ClaudeSessionManager {
119
124
  }
120
125
 
121
126
  async terminateAll(): Promise<void> {
122
- await Promise.all([...this.sessions.values()].map((s) => s.terminate()));
127
+ await Promise.all([...this.sessions.values()].map((s) => s.terminate("shutdown")));
123
128
  this.sessions.clear();
124
129
  }
125
130