@inixiative/agent-session 0.6.3 → 0.6.5
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 +24 -0
- package/package.json +2 -2
- package/src/claude-code-session.ts +107 -1
- package/src/claude-subagents.ts +170 -72
- package/src/codex-session.ts +88 -0
- package/src/harness-session.ts +42 -2
- 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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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.83",
|
|
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,
|
|
@@ -314,6 +315,8 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
314
315
|
* are distinct compactions.
|
|
315
316
|
*/
|
|
316
317
|
private _seenBoundaries = new Set<string>();
|
|
318
|
+
/** Task ids whose task_started was a subagent (local_agent); later task frames carry no type. */
|
|
319
|
+
private _agentTasks = new Set<string>();
|
|
317
320
|
|
|
318
321
|
// -- Turn queue --
|
|
319
322
|
private _queue: QueuedTurn[] = [];
|
|
@@ -1104,6 +1107,34 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1104
1107
|
return;
|
|
1105
1108
|
}
|
|
1106
1109
|
|
|
1110
|
+
// A subagent's frames and lifecycle are never part of an admission, whichever turn is in flight
|
|
1111
|
+
// (a background subagent outlives the turn that started it): they reach handlers tagged
|
|
1112
|
+
// `subagent`, with the spawning call in `parentToolUseId`.
|
|
1113
|
+
const parentToolUseId =
|
|
1114
|
+
typeof raw.parent_tool_use_id === 'string' && raw.parent_tool_use_id
|
|
1115
|
+
? raw.parent_tool_use_id
|
|
1116
|
+
: undefined;
|
|
1117
|
+
const lifecycle = this._subagentLifecycle(raw);
|
|
1118
|
+
if (parentToolUseId || lifecycle) {
|
|
1119
|
+
const message = raw.message as Record<string, unknown> | undefined;
|
|
1120
|
+
const events: SessionEvent[] = lifecycle
|
|
1121
|
+
? [{ kind: 'subagent', timestamp: Date.now(), subagent: lifecycle, raw }]
|
|
1122
|
+
: this._classify(raw, parentToolUseId);
|
|
1123
|
+
if (!events.length) {
|
|
1124
|
+
this._unattributed(raw, 'subagent');
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
for (const event of events)
|
|
1128
|
+
this._emit({
|
|
1129
|
+
...event,
|
|
1130
|
+
correlation: 'unknown',
|
|
1131
|
+
unattributedReason: 'subagent',
|
|
1132
|
+
nativeSessionId: typeof raw.session_id === 'string' ? raw.session_id : undefined,
|
|
1133
|
+
messageId: typeof message?.id === 'string' ? message.id : undefined,
|
|
1134
|
+
});
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1107
1138
|
const turn = this._inflight;
|
|
1108
1139
|
if (!turn) {
|
|
1109
1140
|
if (terminalKey) this._seenTerminals.add(terminalKey);
|
|
@@ -1373,7 +1404,34 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1373
1404
|
// Private — event classification
|
|
1374
1405
|
// ---------------------------------------------------------------------------
|
|
1375
1406
|
|
|
1376
|
-
|
|
1407
|
+
/**
|
|
1408
|
+
* A Claude Code `system` task frame for a subagent (`task_type: local_agent`), as lifecycle. Other
|
|
1409
|
+
* task types (a background shell, a monitor, a workflow) are not subagents and return nothing.
|
|
1410
|
+
* Later phases carry no task type, so the session remembers which task ids are subagents.
|
|
1411
|
+
*/
|
|
1412
|
+
private _subagentLifecycle(msg: Record<string, unknown>): SubagentLifecycle | undefined {
|
|
1413
|
+
if (msg.type !== 'system' || typeof msg.subtype !== 'string') return undefined;
|
|
1414
|
+
const phase = CLAUDE_TASK_PHASES[msg.subtype];
|
|
1415
|
+
const taskId = typeof msg.task_id === 'string' && msg.task_id ? msg.task_id : undefined;
|
|
1416
|
+
if (!phase || !taskId) return undefined;
|
|
1417
|
+
if (phase === 'started') {
|
|
1418
|
+
const agent =
|
|
1419
|
+
msg.task_type === 'local_agent' ||
|
|
1420
|
+
(msg.task_type === undefined && typeof msg.subagent_type === 'string');
|
|
1421
|
+
if (!agent) return undefined;
|
|
1422
|
+
this._agentTasks.add(taskId);
|
|
1423
|
+
} else if (!this._agentTasks.has(taskId)) return undefined;
|
|
1424
|
+
if (phase === 'ended') this._agentTasks.delete(taskId);
|
|
1425
|
+
return claudeSubagentLifecycle(msg, phase, taskId);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
private _classify(msg: Record<string, unknown>, parentToolUseId?: string): SessionEvent[] {
|
|
1429
|
+
return this._classifyFrame(msg).map((event) =>
|
|
1430
|
+
parentToolUseId ? { ...event, parentToolUseId } : event,
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
private _classifyFrame(msg: Record<string, unknown>): SessionEvent[] {
|
|
1377
1435
|
const events: SessionEvent[] = [];
|
|
1378
1436
|
const ts = Date.now();
|
|
1379
1437
|
|
|
@@ -1558,3 +1616,51 @@ function processAlive(pid: number): boolean {
|
|
|
1558
1616
|
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
1559
1617
|
}
|
|
1560
1618
|
}
|
|
1619
|
+
|
|
1620
|
+
const CLAUDE_TASK_PHASES: Readonly<Record<string, SubagentLifecycle['phase']>> = {
|
|
1621
|
+
task_started: 'started',
|
|
1622
|
+
task_progress: 'progress',
|
|
1623
|
+
task_updated: 'updated',
|
|
1624
|
+
task_notification: 'ended',
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
function claudeSubagentLifecycle(
|
|
1628
|
+
msg: Record<string, unknown>,
|
|
1629
|
+
phase: SubagentLifecycle['phase'],
|
|
1630
|
+
taskId: string,
|
|
1631
|
+
): SubagentLifecycle {
|
|
1632
|
+
const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
|
|
1633
|
+
const record = (value: unknown) =>
|
|
1634
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
1635
|
+
? (value as Record<string, unknown>)
|
|
1636
|
+
: undefined;
|
|
1637
|
+
const patch = record(msg.patch);
|
|
1638
|
+
const usage = record(msg.usage);
|
|
1639
|
+
const status = text(msg.status) ?? text(patch?.status);
|
|
1640
|
+
const count = (value: unknown) =>
|
|
1641
|
+
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
1642
|
+
return {
|
|
1643
|
+
phase,
|
|
1644
|
+
taskId,
|
|
1645
|
+
...(text(msg.tool_use_id) ? { toolUseId: text(msg.tool_use_id) } : {}),
|
|
1646
|
+
...(text(msg.parent_task_id) ? { parentTaskId: text(msg.parent_task_id) } : {}),
|
|
1647
|
+
...(text(msg.subagent_type) ? { agentType: text(msg.subagent_type) } : {}),
|
|
1648
|
+
...(text(msg.description) ? { description: text(msg.description) } : {}),
|
|
1649
|
+
...(typeof msg.spawn_depth === 'number' ? { depth: msg.spawn_depth } : {}),
|
|
1650
|
+
...(typeof msg.is_backgrounded === 'boolean' ? { backgrounded: msg.is_backgrounded } : {}),
|
|
1651
|
+
...(status ? { status } : {}),
|
|
1652
|
+
...(text(msg.summary) ? { summary: text(msg.summary) } : {}),
|
|
1653
|
+
...(text(msg.prompt) ? { prompt: text(msg.prompt) } : {}),
|
|
1654
|
+
...(usage && count(usage.total_tokens) !== undefined
|
|
1655
|
+
? {
|
|
1656
|
+
usage: {
|
|
1657
|
+
totalTokens: count(usage.total_tokens)!,
|
|
1658
|
+
...(count(usage.tool_uses) !== undefined ? { toolUses: count(usage.tool_uses) } : {}),
|
|
1659
|
+
...(count(usage.duration_ms) !== undefined
|
|
1660
|
+
? { durationMs: count(usage.duration_ms) }
|
|
1661
|
+
: {}),
|
|
1662
|
+
},
|
|
1663
|
+
}
|
|
1664
|
+
: {}),
|
|
1665
|
+
};
|
|
1666
|
+
}
|
package/src/claude-subagents.ts
CHANGED
|
@@ -30,6 +30,15 @@
|
|
|
30
30
|
// Each spare serves one decision; `prestart` with new standby
|
|
31
31
|
// context replaces the spares (a turn landed), and a consumed one
|
|
32
32
|
// is pre-started again.
|
|
33
|
+
// pairs each role keeps `sparesPerRole` spares (default 2), each copy in
|
|
34
|
+
// a different standby side session. A decision takes the spare
|
|
35
|
+
// whose side session is already most used, so a trigger empties
|
|
36
|
+
// whole side sessions; a side session whose spares were partly
|
|
37
|
+
// used ends its standby turn once nothing it serves is in flight,
|
|
38
|
+
// and is pre-started again while the other copy serves.
|
|
39
|
+
// sides standby side sessions hold spares only; fan-outs (decisions with
|
|
40
|
+
// no spare) run in their own side sessions, so they never take a
|
|
41
|
+
// spare's place.
|
|
33
42
|
//
|
|
34
43
|
// Every member is a fresh subagent conversation: nothing of one decision
|
|
35
44
|
// reaches another. Groups above `maxMembers` split evenly across more side
|
|
@@ -80,10 +89,12 @@ export interface ClaudeSubagentConfig {
|
|
|
80
89
|
argv: string[],
|
|
81
90
|
options: { cwd: string; env: Record<string, string | undefined> },
|
|
82
91
|
) => PipedSubprocess;
|
|
83
|
-
/** Members per fan-out; a larger
|
|
92
|
+
/** Members per fan-out or standby turn; a larger set splits evenly across side sessions. Default 12. */
|
|
84
93
|
maxMembers?: number;
|
|
85
|
-
/**
|
|
94
|
+
/** Fan-out side sessions per group. Standby side sessions are sized by the spares they hold. Default 6. */
|
|
86
95
|
maxSessionsPerGroup?: number;
|
|
96
|
+
/** Pre-started spares kept per role, each copy in its own standby side session. Default 2. */
|
|
97
|
+
sparesPerRole?: number;
|
|
87
98
|
/** Decisions arriving within this window of each other fan out together. Default 25 ms. */
|
|
88
99
|
batchWindowMs?: number;
|
|
89
100
|
/** Close a side session idle this long. Default 30 min. */
|
|
@@ -144,6 +155,8 @@ interface Spare {
|
|
|
144
155
|
|
|
145
156
|
interface Side {
|
|
146
157
|
readonly group: string;
|
|
158
|
+
/** Holds pre-started spares; never takes a fan-out. */
|
|
159
|
+
readonly standby: boolean;
|
|
147
160
|
readonly names: ReadonlySet<string>;
|
|
148
161
|
readonly proc: PipedSubprocess;
|
|
149
162
|
readonly dir: string;
|
|
@@ -162,6 +175,8 @@ interface Side {
|
|
|
162
175
|
spares: Map<string, Spare>;
|
|
163
176
|
/** Its standby context was replaced: end its standby turn once no decision uses it. */
|
|
164
177
|
retire?: boolean;
|
|
178
|
+
/** Its standby turn is being ended; it is pre-started again once rewound. */
|
|
179
|
+
ending?: boolean;
|
|
165
180
|
onResult?: () => void;
|
|
166
181
|
control: Map<string, { resolve(r: Record<string, unknown>): void; reject(e: Error): void }>;
|
|
167
182
|
}
|
|
@@ -197,7 +212,12 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
197
212
|
Required<
|
|
198
213
|
Pick<
|
|
199
214
|
ClaudeSubagentConfig,
|
|
200
|
-
|
|
215
|
+
| 'maxMembers'
|
|
216
|
+
| 'maxSessionsPerGroup'
|
|
217
|
+
| 'sparesPerRole'
|
|
218
|
+
| 'batchWindowMs'
|
|
219
|
+
| 'idleMs'
|
|
220
|
+
| 'timeoutMs'
|
|
201
221
|
>
|
|
202
222
|
>;
|
|
203
223
|
private _groups = new Map<string, Group>();
|
|
@@ -213,11 +233,16 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
213
233
|
...config,
|
|
214
234
|
maxMembers: config.maxMembers ?? 12,
|
|
215
235
|
maxSessionsPerGroup: config.maxSessionsPerGroup ?? 6,
|
|
236
|
+
sparesPerRole: config.sparesPerRole ?? 2,
|
|
216
237
|
batchWindowMs: config.batchWindowMs ?? 25,
|
|
217
238
|
idleMs: config.idleMs ?? 30 * 60_000,
|
|
218
239
|
timeoutMs: config.timeoutMs ?? 30_000,
|
|
219
240
|
};
|
|
220
|
-
if (
|
|
241
|
+
if (
|
|
242
|
+
this._config.maxMembers < 1 ||
|
|
243
|
+
this._config.maxSessionsPerGroup < 1 ||
|
|
244
|
+
this._config.sparesPerRole < 1
|
|
245
|
+
)
|
|
221
246
|
throw Error('Invalid subagent session bounds');
|
|
222
247
|
}
|
|
223
248
|
|
|
@@ -423,20 +448,26 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
423
448
|
return name;
|
|
424
449
|
}
|
|
425
450
|
|
|
451
|
+
/**
|
|
452
|
+
* A free spare for this role and standby context. A held one beats one still starting; among
|
|
453
|
+
* those, the one whose side session has the fewest free spares left, so a trigger empties whole
|
|
454
|
+
* side sessions and the other copies stay held.
|
|
455
|
+
*/
|
|
426
456
|
private _spareFor(group: Group, name: string, standby: string): Spare | undefined {
|
|
427
457
|
const standbyHash = hash(standby);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
458
|
+
const free = (sp: Spare) => !sp.member && !sp.done && !sp.violation;
|
|
459
|
+
let best: { spare: Spare; held: boolean; left: number } | undefined;
|
|
460
|
+
for (const side of group.sides) {
|
|
461
|
+
if (!side.alive || side.retire) continue;
|
|
462
|
+
const left = [...side.spares.values()].filter(free).length;
|
|
463
|
+
for (const spare of side.spares.values()) {
|
|
464
|
+
if (spare.name !== name || spare.standbyHash !== standbyHash || !free(spare)) continue;
|
|
465
|
+
const held = !!spare.pending;
|
|
466
|
+
if (!best || (held && !best.held) || (held === best.held && left < best.left))
|
|
467
|
+
best = { spare, held, left };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return best?.spare;
|
|
440
471
|
}
|
|
441
472
|
|
|
442
473
|
/** Admit a decision onto its spare; the input goes out when the spare's moment call is held. */
|
|
@@ -589,6 +620,9 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
589
620
|
spare.pending = { requestId, rpcId: id };
|
|
590
621
|
this._reportStandby(spare);
|
|
591
622
|
this._deliver(spare);
|
|
623
|
+
// A copy now holds: a partly used side session elsewhere may be free to refill.
|
|
624
|
+
const held = this._groups.get(side.group);
|
|
625
|
+
if (held && !spare.member) this._releaseSpent(held);
|
|
592
626
|
return true;
|
|
593
627
|
}
|
|
594
628
|
if (id === undefined) return this._mcpReply(side, requestId, null);
|
|
@@ -599,62 +633,121 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
599
633
|
});
|
|
600
634
|
}
|
|
601
635
|
|
|
602
|
-
/**
|
|
636
|
+
/**
|
|
637
|
+
* Keep `sparesPerRole` spares of every desired role holding its current standby context. Copies
|
|
638
|
+
* of one role always go to different standby side sessions: each round of missing copies is its
|
|
639
|
+
* own set of standby turns.
|
|
640
|
+
*/
|
|
603
641
|
private _replenish(group: Group): void {
|
|
604
642
|
if (this._closed || !group.desired.size || Date.now() < group.retryAt) return;
|
|
605
643
|
// Spares are created when their standby turn is assigned, so one still starting counts as held.
|
|
606
|
-
const held = new
|
|
644
|
+
const held = new Map<string, number>();
|
|
607
645
|
for (const side of group.sides)
|
|
608
646
|
if (side.alive && !side.retire)
|
|
609
647
|
for (const spare of side.spares.values())
|
|
610
648
|
if (!spare.done && !spare.member && !spare.violation)
|
|
611
649
|
if (group.desired.get(spare.name)?.standbyHash === spare.standbyHash)
|
|
612
|
-
held.
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
for (let
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
650
|
+
held.set(spare.name, (held.get(spare.name) ?? 0) + 1);
|
|
651
|
+
const copies = this._config.sparesPerRole;
|
|
652
|
+
// Standby sessions this group may hold: every copy of every role, plus two for refills while
|
|
653
|
+
// partly used side sessions keep serving their unused spares.
|
|
654
|
+
const cap = copies * Math.ceil(group.desired.size / this._config.maxMembers) + 2;
|
|
655
|
+
for (let round = 0; round < copies; round++) {
|
|
656
|
+
const missing = [...group.desired.keys()].filter((name) => (held.get(name) ?? 0) <= round);
|
|
657
|
+
if (!missing.length) continue;
|
|
658
|
+
const chunks = Math.ceil(missing.length / this._config.maxMembers);
|
|
659
|
+
const size = Math.ceil(missing.length / chunks);
|
|
660
|
+
for (let i = 0; i < missing.length; i += size) {
|
|
661
|
+
const chunk = missing.slice(i, i + size);
|
|
662
|
+
// A side session launched with these roles' definitions (each has its standby variant).
|
|
663
|
+
let side = group.sides.find(
|
|
664
|
+
(s) => s.standby && s.alive && !s.busy && chunk.every((n) => s.names.has(n)),
|
|
665
|
+
);
|
|
666
|
+
if (!side) {
|
|
667
|
+
// A standby turn being ended, or with every spare used or taken, frees its side session in
|
|
668
|
+
// a moment: no new process meanwhile.
|
|
669
|
+
if (
|
|
670
|
+
group.sides.some(
|
|
671
|
+
(s) =>
|
|
672
|
+
s.standby &&
|
|
673
|
+
s.alive &&
|
|
674
|
+
(s.ending ||
|
|
675
|
+
(s.spares.size > 0 &&
|
|
676
|
+
[...s.spares.values()].every((sp) => sp.done || sp.member))),
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
return;
|
|
680
|
+
if (group.sides.filter((s) => s.standby).length >= cap) return; // retried when one frees
|
|
681
|
+
try {
|
|
682
|
+
side = this._spawn(group, true);
|
|
683
|
+
} catch {
|
|
684
|
+
this._startFailed(group);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
628
687
|
}
|
|
688
|
+
const owner = side;
|
|
689
|
+
owner.busy = true;
|
|
690
|
+
owner.spares = new Map(
|
|
691
|
+
chunk.map((name, index) => {
|
|
692
|
+
const desired = group.desired.get(name)!;
|
|
693
|
+
// A position in this standby turn: deterministic, so a recorded turn replays.
|
|
694
|
+
const token = `s${index + 1}`;
|
|
695
|
+
const spare: Spare = {
|
|
696
|
+
name,
|
|
697
|
+
standbyHash: desired.standbyHash,
|
|
698
|
+
prompt: standbyPrompt(desired.standby, token),
|
|
699
|
+
token,
|
|
700
|
+
side: owner,
|
|
701
|
+
done: false,
|
|
702
|
+
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
703
|
+
};
|
|
704
|
+
return [token, spare] as const;
|
|
705
|
+
}),
|
|
706
|
+
);
|
|
707
|
+
for (const name of chunk) held.set(name, (held.get(name) ?? 0) + 1);
|
|
708
|
+
void this._standbyFanOut(owner, group)
|
|
709
|
+
.catch(() => undefined)
|
|
710
|
+
.finally(() => {
|
|
711
|
+
owner.busy = false;
|
|
712
|
+
owner.retire = false;
|
|
713
|
+
owner.ending = false;
|
|
714
|
+
owner.lastUsed = Date.now();
|
|
715
|
+
this._replenish(group);
|
|
716
|
+
});
|
|
629
717
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Refills are staggered so one copy of every role is always serving. A partly used standby turn
|
|
723
|
+
* keeps serving its unused spares (the used copies refill elsewhere) and ends only once nothing it
|
|
724
|
+
* serves is in flight and every role it still holds has another held copy: ending it then frees
|
|
725
|
+
* its process without leaving any role uncovered.
|
|
726
|
+
*/
|
|
727
|
+
private _releaseSpent(group: Group): void {
|
|
728
|
+
if (this._config.sparesPerRole < 2) return;
|
|
729
|
+
const free = (sp: Spare) => !sp.member && !sp.done && !sp.violation;
|
|
730
|
+
for (const side of group.sides) {
|
|
731
|
+
if (!side.standby || !side.alive || side.ending || side.retire || !side.busy) continue;
|
|
732
|
+
const spares = [...side.spares.values()];
|
|
733
|
+
if (!spares.some((sp) => sp.done || sp.member)) continue; // untouched
|
|
734
|
+
if (spares.some((sp) => sp.member && !sp.member.settled)) continue; // still serving
|
|
735
|
+
const covered = spares
|
|
736
|
+
.filter(free)
|
|
737
|
+
.every((sp) =>
|
|
738
|
+
group.sides.some(
|
|
739
|
+
(other) =>
|
|
740
|
+
other !== side &&
|
|
741
|
+
other.alive &&
|
|
742
|
+
!other.ending &&
|
|
743
|
+
!other.retire &&
|
|
744
|
+
[...other.spares.values()].some(
|
|
745
|
+
(o) =>
|
|
746
|
+
o.name === sp.name && o.standbyHash === sp.standbyHash && free(o) && !!o.pending,
|
|
747
|
+
),
|
|
748
|
+
),
|
|
749
|
+
);
|
|
750
|
+
if (covered) this._endStandbyNow(side);
|
|
658
751
|
}
|
|
659
752
|
}
|
|
660
753
|
|
|
@@ -668,12 +761,14 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
668
761
|
private _endStandby(side: Side): void {
|
|
669
762
|
const inUse = [...side.spares.values()].some((sp) => sp.member && !sp.member.settled);
|
|
670
763
|
if (inUse || !side.spares.size) return;
|
|
764
|
+
side.ending = true;
|
|
671
765
|
void this._control(side, { subtype: 'interrupt' }, 5_000).catch(
|
|
672
766
|
() => void this._kill(side, 'standby interrupt unanswered'),
|
|
673
767
|
);
|
|
674
768
|
}
|
|
675
769
|
|
|
676
770
|
private _endStandbyNow(side: Side): void {
|
|
771
|
+
side.ending = true;
|
|
677
772
|
void this._control(side, { subtype: 'interrupt' }, 5_000).catch(
|
|
678
773
|
() => void this._kill(side, 'standby interrupt unanswered'),
|
|
679
774
|
);
|
|
@@ -791,19 +886,18 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
791
886
|
continue;
|
|
792
887
|
}
|
|
793
888
|
const needs = new Set(chunk.map((m) => m.name));
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
);
|
|
889
|
+
const fanOuts = group.sides.filter((s) => !s.standby);
|
|
890
|
+
let side = fanOuts.find((s) => s.alive && !s.busy && [...needs].every((n) => s.names.has(n)));
|
|
797
891
|
if (!side) {
|
|
798
|
-
const idleLacking =
|
|
799
|
-
if (
|
|
892
|
+
const idleLacking = fanOuts.find((s) => !s.busy);
|
|
893
|
+
if (fanOuts.length >= this._config.maxSessionsPerGroup) {
|
|
800
894
|
if (!idleLacking) return; // every side session is busy: the chunk waits
|
|
801
895
|
// A side session without these definitions is replaced by one that has them all.
|
|
802
896
|
group.sides.splice(group.sides.indexOf(idleLacking), 1);
|
|
803
897
|
void this._kill(idleLacking, 'definitions changed');
|
|
804
898
|
}
|
|
805
899
|
try {
|
|
806
|
-
side = this._spawn(group);
|
|
900
|
+
side = this._spawn(group, false);
|
|
807
901
|
} catch (error) {
|
|
808
902
|
group.waiting.shift();
|
|
809
903
|
for (const m of chunk)
|
|
@@ -828,18 +922,18 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
828
922
|
}
|
|
829
923
|
}
|
|
830
924
|
|
|
831
|
-
private _spawn(group: Group): Side {
|
|
925
|
+
private _spawn(group: Group, standby: boolean): Side {
|
|
832
926
|
const generation = ++this._generation;
|
|
833
927
|
const dir = mkdtempSync(join(tmpdir(), 'agent-session-agents-'));
|
|
834
928
|
try {
|
|
835
|
-
return this._launch(group, generation, dir);
|
|
929
|
+
return this._launch(group, generation, dir, standby);
|
|
836
930
|
} catch (error) {
|
|
837
931
|
rmSync(dir, { recursive: true, force: true });
|
|
838
932
|
throw error;
|
|
839
933
|
}
|
|
840
934
|
}
|
|
841
935
|
|
|
842
|
-
private _launch(group: Group, generation: number, dir: string): Side {
|
|
936
|
+
private _launch(group: Group, generation: number, dir: string, standby: boolean): Side {
|
|
843
937
|
chmodSync(dir, 0o700);
|
|
844
938
|
const agents: Record<string, unknown> = {};
|
|
845
939
|
for (const [name, def] of group.defs) {
|
|
@@ -909,6 +1003,7 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
909
1003
|
}) as unknown as PipedSubprocess);
|
|
910
1004
|
const side: Side = {
|
|
911
1005
|
group: group.id,
|
|
1006
|
+
standby,
|
|
912
1007
|
names: new Set(group.defs.keys()),
|
|
913
1008
|
proc,
|
|
914
1009
|
dir,
|
|
@@ -1108,7 +1203,10 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
1108
1203
|
if ([...side.spares.values()].every((sp) => sp.done)) this._endStandbyNow(side);
|
|
1109
1204
|
else if (side.retire) this._endStandby(side);
|
|
1110
1205
|
const group = this._groups.get(side.group);
|
|
1111
|
-
if (group)
|
|
1206
|
+
if (group) {
|
|
1207
|
+
this._releaseSpent(group);
|
|
1208
|
+
this._replenish(group);
|
|
1209
|
+
}
|
|
1112
1210
|
return;
|
|
1113
1211
|
}
|
|
1114
1212
|
const member = this._byToolUse(side, frame.tool_use_id);
|
package/src/codex-session.ts
CHANGED
|
@@ -944,6 +944,8 @@ export class CodexAppServerSession implements HarnessSession {
|
|
|
944
944
|
private _approvals = new Map<string, AbortController>();
|
|
945
945
|
/** The owned turn's in-progress fileChange items' changes, by item id, for their approvals. */
|
|
946
946
|
private _fileChanges = new Map<string, readonly CodexFileChange[]>();
|
|
947
|
+
/** Child threads this session's turns spawned (collab `spawnAgent`), to the spawning item's id. */
|
|
948
|
+
private _children = new Map<string, string>();
|
|
947
949
|
private _items = new Map<
|
|
948
950
|
string,
|
|
949
951
|
{
|
|
@@ -1266,6 +1268,8 @@ export class CodexAppServerSession implements HarnessSession {
|
|
|
1266
1268
|
thread.turns.some((value) => !this._turn(value)))
|
|
1267
1269
|
)
|
|
1268
1270
|
this._refuse('configuration-missing-or-incompatible');
|
|
1271
|
+
// Sub-agents belong to the thread that spawned them.
|
|
1272
|
+
if (this._externalSessionId !== id) this._children.clear();
|
|
1269
1273
|
this._externalSessionId = id;
|
|
1270
1274
|
this._nativeSessionId =
|
|
1271
1275
|
typeof thread?.sessionId === 'string' && thread.sessionId ? thread.sessionId : undefined;
|
|
@@ -1430,6 +1434,9 @@ export class CodexAppServerSession implements HarnessSession {
|
|
|
1430
1434
|
},
|
|
1431
1435
|
];
|
|
1432
1436
|
const a = this._inflight?.evidence;
|
|
1437
|
+
const spawnedBy = threadId ? this._children.get(threadId) : undefined;
|
|
1438
|
+
if (threadId && spawnedBy)
|
|
1439
|
+
return this._childEvents(method, threadId, spawnedBy, turn, item, unowned);
|
|
1433
1440
|
if (!threadId || threadId !== this._externalSessionId) return unowned('foreign-session');
|
|
1434
1441
|
if (!a || this._appAdmission !== a.admissionId) return unowned('no-admission');
|
|
1435
1442
|
if (turnId && this._seenTerminals.has(turnId))
|
|
@@ -1519,6 +1526,50 @@ export class CodexAppServerSession implements HarnessSession {
|
|
|
1519
1526
|
else this._fileChanges.delete(itemId);
|
|
1520
1527
|
return unowned('unrecognized-event');
|
|
1521
1528
|
}
|
|
1529
|
+
if (item.type === 'collabAgentToolCall') {
|
|
1530
|
+
// A sub-agent this turn spawned (its thread is named once known, on start or completion),
|
|
1531
|
+
// or closed. Other collab calls (send input, wait) are diagnostics.
|
|
1532
|
+
const receivers = Array.isArray(item.receiverThreadIds)
|
|
1533
|
+
? item.receiverThreadIds.filter((id): id is string => typeof id === 'string' && !!id)
|
|
1534
|
+
: [];
|
|
1535
|
+
const prompt = typeof item.prompt === 'string' && item.prompt ? item.prompt : undefined;
|
|
1536
|
+
if (item.tool === 'spawnAgent') {
|
|
1537
|
+
const fresh = receivers.filter((child) => !this._children.has(child));
|
|
1538
|
+
if (!fresh.length) return status();
|
|
1539
|
+
return fresh.map((child): SessionEvent => {
|
|
1540
|
+
this._children.set(child, itemId);
|
|
1541
|
+
return {
|
|
1542
|
+
...identity,
|
|
1543
|
+
kind: 'subagent',
|
|
1544
|
+
timestamp: Date.now(),
|
|
1545
|
+
subagent: {
|
|
1546
|
+
phase: 'started',
|
|
1547
|
+
taskId: child,
|
|
1548
|
+
toolUseId: itemId,
|
|
1549
|
+
agentType: 'spawnAgent',
|
|
1550
|
+
depth: 1,
|
|
1551
|
+
backgrounded: true,
|
|
1552
|
+
...(prompt ? { prompt } : {}),
|
|
1553
|
+
},
|
|
1554
|
+
};
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
if (item.tool === 'closeAgent' && method === 'item/completed') {
|
|
1558
|
+
const closed = receivers.filter((child) => this._children.has(child));
|
|
1559
|
+
if (!closed.length) return status();
|
|
1560
|
+
return closed.map((child): SessionEvent => {
|
|
1561
|
+
const toolUseId = this._children.get(child)!;
|
|
1562
|
+
this._children.delete(child);
|
|
1563
|
+
return {
|
|
1564
|
+
...identity,
|
|
1565
|
+
kind: 'subagent',
|
|
1566
|
+
timestamp: Date.now(),
|
|
1567
|
+
subagent: { phase: 'ended', taskId: child, toolUseId, status: 'closed' },
|
|
1568
|
+
};
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
return status();
|
|
1572
|
+
}
|
|
1522
1573
|
if (item.type === 'agentMessage') {
|
|
1523
1574
|
if (method !== 'item/completed') return status();
|
|
1524
1575
|
if (this._items.get(itemId)?.completed) return unowned('after-terminal');
|
|
@@ -1638,6 +1689,43 @@ export class CodexAppServerSession implements HarnessSession {
|
|
|
1638
1689
|
}
|
|
1639
1690
|
return method === 'turn/started' ? status() : unowned('unrecognized-event');
|
|
1640
1691
|
}
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* A spawned sub-agent's thread, outside any admission of this session: its final messages as
|
|
1695
|
+
* text and its turns' ends as lifecycle, tagged with the spawning item. Anything else stays a
|
|
1696
|
+
* diagnostic, tagged the same way.
|
|
1697
|
+
*/
|
|
1698
|
+
private _childEvents(
|
|
1699
|
+
method: string,
|
|
1700
|
+
threadId: string,
|
|
1701
|
+
spawnedBy: string,
|
|
1702
|
+
turn: { id: string; status: string; error?: unknown } | undefined,
|
|
1703
|
+
item: Record<string, unknown> | undefined,
|
|
1704
|
+
unowned: (reason: NonNullable<SessionEvent['unattributedReason']>) => SessionEvent[],
|
|
1705
|
+
): SessionEvent[] {
|
|
1706
|
+
const tag = { parentToolUseId: spawnedBy, unattributedReason: 'subagent' as const };
|
|
1707
|
+
const base = { threadId, correlation: 'unknown' as const, timestamp: Date.now(), ...tag };
|
|
1708
|
+
if (
|
|
1709
|
+
method === 'item/completed' &&
|
|
1710
|
+
item?.type === 'agentMessage' &&
|
|
1711
|
+
typeof item.text === 'string'
|
|
1712
|
+
)
|
|
1713
|
+
return [{ ...base, kind: 'text', text: item.text }];
|
|
1714
|
+
if (method === 'turn/completed' && turn && turn.status !== 'inProgress')
|
|
1715
|
+
return [
|
|
1716
|
+
{
|
|
1717
|
+
...base,
|
|
1718
|
+
kind: 'subagent',
|
|
1719
|
+
subagent: {
|
|
1720
|
+
phase: 'updated',
|
|
1721
|
+
taskId: threadId,
|
|
1722
|
+
toolUseId: spawnedBy,
|
|
1723
|
+
status: turn.status,
|
|
1724
|
+
},
|
|
1725
|
+
},
|
|
1726
|
+
];
|
|
1727
|
+
return unowned('subagent').map((event) => ({ ...event, ...tag }));
|
|
1728
|
+
}
|
|
1641
1729
|
}
|
|
1642
1730
|
|
|
1643
1731
|
// ---------------------------------------------------------------------------
|
package/src/harness-session.ts
CHANGED
|
@@ -92,7 +92,38 @@ export type SessionEventKind =
|
|
|
92
92
|
| 'usage'
|
|
93
93
|
| 'rate_limit'
|
|
94
94
|
| 'result'
|
|
95
|
-
| 'error'
|
|
95
|
+
| 'error'
|
|
96
|
+
| 'subagent';
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A subagent's lifecycle as the native runtime reports it (kind `subagent`). Claude Code: its
|
|
100
|
+
* `system` task frames for an Agent/Task call. Codex: a collab `spawnAgent` item and its child thread.
|
|
101
|
+
*/
|
|
102
|
+
export interface SubagentLifecycle {
|
|
103
|
+
readonly phase: 'started' | 'progress' | 'updated' | 'ended';
|
|
104
|
+
/** The native id of the subagent run (Claude task id, Codex child thread id). */
|
|
105
|
+
readonly taskId: string;
|
|
106
|
+
/** The tool call that spawned it, in its parent's stream. */
|
|
107
|
+
readonly toolUseId?: string;
|
|
108
|
+
/** The task that spawned it, when a subagent spawned it (Claude). */
|
|
109
|
+
readonly parentTaskId?: string;
|
|
110
|
+
readonly agentType?: string;
|
|
111
|
+
readonly description?: string;
|
|
112
|
+
/** 1 for a child of the main thread. */
|
|
113
|
+
readonly depth?: number;
|
|
114
|
+
readonly backgrounded?: boolean;
|
|
115
|
+
/** The native status at the end (completed, stopped, failed, killed). */
|
|
116
|
+
readonly status?: string;
|
|
117
|
+
readonly summary?: string;
|
|
118
|
+
/** The task the subagent was given. */
|
|
119
|
+
readonly prompt?: string;
|
|
120
|
+
/** Its own running totals, when the runtime reports them (Claude progress and end frames). */
|
|
121
|
+
readonly usage?: {
|
|
122
|
+
readonly totalTokens: number;
|
|
123
|
+
readonly toolUses?: number;
|
|
124
|
+
readonly durationMs?: number;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
96
127
|
|
|
97
128
|
/** Input excludes cache reads/writes when those disjoint counters are supplied.
|
|
98
129
|
* Thinking is a subset of output; TTL counters are subsets of cacheWrite.
|
|
@@ -130,7 +161,8 @@ export interface SessionEvent extends SessionIdentity {
|
|
|
130
161
|
| 'duplicate-tool'
|
|
131
162
|
| 'unmatched-tool'
|
|
132
163
|
| 'malformed-tool'
|
|
133
|
-
| 'account-status'
|
|
164
|
+
| 'account-status'
|
|
165
|
+
| 'subagent';
|
|
134
166
|
/** Local transport observation, not a native terminal acknowledgment. */
|
|
135
167
|
readonly transportOutcome?: SessionAttempt['transportOutcome'];
|
|
136
168
|
readonly nativeOutcome?: NativeOutcome;
|
|
@@ -174,6 +206,14 @@ export interface SessionEvent extends SessionIdentity {
|
|
|
174
206
|
readonly limits?: LimitSnapshot;
|
|
175
207
|
/** Owned, deeply frozen native JSON data (for Oracle introspection). */
|
|
176
208
|
readonly raw?: unknown;
|
|
209
|
+
/**
|
|
210
|
+
* The tool call (in its parent's stream) that spawned the subagent this event came from; absent
|
|
211
|
+
* for the main thread's own events. Every subagent event, its lifecycle included, is outside any
|
|
212
|
+
* admission (`unattributedReason: "subagent"`), so it never touches a turn's content or results.
|
|
213
|
+
*/
|
|
214
|
+
readonly parentToolUseId?: string;
|
|
215
|
+
/** A subagent's lifecycle (kind `subagent`). */
|
|
216
|
+
readonly subagent?: SubagentLifecycle;
|
|
177
217
|
}
|
|
178
218
|
|
|
179
219
|
/** Result of a single send() — the turn's content plus all classified events. */
|
package/src/turn-state.ts
CHANGED
|
@@ -63,8 +63,11 @@ export class TurnState {
|
|
|
63
63
|
record(event: SessionEvent): SessionEvent {
|
|
64
64
|
const e = retainEvidence({ ...this.identity, ...event, admissionId: this.admissionId });
|
|
65
65
|
this.events.push(e);
|
|
66
|
-
|
|
67
|
-
if (e.
|
|
66
|
+
// A subagent's text is its own; the parent turn's content is only the parent's.
|
|
67
|
+
if (!e.parentToolUseId) {
|
|
68
|
+
if ((e.kind === 'text' || e.kind === 'result') && e.text) this.content = e.text;
|
|
69
|
+
if (e.kind === 'text_delta' && e.text) this.content += e.text;
|
|
70
|
+
}
|
|
68
71
|
// Usage events are per-request snapshots; the terminal result carries the turn total.
|
|
69
72
|
if (e.tokens && e.kind !== 'usage') this.tokens = e.tokens;
|
|
70
73
|
return e;
|