@inixiative/agent-session 0.6.7 → 0.7.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/README.md CHANGED
@@ -50,6 +50,12 @@ session.kill();
50
50
  refused before a byte is written.
51
51
  - **fork / resume / interrupt** and pre-send hooks (rewrite the outgoing
52
52
  message — e.g. inject just-in-time context per turn).
53
+ - **A resume whose native conversation is not found is typed.** Claude finding no transcript
54
+ ("No conversation found with session ID": cleaned up, deleted, or under another project
55
+ directory or config home) and Codex finding no rollout ("no rollout found for thread id") both
56
+ raise `NativeBindingMissingError` (`nativeBindingMissing(error)` finds it behind a turn error,
57
+ with the `cwd` the lookup ran in). No turn ran, so the attempt settles as a no-send instead of
58
+ uncertain native work, and the caller can start fresh from its own record.
53
59
  - **Transports with declared capabilities.** How a model is reached is a
54
60
  pluggable transport; switching it is a routing change, not a rewrite.
55
61
  - **Zero dependencies.** Just Bun + the agent CLI you're driving. The Agent SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
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",
@@ -36,18 +36,18 @@
36
36
  ],
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
- "@inixiative/json-rules": "^3.5.4"
39
+ "@inixiative/json-rules": "^3.16.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@biomejs/biome": "2.5.2",
43
- "@inixiative/config": "^0.5.105",
44
- "@types/bun": "1.4.2",
43
+ "@inixiative/config": "^0.5.166",
44
+ "@types/bun": "1.4.4",
45
45
  "lefthook": "2.1.9",
46
46
  "typescript": "6.0.3"
47
47
  },
48
- "packageManager": "bun@1.4.2",
48
+ "packageManager": "bun@1.4.4",
49
49
  "engines": {
50
- "bun": ">=1.4.2"
50
+ "bun": ">=1.4.4"
51
51
  },
52
52
  "repository": {
53
53
  "type": "git",
@@ -59,11 +59,12 @@ import type {
59
59
  SessionSendOptions,
60
60
  SessionTokens,
61
61
  SubagentLifecycle,
62
+ TaskLifecycle,
62
63
  ToolDecision,
63
64
  ToolGate,
64
65
  ToolRequest,
65
66
  } from './harness-session';
66
- import { sendableImages } from './harness-session';
67
+ import { NativeBindingMissingError, sendableImages } from './harness-session';
67
68
 
68
69
  // Re-export types so existing import paths keep working
69
70
  export type {
@@ -354,6 +355,8 @@ export class ClaudeCodeSession implements HarnessSession {
354
355
  // so we know the concrete types at runtime.
355
356
  private _proc: PipedSubprocess | null = null;
356
357
  private _stderr = '';
358
+ /** The stderr reader of the running process; awaited (capped) before stderr is interpreted. */
359
+ private _stderrDone: Promise<void> = Promise.resolve();
357
360
 
358
361
  // -- Session state --
359
362
  /**
@@ -383,6 +386,8 @@ export class ClaudeCodeSession implements HarnessSession {
383
386
  private _seenBoundaries = new Set<string>();
384
387
  /** Task ids whose task_started was a subagent (local_agent); later task frames carry no type. */
385
388
  private _agentTasks = new Set<string>();
389
+ /** Task ids whose task_started was another task type (a background shell, a monitor): id → type. */
390
+ private _otherTasks = new Map<string, string>();
386
391
 
387
392
  // -- Turn queue --
388
393
  private _queue: QueuedTurn[] = [];
@@ -560,7 +565,7 @@ export class ClaudeCodeSession implements HarnessSession {
560
565
  // Background readers — run for session lifetime (don't await)
561
566
  const proc = this._proc;
562
567
  const stdoutDone = this._readStdout(proc);
563
- this._readStderr();
568
+ this._stderrDone = this._readStderr();
564
569
 
565
570
  // Monitor process exit for cleanup
566
571
  // The process read its base context file at startup; it goes when the process does.
@@ -577,7 +582,7 @@ export class ClaudeCodeSession implements HarnessSession {
577
582
  const errMsg = this._stderr.trim()
578
583
  ? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
579
584
  : `Process exited with code ${code}`;
580
- this._rejectInflight(new Error(errMsg));
585
+ this._rejectInflight((await this._settleMissing(errMsg)) ?? new Error(errMsg));
581
586
  this._rejectQueue(new Error('Session ended'));
582
587
  this._rejectControl(new Error('Session ended'));
583
588
  this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
@@ -616,6 +621,8 @@ export class ClaudeCodeSession implements HarnessSession {
616
621
  );
617
622
  } catch (error) {
618
623
  this.kill();
624
+ const missing = this._missingBinding((error as Error).message);
625
+ if (missing) throw missing;
619
626
  throw new Error(
620
627
  `${this._toolGate ? 'Tool gate' : 'Extension'} registration failed: ${(error as Error).message}`,
621
628
  );
@@ -630,6 +637,41 @@ export class ClaudeCodeSession implements HarnessSession {
630
637
  }
631
638
  }
632
639
 
640
+ /**
641
+ * The CLI refused to resume because the transcript is gone (Claude Code's cleanup of
642
+ * `~/.claude/projects`, or a deleted file): it says so and exits before reading a turn.
643
+ */
644
+ private _missingBinding(detail: string): NativeBindingMissingError | undefined {
645
+ const binding = this._externalSessionId;
646
+ if (!binding) return undefined;
647
+ const marker = `No conversation found with session ID: ${binding}`;
648
+ return this._stderr.includes(marker) || detail.includes(marker)
649
+ ? new NativeBindingMissingError('claude-code', binding, marker, this._cwd)
650
+ : undefined;
651
+ }
652
+
653
+ /**
654
+ * After the process ended: was the binding missing? stderr is read first (capped), since the
655
+ * marker is there. A turn written to a CLI that never loaded the conversation (no `system/init`,
656
+ * no terminal) could not run: it settles as a no-send rather than uncertain native work.
657
+ */
658
+ private async _settleMissing(detail: string): Promise<NativeBindingMissingError | undefined> {
659
+ await Promise.race([this._stderrDone, Bun.sleep(250)]);
660
+ const missing = this._missingBinding(detail);
661
+ const a = this._inflight?.evidence;
662
+ if (
663
+ missing &&
664
+ a &&
665
+ a.nativeOutcome === 'unknown' &&
666
+ !a.events.some((event) => {
667
+ const raw = event.raw as { type?: unknown; subtype?: unknown } | undefined;
668
+ return raw?.type === 'system' && raw.subtype === 'init';
669
+ })
670
+ )
671
+ a.dispatch = 'not-dispatched';
672
+ return missing;
673
+ }
674
+
633
675
  private _detachExtension(reason: string): void {
634
676
  try {
635
677
  this._extension?.detach(reason);
@@ -790,6 +832,11 @@ export class ClaudeCodeSession implements HarnessSession {
790
832
  return turn.evidence.nativeOutcome === 'unknown' ? 'unacknowledged' : 'acknowledged';
791
833
  }
792
834
 
835
+ /** Stop a background task via the CLI control protocol (`stop_task`); its end comes as lifecycle. */
836
+ async stopTask(taskId: string, opts?: { timeoutMs?: number }): Promise<void> {
837
+ await this._controlRequest({ subtype: 'stop_task', task_id: taskId }, opts?.timeoutMs ?? 5_000);
838
+ }
839
+
793
840
  /** Account limits via the CLI control protocol (`get_usage`); no model turn. Requires a started session. */
794
841
  async readLimits(opts?: { timeoutMs?: number }): Promise<LimitSnapshot | undefined> {
795
842
  const response = await this._controlRequest(
@@ -1065,14 +1112,12 @@ export class ClaudeCodeSession implements HarnessSession {
1065
1112
  if (buffer.trim() && (this._proc === proc || !this._proc)) {
1066
1113
  this._processLine(buffer);
1067
1114
  }
1068
- if (this._proc === proc && this._alive)
1069
- this._transportFailure(
1070
- new Error(
1071
- this._stderr.trim()
1072
- ? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}`
1073
- : 'Native stdout closed',
1074
- ),
1075
- );
1115
+ if (this._proc === proc && this._alive) {
1116
+ const detail = this._stderr.trim()
1117
+ ? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}`
1118
+ : 'Native stdout closed';
1119
+ this._transportFailure((await this._settleMissing(detail)) ?? new Error(detail));
1120
+ }
1076
1121
  } catch (err) {
1077
1122
  if (this._proc === proc) this._transportFailure(err as Error);
1078
1123
  }
@@ -1260,6 +1305,20 @@ export class ClaudeCodeSession implements HarnessSession {
1260
1305
  ? raw.parent_tool_use_id
1261
1306
  : undefined;
1262
1307
  const lifecycle = this._subagentLifecycle(raw);
1308
+ // A background task (a shell, a monitor) outlives its turn like a subagent: never an admission's.
1309
+ const task = lifecycle || parentToolUseId ? undefined : this._taskLifecycle(raw);
1310
+ if (task) {
1311
+ this._emit({
1312
+ kind: 'task',
1313
+ timestamp: Date.now(),
1314
+ task,
1315
+ raw,
1316
+ correlation: 'unknown',
1317
+ unattributedReason: 'task',
1318
+ nativeSessionId: typeof raw.session_id === 'string' ? raw.session_id : undefined,
1319
+ });
1320
+ return;
1321
+ }
1263
1322
  if (parentToolUseId || lifecycle) {
1264
1323
  const message = raw.message as Record<string, unknown> | undefined;
1265
1324
  const events: SessionEvent[] = lifecycle
@@ -1632,6 +1691,40 @@ export class ClaudeCodeSession implements HarnessSession {
1632
1691
  return claudeSubagentLifecycle(msg, phase, taskId);
1633
1692
  }
1634
1693
 
1694
+ /**
1695
+ * A Claude Code `system` task frame for a task that is not a subagent (`task_type` other than
1696
+ * `local_agent`), as lifecycle. Later phases carry no task type: the session remembers it.
1697
+ */
1698
+ private _taskLifecycle(msg: Record<string, unknown>): TaskLifecycle | undefined {
1699
+ if (msg.type !== 'system' || typeof msg.subtype !== 'string') return undefined;
1700
+ const phase = CLAUDE_TASK_PHASES[msg.subtype];
1701
+ const taskId = typeof msg.task_id === 'string' && msg.task_id ? msg.task_id : undefined;
1702
+ if (!phase || !taskId) return undefined;
1703
+ if (phase === 'started') {
1704
+ if (typeof msg.task_type !== 'string' || msg.task_type === 'local_agent') return undefined;
1705
+ this._otherTasks.set(taskId, msg.task_type);
1706
+ }
1707
+ const taskType = this._otherTasks.get(taskId);
1708
+ if (!taskType) return undefined;
1709
+ if (phase === 'ended') this._otherTasks.delete(taskId);
1710
+ const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
1711
+ const patch =
1712
+ msg.patch && typeof msg.patch === 'object' && !Array.isArray(msg.patch)
1713
+ ? (msg.patch as Record<string, unknown>)
1714
+ : undefined;
1715
+ const status = text(msg.status) ?? text(patch?.status);
1716
+ return {
1717
+ phase,
1718
+ taskId,
1719
+ taskType,
1720
+ ...(text(msg.tool_use_id) ? { toolUseId: text(msg.tool_use_id) } : {}),
1721
+ ...(text(msg.description) ? { description: text(msg.description) } : {}),
1722
+ ...(status ? { status } : {}),
1723
+ ...(text(msg.reason) ? { reason: text(msg.reason) } : {}),
1724
+ ...(text(msg.summary) ? { summary: text(msg.summary) } : {}),
1725
+ };
1726
+ }
1727
+
1635
1728
  private _classify(msg: Record<string, unknown>, parentToolUseId?: string): SessionEvent[] {
1636
1729
  return this._classifyFrame(msg).map((event) =>
1637
1730
  parentToolUseId ? { ...event, parentToolUseId } : event,
@@ -27,7 +27,7 @@ import type {
27
27
  SessionResult,
28
28
  ToolGate,
29
29
  } from './harness-session';
30
- import { sendableImages } from './harness-session';
30
+ import { NativeBindingMissingError, sendableImages } from './harness-session';
31
31
 
32
32
  // Re-export types so importers can stay on one path (mirrors claude-code-session).
33
33
  export type {
@@ -592,8 +592,11 @@ export class CodexAppServerSession implements HarnessSession {
592
592
 
593
593
  private _releaseKnownTurn(): void {
594
594
  const a = this._inflight?.evidence;
595
- if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome === 'unknown') return;
596
- this._turns++;
595
+ if (!a) return;
596
+ // Refused before `turn/start` was written: nothing native is outstanding.
597
+ const noSend = a.dispatch === 'not-dispatched' && a.localOutcome === 'rejected';
598
+ if (!noSend && (!this._canReleaseAttempt(a) || a.nativeOutcome === 'unknown')) return;
599
+ if (!noSend) this._turns++;
597
600
  this._inflight = null;
598
601
  this._processNextTurn();
599
602
  }
@@ -1222,8 +1225,9 @@ export class CodexAppServerSession implements HarnessSession {
1222
1225
  if (!this._threadReady) {
1223
1226
  const binding = this._externalSessionId;
1224
1227
  const method = binding ? 'thread/resume' : 'thread/start';
1225
- const response = object(
1226
- await this._rpcRequest(method, {
1228
+ let resumed: unknown;
1229
+ try {
1230
+ resumed = await this._rpcRequest(method, {
1227
1231
  ...(binding ? { threadId: binding } : {}),
1228
1232
  model: this._model,
1229
1233
  cwd: this._cwd,
@@ -1232,8 +1236,22 @@ export class CodexAppServerSession implements HarnessSession {
1232
1236
  ...(this._gated ? { approvalsReviewer: 'user' } : {}),
1233
1237
  sandbox: this._sandbox,
1234
1238
  ...(this._options.config ? { config: this._options.config } : {}),
1235
- }),
1236
- );
1239
+ });
1240
+ } catch (error) {
1241
+ // The rollout is gone (Codex's own cleanup, or deleted). Codex answered that no thread
1242
+ // exists, so no turn could run and none was written: a settled no-send, not uncertain
1243
+ // native work. Any other failure here keeps its outcome unknown.
1244
+ if (binding && /^no rollout found for thread id\b/i.test((error as Error).message ?? '')) {
1245
+ a.dispatch = 'not-dispatched';
1246
+ throw new NativeBindingMissingError(
1247
+ 'codex-app-server',
1248
+ binding,
1249
+ (error as Error).message,
1250
+ );
1251
+ }
1252
+ throw error;
1253
+ }
1254
+ const response = object(resumed);
1237
1255
  const thread = object(response?.thread),
1238
1256
  id = thread?.id;
1239
1257
  if (typeof id !== 'string' || !id || (binding && id !== binding))
@@ -79,6 +79,35 @@ export class SessionTurnError extends Error {
79
79
  }
80
80
  }
81
81
 
82
+ /**
83
+ * The native binding a session was told to resume was not found by the runtime, from this
84
+ * session's cwd and config home: Claude found no transcript ("No conversation found with session
85
+ * ID": cleaned up, deleted, or kept under another project directory or `CLAUDE_CONFIG_DIR`), or
86
+ * Codex found no rollout ("no rollout found for thread id": deleted, or under another
87
+ * `CODEX_HOME`). No turn ran. The caller decides what comes next, typically a fresh session seeded
88
+ * from its own record of the conversation; `cwd` says where the lookup was made.
89
+ */
90
+ export class NativeBindingMissingError extends Error {
91
+ constructor(
92
+ readonly runtime: 'claude-code' | 'codex-app-server',
93
+ readonly binding: string,
94
+ message: string,
95
+ readonly cwd?: string,
96
+ ) {
97
+ super(message);
98
+ this.name = 'NativeBindingMissingError';
99
+ }
100
+ }
101
+
102
+ /** The missing-binding error behind a session error, if any (it may ride as a turn error's cause). */
103
+ export function nativeBindingMissing(error: unknown): NativeBindingMissingError | undefined {
104
+ for (let current = error, depth = 0; current && depth < 5; depth++) {
105
+ if (current instanceof NativeBindingMissingError) return current;
106
+ current = (current as { cause?: unknown }).cause;
107
+ }
108
+ return undefined;
109
+ }
110
+
82
111
  export type SessionEventKind =
83
112
  | 'text_delta'
84
113
  | 'native_status'
@@ -93,7 +122,8 @@ export type SessionEventKind =
93
122
  | 'rate_limit'
94
123
  | 'result'
95
124
  | 'error'
96
- | 'subagent';
125
+ | 'subagent'
126
+ | 'task';
97
127
 
98
128
  /**
99
129
  * A subagent's lifecycle as the native runtime reports it (kind `subagent`). Claude Code: its
@@ -125,6 +155,26 @@ export interface SubagentLifecycle {
125
155
  };
126
156
  }
127
157
 
158
+ /**
159
+ * A background task that is not a subagent (kind `task`): Claude Code's `system` task frames for a
160
+ * background shell (`local_bash`), a monitor or a workflow. Like a subagent it can outlive the turn
161
+ * that started it, so its events belong to no admission (`unattributedReason: "task"`).
162
+ */
163
+ export interface TaskLifecycle {
164
+ readonly phase: 'started' | 'progress' | 'updated' | 'ended';
165
+ /** The native task id (the id the agent's own task tools name). */
166
+ readonly taskId: string;
167
+ /** The runtime's task type, e.g. `local_bash`. */
168
+ readonly taskType: string;
169
+ /** The tool call that started it, in the session's stream (its command is that call's input). */
170
+ readonly toolUseId?: string;
171
+ readonly description?: string;
172
+ /** The native status at the end (completed, failed, stopped, killed). */
173
+ readonly status?: string;
174
+ readonly reason?: string;
175
+ readonly summary?: string;
176
+ }
177
+
128
178
  /** Input excludes cache reads/writes when those disjoint counters are supplied.
129
179
  * Thinking is a subset of output; TTL counters are subsets of cacheWrite.
130
180
  * Missing optional counters mean the runtime did not report them.
@@ -162,7 +212,8 @@ export interface SessionEvent extends SessionIdentity {
162
212
  | 'unmatched-tool'
163
213
  | 'malformed-tool'
164
214
  | 'account-status'
165
- | 'subagent';
215
+ | 'subagent'
216
+ | 'task';
166
217
  /** Local transport observation, not a native terminal acknowledgment. */
167
218
  readonly transportOutcome?: SessionAttempt['transportOutcome'];
168
219
  readonly nativeOutcome?: NativeOutcome;
@@ -214,6 +265,8 @@ export interface SessionEvent extends SessionIdentity {
214
265
  readonly parentToolUseId?: string;
215
266
  /** A subagent's lifecycle (kind `subagent`). */
216
267
  readonly subagent?: SubagentLifecycle;
268
+ /** A background task's lifecycle (kind `task`). */
269
+ readonly task?: TaskLifecycle;
217
270
  }
218
271
 
219
272
  /** Result of a single send() — the turn's content plus all classified events. */
@@ -369,6 +422,11 @@ export interface HarnessSession {
369
422
  interruptNative?(opts?: { timeoutMs?: number }): Promise<NativeInterruptOutcome>;
370
423
  /** Poll account usage limits without a model turn. Present where the transport declares `limits.poll`. */
371
424
  readLimits?(opts?: { timeoutMs?: number }): Promise<LimitSnapshot | undefined>;
425
+ /**
426
+ * Stop one background task (a `task` lifecycle's `taskId`) through the runtime's own control
427
+ * channel. Its end arrives as the task's `ended` lifecycle. Claude Code: the `stop_task` request.
428
+ */
429
+ stopTask?(taskId: string, opts?: { timeoutMs?: number }): Promise<void>;
372
430
  /** One immutable admission snapshot, without copying unrelated session history. */
373
431
  inspectAttempt?(admissionId: string): SessionAttempt | undefined;
374
432
  readonly admissionProtocol?: 'prewrite-v1';