@inixiative/agent-session 0.6.4 → 0.6.6
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/README.md +65 -0
- package/package.json +2 -2
- package/src/claude-code-session.ts +305 -4
- package/src/claude-in-session.ts +1369 -0
- package/src/codex-session.ts +88 -0
- package/src/harness-session.ts +42 -2
- package/src/index.ts +10 -0
- package/src/turn-state.ts +5 -2
package/README.md
CHANGED
|
@@ -114,6 +114,30 @@ session.onToolRequest(async (call) => {
|
|
|
114
114
|
A turn timeout does not withdraw a pending Claude ask; `interruptNative()` does.
|
|
115
115
|
- A fork starts with a snapshot of the parent's gates.
|
|
116
116
|
|
|
117
|
+
## Subagents
|
|
118
|
+
|
|
119
|
+
A subagent's activity reaches `onEvent`, but never becomes part of a turn:
|
|
120
|
+
|
|
121
|
+
- **Outside every admission.** Every subagent event, lifecycle included,
|
|
122
|
+
carries `unattributedReason: "subagent"`. It is never in a turn's
|
|
123
|
+
`result.events` and never its `content`. This holds whichever turn is in
|
|
124
|
+
flight, because a background subagent outlives the turn that started it.
|
|
125
|
+
- **`parentToolUseId`** names the parent's tool call that spawned it. On
|
|
126
|
+
Claude this is the frame's `parent_tool_use_id`, with the native envelope
|
|
127
|
+
kept in `raw`. On Codex it is the collab `spawnAgent` item.
|
|
128
|
+
- **`kind: "subagent"`** carries its lifecycle in `subagent`:
|
|
129
|
+
- `phase`: started, progress, updated or ended;
|
|
130
|
+
- identity and shape: `taskId`, `toolUseId`, `parentTaskId`, `agentType`,
|
|
131
|
+
`depth`, `backgrounded`;
|
|
132
|
+
- outcome: `status`, `summary`, `prompt`, and running `usage`.
|
|
133
|
+
- **Claude** builds the lifecycle from its `system` task frames for
|
|
134
|
+
`task_type: "local_agent"`. Background shells, monitors and workflows are
|
|
135
|
+
tasks too, and they are not reported as subagents.
|
|
136
|
+
- **Codex** reports `started` on the spawn and `ended` on `closeAgent`, plus
|
|
137
|
+
`updated` (with the turn's status) as each child turn ends. Child threads
|
|
138
|
+
carry their final messages as text; their other items stay diagnostics.
|
|
139
|
+
Approval requests from child threads are not routed to gates yet.
|
|
140
|
+
|
|
117
141
|
## Primed decision sessions
|
|
118
142
|
|
|
119
143
|
One live, warm session per middleware role, primed once with its role
|
|
@@ -140,6 +164,47 @@ a pre-spawned spare. Measured on a ChatGPT login: warm Codex decisions 2.45 s
|
|
|
140
164
|
median vs 3.9 s for `codex exec` per decision, with 34% fewer input tokens; warm
|
|
141
165
|
Claude (haiku) decisions 1.95 s with the whole primed prefix read from cache.
|
|
142
166
|
|
|
167
|
+
### In the main session's own process (Claude)
|
|
168
|
+
|
|
169
|
+
`ClaudeInSessionDecisions` holds decision roles as subagents of a main
|
|
170
|
+
`ClaudeCodeSession`, so a thread and all its decision roles are one process:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const decisions = new ClaudeInSessionDecisions({ model: "claude-haiku-5-5", effort: "low" });
|
|
174
|
+
const session = new ClaudeCodeSession({ model: "claude-opus-5-5", cwd, toolGate: true });
|
|
175
|
+
session.extend(decisions); // before start(): agents, an SDK MCP server, a PreToolUse claim
|
|
176
|
+
await session.start(); // one internal setup turn starts the root agent
|
|
177
|
+
decisions.prestart(threadId, roles.map((spec) => ({ spec, standby })));
|
|
178
|
+
await decisions.ready();
|
|
179
|
+
const result = await decisions.decide(spec, { input, standby });
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- **Shape.** The setup turn asks the main model to start `foundry-root` in the
|
|
183
|
+
background. The root never ends: it starts dispatcher generations
|
|
184
|
+
(`foundry-dispatch`), which start members (`foundry-warden`), all in the
|
|
185
|
+
background, through a `next` tool the host holds open. A member holds the
|
|
186
|
+
host's `moment` tool until a decision answers it, and answers through
|
|
187
|
+
`answer`. After setup, no member activity needs a main-model turn.
|
|
188
|
+
- **Pinned input.** Every spawn passes the session's PreToolUse callback,
|
|
189
|
+
which replaces its prompt with the composed text, so agent definitions are
|
|
190
|
+
generic and an edited role reaches the next spare without a relaunch.
|
|
191
|
+
Spawns of these agent types from anywhere else are denied; the main agent's
|
|
192
|
+
own subagents go to the tool gate untouched.
|
|
193
|
+
- **Fresh per call.** Each member serves one decision. A used one is replaced
|
|
194
|
+
at once; a role with no held spare starts a member with its input inline.
|
|
195
|
+
- **Recycle.** A dispatcher's context grows ~700 tokens per member started;
|
|
196
|
+
past `recycleTokens` (100k) a new generation takes over and the old one
|
|
197
|
+
ends into the root once empty.
|
|
198
|
+
- **Orphans.** A nested agent whose parent ended reports its own end to the
|
|
199
|
+
main thread, which wakes the main model (Claude Code 2.1.300). Members of a
|
|
200
|
+
dispatcher that ended unasked are therefore never retired or stopped, and an
|
|
201
|
+
orphan's `answer` call is held for the session's life. Past `maxOrphans`
|
|
202
|
+
(40) the host stops serving and its caller falls back.
|
|
203
|
+
- **Limits.** Claude Code runs 20 subagents at once by default; the launch
|
|
204
|
+
raises `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` (`maxSubagents`, 96). The
|
|
205
|
+
session declares `perTaskStopAffordance`, so the user's Stop spares
|
|
206
|
+
background agents.
|
|
207
|
+
|
|
143
208
|
## Pools, routing and continuity
|
|
144
209
|
|
|
145
210
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6",
|
|
4
4
|
"description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions over interchangeable transports, with primed decision sessions, subscription pools, routing, limit polling and continuity.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@biomejs/biome": "2.5.2",
|
|
43
|
-
"@inixiative/config": "^0.5.
|
|
43
|
+
"@inixiative/config": "^0.5.88",
|
|
44
44
|
"@types/bun": "1.4.2",
|
|
45
45
|
"lefthook": "2.1.9",
|
|
46
46
|
"typescript": "6.0.3"
|
|
@@ -57,6 +57,7 @@ import type {
|
|
|
57
57
|
SessionResult,
|
|
58
58
|
SessionSendOptions,
|
|
59
59
|
SessionTokens,
|
|
60
|
+
SubagentLifecycle,
|
|
60
61
|
ToolDecision,
|
|
61
62
|
ToolGate,
|
|
62
63
|
ToolRequest,
|
|
@@ -142,6 +143,66 @@ export interface ClaudeCodeSessionConfig {
|
|
|
142
143
|
) => PipedSubprocess;
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Extension: work a host runs inside this session's own process (ClaudeInSessionDecisions)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/** A custom agent definition (`--agents`), as Claude Code reads it. */
|
|
151
|
+
export interface ClaudeAgentDefinition {
|
|
152
|
+
readonly description: string;
|
|
153
|
+
readonly prompt: string;
|
|
154
|
+
readonly tools: readonly string[];
|
|
155
|
+
readonly model?: string;
|
|
156
|
+
readonly effort?: string;
|
|
157
|
+
readonly permissionMode?: string;
|
|
158
|
+
readonly omitClaudeMd?: boolean;
|
|
159
|
+
readonly background?: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What the session offers an extension once its process is up. */
|
|
163
|
+
export interface ClaudeSessionHost {
|
|
164
|
+
/** A control request to the CLI (e.g. `stop_task`). */
|
|
165
|
+
control(request: Record<string, unknown>, timeoutMs: number): Promise<Record<string, unknown>>;
|
|
166
|
+
/** Answer an MCP message the extension was handed. */
|
|
167
|
+
mcpReply(requestId: string, message: unknown): void;
|
|
168
|
+
/**
|
|
169
|
+
* A turn of the extension's own, never a caller's: queued like any send, and nothing of it is
|
|
170
|
+
* attributed to a caller's admission. No deadline: its native outcome must settle.
|
|
171
|
+
*/
|
|
172
|
+
sendInternal(message: string): Promise<SessionResult>;
|
|
173
|
+
alive(): boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Work that runs inside this session's process (agents, an SDK MCP server, a PreToolUse claim on
|
|
178
|
+
* its own tool calls), never through vendor hook files. Attached before `start()`.
|
|
179
|
+
*/
|
|
180
|
+
export interface ClaudeSessionExtension {
|
|
181
|
+
/** Agent definitions added to the launch. */
|
|
182
|
+
readonly agents: Readonly<Record<string, ClaudeAgentDefinition>>;
|
|
183
|
+
/** SDK MCP servers it answers (declared in `initialize`). */
|
|
184
|
+
readonly mcpServers: readonly string[];
|
|
185
|
+
/** Environment its agents need on the launch (merged over the session's own). */
|
|
186
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
187
|
+
/** The process runs (before `initialize`): it may answer MCP messages from now on. */
|
|
188
|
+
attach(host: ClaudeSessionHost): void;
|
|
189
|
+
/** `initialize` was answered: it may write turns and control requests. */
|
|
190
|
+
started(): void;
|
|
191
|
+
/** An MCP message for one of its servers; answered through `host.mcpReply`. */
|
|
192
|
+
mcp(server: string, requestId: string, message: Record<string, unknown>): void;
|
|
193
|
+
/**
|
|
194
|
+
* A PreToolUse callback: the hook output when the call is its own (its agents' calls, a spawn of
|
|
195
|
+
* one of its agent types), else undefined and the tool gate decides.
|
|
196
|
+
*/
|
|
197
|
+
preToolUse(input: Record<string, unknown>): Record<string, unknown> | undefined;
|
|
198
|
+
/** A permission ask (`can_use_tool`): its answer when the asking agent is its own, else undefined. */
|
|
199
|
+
permission(request: Record<string, unknown>): Record<string, unknown> | undefined;
|
|
200
|
+
/** A native frame: true when it is its own (never emitted, never part of an admission). */
|
|
201
|
+
frame(raw: Record<string, unknown>): boolean;
|
|
202
|
+
/** The process ended or was killed. */
|
|
203
|
+
detach(reason: string): void;
|
|
204
|
+
}
|
|
205
|
+
|
|
145
206
|
// ---------------------------------------------------------------------------
|
|
146
207
|
// Internal turn queue entry
|
|
147
208
|
// ---------------------------------------------------------------------------
|
|
@@ -272,6 +333,9 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
272
333
|
private _persistSession: boolean;
|
|
273
334
|
private _toolGate: boolean;
|
|
274
335
|
private _gates = new ToolGates();
|
|
336
|
+
private _extension?: ClaudeSessionExtension;
|
|
337
|
+
/** The private directory holding the extension's agent definitions while a process runs. */
|
|
338
|
+
private _agentsDir?: string;
|
|
275
339
|
/** Inbound control requests awaiting a gate decision, by request id. */
|
|
276
340
|
private _inbound = new Map<string, AbortController>();
|
|
277
341
|
private readonly _hookCallbackId = `agent-session-pre-tool-${crypto.randomUUID()}`;
|
|
@@ -314,6 +378,8 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
314
378
|
* are distinct compactions.
|
|
315
379
|
*/
|
|
316
380
|
private _seenBoundaries = new Set<string>();
|
|
381
|
+
/** Task ids whose task_started was a subagent (local_agent); later task frames carry no type. */
|
|
382
|
+
private _agentTasks = new Set<string>();
|
|
317
383
|
|
|
318
384
|
// -- Turn queue --
|
|
319
385
|
private _queue: QueuedTurn[] = [];
|
|
@@ -407,6 +473,17 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
407
473
|
return this._gates.add(gate);
|
|
408
474
|
}
|
|
409
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Run an extension inside this session's process (ClaudeInSessionDecisions). Before `start()`
|
|
478
|
+
* only, once, and never on a text-only session (it needs tools and an MCP server).
|
|
479
|
+
*/
|
|
480
|
+
extend(extension: ClaudeSessionExtension): void {
|
|
481
|
+
if (this._proc) throw new Error('Extensions attach before start()');
|
|
482
|
+
if (this._extension) throw new Error('Session already has an extension');
|
|
483
|
+
if (this._textOnly) throw new Error('A text-only session cannot host an extension');
|
|
484
|
+
this._extension = extension;
|
|
485
|
+
}
|
|
486
|
+
|
|
410
487
|
onBeforeSend(hook: BeforeSendHook): () => void {
|
|
411
488
|
this._beforeSendHooks.push(hook);
|
|
412
489
|
return () => {
|
|
@@ -448,6 +525,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
448
525
|
const env: Record<string, string | undefined> = {
|
|
449
526
|
...process.env,
|
|
450
527
|
...this._env,
|
|
528
|
+
...this._extension?.env,
|
|
451
529
|
DISABLE_AUTOUPDATER: '1',
|
|
452
530
|
};
|
|
453
531
|
delete env.ANTHROPIC_API_KEY;
|
|
@@ -468,6 +546,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
468
546
|
}
|
|
469
547
|
} catch (error) {
|
|
470
548
|
this._removeBaseContext();
|
|
549
|
+
this._removeAgents();
|
|
471
550
|
throw error;
|
|
472
551
|
}
|
|
473
552
|
|
|
@@ -483,9 +562,13 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
483
562
|
// Monitor process exit for cleanup
|
|
484
563
|
// The process read its base context file at startup; it goes when the process does.
|
|
485
564
|
const baseDir = this._baseContextDir;
|
|
565
|
+
const agentsDir = this._agentsDir;
|
|
486
566
|
proc.exited.then(async (code) => {
|
|
487
567
|
this._removeBaseContext(baseDir);
|
|
488
|
-
|
|
568
|
+
this._removeAgents(agentsDir);
|
|
569
|
+
if (this._proc !== proc) return;
|
|
570
|
+
this._detachExtension('process exited');
|
|
571
|
+
if (!this._alive) return;
|
|
489
572
|
this._alive = false;
|
|
490
573
|
await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
|
|
491
574
|
const errMsg = this._stderr.trim()
|
|
@@ -498,22 +581,60 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
498
581
|
});
|
|
499
582
|
|
|
500
583
|
// The PreToolUse callback is registered before any turn is written, so it covers the first call.
|
|
501
|
-
|
|
584
|
+
// An extension's agents run their own tools: it claims them through the same callback.
|
|
585
|
+
const extension = this._extension;
|
|
586
|
+
// The extension can answer from the moment the process runs: the CLI connects its SDK MCP
|
|
587
|
+
// server while `initialize` is still in flight, and an unanswered connect costs a 30 s retry.
|
|
588
|
+
if (extension)
|
|
589
|
+
extension.attach({
|
|
590
|
+
control: (request, timeoutMs) => this._controlRequest(request, timeoutMs),
|
|
591
|
+
mcpReply: (requestId, message) => {
|
|
592
|
+
if (this._proc === proc && this._alive)
|
|
593
|
+
this._writeControlResponse(proc, requestId, { mcp_response: message });
|
|
594
|
+
},
|
|
595
|
+
sendInternal: (message) => this.send(message, { timeout: 0 }),
|
|
596
|
+
alive: () => this._proc === proc && this._alive,
|
|
597
|
+
});
|
|
598
|
+
if (this._toolGate || extension) {
|
|
502
599
|
try {
|
|
503
600
|
await this._controlRequest(
|
|
504
601
|
{
|
|
505
602
|
subtype: 'initialize',
|
|
506
603
|
hooks: { PreToolUse: [{ matcher: '.*', hookCallbackIds: [this._hookCallbackId] }] },
|
|
604
|
+
...(extension
|
|
605
|
+
? {
|
|
606
|
+
sdkMcpServers: [...extension.mcpServers],
|
|
607
|
+
// The user's Stop aborts the turn only; background agents (the extension's) survive.
|
|
608
|
+
perTaskStopAffordance: true,
|
|
609
|
+
}
|
|
610
|
+
: {}),
|
|
507
611
|
},
|
|
508
612
|
30_000,
|
|
509
613
|
);
|
|
510
614
|
} catch (error) {
|
|
511
615
|
this.kill();
|
|
512
|
-
throw new Error(
|
|
616
|
+
throw new Error(
|
|
617
|
+
`${this._toolGate ? 'Tool gate' : 'Extension'} registration failed: ${(error as Error).message}`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (extension && this._proc === proc && this._alive) {
|
|
622
|
+
try {
|
|
623
|
+
extension.started();
|
|
624
|
+
} catch {
|
|
625
|
+
/* an extension's failure never touches the session */
|
|
513
626
|
}
|
|
514
627
|
}
|
|
515
628
|
}
|
|
516
629
|
|
|
630
|
+
private _detachExtension(reason: string): void {
|
|
631
|
+
try {
|
|
632
|
+
this._extension?.detach(reason);
|
|
633
|
+
} catch {
|
|
634
|
+
/* an extension's failure never touches the session */
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
517
638
|
private _rejectControl(error: Error): void {
|
|
518
639
|
for (const pending of this._control.values()) pending.reject(error);
|
|
519
640
|
this._control.clear();
|
|
@@ -749,6 +870,8 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
749
870
|
}
|
|
750
871
|
this._proc = null;
|
|
751
872
|
this._removeBaseContext();
|
|
873
|
+
this._removeAgents();
|
|
874
|
+
this._detachExtension('killed');
|
|
752
875
|
|
|
753
876
|
this._emit({ kind: 'session_end', timestamp: Date.now() });
|
|
754
877
|
}
|
|
@@ -1004,6 +1127,8 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1004
1127
|
if (raw.type === 'control_request') {
|
|
1005
1128
|
const id = typeof raw.request_id === 'string' ? raw.request_id : undefined;
|
|
1006
1129
|
const request = raw.request as Record<string, unknown> | undefined;
|
|
1130
|
+
if (id && this._proc && this._extension && this._extensionRequest(this._proc, id, request))
|
|
1131
|
+
return;
|
|
1007
1132
|
const toolRequest =
|
|
1008
1133
|
id && this._toolGate && this._proc ? this._toolRequestFrom(request) : undefined;
|
|
1009
1134
|
if (id && toolRequest) {
|
|
@@ -1104,6 +1229,45 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1104
1229
|
return;
|
|
1105
1230
|
}
|
|
1106
1231
|
|
|
1232
|
+
// An extension's own agents (their frames and task lifecycle) are its alone.
|
|
1233
|
+
if (this._extension) {
|
|
1234
|
+
let own = false;
|
|
1235
|
+
try {
|
|
1236
|
+
own = this._extension.frame(raw);
|
|
1237
|
+
} catch {
|
|
1238
|
+
/* an extension's failure never touches the session */
|
|
1239
|
+
}
|
|
1240
|
+
if (own) return;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// A subagent's frames and lifecycle are never part of an admission, whichever turn is in flight
|
|
1244
|
+
// (a background subagent outlives the turn that started it): they reach handlers tagged
|
|
1245
|
+
// `subagent`, with the spawning call in `parentToolUseId`.
|
|
1246
|
+
const parentToolUseId =
|
|
1247
|
+
typeof raw.parent_tool_use_id === 'string' && raw.parent_tool_use_id
|
|
1248
|
+
? raw.parent_tool_use_id
|
|
1249
|
+
: undefined;
|
|
1250
|
+
const lifecycle = this._subagentLifecycle(raw);
|
|
1251
|
+
if (parentToolUseId || lifecycle) {
|
|
1252
|
+
const message = raw.message as Record<string, unknown> | undefined;
|
|
1253
|
+
const events: SessionEvent[] = lifecycle
|
|
1254
|
+
? [{ kind: 'subagent', timestamp: Date.now(), subagent: lifecycle, raw }]
|
|
1255
|
+
: this._classify(raw, parentToolUseId);
|
|
1256
|
+
if (!events.length) {
|
|
1257
|
+
this._unattributed(raw, 'subagent');
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
for (const event of events)
|
|
1261
|
+
this._emit({
|
|
1262
|
+
...event,
|
|
1263
|
+
correlation: 'unknown',
|
|
1264
|
+
unattributedReason: 'subagent',
|
|
1265
|
+
nativeSessionId: typeof raw.session_id === 'string' ? raw.session_id : undefined,
|
|
1266
|
+
messageId: typeof message?.id === 'string' ? message.id : undefined,
|
|
1267
|
+
});
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1107
1271
|
const turn = this._inflight;
|
|
1108
1272
|
if (!turn) {
|
|
1109
1273
|
if (terminalKey) this._seenTerminals.add(terminalKey);
|
|
@@ -1227,6 +1391,52 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1227
1391
|
};
|
|
1228
1392
|
}
|
|
1229
1393
|
|
|
1394
|
+
/**
|
|
1395
|
+
* A control request the extension answers: an MCP message for its servers, or a PreToolUse
|
|
1396
|
+
* callback / permission ask that belongs to its agents or spawns one of its agent types. With no
|
|
1397
|
+
* tool gate, our callback's other calls get no opinion (the session's permission mode decides).
|
|
1398
|
+
*/
|
|
1399
|
+
private _extensionRequest(
|
|
1400
|
+
proc: PipedSubprocess,
|
|
1401
|
+
id: string,
|
|
1402
|
+
request: Record<string, unknown> | undefined,
|
|
1403
|
+
): boolean {
|
|
1404
|
+
const extension = this._extension!;
|
|
1405
|
+
try {
|
|
1406
|
+
if (
|
|
1407
|
+
request?.subtype === 'mcp_message' &&
|
|
1408
|
+
typeof request.server_name === 'string' &&
|
|
1409
|
+
extension.mcpServers.includes(request.server_name)
|
|
1410
|
+
) {
|
|
1411
|
+
extension.mcp(request.server_name, id, (request.message ?? {}) as Record<string, unknown>);
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
if (this._ownHookCallback(request)) {
|
|
1415
|
+
const input = request!.input as Record<string, unknown> | undefined;
|
|
1416
|
+
const output = input ? extension.preToolUse(input) : undefined;
|
|
1417
|
+
if (output) {
|
|
1418
|
+
this._writeControlResponse(proc, id, { hookSpecificOutput: output });
|
|
1419
|
+
return true;
|
|
1420
|
+
}
|
|
1421
|
+
if (!this._toolGate) {
|
|
1422
|
+
this._writeControlResponse(proc, id, {});
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1425
|
+
return false;
|
|
1426
|
+
}
|
|
1427
|
+
if (request?.subtype === 'can_use_tool') {
|
|
1428
|
+
const answer = extension.permission(request);
|
|
1429
|
+
if (answer) {
|
|
1430
|
+
this._writeControlResponse(proc, id, answer);
|
|
1431
|
+
return true;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
} catch {
|
|
1435
|
+
/* an extension's failure falls through to the session's own handling */
|
|
1436
|
+
}
|
|
1437
|
+
return false;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1230
1440
|
private _ownHookCallback(request: Record<string, unknown> | undefined): boolean {
|
|
1231
1441
|
return request?.subtype === 'hook_callback' && request.callback_id === this._hookCallbackId;
|
|
1232
1442
|
}
|
|
@@ -1345,10 +1555,26 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1345
1555
|
if (this._baseContext) {
|
|
1346
1556
|
args.push('--append-system-prompt-file', this._writeBaseContext(this._baseContext));
|
|
1347
1557
|
}
|
|
1558
|
+
// An extension's agents, from a private file; its MCP servers are declared in `initialize`.
|
|
1559
|
+
if (this._extension) args.push('--agents', this._writeAgents(this._extension.agents));
|
|
1348
1560
|
|
|
1349
1561
|
return args;
|
|
1350
1562
|
}
|
|
1351
1563
|
|
|
1564
|
+
private _writeAgents(agents: ClaudeSessionExtension['agents']): string {
|
|
1565
|
+
this._removeAgents();
|
|
1566
|
+
this._agentsDir = mkdtempSync(join(tmpdir(), `${BASE_CONTEXT_PREFIX}${process.pid}-agents-`));
|
|
1567
|
+
const file = join(this._agentsDir, 'agents.json');
|
|
1568
|
+
writeFileSync(file, JSON.stringify(agents), { mode: 0o600 });
|
|
1569
|
+
return file;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
private _removeAgents(dir = this._agentsDir): void {
|
|
1573
|
+
if (!dir) return;
|
|
1574
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1575
|
+
if (dir === this._agentsDir) this._agentsDir = undefined;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1352
1578
|
/** The base context as a private file: argv carries its path, never its text. */
|
|
1353
1579
|
private _writeBaseContext(text: string): string {
|
|
1354
1580
|
this._removeBaseContext();
|
|
@@ -1373,7 +1599,34 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1373
1599
|
// Private — event classification
|
|
1374
1600
|
// ---------------------------------------------------------------------------
|
|
1375
1601
|
|
|
1376
|
-
|
|
1602
|
+
/**
|
|
1603
|
+
* A Claude Code `system` task frame for a subagent (`task_type: local_agent`), as lifecycle. Other
|
|
1604
|
+
* task types (a background shell, a monitor, a workflow) are not subagents and return nothing.
|
|
1605
|
+
* Later phases carry no task type, so the session remembers which task ids are subagents.
|
|
1606
|
+
*/
|
|
1607
|
+
private _subagentLifecycle(msg: Record<string, unknown>): SubagentLifecycle | undefined {
|
|
1608
|
+
if (msg.type !== 'system' || typeof msg.subtype !== 'string') return undefined;
|
|
1609
|
+
const phase = CLAUDE_TASK_PHASES[msg.subtype];
|
|
1610
|
+
const taskId = typeof msg.task_id === 'string' && msg.task_id ? msg.task_id : undefined;
|
|
1611
|
+
if (!phase || !taskId) return undefined;
|
|
1612
|
+
if (phase === 'started') {
|
|
1613
|
+
const agent =
|
|
1614
|
+
msg.task_type === 'local_agent' ||
|
|
1615
|
+
(msg.task_type === undefined && typeof msg.subagent_type === 'string');
|
|
1616
|
+
if (!agent) return undefined;
|
|
1617
|
+
this._agentTasks.add(taskId);
|
|
1618
|
+
} else if (!this._agentTasks.has(taskId)) return undefined;
|
|
1619
|
+
if (phase === 'ended') this._agentTasks.delete(taskId);
|
|
1620
|
+
return claudeSubagentLifecycle(msg, phase, taskId);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
private _classify(msg: Record<string, unknown>, parentToolUseId?: string): SessionEvent[] {
|
|
1624
|
+
return this._classifyFrame(msg).map((event) =>
|
|
1625
|
+
parentToolUseId ? { ...event, parentToolUseId } : event,
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
private _classifyFrame(msg: Record<string, unknown>): SessionEvent[] {
|
|
1377
1630
|
const events: SessionEvent[] = [];
|
|
1378
1631
|
const ts = Date.now();
|
|
1379
1632
|
|
|
@@ -1558,3 +1811,51 @@ function processAlive(pid: number): boolean {
|
|
|
1558
1811
|
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
1559
1812
|
}
|
|
1560
1813
|
}
|
|
1814
|
+
|
|
1815
|
+
const CLAUDE_TASK_PHASES: Readonly<Record<string, SubagentLifecycle['phase']>> = {
|
|
1816
|
+
task_started: 'started',
|
|
1817
|
+
task_progress: 'progress',
|
|
1818
|
+
task_updated: 'updated',
|
|
1819
|
+
task_notification: 'ended',
|
|
1820
|
+
};
|
|
1821
|
+
|
|
1822
|
+
function claudeSubagentLifecycle(
|
|
1823
|
+
msg: Record<string, unknown>,
|
|
1824
|
+
phase: SubagentLifecycle['phase'],
|
|
1825
|
+
taskId: string,
|
|
1826
|
+
): SubagentLifecycle {
|
|
1827
|
+
const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
|
|
1828
|
+
const record = (value: unknown) =>
|
|
1829
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
1830
|
+
? (value as Record<string, unknown>)
|
|
1831
|
+
: undefined;
|
|
1832
|
+
const patch = record(msg.patch);
|
|
1833
|
+
const usage = record(msg.usage);
|
|
1834
|
+
const status = text(msg.status) ?? text(patch?.status);
|
|
1835
|
+
const count = (value: unknown) =>
|
|
1836
|
+
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
1837
|
+
return {
|
|
1838
|
+
phase,
|
|
1839
|
+
taskId,
|
|
1840
|
+
...(text(msg.tool_use_id) ? { toolUseId: text(msg.tool_use_id) } : {}),
|
|
1841
|
+
...(text(msg.parent_task_id) ? { parentTaskId: text(msg.parent_task_id) } : {}),
|
|
1842
|
+
...(text(msg.subagent_type) ? { agentType: text(msg.subagent_type) } : {}),
|
|
1843
|
+
...(text(msg.description) ? { description: text(msg.description) } : {}),
|
|
1844
|
+
...(typeof msg.spawn_depth === 'number' ? { depth: msg.spawn_depth } : {}),
|
|
1845
|
+
...(typeof msg.is_backgrounded === 'boolean' ? { backgrounded: msg.is_backgrounded } : {}),
|
|
1846
|
+
...(status ? { status } : {}),
|
|
1847
|
+
...(text(msg.summary) ? { summary: text(msg.summary) } : {}),
|
|
1848
|
+
...(text(msg.prompt) ? { prompt: text(msg.prompt) } : {}),
|
|
1849
|
+
...(usage && count(usage.total_tokens) !== undefined
|
|
1850
|
+
? {
|
|
1851
|
+
usage: {
|
|
1852
|
+
totalTokens: count(usage.total_tokens)!,
|
|
1853
|
+
...(count(usage.tool_uses) !== undefined ? { toolUses: count(usage.tool_uses) } : {}),
|
|
1854
|
+
...(count(usage.duration_ms) !== undefined
|
|
1855
|
+
? { durationMs: count(usage.duration_ms) }
|
|
1856
|
+
: {}),
|
|
1857
|
+
},
|
|
1858
|
+
}
|
|
1859
|
+
: {}),
|
|
1860
|
+
};
|
|
1861
|
+
}
|