@inixiative/agent-session 0.6.6 → 0.6.8

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
@@ -44,8 +44,18 @@ session.kill();
44
44
  `ANTHROPIC_API_KEY` and authenticates via the CLI's subscription / an
45
45
  `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token` — works headless and in
46
46
  containers.
47
+ - **Images with a turn.** `send(text, { images: [{ mediaType, data }] })` (png, jpeg, gif or
48
+ webp, base64) where the transport declares `capabilities.images`: Claude gets base64 `image`
49
+ blocks in the same user message, Codex `image` inputs with a `data:` URL. Anything else is
50
+ refused before a byte is written.
47
51
  - **fork / resume / interrupt** and pre-send hooks (rewrite the outgoing
48
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.
49
59
  - **Transports with declared capabilities.** How a model is reached is a
50
60
  pluggable transport; switching it is a routing change, not a rewrite.
51
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.6",
3
+ "version": "0.6.8",
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.88",
43
+ "@inixiative/config": "^0.5.118",
44
44
  "@types/bun": "1.4.2",
45
45
  "lefthook": "2.1.9",
46
46
  "typescript": "6.0.3"
@@ -54,6 +54,7 @@ import type {
54
54
  SessionArtifact,
55
55
  SessionEvent,
56
56
  SessionEventHandler,
57
+ SessionImage,
57
58
  SessionResult,
58
59
  SessionSendOptions,
59
60
  SessionTokens,
@@ -62,6 +63,7 @@ import type {
62
63
  ToolGate,
63
64
  ToolRequest,
64
65
  } from './harness-session';
66
+ import { NativeBindingMissingError, sendableImages } from './harness-session';
65
67
 
66
68
  // Re-export types so existing import paths keep working
67
69
  export type {
@@ -211,6 +213,7 @@ interface QueuedTurn {
211
213
  onAdmission?: SessionSendOptions['onAdmission'];
212
214
  evidence: TurnState;
213
215
  message: string;
216
+ images: readonly SessionImage[];
214
217
  timeout: number;
215
218
  resolve: (result: SessionResult) => void;
216
219
  reject: (error: Error) => void;
@@ -351,6 +354,8 @@ export class ClaudeCodeSession implements HarnessSession {
351
354
  // so we know the concrete types at runtime.
352
355
  private _proc: PipedSubprocess | null = null;
353
356
  private _stderr = '';
357
+ /** The stderr reader of the running process; awaited (capped) before stderr is interpreted. */
358
+ private _stderrDone: Promise<void> = Promise.resolve();
354
359
 
355
360
  // -- Session state --
356
361
  /**
@@ -557,7 +562,7 @@ export class ClaudeCodeSession implements HarnessSession {
557
562
  // Background readers — run for session lifetime (don't await)
558
563
  const proc = this._proc;
559
564
  const stdoutDone = this._readStdout(proc);
560
- this._readStderr();
565
+ this._stderrDone = this._readStderr();
561
566
 
562
567
  // Monitor process exit for cleanup
563
568
  // The process read its base context file at startup; it goes when the process does.
@@ -574,7 +579,7 @@ export class ClaudeCodeSession implements HarnessSession {
574
579
  const errMsg = this._stderr.trim()
575
580
  ? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
576
581
  : `Process exited with code ${code}`;
577
- this._rejectInflight(new Error(errMsg));
582
+ this._rejectInflight((await this._settleMissing(errMsg)) ?? new Error(errMsg));
578
583
  this._rejectQueue(new Error('Session ended'));
579
584
  this._rejectControl(new Error('Session ended'));
580
585
  this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
@@ -613,6 +618,8 @@ export class ClaudeCodeSession implements HarnessSession {
613
618
  );
614
619
  } catch (error) {
615
620
  this.kill();
621
+ const missing = this._missingBinding((error as Error).message);
622
+ if (missing) throw missing;
616
623
  throw new Error(
617
624
  `${this._toolGate ? 'Tool gate' : 'Extension'} registration failed: ${(error as Error).message}`,
618
625
  );
@@ -627,6 +634,41 @@ export class ClaudeCodeSession implements HarnessSession {
627
634
  }
628
635
  }
629
636
 
637
+ /**
638
+ * The CLI refused to resume because the transcript is gone (Claude Code's cleanup of
639
+ * `~/.claude/projects`, or a deleted file): it says so and exits before reading a turn.
640
+ */
641
+ private _missingBinding(detail: string): NativeBindingMissingError | undefined {
642
+ const binding = this._externalSessionId;
643
+ if (!binding) return undefined;
644
+ const marker = `No conversation found with session ID: ${binding}`;
645
+ return this._stderr.includes(marker) || detail.includes(marker)
646
+ ? new NativeBindingMissingError('claude-code', binding, marker, this._cwd)
647
+ : undefined;
648
+ }
649
+
650
+ /**
651
+ * After the process ended: was the binding missing? stderr is read first (capped), since the
652
+ * marker is there. A turn written to a CLI that never loaded the conversation (no `system/init`,
653
+ * no terminal) could not run: it settles as a no-send rather than uncertain native work.
654
+ */
655
+ private async _settleMissing(detail: string): Promise<NativeBindingMissingError | undefined> {
656
+ await Promise.race([this._stderrDone, Bun.sleep(250)]);
657
+ const missing = this._missingBinding(detail);
658
+ const a = this._inflight?.evidence;
659
+ if (
660
+ missing &&
661
+ a &&
662
+ a.nativeOutcome === 'unknown' &&
663
+ !a.events.some((event) => {
664
+ const raw = event.raw as { type?: unknown; subtype?: unknown } | undefined;
665
+ return raw?.type === 'system' && raw.subtype === 'init';
666
+ })
667
+ )
668
+ a.dispatch = 'not-dispatched';
669
+ return missing;
670
+ }
671
+
630
672
  private _detachExtension(reason: string): void {
631
673
  try {
632
674
  this._extension?.detach(reason);
@@ -671,6 +713,7 @@ export class ClaudeCodeSession implements HarnessSession {
671
713
  if (!this._alive) throw new Error('Session ended');
672
714
 
673
715
  const timeout = opts?.timeout ?? this._defaultTimeout;
716
+ const images = sendableImages(this.transport, opts?.images);
674
717
 
675
718
  // Compose pre-send hooks in registration order. Each sees the previous
676
719
  // hook's output. Errors in a hook reject the send() — callers should
@@ -686,6 +729,7 @@ export class ClaudeCodeSession implements HarnessSession {
686
729
  const turn: QueuedTurn = {
687
730
  evidence,
688
731
  message: transformed,
732
+ images,
689
733
  timeout,
690
734
  resolve,
691
735
  reject,
@@ -937,11 +981,18 @@ export class ClaudeCodeSession implements HarnessSession {
937
981
  // Wire format validated empirically against claude 2.1.114:
938
982
  // {type:"user", message:{role,content:[{type:"text",text}]}}
939
983
  // Alternative shapes ({type:"user_message"}, {role,content}) are silently dropped.
984
+ // Images are Messages API base64 image blocks, ahead of the text they accompany.
940
985
  const payload = `${JSON.stringify({
941
986
  type: 'user',
942
987
  message: {
943
988
  role: 'user',
944
- content: [{ type: 'text', text: turn.message }],
989
+ content: [
990
+ ...turn.images.map((image) => ({
991
+ type: 'image',
992
+ source: { type: 'base64', media_type: image.mediaType, data: image.data },
993
+ })),
994
+ { type: 'text', text: turn.message },
995
+ ],
945
996
  },
946
997
  })}\n`;
947
998
  try {
@@ -1053,14 +1104,12 @@ export class ClaudeCodeSession implements HarnessSession {
1053
1104
  if (buffer.trim() && (this._proc === proc || !this._proc)) {
1054
1105
  this._processLine(buffer);
1055
1106
  }
1056
- if (this._proc === proc && this._alive)
1057
- this._transportFailure(
1058
- new Error(
1059
- this._stderr.trim()
1060
- ? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}`
1061
- : 'Native stdout closed',
1062
- ),
1063
- );
1107
+ if (this._proc === proc && this._alive) {
1108
+ const detail = this._stderr.trim()
1109
+ ? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}`
1110
+ : 'Native stdout closed';
1111
+ this._transportFailure((await this._settleMissing(detail)) ?? new Error(detail));
1112
+ }
1064
1113
  } catch (err) {
1065
1114
  if (this._proc === proc) this._transportFailure(err as Error);
1066
1115
  }
@@ -23,9 +23,11 @@ import type {
23
23
  SessionArtifact,
24
24
  SessionEvent,
25
25
  SessionEventHandler,
26
+ SessionImage,
26
27
  SessionResult,
27
28
  ToolGate,
28
29
  } from './harness-session';
30
+ import { NativeBindingMissingError, sendableImages } from './harness-session';
29
31
 
30
32
  // Re-export types so importers can stay on one path (mirrors claude-code-session).
31
33
  export type {
@@ -153,6 +155,7 @@ interface QueuedTurn {
153
155
  onAdmission?: import('./harness-session').SessionSendOptions['onAdmission'];
154
156
  evidence: TurnState;
155
157
  message: string;
158
+ images: readonly SessionImage[];
156
159
  timeout: number;
157
160
  resolve: (result: SessionResult) => void;
158
161
  reject: (error: Error) => void;
@@ -455,6 +458,7 @@ export class CodexAppServerSession implements HarnessSession {
455
458
  if (!this._alive) throw new Error('Session ended');
456
459
 
457
460
  const timeout = opts?.timeout ?? this._defaultTimeout;
461
+ const images = sendableImages(this.transport, opts?.images);
458
462
 
459
463
  let transformed = message;
460
464
  for (const hook of this._beforeSendHooks) {
@@ -473,6 +477,7 @@ export class CodexAppServerSession implements HarnessSession {
473
477
  const turn: QueuedTurn = {
474
478
  evidence,
475
479
  message: transformed,
480
+ images,
476
481
  timeout,
477
482
  resolve,
478
483
  reject,
@@ -539,7 +544,7 @@ export class CodexAppServerSession implements HarnessSession {
539
544
  this._resultText = '';
540
545
 
541
546
  // Resolves once the native terminal and the turn/start RPC have both settled.
542
- this._sendTurn(turn.message)
547
+ this._sendTurn(turn.message, turn.images)
543
548
  .then((tokens) => {
544
549
  if (this._inflight !== turn) return;
545
550
  turn.evidence.rpcSettled = true;
@@ -587,8 +592,11 @@ export class CodexAppServerSession implements HarnessSession {
587
592
 
588
593
  private _releaseKnownTurn(): void {
589
594
  const a = this._inflight?.evidence;
590
- if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome === 'unknown') return;
591
- 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++;
592
600
  this._inflight = null;
593
601
  this._processNextTurn();
594
602
  }
@@ -1209,13 +1217,17 @@ export class CodexAppServerSession implements HarnessSession {
1209
1217
  this._rpcNotify('initialized');
1210
1218
  }
1211
1219
 
1212
- private async _sendTurn(message: string): Promise<{ input: number; output: number } | undefined> {
1220
+ private async _sendTurn(
1221
+ message: string,
1222
+ images: readonly SessionImage[],
1223
+ ): Promise<{ input: number; output: number } | undefined> {
1213
1224
  const a = this._inflight!.evidence;
1214
1225
  if (!this._threadReady) {
1215
1226
  const binding = this._externalSessionId;
1216
1227
  const method = binding ? 'thread/resume' : 'thread/start';
1217
- const response = object(
1218
- await this._rpcRequest(method, {
1228
+ let resumed: unknown;
1229
+ try {
1230
+ resumed = await this._rpcRequest(method, {
1219
1231
  ...(binding ? { threadId: binding } : {}),
1220
1232
  model: this._model,
1221
1233
  cwd: this._cwd,
@@ -1224,8 +1236,22 @@ export class CodexAppServerSession implements HarnessSession {
1224
1236
  ...(this._gated ? { approvalsReviewer: 'user' } : {}),
1225
1237
  sandbox: this._sandbox,
1226
1238
  ...(this._options.config ? { config: this._options.config } : {}),
1227
- }),
1228
- );
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);
1229
1255
  const thread = object(response?.thread),
1230
1256
  id = thread?.id;
1231
1257
  if (typeof id !== 'string' || !id || (binding && id !== binding))
@@ -1344,7 +1370,13 @@ export class CodexAppServerSession implements HarnessSession {
1344
1370
  });
1345
1371
  await this._rpcRequest('turn/start', {
1346
1372
  threadId: this._externalSessionId,
1347
- input: [{ type: 'text', text: message }],
1373
+ input: [
1374
+ { type: 'text', text: message },
1375
+ ...images.map((image) => ({
1376
+ type: 'image',
1377
+ url: `data:${image.mediaType};base64,${image.data}`,
1378
+ })),
1379
+ ],
1348
1380
  ...(this._effort ? { effort: this._effort } : {}),
1349
1381
  });
1350
1382
  await completed; // Native terminal and RPC acknowledgment are both required.
@@ -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'
@@ -266,10 +295,47 @@ export type SessionEventHandler = (event: SessionEvent) => void;
266
295
  */
267
296
  export type BeforeSendHook = (message: string) => string | Promise<string>;
268
297
 
298
+ export const SESSION_IMAGE_MEDIA_TYPES = [
299
+ 'image/png',
300
+ 'image/jpeg',
301
+ 'image/gif',
302
+ 'image/webp',
303
+ ] as const;
304
+ export type SessionImageMediaType = (typeof SESSION_IMAGE_MEDIA_TYPES)[number];
305
+
306
+ /** An image sent with a turn's text, where the transport declares `capabilities.images`. */
307
+ export interface SessionImage {
308
+ readonly mediaType: SessionImageMediaType;
309
+ /** The image bytes, base64 (no `data:` prefix). */
310
+ readonly data: string;
311
+ }
312
+
269
313
  export interface SessionSendOptions {
270
314
  timeout?: number;
271
315
  /** Required ownership registration, not an observer. Rejection prevents the native write. */
272
316
  onAdmission?: (attempt: SessionAttempt) => void | Promise<void>;
317
+ /**
318
+ * Images sent with the message, in order, as part of the same user turn. Refused (before
319
+ * anything is written) by a transport without `capabilities.images` and for any other media type.
320
+ */
321
+ images?: readonly SessionImage[];
322
+ }
323
+
324
+ /** Images a send may write: a copy of each, or a refusal naming the first one that is not. */
325
+ export function sendableImages(
326
+ transport: TransportDescriptor | undefined,
327
+ images: readonly SessionImage[] | undefined,
328
+ ): SessionImage[] {
329
+ if (!images?.length) return [];
330
+ if (!transport?.capabilities.images)
331
+ throw new Error(`Transport "${transport?.kind ?? 'unknown'}" does not accept image input`);
332
+ return images.map((image, index) => {
333
+ if (!SESSION_IMAGE_MEDIA_TYPES.includes(image?.mediaType))
334
+ throw new Error(`Image ${index + 1}: unsupported media type ${String(image?.mediaType)}`);
335
+ if (typeof image.data !== 'string' || !image.data || /[^A-Za-z0-9+/=]/.test(image.data))
336
+ throw new Error(`Image ${index + 1}: data must be non-empty base64`);
337
+ return { mediaType: image.mediaType, data: image.data };
338
+ });
273
339
  }
274
340
 
275
341
  // ---------------------------------------------------------------------------
package/src/transport.ts CHANGED
@@ -36,6 +36,8 @@ export interface TransportCapabilities {
36
36
  readonly limits: { readonly stream: boolean; readonly poll: boolean };
37
37
  /** A tool-free, text-only session can be enforced (decisions). */
38
38
  readonly textOnly: boolean;
39
+ /** send() takes images beside the text (`SessionSendOptions.images`): png, jpeg, gif, webp. */
40
+ readonly images: boolean;
39
41
  /** Primed decision host on this transport and how it returns to its primed state each cycle (see primed.ts). */
40
42
  readonly primed: 'fork' | 'none';
41
43
  readonly billing: 'subscription' | 'api';
@@ -70,6 +72,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
70
72
  usage: true,
71
73
  limits: { stream: true, poll: true },
72
74
  textOnly: true,
75
+ images: true,
73
76
  primed: 'fork',
74
77
  billing: 'subscription',
75
78
  },
@@ -80,6 +83,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
80
83
  "interruptNative() and readLimits() use the CLI's stdio control protocol (`interrupt`, `get_usage`).",
81
84
  'toolGate: true launches with `--permission-prompt-tool stdio` and registers a PreToolUse callback over the control protocol: onToolRequest() gates see every call (`pre-use`, any mode, bypass included; only a deny counts) and the asks the permission mode raises (`permission`, final).',
82
85
  'Rollback (`--resume-session-at`) exists natively but is not wired here.',
86
+ 'Images ride in the same stream-json user message as base64 `image` content blocks, before the text.',
83
87
  ],
84
88
  },
85
89
  'codex-app-server': {
@@ -97,6 +101,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
97
101
  usage: true,
98
102
  limits: { stream: true, poll: true },
99
103
  textOnly: false,
104
+ images: true,
100
105
  primed: 'fork',
101
106
  billing: 'subscription',
102
107
  },
@@ -107,6 +112,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
107
112
  'appServer.approvalPolicy/sandbox (default never / danger-full-access; `codexPermissions(mode)`) set the thread policy; command and file-change approvals for the owned turn go to onToolRequest() gates (`permission`) and are answered accept/decline. Other server requests are refused (-32601).',
108
113
  'Primed decisions (CodexPrimedSessions) host every key as a thread of one process per account and fork the primed thread per cycle.',
109
114
  'thread/rollback is refused by codex-cli 0.155 for both ephemeral and paginated threads; session fork() is not wired.',
115
+ 'Images go to turn/start as `image` inputs with a `data:` URL, after the text.',
110
116
  ],
111
117
  },
112
118
  'claude-agent-sdk': {
@@ -124,6 +130,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
124
130
  usage: true,
125
131
  limits: { stream: true, poll: false },
126
132
  textOnly: true,
133
+ images: true,
127
134
  primed: 'none',
128
135
  billing: 'subscription',
129
136
  },
@@ -147,6 +154,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
147
154
  usage: false,
148
155
  limits: none,
149
156
  textOnly: false,
157
+ images: false,
150
158
  primed: 'none',
151
159
  billing: 'subscription',
152
160
  },
@@ -169,6 +177,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
169
177
  usage: false,
170
178
  limits: none,
171
179
  textOnly: false,
180
+ images: false,
172
181
  primed: 'none',
173
182
  billing: 'api',
174
183
  },