jeo-code 0.12.1 → 0.12.2

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.
@@ -0,0 +1,499 @@
1
+ import type { ReadableStreamDefaultReader as WebStreamReader } from "node:stream/web";
2
+
3
+ const DEFAULT_TURN_TIMEOUT_MS = 5 * 60_000;
4
+ const DEFAULT_MODEL_TIMEOUT_MS = 15_000;
5
+ const MAX_TURN_TIMEOUT_MS = DEFAULT_TURN_TIMEOUT_MS;
6
+ const MAX_MODEL_TIMEOUT_MS = DEFAULT_MODEL_TIMEOUT_MS;
7
+ const MAX_STREAM_BYTES = 8 * 1024 * 1024;
8
+ const MAX_MODEL_OUTPUT_BYTES = 256 * 1024;
9
+ const KILL_ESCALATION_MS = 250;
10
+
11
+ export interface AgyModelOptions {
12
+ cwd?: string;
13
+ timeoutMs?: number;
14
+ signal?: AbortSignal;
15
+ executable?: string;
16
+ }
17
+
18
+ export interface AgyUsage {
19
+ inputTokens: number;
20
+ outputTokens: number;
21
+ thinkingTokens?: number;
22
+ cacheReadTokens?: number;
23
+ }
24
+
25
+ export interface AgyTurnOptions {
26
+ model: string;
27
+ prompt: string;
28
+ cwd: string;
29
+ conversationId?: string;
30
+ timeoutMs?: number;
31
+ signal?: AbortSignal;
32
+ mode?: "plan" | "auto" | "accept-edits";
33
+ executable?: string;
34
+ onText?: (text: string) => void;
35
+ onNotice?: (text: string) => void;
36
+ onConversation?: (id: string) => void | Promise<void>;
37
+ }
38
+
39
+ export interface AgyTurnResult {
40
+ model: string;
41
+ conversationId: string;
42
+ response: string;
43
+ steps: number;
44
+ usage?: AgyUsage;
45
+ }
46
+
47
+ export type AgyCliErrorKind = "spawn" | "timeout" | "cancelled" | "protocol" | "upstream" | "exit";
48
+
49
+ export class AgyCliError extends Error {
50
+ constructor(
51
+ message: string,
52
+ readonly kind: AgyCliErrorKind,
53
+ readonly exitCode?: number | null,
54
+ readonly stderr?: string,
55
+ ) {
56
+ super(message);
57
+ this.name = "AgyCliError";
58
+ }
59
+ }
60
+
61
+ interface InitPayload {
62
+ model: string;
63
+ }
64
+
65
+ interface StepPayload {
66
+ step_type?: unknown;
67
+ step_index?: unknown;
68
+ state?: unknown;
69
+ text_delta?: unknown;
70
+ tool_name?: unknown;
71
+ tool_info?: unknown;
72
+ permission_mode?: unknown;
73
+ permission?: unknown;
74
+ [key: string]: unknown;
75
+ }
76
+
77
+ interface ResultPayload {
78
+ conversation_id?: unknown;
79
+ status?: unknown;
80
+ response?: unknown;
81
+ error?: unknown;
82
+ model?: unknown;
83
+ usage?: unknown;
84
+ [key: string]: unknown;
85
+ }
86
+
87
+ interface AgyEvent {
88
+ event: unknown;
89
+ conversation_id?: unknown;
90
+ init?: unknown;
91
+ step_update?: unknown;
92
+ result?: unknown;
93
+ }
94
+
95
+ const asRecord = (value: unknown): Record<string, unknown> | undefined => {
96
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
97
+ return value as Record<string, unknown>;
98
+ };
99
+
100
+ const nonEmptyString = (value: unknown): string | undefined =>
101
+ typeof value === "string" && value.trim().length > 0 ? value : undefined;
102
+
103
+ const finiteNonNegative = (value: unknown): number | undefined =>
104
+ typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
105
+
106
+ const normalizeTimeout = (value: number | undefined, defaultMs: number, maxMs: number): number => {
107
+ if (value === undefined) return defaultMs;
108
+ if (!Number.isFinite(value) || value <= 0) throw new AgyCliError("Agy timeout must be a positive finite number of milliseconds.", "protocol");
109
+ return Math.min(Math.max(1, Math.ceil(value)), maxMs);
110
+ };
111
+
112
+
113
+ const canonicalModelParts = (model: string): { canonical: string; slug: string } => {
114
+ if (typeof model !== "string" || !model.startsWith("agy/")) {
115
+ throw new AgyCliError(`Agy model must use the explicit agy/<slug> namespace; received ${String(model)}.`, "protocol");
116
+ }
117
+ const slug = model.slice("agy/".length);
118
+ if (!slug || /[\s/]/u.test(slug)) {
119
+ throw new AgyCliError(`Agy model must contain one non-empty CLI slug; received ${model}.`, "protocol");
120
+ }
121
+ return { canonical: `agy/${slug}`, slug };
122
+ };
123
+
124
+ const decodeError = (error: unknown): string => error instanceof Error ? error.message : String(error);
125
+
126
+ const appendBounded = (current: string, chunk: string, limit: number): string => {
127
+ if (!chunk) return current;
128
+ const next = current + chunk;
129
+ return next.length <= limit ? next : next.slice(0, limit);
130
+ };
131
+
132
+ const killProcess = (proc: Pick<Bun.Subprocess, "kill">, signal: "SIGTERM" | "SIGKILL"): void => {
133
+ try {
134
+ proc.kill(signal);
135
+ } catch {
136
+ // The process may have exited between the liveness check and the signal.
137
+ }
138
+ };
139
+
140
+ function parseModelList(stdout: string): string[] {
141
+ const models: string[] = [];
142
+ const seen = new Set<string>();
143
+ for (const [index, rawLine] of stdout.split(/\r?\n/u).entries()) {
144
+ const line = rawLine.trimEnd();
145
+ if (!line.trim()) continue;
146
+ const tab = line.indexOf("\t");
147
+ if (tab < 0) {
148
+ throw new AgyCliError(`Malformed agy model list record at line ${index + 1}; expected <slug>\\t<label>.`, "protocol");
149
+ }
150
+ const slug = line.slice(0, tab);
151
+ const label = line.slice(tab + 1);
152
+ if (!slug.trim() || !label.trim() || /[\s/]/u.test(slug)) {
153
+ throw new AgyCliError(`Malformed agy model list record at line ${index + 1}; slug and label are required.`, "protocol");
154
+ }
155
+ if (seen.has(slug)) continue;
156
+ seen.add(slug);
157
+ models.push(`agy/${slug}`);
158
+ }
159
+ return models;
160
+ }
161
+
162
+ export async function listAgyModels(options: AgyModelOptions = {}): Promise<string[]> {
163
+ const timeoutMs = normalizeTimeout(options.timeoutMs, DEFAULT_MODEL_TIMEOUT_MS, MAX_MODEL_TIMEOUT_MS);
164
+ if (options.signal?.aborted) throw new AgyCliError("Agy model discovery was aborted before spawn.", "cancelled");
165
+
166
+ const executable = options.executable ?? "agy";
167
+ const cwd = options.cwd ?? process.cwd();
168
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
169
+ try {
170
+ proc = Bun.spawn([executable, "models"], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
171
+ } catch (error) {
172
+ throw new AgyCliError(`Failed to start agy model discovery: ${decodeError(error)}. Install agy and authenticate it with its own CLI.`, "spawn");
173
+ }
174
+
175
+ let stdout = "";
176
+ let stderr = "";
177
+ let stdoutBytes = 0;
178
+ let stderrBytes = 0;
179
+ let timedOut = false;
180
+ let cancelled = false;
181
+ let killed = false;
182
+ let escalation: NodeJS.Timeout | undefined;
183
+ let stdoutReader: WebStreamReader<Uint8Array> | undefined;
184
+ let stderrReader: WebStreamReader<Uint8Array> | undefined;
185
+ let streamFailed: unknown;
186
+
187
+ const stopProcess = (): void => {
188
+ if (killed) return;
189
+ killed = true;
190
+ void stdoutReader?.cancel().catch(() => {});
191
+ void stderrReader?.cancel().catch(() => {});
192
+ killProcess(proc, "SIGTERM");
193
+ escalation = setTimeout(() => killProcess(proc, "SIGKILL"), KILL_ESCALATION_MS);
194
+ };
195
+ const terminate = (reason: "timeout" | "cancelled"): void => {
196
+ if (reason === "timeout") timedOut = true;
197
+ else cancelled = true;
198
+ stopProcess();
199
+ };
200
+ const onAbort = (): void => terminate("cancelled");
201
+ if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true });
202
+
203
+ const drain = async (stream: ReadableStream<Uint8Array>, which: "stdout" | "stderr"): Promise<void> => {
204
+ const reader = stream.getReader();
205
+ if (which === "stdout") stdoutReader = reader;
206
+ else stderrReader = reader;
207
+ const decoder = new TextDecoder();
208
+ try {
209
+ for (;;) {
210
+ const part = await reader.read();
211
+ if (part.done) break;
212
+ const text = decoder.decode(part.value, { stream: true });
213
+ if (which === "stdout") {
214
+ stdoutBytes += part.value.byteLength;
215
+ if (stdoutBytes > MAX_MODEL_OUTPUT_BYTES) throw new AgyCliError("Agy model list exceeded the bounded output limit.", "protocol");
216
+ stdout = appendBounded(stdout, text, MAX_MODEL_OUTPUT_BYTES);
217
+ } else {
218
+ stderrBytes += part.value.byteLength;
219
+ stderr = appendBounded(stderr, text, MAX_MODEL_OUTPUT_BYTES);
220
+ if (stderrBytes > MAX_MODEL_OUTPUT_BYTES) stderr = `${stderr}\n[agy stderr truncated]`;
221
+ }
222
+ }
223
+ const tail = decoder.decode();
224
+ if (which === "stdout") stdout = appendBounded(stdout, tail, MAX_MODEL_OUTPUT_BYTES);
225
+ else stderr = appendBounded(stderr, tail, MAX_MODEL_OUTPUT_BYTES);
226
+ } finally {
227
+ reader.releaseLock();
228
+ }
229
+ };
230
+
231
+ const stdoutPromise = drain(proc.stdout, "stdout").catch(error => { streamFailed = error; stopProcess(); });
232
+ const stderrPromise = drain(proc.stderr, "stderr").catch(error => { if (!streamFailed) streamFailed = error; stopProcess(); });
233
+ try {
234
+ const timer = setTimeout(() => terminate("timeout"), timeoutMs);
235
+ let exitCode: number | null;
236
+ try {
237
+ exitCode = await proc.exited;
238
+ await Promise.all([stdoutPromise, stderrPromise]);
239
+ } finally {
240
+ clearTimeout(timer);
241
+ }
242
+ if (cancelled) throw new AgyCliError("Agy model discovery was cancelled.", "cancelled", exitCode, stderr);
243
+ if (timedOut) throw new AgyCliError(`Agy model discovery timed out after ${timeoutMs}ms.`, "timeout", exitCode, stderr);
244
+ if (streamFailed) throw streamFailed;
245
+ if (exitCode !== 0) {
246
+ const detail = stderr.trim() || `agy models exited with code ${exitCode}`;
247
+ throw new AgyCliError(`${detail} (exit ${exitCode}).`, "exit", exitCode, stderr);
248
+ }
249
+ return parseModelList(stdout);
250
+ } catch (error) {
251
+ if (!killed && proc.exitCode === null) stopProcess();
252
+ if (error instanceof AgyCliError) throw error;
253
+ throw new AgyCliError(`Agy model discovery failed: ${decodeError(error)}${stderr.trim() ? ` — ${stderr.trim()}` : ""}`, "protocol", proc.exitCode, stderr);
254
+ } finally {
255
+ if (options.signal) options.signal.removeEventListener("abort", onAbort);
256
+ try { await proc.exited; } catch { /* ignore exit read failures during cleanup */ }
257
+ clearTimeout(escalation);
258
+ try { await stdoutReader?.cancel(); } catch { /* ignore closed reader */ }
259
+ try { await stderrReader?.cancel(); } catch { /* ignore closed reader */ }
260
+ }
261
+ }
262
+
263
+ function parseUsage(value: unknown): AgyUsage | undefined {
264
+ const usage = asRecord(value);
265
+ if (!usage) return undefined;
266
+ const inputTokens = finiteNonNegative(usage.input_tokens ?? usage.inputTokens);
267
+ const outputTokens = finiteNonNegative(usage.output_tokens ?? usage.outputTokens);
268
+ if (inputTokens === undefined || outputTokens === undefined) return undefined;
269
+ const out: AgyUsage = { inputTokens, outputTokens };
270
+ const thinkingTokens = finiteNonNegative(usage.thinking_tokens ?? usage.thinkingTokens);
271
+ const cacheReadTokens = finiteNonNegative(usage.cache_read_tokens ?? usage.cacheReadTokens);
272
+ if (thinkingTokens !== undefined) out.thinkingTokens = thinkingTokens;
273
+ if (cacheReadTokens !== undefined) out.cacheReadTokens = cacheReadTokens;
274
+ return out;
275
+ }
276
+
277
+ export async function runAgyTurn(options: AgyTurnOptions): Promise<AgyTurnResult> {
278
+ const { canonical, slug } = canonicalModelParts(options.model);
279
+ if (typeof options.prompt !== "string") throw new AgyCliError("Agy prompt must be a string.", "protocol");
280
+ if (!options.cwd) throw new AgyCliError("Agy cwd is required.", "protocol");
281
+ const timeoutMs = normalizeTimeout(options.timeoutMs, DEFAULT_TURN_TIMEOUT_MS, MAX_TURN_TIMEOUT_MS);
282
+ if (options.signal?.aborted) throw new AgyCliError("Agy turn was aborted before spawn.", "cancelled");
283
+
284
+ const args = [
285
+ "--input-format", "stream-json",
286
+ "--output-format", "stream-json",
287
+ "--model", slug,
288
+ "--sandbox",
289
+ "--print-timeout", `${timeoutMs}ms`,
290
+ ];
291
+ if (options.conversationId !== undefined) {
292
+ if (!options.conversationId.trim()) throw new AgyCliError("Agy conversationId must be non-empty when provided.", "protocol");
293
+ args.push("--conversation", options.conversationId);
294
+ }
295
+ if (options.mode !== undefined) args.push("--mode", options.mode);
296
+
297
+ const executable = options.executable ?? "agy";
298
+ let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
299
+ try {
300
+ proc = Bun.spawn([executable, ...args], { cwd: options.cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
301
+ } catch (error) {
302
+ throw new AgyCliError(`Failed to start agy: ${decodeError(error)}. Install agy and authenticate it with its own CLI.`, "spawn");
303
+ }
304
+
305
+ let stdoutBytes = 0;
306
+ let stderr = "";
307
+ let stderrBytes = 0;
308
+ let stdoutLine = "";
309
+ let initModel: string | undefined;
310
+ let conversationId: string | undefined;
311
+ let terminal: ResultPayload | undefined;
312
+ let stepCount = 0;
313
+ let streamedText = "";
314
+ let streamFailed: unknown;
315
+ let timedOut = false;
316
+ let cancelled = false;
317
+ let killed = false;
318
+ let escalation: NodeJS.Timeout | undefined;
319
+ let stdoutReader: WebStreamReader<Uint8Array> | undefined;
320
+ let stderrReader: WebStreamReader<Uint8Array> | undefined;
321
+ const stopProcess = (): void => {
322
+ if (killed) return;
323
+ killed = true;
324
+ void stdoutReader?.cancel().catch(() => {});
325
+ void stderrReader?.cancel().catch(() => {});
326
+ killProcess(proc, "SIGTERM");
327
+ escalation = setTimeout(() => killProcess(proc, "SIGKILL"), KILL_ESCALATION_MS);
328
+ };
329
+ const terminate = (reason: "timeout" | "cancelled"): void => {
330
+ if (reason === "timeout") timedOut = true;
331
+ else cancelled = true;
332
+ stopProcess();
333
+ };
334
+ const onAbort = (): void => terminate("cancelled");
335
+ if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true });
336
+
337
+ const handleEvent = async (line: string): Promise<void> => {
338
+ let parsed: unknown;
339
+ try {
340
+ parsed = JSON.parse(line);
341
+ } catch (error) {
342
+ throw new AgyCliError(`Malformed agy NDJSON event: ${decodeError(error)}`, "protocol", proc.exitCode, stderr);
343
+ }
344
+ const event = asRecord(parsed) as AgyEvent | undefined;
345
+ if (!event || typeof event.event !== "string") throw new AgyCliError("Malformed agy NDJSON envelope: missing event type.", "protocol", proc.exitCode, stderr);
346
+
347
+ if (event.event === "init") {
348
+ if (initModel !== undefined) throw new AgyCliError("Agy emitted more than one init event.", "protocol", proc.exitCode, stderr);
349
+ const init = asRecord(event.init) as InitPayload | undefined;
350
+ const model = nonEmptyString(init?.model);
351
+ const id = nonEmptyString(event.conversation_id);
352
+ if (!init || !model || !id) throw new AgyCliError("Agy init event is missing model or conversation_id.", "protocol", proc.exitCode, stderr);
353
+ if (model !== slug) throw new AgyCliError(`Agy init model mismatch: expected ${slug}, received ${model}.`, "protocol", proc.exitCode, stderr);
354
+ if (options.conversationId !== undefined && id !== options.conversationId) {
355
+ throw new AgyCliError(`Agy init conversation_id mismatch: expected ${options.conversationId}, received ${id}.`, "protocol", proc.exitCode, stderr);
356
+ }
357
+ initModel = model;
358
+ conversationId = id;
359
+ if (options.onConversation) await options.onConversation(id);
360
+ return;
361
+ }
362
+
363
+ if (event.event === "step_update") {
364
+ if (terminal) throw new AgyCliError("Agy emitted step_update after terminal result.", "protocol", proc.exitCode, stderr);
365
+ if (!initModel || !conversationId) throw new AgyCliError("Agy emitted step_update before init.", "protocol", proc.exitCode, stderr);
366
+ const step = asRecord(event.step_update) as StepPayload | undefined;
367
+ if (!step || typeof step.step_index !== "number" || !Number.isInteger(step.step_index) || step.step_index < 0
368
+ || !nonEmptyString(step.state) || !nonEmptyString(step.step_type)
369
+ || (step.text_delta !== undefined && typeof step.text_delta !== "string")
370
+ || (step.tool_name !== undefined && typeof step.tool_name !== "string")
371
+ || (step.permission_mode !== undefined && typeof step.permission_mode !== "string")
372
+ || (step.tool_info !== undefined && !asRecord(step.tool_info))
373
+ || (step.subagent_info !== undefined && !asRecord(step.subagent_info))) {
374
+ throw new AgyCliError("Malformed agy step_update event.", "protocol", proc.exitCode, stderr);
375
+ }
376
+ stepCount++;
377
+ if (step.conversation_id !== conversationId) {
378
+ throw new AgyCliError("Agy step_update conversation_id does not match init.", "protocol", proc.exitCode, stderr);
379
+ }
380
+ const textDelta = typeof step.text_delta === "string" ? step.text_delta : undefined;
381
+ if (step.step_type === "agent_response" && textDelta) {
382
+ streamedText += textDelta;
383
+ options.onText?.(textDelta);
384
+ }
385
+ if (step.step_type === "tool" || step.tool_info !== undefined || step.permission !== undefined || step.permission_mode !== undefined) {
386
+ const notice = typeof step.tool_name === "string" ? `agy tool: ${step.tool_name}` : `agy ${String(step.step_type ?? "permission")} notice`;
387
+ options.onNotice?.(notice);
388
+ }
389
+ return;
390
+ }
391
+
392
+ if (event.event === "result") {
393
+ if (terminal) throw new AgyCliError("Agy emitted more than one terminal result event.", "protocol", proc.exitCode, stderr);
394
+ const result = asRecord(event.result) as ResultPayload | undefined;
395
+ if (!result) throw new AgyCliError("Malformed agy result event.", "protocol", proc.exitCode, stderr);
396
+ terminal = result;
397
+ return;
398
+ }
399
+ // Forward-compatible unknown event types are intentionally ignored.
400
+ };
401
+
402
+ const drainStdout = async (): Promise<void> => {
403
+ const reader = proc.stdout.getReader();
404
+ stdoutReader = reader;
405
+ const decoder = new TextDecoder();
406
+ const consume = async (text: string): Promise<void> => {
407
+ stdoutLine += text;
408
+ if (stdoutLine.length > MAX_STREAM_BYTES) throw new AgyCliError("Agy output exceeded the bounded stream limit.", "protocol", proc.exitCode, stderr);
409
+ let newline = stdoutLine.indexOf("\n");
410
+ while (newline >= 0) {
411
+ const line = stdoutLine.slice(0, newline).replace(/\r$/u, "");
412
+ stdoutLine = stdoutLine.slice(newline + 1);
413
+ if (line.trim()) await handleEvent(line);
414
+ newline = stdoutLine.indexOf("\n");
415
+ }
416
+ };
417
+ try {
418
+ for (;;) {
419
+ const part = await reader.read();
420
+ if (part.done) break;
421
+ stdoutBytes += part.value.byteLength;
422
+ if (stdoutBytes > MAX_STREAM_BYTES) throw new AgyCliError("Agy output exceeded the bounded stream limit.", "protocol", proc.exitCode, stderr);
423
+ await consume(decoder.decode(part.value, { stream: true }));
424
+ }
425
+ await consume(decoder.decode());
426
+ if (stdoutLine.trim()) await handleEvent(stdoutLine.trim());
427
+ stdoutLine = "";
428
+ } finally {
429
+ reader.releaseLock();
430
+ }
431
+ };
432
+
433
+ const drainStderr = async (): Promise<void> => {
434
+ const reader = proc.stderr.getReader();
435
+ stderrReader = reader;
436
+ const decoder = new TextDecoder();
437
+ try {
438
+ for (;;) {
439
+ const part = await reader.read();
440
+ if (part.done) break;
441
+ stderrBytes += part.value.byteLength;
442
+ stderr = appendBounded(stderr, decoder.decode(part.value, { stream: true }), MAX_STREAM_BYTES);
443
+ if (stderrBytes > MAX_STREAM_BYTES) stderr = `${stderr}\n[agy stderr truncated]`;
444
+ }
445
+ stderr = appendBounded(stderr, decoder.decode(), MAX_STREAM_BYTES);
446
+ } finally {
447
+ reader.releaseLock();
448
+ }
449
+ };
450
+
451
+ const stdoutPromise = drainStdout().catch(error => { streamFailed = error; stopProcess(); });
452
+ const stderrPromise = drainStderr().catch(error => { if (!streamFailed) streamFailed = error; stopProcess(); });
453
+ const input = `${JSON.stringify({ event: "user", message: { content: options.prompt } })}\n`;
454
+ const timer = setTimeout(() => terminate("timeout"), timeoutMs);
455
+ try {
456
+ await proc.stdin.write(input);
457
+ await proc.stdin.end();
458
+ const exitCode = await proc.exited;
459
+ await Promise.all([stdoutPromise, stderrPromise]);
460
+ if (cancelled) throw new AgyCliError("Agy turn was cancelled.", "cancelled", exitCode, stderr);
461
+ if (timedOut) throw new AgyCliError(`Agy turn timed out after ${timeoutMs}ms.`, "timeout", exitCode, stderr);
462
+ if (streamFailed) throw streamFailed;
463
+ const terminalStatus = nonEmptyString(terminal?.status)?.toUpperCase();
464
+ if (exitCode !== 0) {
465
+ const upstream = terminal?.error;
466
+ const detail = nonEmptyString(upstream) ?? (stderr.trim() || `agy exited with code ${exitCode}`);
467
+ throw new AgyCliError(`${detail} (exit ${exitCode}).`, terminalStatus === "ERROR" ? "upstream" : "exit", exitCode, stderr);
468
+ }
469
+ if (!initModel || !conversationId) throw new AgyCliError("Agy exited successfully without a valid init event.", "protocol", exitCode, stderr);
470
+ if (!terminal) throw new AgyCliError("Agy exited successfully without a terminal result event.", "protocol", exitCode, stderr);
471
+ const status = terminalStatus;
472
+ const resultConversation = nonEmptyString(terminal.conversation_id);
473
+ if (!resultConversation || resultConversation !== conversationId) throw new AgyCliError("Agy result conversation_id does not match init.", "protocol", exitCode, stderr);
474
+ if (status !== "SUCCESS") {
475
+ const detail = nonEmptyString(terminal.error) ?? `Agy returned terminal status ${status ?? "unknown"}.`;
476
+ throw new AgyCliError(`${detail}${exitCode === 0 ? " (exit 0)." : ` (exit ${exitCode}).`}`, "upstream", exitCode, stderr);
477
+ }
478
+ if (terminal.model !== undefined && terminal.model !== slug) throw new AgyCliError(`Agy result model mismatch: expected ${slug}, received ${String(terminal.model)}.`, "protocol", exitCode, stderr);
479
+ const response = typeof terminal.response === "string" ? terminal.response : undefined;
480
+ if (!response || !response.trim()) throw new AgyCliError("Agy returned SUCCESS with an empty response.", "protocol", exitCode, stderr);
481
+ if (!streamedText && response) options.onText?.(response);
482
+ else if (streamedText && response.startsWith(streamedText) && response.length > streamedText.length) options.onText?.(response.slice(streamedText.length));
483
+ return { model: canonical, conversationId, response, steps: stepCount, usage: parseUsage(terminal.usage) };
484
+ } catch (error) {
485
+ if (!killed && proc.exitCode === null) stopProcess();
486
+ if (cancelled) throw new AgyCliError("Agy turn was cancelled.", "cancelled", proc.exitCode, stderr);
487
+ if (timedOut) throw new AgyCliError(`Agy turn timed out after ${timeoutMs}ms.`, "timeout", proc.exitCode, stderr);
488
+ if (error instanceof AgyCliError) throw error;
489
+ if (error instanceof Error && /abort|cancel/i.test(error.message)) throw new AgyCliError(error.message, "cancelled", proc.exitCode, stderr);
490
+ throw new AgyCliError(`Agy turn failed: ${decodeError(error)}${stderr.trim() ? ` — ${stderr.trim()}` : ""}`, "protocol", proc.exitCode, stderr);
491
+ } finally {
492
+ if (options.signal) options.signal.removeEventListener("abort", onAbort);
493
+ clearTimeout(timer);
494
+ try { await proc.exited; } catch { /* ignore exit read failures during cleanup */ }
495
+ clearTimeout(escalation);
496
+ try { await stdoutReader?.cancel(); } catch { /* ignore closed reader */ }
497
+ try { await stderrReader?.cancel(); } catch { /* ignore closed reader */ }
498
+ }
499
+ }
@@ -13,6 +13,8 @@ export interface SessionHeader {
13
13
  /** Model id pinned to this session; restored on resume so each session can carry
14
14
  * its own model independent of the global default (per-session model selection). */
15
15
  model?: string;
16
+ /** Latest CLI-owned conversation, folded from append-only metadata records. */
17
+ agy?: { conversationId: string; messageCount: number };
16
18
  /** Unsent input-box draft at last save, restored into the prompt on `/resume`
17
19
  * (gajae-code parity: an in-progress cursor survives a quit + resume instead
18
20
  * of being silently lost). Absent/empty when there was nothing to restore. */
@@ -135,6 +137,26 @@ export async function appendMessages(
135
137
  await fs.appendFile(file, chunk, "utf8");
136
138
  }
137
139
 
140
+ function isAgySessionState(value: unknown): value is NonNullable<SessionHeader["agy"]> {
141
+ if (!value || typeof value !== "object") return false;
142
+ const state = value as Record<string, unknown>;
143
+ return typeof state.conversationId === "string" && state.conversationId.length > 0
144
+ && state.conversationId.length <= 1024 && !/[\s\u0000-\u001f\u007f]/.test(state.conversationId)
145
+ && typeof state.messageCount === "number" && Number.isSafeInteger(state.messageCount) && state.messageCount >= 0;
146
+ }
147
+
148
+ /** Append metadata instead of rewriting a header while transcript messages arrive. */
149
+ export async function appendAgySession(
150
+ id: string,
151
+ state: NonNullable<SessionHeader["agy"]>,
152
+ cwd = process.cwd(),
153
+ ): Promise<void> {
154
+ if (!isAgySessionState(state)) throw new Error("Invalid official CLI session metadata");
155
+ await fs.appendFile(sessionPath(id, cwd), JSON.stringify({
156
+ type: "agy_session", timestamp: new Date().toISOString(), state,
157
+ }) + "\n", "utf8");
158
+ }
159
+
138
160
  export async function appendCompaction(
139
161
  id: string,
140
162
  seq: number,
@@ -185,6 +207,9 @@ export async function loadSession(
185
207
  rawMessages.push(entry.message);
186
208
  } else if (entry.type === "compaction") {
187
209
  compactions.push(entry as CompactionEntry);
210
+ } else if (entry.type === "agy_session" && header) {
211
+ if (isAgySessionState(entry.state)) header.agy = entry.state;
212
+ else delete header.agy;
188
213
  }
189
214
  }
190
215
  } catch (err) {
@@ -100,6 +100,7 @@ export function normalizeCacheEntries(raw: unknown): CachedProviderModels[] {
100
100
  const row = entry as Partial<CachedProviderModels>;
101
101
  if (!row || typeof row.provider !== "string" || !Array.isArray(row.models)) continue;
102
102
  const provider = row.provider as ProviderName;
103
+ if (provider === "agy") continue;
103
104
  const source: ModelCacheSource =
104
105
  row.source === "oauth" || row.source === "api_key" || row.source === "keyless" || row.source === "none" ? row.source : "none";
105
106
  const baseUrl = typeof row.baseUrl === "string" ? row.baseUrl.replace(/\/+$/, "") : undefined;
@@ -150,7 +151,7 @@ export function isModelCacheStale(cache: ModelCacheFile | null, now = Date.now()
150
151
  */
151
152
  export function mergeCacheEntries(
152
153
  previous: readonly CachedProviderModels[],
153
- results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string; fallback?: boolean }[],
154
+ results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource | "cli_session"; baseUrl?: string; accountId?: string; fallback?: boolean }[],
154
155
  ): CachedProviderModels[] {
155
156
  const byKey = new Map<string, CachedProviderModels>();
156
157
  const isScopedOAuth = (provider: ProviderName, source: ModelCacheSource): boolean =>
@@ -159,10 +160,12 @@ export function mergeCacheEntries(
159
160
  `${provider}\u0000${baseUrl?.replace(/\/+$/, "") ?? ""}\u0000${isScopedOAuth(provider, source ?? "none") ? accountId ?? "" : ""}\u0000${provider === "openai" || provider === "antigravity" ? source ?? "none" : ""}`;
160
161
  for (const entry of previous) {
161
162
  const scoped = isScopedOAuth(entry.provider, entry.source);
163
+ if (entry.provider === "agy") continue;
162
164
  if ((scoped && !entry.accountId) || (entry.provider === "antigravity" && entry.source !== "oauth")) continue;
163
165
  byKey.set(keyOf(entry.provider, entry.baseUrl, entry.accountId, entry.source), entry);
164
166
  }
165
167
  for (const result of results) {
168
+ if (result.provider === "agy" || result.source === "cli_session") continue;
166
169
  const source: ModelCacheSource = result.source ?? "none";
167
170
  const scoped = isScopedOAuth(result.provider, source);
168
171
  const isAntigravity = result.provider === "antigravity";
@@ -183,13 +186,14 @@ export function mergeCacheEntries(
183
186
 
184
187
  /** Persist discovery results (best-effort; never throws, never blocks a turn). */
185
188
  export async function writeModelCache(
186
- results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string; fallback?: boolean }[],
189
+ results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource | "cli_session"; baseUrl?: string; accountId?: string; fallback?: boolean }[],
187
190
  ): Promise<void> {
188
191
  // Same hermeticity rule as saveGlobalConfig: a test run may only write into an
189
192
  // explicitly sandboxed JEO_CONFIG_DIR, never the developer's real ~/.jeo.
190
193
  if (process.env.NODE_ENV === "test" && !jeoEnv("CONFIG_DIR")) return;
191
194
  // A failed/fallback-only refresh must not make an old snapshot appear fresh.
192
195
  const hasFreshResult = results.some(result => {
196
+ if (result.provider === "agy" || result.source === "cli_session") return false;
193
197
  if (!result.ok || result.fallback) return false;
194
198
  if (result.provider === "antigravity") return result.source === "oauth" && !!result.accountId?.trim();
195
199
  if (result.provider === "openai") return result.models.length > 0 || !(result.source === "oauth" && !result.accountId?.trim());
@@ -220,6 +224,7 @@ export function applyCachedModels(cache: ModelCacheFile | null, openAIOauthAccou
220
224
  if (!cache) return 0;
221
225
  let applied = 0;
222
226
  for (const entry of cache.providers) {
227
+ if (entry.provider === "agy") continue;
223
228
  const isOpenAIOAuth = entry.provider === "openai" && entry.source === "oauth";
224
229
  const isAntigravityOAuth = entry.provider === "antigravity" && entry.source === "oauth";
225
230
  if (isOpenAIOAuth && (!openAIScope || entry.accountId !== openAIScope)) continue;
@@ -278,7 +278,7 @@ export function resetLiveCodexModels(): void {
278
278
  * this process. Unlike `liveCodexModels`, this is NOT an OAuth allow-list; it is a
279
279
  * routing supplement so API-key and OpenAI-compatible catalogs discovered at runtime
280
280
  * can participate in prompt routing without waiting for a static release. */
281
- type LiveProviderModelSource = "oauth" | "api_key" | "keyless" | "none";
281
+ type LiveProviderModelSource = "oauth" | "api_key" | "keyless" | "none" | "cli_session";
282
282
  interface LiveProviderModelRecord {
283
283
  row: CatalogModel;
284
284
  source: LiveProviderModelSource;
@@ -339,6 +339,7 @@ function liveCanonicalId(provider: ProviderName, id: string): string {
339
339
  : `openai/${trimmed}`;
340
340
  }
341
341
  if (provider === "xai" || provider === "kimi") return trimmed;
342
+ if (provider === "agy") return `${provider}/${trimmed}`;
342
343
  return openaiCompatDef(provider) || provider === "ollama" || provider === "lmstudio" || provider === "antigravity"
343
344
  ? `${provider}/${trimmed}`
344
345
  : trimmed;
@@ -391,7 +392,7 @@ export function recordLiveProviderModels(
391
392
  if (provider === "openai" && source === "oauth" && scopedAccountId) {
392
393
  setOpenAIOauthAccountScope(scopedAccountId);
393
394
  }
394
- const shouldReplace = provider === "antigravity" || (provider === "openai" && !!opts.replace);
395
+ const shouldReplace = provider === "antigravity" || provider === "agy" || (provider === "openai" && !!opts.replace);
395
396
  if (shouldReplace) {
396
397
  for (const [key, record] of liveProviderModels) {
397
398
  const sameScope = provider === "openai"
@@ -679,5 +680,6 @@ export function companyLabel(provider: string, entry?: { company?: string }): st
679
680
  const compat = openaiCompatDef(low);
680
681
  if (compat) return compat.label;
681
682
  if (low === "antigravity") return "Antigravity";
683
+ if (low === "agy") return "Argon CLI";
682
684
  return provider.charAt(0).toUpperCase() + provider.slice(1);
683
685
  }