jeo-code 0.12.12 → 0.12.15

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.ja.md +33 -9
  3. package/README.ko.md +33 -9
  4. package/README.md +33 -9
  5. package/README.zh.md +33 -9
  6. package/package.json +4 -2
  7. package/src/agent/browser-tool.ts +9 -1
  8. package/src/agent/config-schema.ts +8 -0
  9. package/src/agent/context-files.ts +19 -0
  10. package/src/agent/engine.ts +25 -6
  11. package/src/agent/eval-tool.ts +6 -0
  12. package/src/agent/goal-verifier.ts +30 -6
  13. package/src/agent/hooks.ts +52 -4
  14. package/src/agent/job-registry.ts +110 -18
  15. package/src/agent/job-tool.ts +43 -9
  16. package/src/agent/loop-guards.ts +17 -2
  17. package/src/agent/memory.ts +151 -112
  18. package/src/agent/monitor-registry.ts +142 -30
  19. package/src/agent/monitor-tool.ts +34 -6
  20. package/src/agent/notify/discord-daemon.ts +135 -42
  21. package/src/agent/notify/session-endpoint.ts +27 -5
  22. package/src/agent/sandbox/assets.d.ts +4 -0
  23. package/src/agent/sandbox/authority.ts +295 -0
  24. package/src/agent/sandbox/bundle.ts +37 -0
  25. package/src/agent/sandbox/control.ts +49 -0
  26. package/src/agent/sandbox/docker.ts +283 -0
  27. package/src/agent/sandbox/hands-source.text +14548 -0
  28. package/src/agent/sandbox/hands-worker.ts +168 -0
  29. package/src/agent/sandbox/policy.ts +155 -0
  30. package/src/agent/sandbox/rpc.ts +90 -0
  31. package/src/agent/sandbox/supervisor.ts +210 -0
  32. package/src/agent/sandbox/types.ts +77 -0
  33. package/src/agent/shell-lease.ts +190 -0
  34. package/src/agent/state.ts +167 -0
  35. package/src/agent/subagents.ts +4 -3
  36. package/src/agent/task-tool.ts +8 -0
  37. package/src/agent/todo-tool.ts +30 -1
  38. package/src/agent/tool-schemas.ts +13 -0
  39. package/src/ai/model-discovery.ts +12 -0
  40. package/src/ai/providers/anthropic.ts +27 -2
  41. package/src/ai/providers/antigravity.ts +10 -2
  42. package/src/ai/providers/gemini.ts +20 -16
  43. package/src/ai/providers/ollama.ts +14 -0
  44. package/src/ai/providers/openai-responses.ts +22 -5
  45. package/src/ai/providers/openai.ts +23 -40
  46. package/src/cli/runner.ts +3 -0
  47. package/src/commands/approve.ts +37 -13
  48. package/src/commands/launch/flags.ts +2 -1
  49. package/src/commands/launch/jobs-slash.ts +1 -1
  50. package/src/commands/launch.ts +261 -35
  51. package/src/commands/messenger.ts +105 -39
  52. package/src/commands/notify.ts +2 -2
  53. package/src/commands/team.ts +44 -12
  54. package/src/commands/ultragoal.ts +6 -16
  55. package/src/messenger/activity.ts +2 -1
  56. package/src/messenger/config.ts +51 -9
  57. package/src/messenger/discord-setup.ts +122 -20
  58. package/src/messenger/discord.ts +39 -52
  59. package/src/messenger/gateway.ts +33 -10
  60. package/src/messenger/model-selection.ts +101 -0
  61. package/src/messenger/session-pool.ts +189 -35
  62. package/src/messenger/types.ts +3 -2
  63. package/src/skills/catalog.ts +207 -0
@@ -43,6 +43,8 @@ export interface ProviderModelsResult {
43
43
  error?: string;
44
44
  /** True when the live endpoint was unusable and ids came from the static catalog. */
45
45
  fallback?: boolean;
46
+ /** Display names from the same live response, keyed by callable model id. */
47
+ displayNames?: Record<string, string>;
46
48
  }
47
49
 
48
50
  export interface DiscoveryOptions {
@@ -565,6 +567,15 @@ export async function listProviderModels(
565
567
  }
566
568
  }
567
569
  let ids = parseModelsBody(provider, body, { openaiCompatible: provider === "openai" && !!baseUrl });
570
+ const displayNames: Record<string, string> = {};
571
+ if (provider === "antigravity" && body && typeof body === "object" && "models" in body && body.models && typeof body.models === "object") {
572
+ for (const [key, row] of Object.entries(body.models)) {
573
+ if (!row || typeof row !== "object" || !("displayName" in row) || typeof row.displayName !== "string" || !row.displayName.trim()) continue;
574
+ const rawId: string = Array.isArray(body.models) ? ("slug" in row && typeof row.slug === "string" ? row.slug : "id" in row && typeof row.id === "string" ? row.id : "name" in row && typeof row.name === "string" ? row.name : "") : key;
575
+ const id = `antigravity/${normalizeAntigravityModelId(rawId)}`;
576
+ if (ids.includes(id)) displayNames[id] = row.displayName;
577
+ }
578
+ }
568
579
  // Gemini paginates: follow nextPageToken (bounded) so the available list is
569
580
  // COMPLETE — page 1 alone silently dropped the newest models (round-15).
570
581
  if (provider === "gemini") {
@@ -609,6 +620,7 @@ export async function listProviderModels(
609
620
  models,
610
621
  ok: true,
611
622
  source,
623
+ ...(Object.keys(displayNames).length ? { displayNames } : {}),
612
624
  ...scope,
613
625
  };
614
626
  } catch (err) {
@@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import type { Credential } from "../../auth";
3
3
  import type { CallOptions, Message, ProviderAdapter } from "../types";
4
4
  import { readSse } from "../sse";
5
- import { ProviderHttpError, parseRetryAfter, parseRetryFromBody, providerHttpError } from "./errors";
5
+ import { ProviderHttpError, ProviderStreamError, parseRetryAfter, parseRetryFromBody, providerHttpError } from "./errors";
6
6
  import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
7
7
  import { sanitizeJsonStrings } from "../../util/sanitize-json";
8
8
  import { jeoEnv } from "../../util/env";
@@ -501,6 +501,13 @@ function emptyCompletionError(stopReason: string | undefined, category?: string)
501
501
  return new Error(`Anthropic returned no content${reason ? ` (${reason})` : ""}${hint}.`);
502
502
  }
503
503
 
504
+ function assertNormalStop(stopReason: string | undefined, category?: string): void {
505
+ if (stopReason && stopReason !== "end_turn" && stopReason !== "tool_use" && stopReason !== "stop_sequence") {
506
+ const hint = stopReason === "max_tokens" ? " — output budget exhausted; raise maxTokens or lower the thinking level" : "";
507
+ throw new Error(`Anthropic response incomplete (stop_reason=${stopReason}${category ? `, category=${category}` : ""})${hint}.`);
508
+ }
509
+ }
510
+
504
511
  export const anthropicAdapter: ProviderAdapter = {
505
512
  name: "anthropic",
506
513
  supportsNativeTools: true,
@@ -508,6 +515,10 @@ export const anthropicAdapter: ProviderAdapter = {
508
515
  const response = await postAnthropic(messages, options, credential, false);
509
516
  const result = (await response.json()) as { model?: string; content: { type: string; text?: string; name?: string; input?: unknown; thinking?: string; signature?: string; data?: string }[]; stop_reason?: string; stop_details?: { category?: string }; usage?: AnthropicUsage };
510
517
  if (result.usage) options.onUsage?.({ inputTokens: totalInputTokens(result.usage), outputTokens: result.usage.output_tokens });
518
+ if (!result.content.some(c => (c.type === "text" && c.text) || (c.type === "tool_use" && c.name)) && result.stop_reason && !["end_turn", "tool_use", "stop_sequence"].includes(result.stop_reason)) {
519
+ throw emptyCompletionError(result.stop_reason, result.stop_details?.category);
520
+ }
521
+ assertNormalStop(result.stop_reason, result.stop_details?.category);
511
522
  // Tag with the model that ACTUALLY produced the content — on a server-side
512
523
  // fallback (see anthropicFallbackModels), `result.model` names the fallback
513
524
  // model, not `options.model` (the originally requested one). Stamping the
@@ -541,6 +552,7 @@ export const anthropicAdapter: ProviderAdapter = {
541
552
  let yieldedAny = false;
542
553
  let stopReason: string | undefined;
543
554
  let stopCategory: string | undefined;
555
+ let terminal = false;
544
556
  // The model that actually served the response — starts at the requested model,
545
557
  // updated from `message_start.message.model` and, on a mid-stream server-side
546
558
  // fallback (see anthropicFallbackModels), from the `fallback` block's `to.model`.
@@ -563,12 +575,18 @@ export const anthropicAdapter: ProviderAdapter = {
563
575
  delta?: { type?: string; text?: string; partial_json?: string; thinking?: string; signature?: string; stop_reason?: string; stop_details?: { category?: string } };
564
576
  message?: { model?: string; usage?: AnthropicUsage; stop_reason?: string; stop_details?: { category?: string } };
565
577
  usage?: { output_tokens?: number };
578
+ error?: { type?: string; message?: string };
566
579
  };
567
580
  try {
568
581
  evt = JSON.parse(data);
569
582
  } catch {
570
583
  continue;
571
584
  }
585
+ if (evt.type === "error") {
586
+ const code = evt.error?.type;
587
+ const statuses: Record<string, number> = { invalid_request_error: 400, authentication_error: 401, permission_error: 403, not_found_error: 404, request_too_large: 413, rate_limit_error: 429, api_error: 500, overloaded_error: 529 };
588
+ throw new ProviderStreamError("Anthropic", evt.error?.message ?? "unknown error", code, code ? statuses[code] : undefined);
589
+ }
572
590
  if (evt.type === "content_block_start" && evt.content_block?.type === "tool_use" && typeof evt.index === "number") {
573
591
  toolBlocks.set(evt.index, { name: evt.content_block.name ?? "", args: "" });
574
592
  } else if (evt.type === "content_block_start" && evt.content_block?.type === "fallback") {
@@ -619,12 +637,19 @@ export const anthropicAdapter: ProviderAdapter = {
619
637
  // message_start is harmless).
620
638
  cachedInput = totalInputTokens(evt.message.usage);
621
639
  }
640
+ } else if (evt.type === "message_stop") {
641
+ terminal = true;
622
642
  } else if (evt.type === "message_delta") {
623
- if (evt.delta?.stop_reason) stopReason = evt.delta.stop_reason;
643
+ if (evt.delta?.stop_reason) { stopReason = evt.delta.stop_reason; terminal = true; }
624
644
  if (evt.delta?.stop_details?.category) stopCategory = evt.delta.stop_details.category;
625
645
  if (evt.usage) options.onUsage?.({ inputTokens: cachedInput, outputTokens: evt.usage.output_tokens });
626
646
  }
627
647
  }
648
+ if (!yieldedAny && !toolBlocks.size && stopReason && !["end_turn", "tool_use", "stop_sequence"].includes(stopReason)) {
649
+ throw emptyCompletionError(stopReason, stopCategory);
650
+ }
651
+ if (!terminal && (yieldedAny || toolBlocks.size)) throw new Error("Anthropic response incomplete (missing terminal event).");
652
+ assertNormalStop(stopReason, stopCategory);
628
653
  // Emit captured thinking blocks as replay artifacts (signed thought + signature).
629
654
  for (const tb of thinkBlocks.values()) {
630
655
  if (tb.text || tb.signature) {
@@ -323,6 +323,7 @@ export const antigravityAdapter: ProviderAdapter = {
323
323
  let out = "";
324
324
  let usage: CcaUsage | undefined;
325
325
  let lastEmptyReason: string | undefined;
326
+ let terminal = false;
326
327
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
327
328
  for await (const data of readSse(response.body)) {
328
329
  let chunk: CcaChunk;
@@ -332,11 +333,14 @@ export const antigravityAdapter: ProviderAdapter = {
332
333
  if (thought) options.onReasoning?.(thought);
333
334
  const delta = textOf(chunk);
334
335
  if (delta) out += delta;
335
- else lastEmptyReason = finishReasonOf(chunk) ?? lastEmptyReason;
336
+ lastEmptyReason = finishReasonOf(chunk) ?? lastEmptyReason;
337
+ if (chunk.response?.candidates?.[0]?.finishReason) terminal = true;
336
338
  fnCalls.push(...functionCallsOf(chunk));
337
339
  if (chunk.response?.usageMetadata) usage = chunk.response.usageMetadata;
338
340
  }
339
341
  if (usage) options.onUsage?.({ inputTokens: usage.promptTokenCount, outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0) });
342
+ if (lastEmptyReason && (out || fnCalls.length)) throw new Error(`Antigravity Cloud Code Assist response incomplete (${lastEmptyReason}).`);
343
+ if (!terminal && (out || fnCalls.length)) throw new Error("Antigravity Cloud Code Assist response incomplete (missing finishReason).");
340
344
  // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
341
345
  const envelope = serializeToolCalls(fnCalls);
342
346
  if (envelope) return envelope;
@@ -352,6 +356,7 @@ export const antigravityAdapter: ProviderAdapter = {
352
356
  let yielded = false;
353
357
  let usage: CcaUsage | undefined;
354
358
  let lastEmptyReason: string | undefined;
359
+ let terminal = false;
355
360
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
356
361
  for await (const data of readSse(response.body, options.onStreamActivity)) {
357
362
  let chunk: CcaChunk;
@@ -361,11 +366,14 @@ export const antigravityAdapter: ProviderAdapter = {
361
366
  if (thought) options.onReasoning?.(thought);
362
367
  const delta = textOf(chunk);
363
368
  if (delta) { yielded = true; yield delta; }
364
- else lastEmptyReason = finishReasonOf(chunk) ?? lastEmptyReason;
369
+ lastEmptyReason = finishReasonOf(chunk) ?? lastEmptyReason;
370
+ if (chunk.response?.candidates?.[0]?.finishReason) terminal = true;
365
371
  fnCalls.push(...functionCallsOf(chunk));
366
372
  if (chunk.response?.usageMetadata) usage = chunk.response.usageMetadata;
367
373
  }
368
374
  if (usage) options.onUsage?.({ inputTokens: usage.promptTokenCount, outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0) });
375
+ if (lastEmptyReason && (yielded || fnCalls.length)) throw new Error(`Antigravity Cloud Code Assist response incomplete (${lastEmptyReason}).`);
376
+ if (!terminal && (yielded || fnCalls.length)) throw new Error("Antigravity Cloud Code Assist response incomplete (missing finishReason).");
369
377
  // Native tool calls have no text deltas — yield the re-serialized envelope once at end.
370
378
  const envelope = serializeToolCalls(fnCalls);
371
379
  if (envelope) { yielded = true; yield envelope; }
@@ -320,9 +320,6 @@ function blockedReason(chunk: GeminiChunk): string | undefined {
320
320
  if (block) return `blockReason=${block}`;
321
321
  const finish = chunk.candidates?.[0]?.finishReason;
322
322
  if (finish === "MAX_TOKENS") {
323
- // Only reached when NO text was produced at all (both call/stream paths guard
324
- // on emptiness): the output budget was consumed before any visible text —
325
- // typically thinking tokens on a 2.5+/latest model.
326
323
  return "finishReason=MAX_TOKENS — output budget exhausted before any text; raise maxTokens or lower the thinking level";
327
324
  }
328
325
  if (finish && finish !== "STOP") return `finishReason=${finish}`;
@@ -360,6 +357,7 @@ async function* ccaTurn(messages: Message[], options: CallOptions, credential: C
360
357
  let lastUsage: GeminiChunk["usageMetadata"];
361
358
  let yieldedAny = false;
362
359
  let lastEmptyReason: string | undefined;
360
+ let terminal = false;
363
361
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
364
362
  const seenSigs = new Set<string>();
365
363
  for await (const data of readSse(response.body, options.onStreamActivity)) {
@@ -379,12 +377,14 @@ async function* ccaTurn(messages: Message[], options: CallOptions, credential: C
379
377
  if (delta) {
380
378
  yieldedAny = true;
381
379
  yield delta;
382
- } else {
383
- lastEmptyReason = blockedReason(inner) ?? lastEmptyReason;
384
380
  }
381
+ lastEmptyReason = blockedReason(inner) ?? lastEmptyReason;
382
+ if (inner.candidates?.[0]?.finishReason) terminal = true;
385
383
  if (inner.usageMetadata) lastUsage = inner.usageMetadata;
386
384
  fnCalls.push(...geminiFunctionCallsOf(inner));
387
385
  }
386
+ if (lastEmptyReason && (yieldedAny || fnCalls.length)) throw new Error(`Gemini (Cloud Code Assist) response incomplete (${lastEmptyReason.replace(" before any text", "")}).`);
387
+ if (!terminal && (yieldedAny || fnCalls.length)) throw new Error("Gemini (Cloud Code Assist) response incomplete (missing finishReason).");
388
388
  const envelope = serializeToolCalls(fnCalls);
389
389
  if (envelope) { yieldedAny = true; yield envelope; }
390
390
  if (!yieldedAny) {
@@ -415,13 +415,14 @@ export const geminiAdapter: ProviderAdapter = {
415
415
  if (result.usageMetadata) {
416
416
  options.onUsage?.({ inputTokens: result.usageMetadata.promptTokenCount, outputTokens: result.usageMetadata.candidatesTokenCount });
417
417
  }
418
+ const reason = blockedReason(result);
419
+ if (reason) {
420
+ const hasContent = !!textOf(result) || geminiFunctionCallsOf(result).length > 0;
421
+ throw new Error(hasContent ? `Gemini response incomplete (${reason.replace(" before any text", "")}).` : `Gemini returned no content (${reason}).`);
422
+ }
418
423
  const envelope = serializeToolCalls(geminiFunctionCallsOf(result));
419
424
  if (envelope) return envelope;
420
425
  const text = textOf(result);
421
- if (!text) {
422
- const reason = blockedReason(result);
423
- if (reason) throw new Error(`Gemini returned no content (${reason}).`);
424
- }
425
426
  return text;
426
427
  },
427
428
  async *stream(messages, options, credential) {
@@ -435,8 +436,9 @@ export const geminiAdapter: ProviderAdapter = {
435
436
  if (!response.body) return;
436
437
  if (geminiThinkingActive(options)) options.onReasoningStart?.();
437
438
  let lastUsage: GeminiChunk["usageMetadata"];
438
- let yieldedAny = false;
439
439
  let lastEmptyReason: string | undefined;
440
+ let terminal = false;
441
+ let receivedText = false;
440
442
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
441
443
  const seenSigs = new Set<string>();
442
444
  for await (const data of readSse(response.body, options.onStreamActivity)) {
@@ -452,21 +454,23 @@ export const geminiAdapter: ProviderAdapter = {
452
454
  captureGeminiSignatures(chunk, options, seenSigs);
453
455
  const delta = textOf(chunk);
454
456
  if (delta) {
455
- yieldedAny = true;
457
+ receivedText = true;
456
458
  yield delta;
457
- } else {
458
- lastEmptyReason = blockedReason(chunk) ?? lastEmptyReason;
459
459
  }
460
+ lastEmptyReason = blockedReason(chunk) ?? lastEmptyReason;
461
+ if (chunk.candidates?.[0]?.finishReason) terminal = true;
460
462
  // Gemini emits cumulative usageMetadata on most chunks; capture the last and
461
463
  // report ONCE after the stream so an accumulating sink can't over-count.
462
464
  if (chunk.usageMetadata) lastUsage = chunk.usageMetadata;
463
465
  fnCalls.push(...geminiFunctionCallsOf(chunk));
464
466
  }
465
- const envelope = serializeToolCalls(fnCalls);
466
- if (envelope) { yieldedAny = true; yield envelope; }
467
- if (!yieldedAny && lastEmptyReason) {
467
+ if (lastEmptyReason) {
468
+ if (receivedText || fnCalls.length) throw new Error(`Gemini response incomplete (${lastEmptyReason.replace(" before any text", "")}).`);
468
469
  throw new Error(`Gemini returned no content (${lastEmptyReason}).`);
469
470
  }
471
+ if (!terminal && (receivedText || fnCalls.length)) throw new Error("Gemini response incomplete (missing finishReason).");
472
+ const envelope = serializeToolCalls(fnCalls);
473
+ if (envelope) yield envelope;
470
474
  if (lastUsage) {
471
475
  options.onUsage?.({ inputTokens: lastUsage.promptTokenCount, outputTokens: lastUsage.candidatesTokenCount });
472
476
  }
@@ -64,6 +64,13 @@ function emptyCompletionError(doneReason: string | undefined): Error {
64
64
  return new Error(`Ollama returned no content${doneReason ? ` (done_reason=${doneReason})` : ""}${hint}.`);
65
65
  }
66
66
 
67
+ function assertNormalDone(doneReason: string | undefined): void {
68
+ if (doneReason && doneReason !== "stop") {
69
+ const hint = doneReason === "length" ? " — output budget exhausted; raise maxTokens" : "";
70
+ throw new Error(`Ollama response incomplete (done_reason=${doneReason})${hint}.`);
71
+ }
72
+ }
73
+
67
74
  export const ollamaAdapter: ProviderAdapter = {
68
75
  name: "ollama",
69
76
  async call(messages, options) {
@@ -72,6 +79,7 @@ export const ollamaAdapter: ProviderAdapter = {
72
79
  if (!response.ok) throw await providerHttpError("Ollama", response, `at ${url}`);
73
80
  const result = (await response.json()) as { message?: { content?: string }; done_reason?: string; prompt_eval_count?: number; eval_count?: number; total_duration?: number };
74
81
  options.onUsage?.({ inputTokens: result.prompt_eval_count, outputTokens: result.eval_count, durationMs: result.total_duration ? Math.round(result.total_duration / 1e6) : undefined });
82
+ assertNormalDone(result.done_reason);
75
83
  const text = result.message?.content ?? "";
76
84
  if (!text) throw emptyCompletionError(result.done_reason);
77
85
  return text;
@@ -83,6 +91,8 @@ export const ollamaAdapter: ProviderAdapter = {
83
91
  if (!response.body) return;
84
92
  let yieldedAny = false;
85
93
  let doneReason: string | undefined;
94
+ let terminal = false;
95
+ let receivedContent = false;
86
96
  // Route inline <think>…</think> (local reasoning models) to the reasoning channel.
87
97
  const think = createThinkSplitter(options.onReasoning);
88
98
  for await (const line of readLines(response.body, options.onStreamActivity)) {
@@ -94,6 +104,7 @@ export const ollamaAdapter: ProviderAdapter = {
94
104
  }
95
105
  const raw = chunk.message?.content;
96
106
  if (raw) {
107
+ receivedContent = true;
97
108
  const visible = think.push(raw);
98
109
  if (visible) {
99
110
  yieldedAny = true;
@@ -105,11 +116,14 @@ export const ollamaAdapter: ProviderAdapter = {
105
116
  const reason = chunk.message?.thinking;
106
117
  if (reason) options.onReasoning?.(reason);
107
118
  if (chunk.done) {
119
+ terminal = true;
108
120
  if (chunk.done_reason) doneReason = chunk.done_reason;
109
121
  options.onUsage?.({ inputTokens: chunk.prompt_eval_count, outputTokens: chunk.eval_count, durationMs: chunk.total_duration ? Math.round(chunk.total_duration / 1e6) : undefined });
110
122
  break;
111
123
  }
112
124
  }
125
+ if (!terminal && receivedContent) throw new Error("Ollama response incomplete (missing done event).");
126
+ assertNormalDone(doneReason);
113
127
  const trailing = think.flush();
114
128
  if (trailing) { yieldedAny = true; yield trailing; }
115
129
  if (!yieldedAny) throw emptyCompletionError(doneReason);
@@ -186,9 +186,9 @@ export interface ResponsesEvent {
186
186
  * carried separately from the human-readable message so callers can classify
187
187
  * retryability without parsing prose (round: in-band SSE error retry fix). */
188
188
  errorCode?: string;
189
- /** `response.incomplete` cause (e.g. max_output_tokens) — surfaced when the
190
- * whole response produced no text (round-5 #1). */
189
+ /** `response.incomplete` cause (e.g. max_output_tokens). */
191
190
  incompleteReason?: string;
191
+ completed?: boolean;
192
192
  /** NATIVE function_call output items (accumulated by the caller across SSE events). */
193
193
  toolCallName?: string;
194
194
  toolCallArgsDelta?: string;
@@ -235,10 +235,11 @@ export function parseResponsesEvent(data: string): ResponsesEvent {
235
235
  return { reasoningDelta: o.delta };
236
236
  }
237
237
  // `response.incomplete` (max_output_tokens / content filter) also carries usage — don't drop it.
238
- if ((o.type === "response.completed" || o.type === "response.incomplete") && o.response?.usage) {
238
+ if (o.type === "response.completed" || o.type === "response.incomplete") {
239
239
  return {
240
- usage: { inputTokens: o.response.usage.input_tokens, outputTokens: o.response.usage.output_tokens },
241
- ...(o.type === "response.incomplete" ? { incompleteReason: o.response.incomplete_details?.reason ?? "incomplete" } : {}),
240
+ ...(o.response?.usage ? { usage: { inputTokens: o.response.usage.input_tokens, outputTokens: o.response.usage.output_tokens } } : {}),
241
+ ...(o.type === "response.completed" ? { completed: true } : {}),
242
+ ...(o.type === "response.incomplete" ? { incompleteReason: o.response?.incomplete_details?.reason ?? "incomplete" } : {}),
242
243
  };
243
244
  }
244
245
  if (o.type === "response.failed" || o.type === "error") {
@@ -284,6 +285,14 @@ function fetchResponses(messages: Message[], options: CallOptions, credential: C
284
285
  );
285
286
  }
286
287
 
288
+ export class ResponsesIncompleteError extends Error {
289
+ constructor(reason: string) {
290
+ const hint = reason === "max_output_tokens" ? " — output budget exhausted; raise maxTokens or lower reasoning effort" : "";
291
+ super(`OpenAI Codex response incomplete (finish_reason=${reason})${hint}.`);
292
+ this.name = "ResponsesIncompleteError";
293
+ }
294
+ }
295
+
287
296
  /** Non-streaming call over the Codex backend (collects the streamed output). */
288
297
  export async function codexResponsesCall(messages: Message[], options: CallOptions, credential: Credential): Promise<string> {
289
298
  const response = await fetchResponses(messages, options, credential);
@@ -291,6 +300,7 @@ export async function codexResponsesCall(messages: Message[], options: CallOptio
291
300
  if (!response.body) return "";
292
301
  let out = "";
293
302
  let incompleteReason: string | undefined;
303
+ let completed = false;
294
304
  const toolAcc = new Map<number, { name: string; args: string }>();
295
305
  for await (const data of readSse(response.body)) {
296
306
  const ev = parseResponsesEvent(data);
@@ -300,8 +310,11 @@ export async function codexResponsesCall(messages: Message[], options: CallOptio
300
310
  accumulateResponsesToolCall(toolAcc, ev);
301
311
  if (ev.usage) options.onUsage?.(ev.usage);
302
312
  if (ev.incompleteReason) incompleteReason = ev.incompleteReason;
313
+ if (ev.completed) completed = true;
303
314
  if (ev.error) throw new ProviderStreamError("OpenAI Codex", ev.error, ev.errorCode);
304
315
  }
316
+ if (incompleteReason) throw new ResponsesIncompleteError(incompleteReason);
317
+ if (!completed && (out || toolAcc.size)) throw new ResponsesIncompleteError("missing_terminal_event");
305
318
  // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
306
319
  const envelope = serializeAccumulatedToolCalls(toolAcc);
307
320
  if (envelope) return envelope;
@@ -320,6 +333,7 @@ export async function* codexResponsesStream(
320
333
  if (!response.body) return;
321
334
  let yieldedAny = false;
322
335
  let incompleteReason: string | undefined;
336
+ let completed = false;
323
337
  const toolAcc = new Map<number, { name: string; args: string }>();
324
338
  for await (const data of readSse(response.body, options.onStreamActivity)) {
325
339
  const ev = parseResponsesEvent(data);
@@ -332,8 +346,11 @@ export async function* codexResponsesStream(
332
346
  accumulateResponsesToolCall(toolAcc, ev);
333
347
  if (ev.usage) options.onUsage?.(ev.usage);
334
348
  if (ev.incompleteReason) incompleteReason = ev.incompleteReason;
349
+ if (ev.completed) completed = true;
335
350
  if (ev.error) throw new ProviderStreamError("OpenAI Codex", ev.error, ev.errorCode);
336
351
  }
352
+ if (incompleteReason) throw new ResponsesIncompleteError(incompleteReason);
353
+ if (!completed && (yieldedAny || toolAcc.size)) throw new ResponsesIncompleteError("missing_terminal_event");
337
354
  // Native tool calls have no output_text deltas — yield the re-serialized envelope once.
338
355
  const envelope = serializeAccumulatedToolCalls(toolAcc);
339
356
  if (envelope) { yieldedAny = true; yield envelope; }
@@ -1,8 +1,8 @@
1
1
  import type { Credential } from "../../auth";
2
2
  import type { CallOptions, Message, ProviderAdapter } from "../types";
3
3
  import { readSse } from "../sse";
4
- import { providerHttpError } from "./errors";
5
- import { codexResponsesCall, codexResponsesStream } from "./openai-responses";
4
+ import { providerHttpError, ProviderStreamError } from "./errors";
5
+ import { codexResponsesCall, codexResponsesStream, ResponsesIncompleteError } from "./openai-responses";
6
6
  import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
7
7
  import { sanitizeJsonStrings } from "../../util/sanitize-json";
8
8
  import { createThinkSplitter, stripLeakedReasoningTags } from "../think-tags";
@@ -110,32 +110,11 @@ function emptyCompletionError(finishReason: string | undefined): Error {
110
110
  return new Error(`OpenAI returned no content${finishReason ? ` (finish_reason=${finishReason})` : ""}${hint}.`);
111
111
  }
112
112
 
113
- /** Scan `text` for the first BALANCED, syntactically valid JSON object that carries a
114
- * `tool`/`tools` key — i.e. a complete jeo tool envelope. Brace-depth aware and string/
115
- * escape safe so a `{` inside a JSON string never miscounts. Returns the raw object
116
- * substring, or "" if none is complete. Used to salvage a truncated (finish_reason=length)
117
- * reasoning stream when the model finished the actionable JSON before it ran out of budget. */
118
- function firstCompleteToolEnvelope(text: string): string {
119
- for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) {
120
- let depth = 0, inStr = false, esc = false;
121
- for (let i = start; i < text.length; i++) {
122
- const ch = text[i];
123
- if (esc) { esc = false; continue; }
124
- if (ch === "\\") { esc = true; continue; }
125
- if (ch === '"') { inStr = !inStr; continue; }
126
- if (inStr) continue;
127
- if (ch === "{") depth++;
128
- else if (ch === "}" && --depth === 0) {
129
- const candidate = text.slice(start, i + 1);
130
- try {
131
- const obj = JSON.parse(candidate) as Record<string, unknown>;
132
- if (obj && (typeof obj.tool === "string" || Array.isArray(obj.tools))) return candidate;
133
- } catch { /* not valid JSON — keep scanning from the next `{` */ }
134
- break;
135
- }
136
- }
113
+ function assertNormalFinish(finishReason: string | undefined): void {
114
+ if (finishReason && finishReason !== "stop" && finishReason !== "tool_calls" && finishReason !== "function_call") {
115
+ const hint = finishReason === "length" ? " — output budget exhausted; raise maxTokens or lower reasoning effort" : "";
116
+ throw new Error(`OpenAI response incomplete (finish_reason=${finishReason})${hint}.`);
137
117
  }
138
- return "";
139
118
  }
140
119
 
141
120
  /** Recover a usable answer from the reasoning channel when `content` came back empty.
@@ -144,16 +123,10 @@ function firstCompleteToolEnvelope(text: string): string {
144
123
  * `reasoning_content`/`reasoning` (or an inline `<think>…</think>` block) and leave
145
124
  * `content` empty, which would otherwise kill the turn with "OpenAI returned no content".
146
125
  *
147
- * Strips leaked think/scaffolding markup and returns the recovered text when the turn ended
148
- * normally (no `finish_reason`, or `stop`). On a `length` finish the reasoning is truncated,
149
- * but a thinking model often COMPLETES the JSON tool envelope before it runs out of budget —
150
- * so we still salvage that complete envelope when present, and otherwise surface the
151
- * actionable budget error. `content_filter` is never recovered (the reply was blocked).
126
+ * Strips leaked think/scaffolding markup after a normal completion.
152
127
  * Returns "" when nothing is recoverable. */
153
- function recoverFromReasoning(reasoning: string, finishReason: string | undefined): string {
128
+ function recoverFromReasoning(reasoning: string): string {
154
129
  if (!reasoning) return "";
155
- if (finishReason === "content_filter") return "";
156
- if (finishReason === "length") return firstCompleteToolEnvelope(reasoning);
157
130
  return stripLeakedReasoningTags(reasoning);
158
131
  }
159
132
 
@@ -234,7 +207,10 @@ export const openaiAdapter: ProviderAdapter = {
234
207
  if (credential.kind === "api_key" && !options.baseUrl && isOpenAIReasoningModel(options.model)) {
235
208
  try {
236
209
  return await codexResponsesCall(messages, options, credential);
237
- } catch { /* /responses unsupported for this model/account — fall through to chat */ }
210
+ } catch (e) {
211
+ if (e instanceof ResponsesIncompleteError || e instanceof ProviderStreamError) throw e;
212
+ // /responses unsupported for this model/account — fall through to chat.
213
+ }
238
214
  }
239
215
  const { url, headers, body } = openaiRequest(messages, options, credential, false);
240
216
  let response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
@@ -251,6 +227,8 @@ export const openaiAdapter: ProviderAdapter = {
251
227
 
252
228
  const result = (await response.json()) as { choices: { message: { content?: string; reasoning_content?: string; reasoning?: string; tool_calls?: { function?: { name?: string; arguments?: string } }[] }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
253
229
  if (result.usage) options.onUsage?.({ inputTokens: result.usage.prompt_tokens, outputTokens: result.usage.completion_tokens });
230
+ const finishReason = result.choices[0]?.finish_reason;
231
+ assertNormalFinish(finishReason);
254
232
  // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
255
233
  const envelope = serializeToolCalls(parseOpenaiToolCalls(result.choices[0]?.message?.tool_calls));
256
234
  if (envelope) return envelope;
@@ -260,9 +238,8 @@ export const openaiAdapter: ProviderAdapter = {
260
238
  // OpenAI-compatible servers) running reasoning models routinely emit the WHOLE reply —
261
239
  // including the `{"tool":…}` JSON — into `reasoning_content`/`reasoning` while leaving
262
240
  // `content` empty, which otherwise dies as a dead "returned no content" turn.
263
- const finishReason = result.choices[0]?.finish_reason;
264
241
  const msg = result.choices[0]?.message;
265
- const recovered = recoverFromReasoning(msg?.reasoning_content ?? msg?.reasoning ?? "", finishReason);
242
+ const recovered = recoverFromReasoning(msg?.reasoning_content ?? msg?.reasoning ?? "");
266
243
  if (recovered) return recovered;
267
244
  throw emptyCompletionError(finishReason);
268
245
  },
@@ -279,7 +256,7 @@ export const openaiAdapter: ProviderAdapter = {
279
256
  for await (const chunk of codexResponsesStream(messages, options, credential)) { started = true; yield chunk; }
280
257
  return;
281
258
  } catch (e) {
282
- if (started) throw e; // mid-stream failure — cannot safely restart on another endpoint
259
+ if (started || e instanceof ResponsesIncompleteError || e instanceof ProviderStreamError) throw e;
283
260
  // else fall through to chat completions below
284
261
  }
285
262
  }
@@ -305,6 +282,7 @@ export const openaiAdapter: ProviderAdapter = {
305
282
  if (!response.body) return;
306
283
  let yieldedAny = false;
307
284
  let finishReason: string | undefined;
285
+ let receivedContent = false;
308
286
  // Mirror every reasoning delta into a local buffer so a content-less reply can be
309
287
  // recovered from the reasoning channel at end-of-stream (see recoverFromReasoning).
310
288
  let reasoningBuf = "";
@@ -323,6 +301,7 @@ export const openaiAdapter: ProviderAdapter = {
323
301
  }
324
302
  const raw = chunk.choices?.[0]?.delta?.content;
325
303
  if (raw) {
304
+ receivedContent = true;
326
305
  const visible = think.push(raw);
327
306
  if (visible) {
328
307
  yieldedAny = true;
@@ -347,6 +326,10 @@ export const openaiAdapter: ProviderAdapter = {
347
326
  if (chunk.choices?.[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
348
327
  if (chunk.usage) options.onUsage?.({ inputTokens: chunk.usage.prompt_tokens, outputTokens: chunk.usage.completion_tokens });
349
328
  }
329
+ if (!finishReason && (receivedContent || reasoningBuf || toolAcc.size)) {
330
+ throw new Error("OpenAI response incomplete (missing finish_reason).");
331
+ }
332
+ assertNormalFinish(finishReason);
350
333
  const trailing = think.flush();
351
334
  if (trailing) { yieldedAny = true; yield trailing; }
352
335
  // Native tool calls stream as tool_calls argument fragments — re-serialize once at end.
@@ -356,7 +339,7 @@ export const openaiAdapter: ProviderAdapter = {
356
339
  // Recover from the reasoning channel before failing the turn. Local reasoning models
357
340
  // (LM Studio, llama.cpp, …) often stream the whole reply — JSON tool call included —
358
341
  // as reasoning/<think> with an empty `content`.
359
- const recovered = recoverFromReasoning(reasoningBuf, finishReason);
342
+ const recovered = recoverFromReasoning(reasoningBuf);
360
343
  if (recovered) { yield recovered; return; }
361
344
  throw emptyCompletionError(finishReason);
362
345
  }
package/src/cli/runner.ts CHANGED
@@ -410,6 +410,9 @@ function leadingGlobalFlag(argv: string[], targets: readonly string[]): boolean
410
410
  }
411
411
  export async function dispatch(argv: string[], ctx: DispatchContext): Promise<number> {
412
412
  const first = argv[0];
413
+ if (process.env.JEO_MESSENGER_SESSION_KEY && first !== "launch") {
414
+ throw new Error("SANDBOX_REQUIRED: messenger workers may only enter the sandboxed launch command");
415
+ }
413
416
 
414
417
  if (first === "--version" || first === "-v") {
415
418
  console.log(`${ctx.appName} v${ctx.version}`);
@@ -5,15 +5,47 @@ import {
5
5
  readGlobalConfig,
6
6
  readWorkflowState,
7
7
  writeWorkflowState,
8
+ readWorkflowFile,
8
9
  } from "../agent/state";
9
10
  import { PlanSchema, normalizePlanShape, parseYaml } from "../agent/plan";
10
11
  import { getSubagentRole, subagentRoleIds } from "../agent/subagents";
12
+ import { currentSandboxExecution } from "../agent/hooks";
11
13
 
12
14
  export interface ApprovePlanResult {
13
15
  ok: boolean;
14
16
  message: string;
15
17
  }
16
18
 
19
+ export interface ApprovalPathInspection {
20
+ exists: boolean;
21
+ canonical: string;
22
+ }
23
+
24
+ /** Inspect only workspace paths in the guest; role configuration stays in the brain. */
25
+ export async function inspectApprovalPath(filePath: string, cwd: string): Promise<ApprovalPathInspection> {
26
+ const resolved = path.resolve(cwd, filePath);
27
+ const sandbox = currentSandboxExecution();
28
+ if (sandbox) {
29
+ const result = await sandbox.supervisor.call("__workflow", { operation: "approvalPath", path: resolved }, { cwd, signal: sandbox.signal });
30
+ if (!result.success) throw new Error(result.error || "SANDBOX_WORKFLOW: approval path inspection failed");
31
+ const value: unknown = JSON.parse(result.output);
32
+ if (typeof value !== "object" || value === null || !("exists" in value) || typeof value.exists !== "boolean" || !("canonical" in value) || typeof value.canonical !== "string") {
33
+ throw new Error("SANDBOX_WORKFLOW: invalid approval path inspection");
34
+ }
35
+ return { exists: value.exists, canonical: value.canonical };
36
+ }
37
+ try {
38
+ await fs.access(resolved);
39
+ } catch {
40
+ return { exists: false, canonical: resolved };
41
+ }
42
+ try {
43
+ return { exists: true, canonical: await fs.realpath(resolved) };
44
+ } catch {
45
+ return { exists: true, canonical: resolved };
46
+ }
47
+ }
48
+
17
49
  /** Core approval gate, shared by the `jeo approve` CLI command and the agent-facing
18
50
  * `approve` tool (src/agent/approve-tool.ts). ALL of the plan-quality checks below
19
51
  * (schema shape, known roles, persisted [OKAY] consensus verdict, hash-vs-consensus
@@ -29,9 +61,8 @@ export async function approvePlan(planPathInput: string, cwd: string): Promise<A
29
61
  const resolvedInputPath = path.resolve(cwd, planPathInput);
30
62
 
31
63
  // Rejection if the plan file doesn't exist on disk
32
- try {
33
- await fs.access(resolvedInputPath);
34
- } catch {
64
+ const inputPath = await inspectApprovalPath(resolvedInputPath, cwd);
65
+ if (!inputPath.exists) {
35
66
  return { ok: false, message: `[ERROR] Plan file not found: ${resolvedInputPath}` };
36
67
  }
37
68
 
@@ -47,16 +78,9 @@ export async function approvePlan(planPathInput: string, cwd: string): Promise<A
47
78
 
48
79
  // Compare canonical (symlink-resolved) paths so a relative arg, an absolute arg,
49
80
  // or a /var↔/private/var (macOS) form all match the stored plan path.
50
- const canonical = async (p: string): Promise<string> => {
51
- try {
52
- return await fs.realpath(p);
53
- } catch {
54
- return p;
55
- }
56
- };
57
81
  const resolvedStatePath = path.resolve(cwd, ralplanState.plan_path);
58
- const [canonInput, canonState] = await Promise.all([canonical(resolvedInputPath), canonical(resolvedStatePath)]);
59
- if (canonInput !== canonState) {
82
+ const statePath = await inspectApprovalPath(resolvedStatePath, cwd);
83
+ if (inputPath.canonical !== statePath.canonical) {
60
84
  return {
61
85
  ok: false,
62
86
  message:
@@ -73,7 +97,7 @@ export async function approvePlan(planPathInput: string, cwd: string): Promise<A
73
97
  // aborting later at execution time.
74
98
  let planContent = "";
75
99
  try {
76
- planContent = await fs.readFile(resolvedInputPath, "utf-8");
100
+ planContent = await readWorkflowFile(resolvedInputPath, cwd);
77
101
  const parsed = PlanSchema.safeParse(normalizePlanShape(parseYaml(planContent)));
78
102
  if (!parsed.success) {
79
103
  return {
@@ -253,12 +253,13 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
253
253
  search: "search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
254
254
  ls: "ls {dirPath} — list a directory's entries (dirs first)",
255
255
  computer: "computer {action, x?, y?, text?, key?, deltaX?, deltaY?, duration?, actions?} — desktop automation (screenshot, click, double_click, move, drag, scroll, type, keypress, wait, batch)",
256
+ sandbox_request: "sandbox_request {host, mode:'ro'|'rw'} requests additional session filesystem access; {action:'status'} reports requests, active grants and guest mount paths. Only direct terminal or authenticated remote /sandbox controls can approve, deny, or revoke access.",
256
257
  };
257
258
 
258
259
  export function buildToolProtocol(allowedTools: Set<string>): string {
259
260
  const lines: string[] = ["You have these tools (call exactly ONE per step):"];
260
261
  let num = 1;
261
- for (const name of ["read", "write", "edit", "bash", "find", "search", "ls", "computer"]) {
262
+ for (const name of ["read", "write", "edit", "bash", "find", "search", "ls", "computer", "sandbox_request"]) {
262
263
  if (allowedTools.has(name)) {
263
264
  lines.push(`${num}. ${TOOL_DESCRIPTIONS[name]}`);
264
265
  num++;