@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.
- package/CHANGELOG.md +317 -0
- package/README.md +34 -4
- package/docs/consuming.md +269 -0
- package/docs/deploy.md +80 -0
- package/docs/harnesses.md +64 -0
- package/docs/rfds/0001-deterministic-echo-ids.md +44 -0
- package/package.json +23 -3
- package/src/client/client.ts +153 -49
- package/src/core/agents/acp/acp-agent.ts +9 -0
- package/src/core/agents/acp/mappings.ts +3 -3
- package/src/core/agents/base.ts +35 -5
- package/src/core/agents/claude-code/adapter.ts +116 -26
- package/src/core/agents/claude-code/claude-agent.ts +44 -7
- package/src/core/agents/claude-code/generator-session.ts +53 -14
- package/src/core/agents/claude-code/options.ts +13 -3
- package/src/core/agents/claude-code/session-manager.ts +9 -4
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +25 -6
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +3 -3
- package/src/core/agents/types.ts +1 -1
- package/src/core/diagnostics.ts +59 -0
- package/src/core/index.ts +6 -2
- package/src/core/presets.ts +15 -2
- package/src/core/provision/pins.ts +5 -1
- package/src/core/proxy/anthropic-proxy.ts +76 -3
- package/src/core/runtime/agent-runtime.ts +215 -28
- package/src/core/runtime/event-processor.ts +51 -26
- package/src/core/utils/errors.ts +37 -3
- package/src/protocol/config.ts +8 -6
- package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
- package/src/protocol/factories.ts +106 -10
- package/src/protocol/guards.ts +53 -0
- package/src/protocol/index.ts +10 -0
- package/src/protocol/lifecycle.ts +289 -112
- package/src/protocol/meta.ts +14 -0
- package/src/protocol/part-input.ts +56 -7
- package/src/protocol/parts.ts +125 -10
- package/src/protocol/reduce.ts +749 -0
- package/src/protocol/selectors.ts +162 -0
- package/src/protocol/seq-cursor.ts +87 -0
- package/src/protocol/stop-reasons.ts +45 -0
- package/src/protocol/time.ts +23 -0
- package/src/protocol/tokens.ts +23 -0
- package/src/protocol/tool-state.ts +85 -25
- package/src/protocol/verify.ts +440 -0
- package/src/protocol/vocabulary.ts +18 -0
- package/src/protocol/wire.ts +103 -7
- package/src/server/acp/binding.ts +23 -2
- package/src/server/acp/translate.ts +51 -14
- package/src/server/agent-server.ts +109 -6
package/src/core/agents/base.ts
CHANGED
|
@@ -14,7 +14,15 @@ import type {
|
|
|
14
14
|
* `allow`/`deny`, codex `accept`/`decline`/`cancel`).
|
|
15
15
|
*/
|
|
16
16
|
export type PermissionDecision =
|
|
17
|
-
| {
|
|
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<
|
|
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<
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
block
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
303
|
-
return
|
|
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
|
|
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"
|
|
355
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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<
|
|
222
|
-
|
|
223
|
-
|
|
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: {
|
|
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
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
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")),
|
|
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
|
-
|
|
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,
|
|
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
|
-
?
|
|
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 "
|
|
71
|
+
case "bypass_permissions":
|
|
72
72
|
return "bypassPermissions";
|
|
73
73
|
case "plan":
|
|
74
74
|
return "plan";
|
|
75
|
-
case "
|
|
75
|
+
case "accept_edits":
|
|
76
76
|
return "acceptEdits";
|
|
77
|
-
case "
|
|
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
|
|