@inixiative/agent-session 0.6.4 → 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 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.4",
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.81",
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
- private _classify(msg: Record<string, unknown>): SessionEvent[] {
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
+ }
@@ -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
  // ---------------------------------------------------------------------------
@@ -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
- if ((e.kind === 'text' || e.kind === 'result') && e.text) this.content = e.text;
67
- if (e.kind === 'text_delta' && e.text) this.content += e.text;
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;