jeo-code 0.12.13 → 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.
- package/CHANGELOG.md +42 -0
- package/README.ja.md +32 -8
- package/README.ko.md +32 -8
- package/README.md +32 -8
- package/README.zh.md +32 -8
- package/package.json +4 -2
- package/src/agent/browser-tool.ts +9 -1
- package/src/agent/config-schema.ts +8 -0
- package/src/agent/context-files.ts +19 -0
- package/src/agent/engine.ts +25 -6
- package/src/agent/eval-tool.ts +6 -0
- package/src/agent/goal-verifier.ts +30 -6
- package/src/agent/hooks.ts +52 -4
- package/src/agent/job-registry.ts +110 -18
- package/src/agent/job-tool.ts +43 -9
- package/src/agent/loop-guards.ts +17 -2
- package/src/agent/memory.ts +151 -112
- package/src/agent/monitor-registry.ts +142 -30
- package/src/agent/monitor-tool.ts +34 -6
- package/src/agent/notify/discord-daemon.ts +135 -42
- package/src/agent/notify/session-endpoint.ts +27 -5
- package/src/agent/sandbox/assets.d.ts +4 -0
- package/src/agent/sandbox/authority.ts +295 -0
- package/src/agent/sandbox/bundle.ts +37 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +283 -0
- package/src/agent/sandbox/hands-source.text +14548 -0
- package/src/agent/sandbox/hands-worker.ts +168 -0
- package/src/agent/sandbox/policy.ts +155 -0
- package/src/agent/sandbox/rpc.ts +90 -0
- package/src/agent/sandbox/supervisor.ts +210 -0
- package/src/agent/sandbox/types.ts +77 -0
- package/src/agent/shell-lease.ts +190 -0
- package/src/agent/state.ts +167 -0
- package/src/agent/subagents.ts +4 -3
- package/src/agent/task-tool.ts +8 -0
- package/src/agent/todo-tool.ts +30 -1
- package/src/agent/tool-schemas.ts +13 -0
- package/src/ai/model-discovery.ts +12 -0
- package/src/ai/providers/anthropic.ts +27 -2
- package/src/ai/providers/antigravity.ts +10 -2
- package/src/ai/providers/gemini.ts +20 -16
- package/src/ai/providers/ollama.ts +14 -0
- package/src/ai/providers/openai-responses.ts +22 -5
- package/src/ai/providers/openai.ts +23 -40
- package/src/cli/runner.ts +3 -0
- package/src/commands/approve.ts +37 -13
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch/jobs-slash.ts +1 -1
- package/src/commands/launch.ts +261 -35
- package/src/commands/messenger.ts +105 -39
- package/src/commands/notify.ts +2 -2
- package/src/commands/team.ts +44 -12
- package/src/commands/ultragoal.ts +6 -16
- package/src/messenger/activity.ts +2 -1
- package/src/messenger/config.ts +51 -9
- package/src/messenger/discord-setup.ts +122 -20
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +33 -10
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +3 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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)
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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 ?? ""
|
|
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
|
|
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
|
|
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}`);
|
package/src/commands/approve.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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
|
|
59
|
-
if (
|
|
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
|
|
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++;
|