jeo-code 0.12.20 → 0.12.22

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.
@@ -570,7 +570,10 @@ export async function runSubagentOnce(
570
570
  // `routing.enabled` off = the model is FIXED: no failover reroute at all (the
571
571
  // engine bail predicates above are withheld too, so a 429 rides its normal
572
572
  // backoff ladder instead of bailing early with nowhere to go).
573
- for (let attempt = 0; failoverEnabled && attempt < MAX_SUBAGENT_REROUTES; attempt++) { const rateLimited = !result.done && isRateLimitError(new Error(result.doneReason ?? ""));
573
+ for (let attempt = 0; failoverEnabled && attempt < MAX_SUBAGENT_REROUTES; attempt++) {
574
+ // An exhausted completion gate puts the model's own candidate answer in doneReason;
575
+ // its text (e.g. "HTTP 429") is not a provider failure and must never reroute.
576
+ const rateLimited = !result.done && !result.completionRejected && isRateLimitError(new Error(result.doneReason ?? ""));
574
577
  const safetyBail = !result.done && !rateLimited && isSafetyFallbackBail(result.doneReason);
575
578
  if (!rateLimited && !safetyBail) break;
576
579
  const previousModel = activeModel;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Host-side workspace fingerprint. The engine brackets bash/computer calls with two snapshots and
3
+ * counts the call as a mutation only when they differ (workspaceSnapshotChanged). Failures and caps
4
+ * are reported as `ok:false`, which always counts as a mutation (safe side).
5
+ *
6
+ * Entry strings folded into an order-independent digest:
7
+ * - directory: `d:<rel>` (presence only; directory mtimes are ignored so creating an excluded
8
+ * child such as `.jeo/` or `node_modules/` never counts)
9
+ * - file/link/other: `<f|l|o>:<rel>:<size>:<mtimeNs>:<ctimeNs>:<ino>` from lstat (never followed)
10
+ * - link: `:t<readlink target>`
11
+ * - regular file with mtime >= anchor - racyWindowMs: `:h<wyhash(content)>` (racy-clean guard
12
+ * against coarse timestamps; the after-snapshot reuses the before-snapshot's anchor)
13
+ * - FIFO/socket/device: stat only, never opened
14
+ */
15
+ import * as fs from "node:fs/promises";
16
+ import { constants } from "node:fs";
17
+ import type { BigIntStats, Dirent } from "node:fs";
18
+ import type { FileHandle } from "node:fs/promises";
19
+ import * as path from "node:path";
20
+
21
+ export interface WorkspaceSnapshotLimits {
22
+ /** Max fingerprinted entries (files+dirs+links) before giving up (counts as a mutation). */
23
+ maxEntries: number;
24
+ /** Wall-clock budget per snapshot (ms). */
25
+ maxMs: number;
26
+ /** Files with mtime within this window of the snapshot start are content-hashed too. */
27
+ racyWindowMs: number;
28
+ /** Per-file and per-snapshot byte caps for racy content hashing. */
29
+ racyMaxFileBytes: number;
30
+ racyMaxTotalBytes: number;
31
+ }
32
+
33
+ export const WORKSPACE_SNAPSHOT_LIMITS: Readonly<WorkspaceSnapshotLimits> = Object.freeze({
34
+ maxEntries: 50_000,
35
+ maxMs: 2_000,
36
+ racyWindowMs: 2_000,
37
+ racyMaxFileBytes: 1024 * 1024,
38
+ racyMaxTotalBytes: 8 * 1024 * 1024,
39
+ });
40
+
41
+ /** Directory NAMES skipped at any depth (A2): exactly these three. */
42
+ export const WORKSPACE_SNAPSHOT_EXCLUDED_DIRS: ReadonlySet<string> = new Set([".git", ".jeo", "node_modules"]);
43
+
44
+ export type WorkspaceSnapshot =
45
+ | { ok: true; digest: bigint; entries: number; elapsedMs: number; startedAtMs: number }
46
+ | { ok: false; reason: "error" | "cap"; detail: string };
47
+
48
+ /** `racySinceMs`: anchor for the racy content-hash predicate. The engine passes the BEFORE snapshot's
49
+ * `startedAtMs` to the AFTER snapshot so both sides hash exactly the same set of unchanged files. */
50
+ export type WorkspaceSnapshotter = (cwd: string, racySinceMs?: number) => Promise<WorkspaceSnapshot>;
51
+
52
+ type Failure = Extract<WorkspaceSnapshot, { ok: false }>;
53
+
54
+ /** Carries the first failure out of the concurrent walk. */
55
+ class SnapshotStop extends Error {
56
+ constructor(readonly result: Failure) { super(result.detail); }
57
+ }
58
+
59
+ const CONCURRENCY = 64;
60
+ const DIGEST_MASK = 0xffff_ffff_ffff_ffffn;
61
+ /** Never follow a link swapped in after lstat, and never block on a FIFO swapped in after lstat. */
62
+ const RACY_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
63
+
64
+ const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error);
65
+ /** A child removed mid-walk: skipped, since a real deletion still changes the digest. */
66
+ const vanished = (error: unknown): boolean => {
67
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
68
+ return code === "ENOENT" || code === "ENOTDIR";
69
+ };
70
+ const stop = (reason: Failure["reason"], detail: string): never => { throw new SnapshotStop({ ok: false, reason, detail }); };
71
+
72
+ async function chunked<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void> {
73
+ for (let i = 0; i < items.length; i += CONCURRENCY) await Promise.all(items.slice(i, i + CONCURRENCY).map(run));
74
+ }
75
+
76
+ interface Dir { rel: string; abs: string }
77
+ interface Leaf extends Dir { dirent: Dirent }
78
+
79
+ export async function snapshotWorkspace(
80
+ cwd: string,
81
+ limits: WorkspaceSnapshotLimits = WORKSPACE_SNAPSHOT_LIMITS,
82
+ racySinceMs?: number,
83
+ ): Promise<WorkspaceSnapshot> {
84
+ const startedAtMs = Date.now();
85
+ const startedAt = performance.now();
86
+ const anchorMs = racySinceMs !== undefined && Number.isFinite(racySinceMs) ? racySinceMs : startedAtMs;
87
+ const racyThresholdNs = BigInt(Math.floor(anchorMs - limits.racyWindowMs)) * 1_000_000n;
88
+ let digest = 0n;
89
+ let entries = 0;
90
+ let racyBytes = 0;
91
+
92
+ const record = (entry: string): void => { digest = (digest + Bun.hash.wyhash(entry)) & DIGEST_MASK; };
93
+ const checkCaps = (): void => {
94
+ if (entries > limits.maxEntries) stop("cap", `workspace has more than ${limits.maxEntries} entries`);
95
+ if (performance.now() - startedAt > limits.maxMs) stop("cap", `workspace snapshot exceeded ${limits.maxMs} ms`);
96
+ };
97
+
98
+ /** Bounded read of a regular file's content; `null` when it vanished before it could be read. */
99
+ const racyHash = async (leaf: Leaf, stat: BigIntStats): Promise<bigint | null> => {
100
+ const size = Number(stat.size);
101
+ if (size > limits.racyMaxFileBytes) stop("error", `racy file too large: ${leaf.rel} (${size} bytes)`);
102
+ racyBytes += size;
103
+ if (racyBytes > limits.racyMaxTotalBytes) stop("error", `racy files too large: more than ${limits.racyMaxTotalBytes} bytes`);
104
+ let handle: FileHandle;
105
+ try { handle = await fs.open(leaf.abs, RACY_OPEN_FLAGS); }
106
+ catch (error) { if (vanished(error)) return null; return stop("error", `cannot open ${leaf.rel}: ${errorMessage(error)}`); }
107
+ try {
108
+ const opened = await handle.stat({ bigint: true });
109
+ if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) stop("error", `file changed during workspace snapshot: ${leaf.rel}`);
110
+ // One spare byte detects growth after lstat without reading past the cap.
111
+ const buffer = new Uint8Array(size + 1);
112
+ let read = 0;
113
+ for (;;) {
114
+ const { bytesRead } = await handle.read(buffer, read, buffer.length - read, read);
115
+ if (bytesRead === 0) break;
116
+ read += bytesRead;
117
+ if (read > size) stop("error", `file changed during workspace snapshot: ${leaf.rel}`);
118
+ }
119
+ return Bun.hash.wyhash(buffer.subarray(0, read));
120
+ } finally {
121
+ await handle.close().catch(() => {});
122
+ }
123
+ };
124
+
125
+ const fingerprint = async (leaf: Leaf, queue: Dir[]): Promise<void> => {
126
+ let stat: BigIntStats;
127
+ try { stat = await fs.lstat(leaf.abs, { bigint: true }); }
128
+ catch (error) {
129
+ if (vanished(error)) { entries -= 1; return; }
130
+ return stop("error", `cannot stat ${leaf.rel}: ${errorMessage(error)}`);
131
+ }
132
+ if (stat.isDirectory()) {
133
+ // Unreported or raced dirent type: a real directory is never fingerprinted by metadata.
134
+ if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(leaf.dirent.name)) { entries -= 1; return; }
135
+ record(`d:${leaf.rel}`);
136
+ queue.push(leaf);
137
+ return;
138
+ }
139
+ const kind = stat.isSymbolicLink() ? "l" : stat.isFile() ? "f" : "o";
140
+ let entry = `${kind}:${leaf.rel}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}:${stat.ino}`;
141
+ if (kind === "l") {
142
+ try { entry += `:t${await fs.readlink(leaf.abs)}`; }
143
+ catch (error) {
144
+ if (vanished(error)) { entries -= 1; return; }
145
+ return stop("error", `cannot read link ${leaf.rel}: ${errorMessage(error)}`);
146
+ }
147
+ } else if (kind === "f" && leaf.dirent.isFile() && stat.mtimeNs >= racyThresholdNs) {
148
+ const hash = await racyHash(leaf, stat);
149
+ if (hash === null) { entries -= 1; return; }
150
+ entry += `:h${hash}`;
151
+ }
152
+ record(entry);
153
+ };
154
+
155
+ const list = async (dir: Dir, next: Dir[], leaves: Leaf[]): Promise<void> => {
156
+ let dirents: Dirent[];
157
+ try { dirents = await fs.readdir(dir.abs, { withFileTypes: true }); }
158
+ catch (error) {
159
+ if (dir.rel !== "" && vanished(error)) return;
160
+ return stop("error", `cannot read directory ${dir.rel || "."}: ${errorMessage(error)}`);
161
+ }
162
+ for (const dirent of dirents) {
163
+ const rel = dir.rel ? `${dir.rel}/${dirent.name}` : dirent.name;
164
+ const child: Leaf = { rel, abs: path.join(dir.abs, dirent.name), dirent };
165
+ if (dirent.isDirectory()) {
166
+ if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(dirent.name)) continue;
167
+ entries += 1;
168
+ record(`d:${rel}`);
169
+ next.push(child);
170
+ } else {
171
+ entries += 1;
172
+ leaves.push(child);
173
+ }
174
+ }
175
+ checkCaps();
176
+ };
177
+
178
+ try {
179
+ let root: BigIntStats;
180
+ try { root = await fs.stat(cwd, { bigint: true }); }
181
+ catch (error) { return { ok: false, reason: "error", detail: `cannot stat workspace root: ${errorMessage(error)}` }; }
182
+ if (!root.isDirectory()) return { ok: false, reason: "error", detail: "workspace root is not a directory" };
183
+
184
+ let level: Dir[] = [{ rel: "", abs: cwd }];
185
+ while (level.length) {
186
+ const next: Dir[] = [];
187
+ for (let i = 0; i < level.length; i += CONCURRENCY) {
188
+ const leaves: Leaf[] = [];
189
+ await Promise.all(level.slice(i, i + CONCURRENCY).map(dir => list(dir, next, leaves)));
190
+ await chunked(leaves, leaf => fingerprint(leaf, next));
191
+ checkCaps();
192
+ }
193
+ level = next;
194
+ }
195
+ return { ok: true, digest, entries, elapsedMs: performance.now() - startedAt, startedAtMs };
196
+ } catch (error) {
197
+ if (error instanceof SnapshotStop) return error.result;
198
+ return { ok: false, reason: "error", detail: errorMessage(error) };
199
+ }
200
+ }
201
+
202
+ /** Production default: default limits, forwards the racy anchor. */
203
+ export const defaultWorkspaceSnapshotter: WorkspaceSnapshotter = (cwd, since) => snapshotWorkspace(cwd, WORKSPACE_SNAPSHOT_LIMITS, since);
204
+
205
+ /** Pure: true when either side failed, or digest/entry count differ. */
206
+ export function workspaceSnapshotChanged(before: WorkspaceSnapshot, after: WorkspaceSnapshot): boolean {
207
+ if (!before.ok || !after.ok) return true;
208
+ return before.digest !== after.digest || before.entries !== after.entries;
209
+ }
package/src/cli/runner.ts CHANGED
@@ -235,7 +235,7 @@ export const COMMANDS: readonly CommandSpec[] = [
235
235
  {
236
236
  name: "update",
237
237
  summary: "Update jeo-code to the latest npm release (bare = install; --check only checks).",
238
- usage: "update [--check|--install] [--json] [--strict] [--version <semver>]",
238
+ usage: "update [--check|--install] [--json] [--strict] [--version <semver>] [--allow-unlocked-global]",
239
239
  loader: async () => {
240
240
  const m = await import("../commands/update");
241
241
  return args => m.runUpdateCommand(args);
@@ -14,6 +14,9 @@ import { visibleWidth, truncateToWidth } from "../tui/components/width";
14
14
  import chalk from "chalk";
15
15
  import { extractChatgptAccountId, CODEX_RESPONSES_URL } from "../ai/providers/openai-responses";
16
16
  import { resolveTierModel, tierModelPool, cheapestCredentialed } from "../agent/prompt-router";
17
+ import { DEFAULT_SIZE_CAP_MS, collectSharedInstallReport, type SharedInstallReport } from "../util/shared-install-report";
18
+ import { formatBytes } from "../tui/components/session-picker";
19
+ import { compareVersions } from "./update";
17
20
 
18
21
  interface ProbeResult {
19
22
  status: "ok" | "fail" | "skipped";
@@ -219,7 +222,93 @@ function providerTableLines(
219
222
  return renderMarkdownTables(md, { unicode, maxColWidth: detailBudget }).split("\n");
220
223
  }
221
224
 
222
- export async function runDoctorCommand(args: string[] = []): Promise<void> {
225
+ export interface DoctorDeps {
226
+ /** Produces the shared bun global report; tests inject it, the default scans the real machine (memoized). */
227
+ sharedInstall?: () => SharedInstallReport | Promise<SharedInstallReport>;
228
+ }
229
+
230
+ const SHARED_INSTALL_TTL_MS = 30_000;
231
+ let sharedInstallMemo: { key: string; at: number; report: SharedInstallReport } | undefined;
232
+
233
+ /** The real scan, memoized for 30 s per environment so repeated doctor runs in one process walk the global dir once. */
234
+ function defaultSharedInstall(): SharedInstallReport {
235
+ const key = [process.env.HOME, process.env.BUN_INSTALL, process.env.BUN_INSTALL_GLOBAL_DIR, process.env.XDG_CONFIG_HOME, process.env.PATH, process.platform].join("|");
236
+ if (sharedInstallMemo?.key === key && Date.now() - sharedInstallMemo.at < SHARED_INSTALL_TTL_MS) return sharedInstallMemo.report;
237
+ const report = collectSharedInstallReport();
238
+ sharedInstallMemo = { key, at: Date.now(), report };
239
+ return report;
240
+ }
241
+
242
+ const ORPHAN_KIND_NOTE: Record<"directory" | "symlink" | "dangling-symlink", string> = {
243
+ directory: "possible orphan",
244
+ symlink: "possible orphan, symlink",
245
+ "dangling-symlink": "possible orphan, dangling symlink",
246
+ };
247
+
248
+ /** Human text for the shared install section. Warnings are yellow, an ok lockfile green; paths are never truncated. */
249
+ function sharedInstallLines(report: SharedInstallReport): string[] {
250
+ const lines: string[] = [];
251
+ const { manifest, lockfile, leftovers, sizes } = report;
252
+ if (report.skipped !== null) {
253
+ lines.push(`Shared install: no usable bun global (${report.skipped}); lockfile, leftover and size checks skipped.`);
254
+ } else {
255
+ lines.push(`Shared install (${report.globalDir ?? "unknown"}):`);
256
+ }
257
+ if (manifest !== null) lines.push(` packages: ${manifest.dependencies} in manifest (${manifest.external} besides jeo-code)`);
258
+ if (lockfile !== null) {
259
+ if (lockfile.status === "ok") {
260
+ const total = manifest?.external ?? lockfile.uncovered.length;
261
+ lines.push(chalk.green(` bun.lock: ok (${total - lockfile.uncovered.length} of ${total} covered)`));
262
+ } else if (lockfile.status === "not-required") {
263
+ lines.push(" bun.lock: not required (jeo-code is the only package)");
264
+ } else if (lockfile.status === "missing") {
265
+ lines.push(chalk.yellow(" bun.lock: MISSING - jeo update will refuse until it is restored (or pass --allow-unlocked-global)"));
266
+ } else if (lockfile.status === "incomplete") {
267
+ lines.push(chalk.yellow(` bun.lock: INCOMPLETE - no resolved entry for: ${lockfile.uncovered.join(", ")}`));
268
+ } else {
269
+ lines.push(chalk.yellow(` bun.lock: UNREADABLE${lockfile.detail ? ` (${lockfile.detail})` : ""}`));
270
+ }
271
+ }
272
+ if (report.jeoOnPath.length === 0) {
273
+ lines.push(" jeo on PATH: none found");
274
+ } else {
275
+ const activeVersion = report.jeoOnPath.find(entry => entry.active)?.version ?? null;
276
+ lines.push(" jeo on PATH (first = active):");
277
+ for (const entry of report.jeoOnPath) {
278
+ const marker = entry.active ? "*" : " ";
279
+ const text = ` ${marker} ${entry.path} ${entry.version ?? "(version unknown)"}${entry.active ? " (active)" : ""}`;
280
+ if (entry.stale && entry.version !== null && activeVersion !== null) {
281
+ const direction = compareVersions(entry.version, activeVersion) < 0 ? "older" : "newer";
282
+ lines.push(chalk.yellow(`${text} [stale: ${direction} than active ${activeVersion}]`));
283
+ } else {
284
+ lines.push(text);
285
+ }
286
+ }
287
+ }
288
+ if (leftovers !== null) {
289
+ if (leftovers.backupFiles.length === 0 && leftovers.possibleOrphans.length === 0 && leftovers.orphanScanComplete) {
290
+ lines.push(" leftovers: none");
291
+ } else {
292
+ lines.push(" leftovers (not touched):");
293
+ for (const file of leftovers.backupFiles) lines.push(chalk.yellow(` file ${file}`));
294
+ for (const orphan of leftovers.possibleOrphans) lines.push(chalk.yellow(` orphan ${orphan.name} (${ORPHAN_KIND_NOTE[orphan.kind]})`));
295
+ if (!leftovers.orphanScanComplete) lines.push(chalk.yellow(" orphan scan stopped at its cap; no orphans reported"));
296
+ }
297
+ }
298
+ if (sizes !== null) {
299
+ const top = `top ${sizes.top.length}: ${sizes.top.map(p => `${p.name} ${formatBytes(p.bytes)}`).join(", ")}`;
300
+ if (sizes.capped === null) {
301
+ lines.push(` size: ${formatBytes(sizes.totalBytes)} total${sizes.top.length > 0 ? `; ${top}` : ""}`);
302
+ } else {
303
+ const cap = sizes.capped === "time" ? `time cap (${DEFAULT_SIZE_CAP_MS} ms)` : `entry cap (${sizes.scannedEntries} entries)`;
304
+ lines.push(chalk.yellow(` size scan stopped at the ${cap}; figures are lower bounds`));
305
+ lines.push(` at least ${formatBytes(sizes.totalBytes)} scanned${sizes.top.length > 0 ? `; ${top}` : ""}`);
306
+ }
307
+ }
308
+ return lines;
309
+ }
310
+
311
+ export async function runDoctorCommand(args: string[] = [], deps: DoctorDeps = {}): Promise<void> {
223
312
  const strict = args.includes("--strict");
224
313
  const json = args.includes("--json");
225
314
  const config = await readGlobalConfig();
@@ -379,6 +468,14 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
379
468
  ? defaultProbe?.result.status === "skipped"
380
469
  : defaultProbe?.result.status === "ok";
381
470
 
471
+ // Shared bun global diagnostics: read-only, never affects `ready` or the exit code.
472
+ let sharedInstall: SharedInstallReport | { error: string };
473
+ try {
474
+ sharedInstall = await (deps.sharedInstall ?? defaultSharedInstall)();
475
+ } catch (err) {
476
+ sharedInstall = { error: err instanceof Error ? err.message : String(err) };
477
+ }
478
+
382
479
  // --- JSON output mode (CI / scripting) ---
383
480
  if (json) {
384
481
  const report = {
@@ -402,6 +499,7 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
402
499
  oauth: oauthHealth,
403
500
  ready,
404
501
  ...(routingEnabled ? { routing: { enabled: true, smolConfigured, preview: routingPreview, ...(routingNotes.length ? { notes: routingNotes } : {}) } } : {}),
502
+ sharedInstall,
405
503
 
406
504
  };
407
505
  console.log(JSON.stringify(report, null, 2));
@@ -490,6 +588,13 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
490
588
  console.log("");
491
589
  }
492
590
 
591
+ if ("error" in sharedInstall) {
592
+ console.log(chalk.yellow(`Shared install: check failed (${sharedInstall.error})`));
593
+ } else {
594
+ for (const line of sharedInstallLines(sharedInstall)) console.log(line);
595
+ }
596
+ console.log("");
597
+
493
598
  // Final verdict
494
599
  if (ready) {
495
600
  console.log(`${chalk.green("[READY]")} Default model '${config.defaultModel}' is reachable.`);
@@ -1229,7 +1229,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
1229
1229
  // injected SKILL.md is NOT user-authored and the [skill] card already names it,
1230
1230
  // gjc-style); a string → show that compact label instead of the raw input.
1231
1231
  opts?: { userCard?: string | null; signal?: AbortSignal; trustedContext?: string },
1232
- ): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
1232
+ ): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string; unverified: boolean }> => {
1233
1233
  opts?.signal?.throwIfAborted();
1234
1234
  if (currentSandboxExecution() && sessionId !== sandboxTranscriptId) throw new Error("SESSION_MISMATCH: start a new launch for the selected transcript; sandbox grants cannot cross sessions");
1235
1235
  let busyDraftHasPaste = false;
@@ -1370,7 +1370,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
1370
1370
  sessionNotifyEndpoint?.sendTurnStream(notifyRedact ? "[redacted]" : reply);
1371
1371
  cliTui?.events().onStep?.(result.steps);
1372
1372
  cliTui?.finish(reply);
1373
- return { done: true, steps: result.steps, reply, rendered: !!cliTui, usage };
1373
+ return { done: true, steps: result.steps, reply, rendered: !!cliTui, usage, unverified: false };
1374
1374
  } catch (error) {
1375
1375
  if (oneShot) {
1376
1376
  process.exitCode = error instanceof AgyCliError
@@ -1883,13 +1883,24 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
1883
1883
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
1884
1884
  // because nothing ever forced the model to update item statuses).
1885
1885
  let turnTodos: { title: string; status: string }[] = [];
1886
+ // Evidence from earlier engine runs of THIS turn always counts; an earlier turn's
1887
+ // unverified mutation counts only while a goal is active (valid or corrupt goal file).
1888
+ let turnPendingMutation = false;
1889
+ try {
1890
+ turnPendingMutation = (await readGoalState(cwd)) !== null && pendingMutationVerification;
1891
+ } catch (error) {
1892
+ if (!(error instanceof GoalStateCorruptError)) throw error;
1893
+ turnPendingMutation = pendingMutationVerification;
1894
+ }
1886
1895
  const onEvidence = async (
1887
1896
  evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean },
1888
1897
  ): Promise<void> => {
1889
1898
  if (evidence.sawVerification && !evidence.verificationStale) {
1890
1899
  pendingMutationVerification = false;
1900
+ turnPendingMutation = false;
1891
1901
  } else if (evidence.sawMutation) {
1892
1902
  pendingMutationVerification = true;
1903
+ turnPendingMutation = true;
1893
1904
  }
1894
1905
 
1895
1906
  let goalState: GoalState | null;
@@ -1965,7 +1976,8 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
1965
1976
  }
1966
1977
  throw error;
1967
1978
  }
1968
- if ((pendingMutationVerification || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
1979
+ const carriedPending = turnPendingMutation || (goalState !== null && pendingMutationVerification);
1980
+ if ((carriedPending || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
1969
1981
  return (
1970
1982
  `Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification. ` +
1971
1983
  `Run a relevant test/build/typecheck/lint after the last change (e.g. 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done again.`
@@ -2261,8 +2273,9 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
2261
2273
 
2262
2274
  // A loop guard is a local safety boundary, not a provider failure. A
2263
2275
  // fresh loop on an equivalent model loses the guard state and could
2264
- // re-execute a previously skipped mutation.
2265
- const routedFailureReason = !result.done && !result.stopClass
2276
+ // re-execute a previously skipped mutation. A rejected completion carries
2277
+ // the model's own answer in doneReason, which must not be read as a provider error.
2278
+ const routedFailureReason = !result.done && !result.stopClass && !result.completionRejected
2266
2279
  ? routeFailureReason(result.doneReason)
2267
2280
  : null;
2268
2281
  if (!routedFailureReason) break;
@@ -2361,7 +2374,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
2361
2374
  : `(reached the ${result.steps}-step limit without signaling done)`);
2362
2375
  // Routing off = the model is fixed and nothing fails over. When the turn died on
2363
2376
  // an account-level limit, say so and name the one switch that re-enables failover.
2364
- const failoverHint = !routingEnabled && !result.done && (() => {
2377
+ const failoverHint = !routingEnabled && !result.done && !result.completionRejected && (() => {
2365
2378
  const failure = new Error(result.doneReason ?? "");
2366
2379
  return isRateLimitError(failure) || isUsageLimitError(failure);
2367
2380
  })()
@@ -2469,7 +2482,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
2469
2482
  }
2470
2483
 
2471
2484
  lastActiveModel = activeModel;
2472
- return { done: result.done, steps: result.steps, reply, rendered: !!tui, usage };
2485
+ return { done: result.done, steps: result.steps, reply, rendered: !!tui, usage, unverified: result.completionRejected === true };
2473
2486
  } finally {
2474
2487
  preflightHarness?.dispose();
2475
2488
  interactiveTurnActive = false;
@@ -2514,9 +2527,12 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
2514
2527
  try {
2515
2528
  const message = JSON.parse(line);
2516
2529
  if (typeof message !== "string" || !message.trim()) throw new Error("Invalid messenger request");
2517
- const { done, reply } = await runTurn(message, false);
2518
- if (!done) throw new Error("Messenger agent turn did not complete");
2519
- console.log(`JEO_MESSENGER_RESULT ${JSON.stringify(reply)}`);
2530
+ const { done, reply, unverified } = await runTurn(message, false);
2531
+ if (done) console.log(`JEO_MESSENGER_RESULT ${JSON.stringify(reply)}`);
2532
+ // The turn ended in a controlled state with an UNVERIFIED-marked answer:
2533
+ // deliver it as a failure and keep serving this conversation's follow-ups.
2534
+ else if (unverified && reply.trim()) console.log(`JEO_MESSENGER_UNVERIFIED ${JSON.stringify(reply)}`);
2535
+ else throw new Error("Messenger agent turn did not complete");
2520
2536
  } catch {
2521
2537
  console.log("JEO_MESSENGER_ERROR");
2522
2538
  break;
@@ -2752,10 +2768,13 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
2752
2768
  return;
2753
2769
  }
2754
2770
  try {
2755
- const { done, reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
2771
+ const { done, reply, rendered, usage, unverified } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
2756
2772
  if (flags.literalPrompt) {
2757
- if (!done) throw new Error("Messenger agent turn did not complete");
2758
- console.log(`JEO_MESSENGER_RESULT ${JSON.stringify(reply)}`);
2773
+ if (done) console.log(`JEO_MESSENGER_RESULT ${JSON.stringify(reply)}`);
2774
+ else if (unverified && reply.trim()) {
2775
+ console.log(`JEO_MESSENGER_UNVERIFIED ${JSON.stringify(reply)}`);
2776
+ process.exitCode = 1;
2777
+ } else throw new Error("Messenger agent turn did not complete");
2759
2778
  } else if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
2760
2779
  else if (usage) console.log(usage.trim());
2761
2780
  } catch (err) {