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.
@@ -1,6 +1,22 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import pkg from "../../package.json";
3
3
  import { messengerProfilesPath } from "../messenger/runtime";
4
+ import {
5
+ checkLockCoverage,
6
+ detectInstallOrigin,
7
+ diffInstalled,
8
+ formatChangeLine,
9
+ formatRestoreCommands,
10
+ formatUnchangedSummary,
11
+ locateSharedGlobal,
12
+ readManifestDeps,
13
+ snapshotInstalled,
14
+ type ExternalChange,
15
+ type InstalledSnapshot,
16
+ type InstallOrigin,
17
+ type LocatedGlobal,
18
+ type LockStatus,
19
+ } from "../util/shared-global";
4
20
 
5
21
  /**
6
22
  * Compares two semver-ish version strings.
@@ -34,10 +50,18 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
34
50
  return 0;
35
51
  }
36
52
 
53
+ /** Outcome of one self-install. `manual` carries the commands to run by hand when no automatic attempt worked. */
54
+ export interface InstallResult {
55
+ success: boolean;
56
+ stdout?: string;
57
+ stderr?: string;
58
+ manual?: string[];
59
+ }
60
+
37
61
  export interface UpdateDeps {
38
62
  fetchJson: (url: string, options?: { signal?: AbortSignal }) => Promise<any>;
39
63
  localVersion: () => string;
40
- install: (version?: string) => Promise<{ success: boolean; stdout?: string; stderr?: string }>;
64
+ install: (version?: string) => Promise<InstallResult>;
41
65
  /** Display release notes after a successful self-update (best-effort, no-op in tests). */
42
66
  showWhatsNew?: () => void;
43
67
  /**
@@ -50,6 +74,11 @@ export interface UpdateDeps {
50
74
  /** Version of the `jeo` actually on PATH, read after install to catch a silent no-op
51
75
  * (installed but PATH still points at a different/older binary). Optional in tests. */
52
76
  activeVersion?: () => string | null;
77
+ /**
78
+ * Where the shared bun global lives, so an update can refuse before any installer could re-resolve packages
79
+ * jeo does not own and can report any that moved. Optional: absent = no inspection (every existing test).
80
+ */
81
+ sharedGlobal?: { locate: () => LocatedGlobal };
53
82
  }
54
83
 
55
84
  /**
@@ -58,16 +87,72 @@ export interface UpdateDeps {
58
87
  * `bun install -g` alone is not enough in practice: right after a publish bun can
59
88
  * still hold a stale registry manifest and fail with `No version matching "<v>"
60
89
  * found ... (but package exists)`. `--force` re-resolves against the registry and
61
- * is what actually recovers that state. npm remains the last resort for
62
- * npm-installed globals. Pure so the policy is unit-testable without spawning.
90
+ * is what actually recovers that state. npm is the last resort only for an active jeo that
91
+ * lives under npm's global root (`origin` 'npm-global'): from a bun-global or unknown location
92
+ * an npm install would create a stale second copy, so it is never attempted there. `origin`
93
+ * undefined keeps the legacy three attempts. Pure so the policy is unit-testable without spawning.
63
94
  */
64
- export function installCandidates(target: string): string[][] {
65
- return [
95
+ export function installCandidates(target: string, origin?: InstallOrigin): string[][] {
96
+ const candidates = [
66
97
  ["bun", "install", "-g", target],
67
98
  // Cache-buster: bun's resolver held a stale manifest for a just-published version.
68
99
  ["bun", "install", "-g", target, "--force"],
69
- ["npm", "install", "-g", target],
70
100
  ];
101
+ if (origin === undefined || origin === "npm-global") candidates.push(["npm", "install", "-g", target]);
102
+ return candidates;
103
+ }
104
+
105
+ /** What to tell the user to run when every automatic attempt failed and npm was deliberately not tried. */
106
+ export function manualInstallLines(target: string, origin: InstallOrigin): string[] {
107
+ if (origin === "npm-global") return [];
108
+ return [
109
+ `bun install -g ${target} --force`,
110
+ origin === "bun-global"
111
+ ? "(npm was not tried: the active jeo is under the bun global install)"
112
+ : `(npm was not tried: the active jeo is not under the npm global root; if it came from npm run: npm install -g ${target})`,
113
+ ];
114
+ }
115
+
116
+ /** Side effects `runInstall` needs, injected so the attempt policy is testable without spawning. */
117
+ export interface InstallIo {
118
+ spawn: (cmd: string[]) => { success: boolean; exitCode?: number | null };
119
+ activeVersion: () => string | null;
120
+ origin: () => InstallOrigin;
121
+ }
122
+
123
+ function errorText(err: unknown): string {
124
+ const message = err !== null && typeof err === "object" && "message" in err ? err.message : undefined;
125
+ return message ? String(message) : String(err);
126
+ }
127
+
128
+ /**
129
+ * Self-update the global install via the toolchain it was installed with. jeo ships as a
130
+ * `#!/usr/bin/env bun` script so bun is the common case; npm is tried last, and only when the
131
+ * active jeo lives under npm's global root (see installCandidates). The origin is resolved once,
132
+ * before the first spawn.
133
+ */
134
+ export function runInstall(version: string | undefined, io: InstallIo): InstallResult {
135
+ const target = `jeo-code@${version ?? "latest"}`;
136
+ const origin = io.origin();
137
+ let lastErr = "";
138
+ for (const cmd of installCandidates(target, origin)) {
139
+ try {
140
+ const proc = io.spawn(cmd);
141
+ if (proc.success) {
142
+ // A zero exit is NOT proof the user's `jeo` moved: npm's global prefix is
143
+ // often a different directory than the bun prefix PATH actually resolves,
144
+ // so an npm fallback can "succeed" while the active binary is untouched.
145
+ // Only stop once the binary on PATH really reports the requested version.
146
+ if (!version || io.activeVersion() === version) return { success: true };
147
+ lastErr = `${cmd[0]} reported success but the active 'jeo' is still ${io.activeVersion() ?? "unknown"}`;
148
+ continue;
149
+ }
150
+ lastErr = `${cmd[0]} exited with code ${proc.exitCode}`;
151
+ } catch (err) {
152
+ lastErr = `${cmd[0]} unavailable: ${errorText(err)}`;
153
+ }
154
+ }
155
+ return { success: false, stderr: lastErr, manual: manualInstallLines(target, origin) };
71
156
  }
72
157
 
73
158
  /** Version of the `jeo` binary PATH actually resolves, or null when unreadable. */
@@ -100,32 +185,13 @@ export const defaultDeps: UpdateDeps = {
100
185
  localVersion: () => {
101
186
  return pkg.version;
102
187
  },
103
- install: async (version?: string) => {
104
- // Self-update the global install via the SAME toolchain it was installed with. jeo
105
- // ships as a `#!/usr/bin/env bun` script so bun is the common case, but npm-installed
106
- // globals exist too — try bun first, fall back to npm (and tolerate either missing).
107
- const target = `jeo-code@${version ?? "latest"}`;
108
- let lastErr = "";
109
- for (const cmd of installCandidates(target)) {
110
- try {
111
- const proc = Bun.spawnSync(cmd, { stdout: "inherit", stderr: "inherit" });
112
- if (proc.success) {
113
- // A zero exit is NOT proof the user's `jeo` moved: npm's global prefix is
114
- // often a different directory than the bun prefix PATH actually resolves,
115
- // so an npm fallback can "succeed" while the active binary is untouched.
116
- // Only stop once the binary on PATH really reports the requested version.
117
- if (!version || readActiveVersion() === version) return { success: true };
118
- lastErr = `${cmd[0]} reported success but the active 'jeo' is still ${readActiveVersion() ?? "unknown"}`;
119
- continue;
120
- }
121
- lastErr = `${cmd[0]} exited with code ${proc.exitCode}`;
122
- } catch (err: any) {
123
- lastErr = `${cmd[0]} unavailable: ${err?.message ?? String(err)}`;
124
- }
125
- }
126
- return { success: false, stderr: lastErr };
127
- }
128
- ,
188
+ sharedGlobal: { locate: locateSharedGlobal },
189
+ install: async (version?: string) =>
190
+ runInstall(version, {
191
+ spawn: cmd => Bun.spawnSync(cmd, { stdout: "inherit", stderr: "inherit" }),
192
+ activeVersion: readActiveVersion,
193
+ origin: () => detectInstallOrigin().origin,
194
+ }),
129
195
  // Version of the `jeo` actually on PATH — may differ from this process's bundled
130
196
  // version after a self-update (e.g. npm installed into a prefix PATH does not
131
197
  // resolve). Detects the silent "installed but PATH unchanged" no-op.
@@ -159,14 +225,143 @@ export async function runUpdateCommand(args: string[] = []): Promise<void> {
159
225
  return runUpdateCommandWith(args, defaultDeps);
160
226
  }
161
227
 
228
+ /** What the guard found in the shared bun global before the installer ran (the `preflight` JSON key). */
229
+ interface SharedPreflight {
230
+ globalDir: string;
231
+ externalCount: number;
232
+ lockfile: LockStatus;
233
+ uncovered: string[];
234
+ allowedUnlocked: boolean;
235
+ }
236
+
237
+ interface SharedArmed {
238
+ kind: "armed";
239
+ dir: string;
240
+ /** Manifest dependency names other than jeo-code, as they were before the install. */
241
+ beforeNames: string[];
242
+ /** Installed versions captured BEFORE the install; settle never re-reads them from disk. */
243
+ before: InstalledSnapshot;
244
+ /** Null when the manifest has no packages besides jeo-code (nothing to guard, nothing to report). */
245
+ preflight: SharedPreflight | null;
246
+ }
247
+
248
+ type SharedArm = { kind: "off" } | SharedArmed;
249
+ type SharedBegin = SharedArm | { kind: "refused"; preflight: SharedPreflight; error: string; lines: string[] };
250
+
251
+ interface SharedSettled {
252
+ dir: string;
253
+ changes: ExternalChange[];
254
+ /** `shared global unchanged: ...`, only when a shared global with external packages was inspected and nothing moved. */
255
+ summary: string | null;
256
+ preflight: SharedPreflight | null;
257
+ driftError: string | null;
258
+ }
259
+
260
+ function skipShared(hasJson: boolean, reason: string): SharedBegin {
261
+ if (!hasJson) console.log(`Shared bun global not inspected (${reason}); the isolation check is skipped.`);
262
+ return { kind: "off" };
263
+ }
264
+
265
+ /**
266
+ * Runs at the start of the install branch, before any installer. Refuses when the shared bun global lists
267
+ * packages besides jeo-code and its bun.lock cannot pin them (bun would re-resolve and could move them);
268
+ * otherwise captures the installed versions that settleShared compares against after the install.
269
+ */
270
+ function beginShared(deps: UpdateDeps, opts: { hasJson: boolean; allowUnlocked: boolean }): SharedBegin {
271
+ if (!deps.sharedGlobal) return { kind: "off" };
272
+ const located = deps.sharedGlobal.locate();
273
+ if (!located.ok) return skipShared(opts.hasJson, located.reason);
274
+ const manifest = readManifestDeps(located.dir);
275
+ if (!manifest.ok) return skipShared(opts.hasJson, "manifest-unreadable");
276
+ const dir = located.dir;
277
+ const external = manifest.deps.external;
278
+ const before = snapshotInstalled(dir, external);
279
+ if (external.length === 0) return { kind: "armed", dir, beforeNames: external, before, preflight: null };
280
+ const lock = checkLockCoverage(dir, external);
281
+ const unlocked = lock.status !== "ok";
282
+ const preflight: SharedPreflight = {
283
+ globalDir: dir,
284
+ externalCount: external.length,
285
+ lockfile: lock.status,
286
+ uncovered: lock.uncovered,
287
+ allowedUnlocked: unlocked && opts.allowUnlocked,
288
+ };
289
+ if (!unlocked) return { kind: "armed", dir, beforeNames: external, before, preflight };
290
+ const names = lock.uncovered.join(", ");
291
+ if (!opts.allowUnlocked) {
292
+ const problem =
293
+ lock.status === "missing"
294
+ ? "is missing"
295
+ : lock.status === "incomplete"
296
+ ? `has no resolved entry for ${names}`
297
+ : `could not be read${lock.detail ? ` (${lock.detail})` : ""}`;
298
+ return {
299
+ kind: "refused",
300
+ preflight,
301
+ error: `shared bun global lockfile ${lock.status} (${names}); refusing to run any installer, re-run with --allow-unlocked-global to override`,
302
+ lines: [
303
+ `[REFUSED] jeo update did not run: the shared bun global at ${dir} has packages besides jeo-code but its bun.lock ${problem}.`,
304
+ ` bun would re-resolve every package in that directory and could move: ${names}`,
305
+ " Nothing was installed or changed.",
306
+ ` Restore ${lock.path} (for example from a backup), or accept the re-resolution with: jeo update --allow-unlocked-global`,
307
+ ...(lock.legacyBinaryLock ? [" (a binary bun.lockb is not accepted)"] : []),
308
+ ],
309
+ };
310
+ }
311
+ if (!opts.hasJson) console.warn(`Warning: bun.lock ${lock.status} for: ${names}; --allow-unlocked-global lets bun re-resolve them, drift will be reported.`);
312
+ return { kind: "armed", dir, beforeNames: external, before, preflight };
313
+ }
314
+
315
+ /**
316
+ * Runs exactly once, right after the installer returned or threw. The before map is the one captured by
317
+ * beginShared (re-reading it here would see post-install versions and could never show drift); only the
318
+ * after map is read from disk.
319
+ */
320
+ function settleShared(arm: SharedArm): SharedSettled {
321
+ if (arm.kind === "off") return { dir: "", changes: [], summary: null, preflight: null, driftError: null };
322
+ const manifest = readManifestDeps(arm.dir);
323
+ const afterNames = manifest.ok ? manifest.deps.external : arm.beforeNames;
324
+ const names = [...new Set([...arm.beforeNames, ...afterNames])];
325
+ const before: InstalledSnapshot = Object.fromEntries(names.map(name => [name, Object.hasOwn(arm.before, name) ? arm.before[name] : null]));
326
+ const after = snapshotInstalled(arm.dir, names, new Set(afterNames));
327
+ const changes = diffInstalled(before, after);
328
+ return {
329
+ dir: arm.dir,
330
+ changes,
331
+ summary: changes.length === 0 && arm.preflight !== null ? formatUnchangedSummary(after) : null,
332
+ preflight: arm.preflight,
333
+ driftError: changes.length > 0 ? `shared bun global changed: ${changes.map(formatChangeLine).join("; ")}` : null,
334
+ };
335
+ }
336
+
337
+ /** `externalChanges` and `preflight` JSON keys, present only when a shared global with external packages was inspected. */
338
+ function sharedJson(s: SharedSettled): { externalChanges?: ExternalChange[]; preflight?: SharedPreflight } {
339
+ if (s.preflight === null && s.changes.length === 0) return {};
340
+ return { externalChanges: s.changes, ...(s.preflight === null ? {} : { preflight: s.preflight }) };
341
+ }
342
+
343
+ /** The human drift report: what moved and the commands that would put it back. They are printed, never run. */
344
+ function printSharedDrift(s: SharedSettled, version: string | null, installed: boolean): void {
345
+ console.error(
346
+ installed
347
+ ? `[FAILED] jeo-code@${version} was installed, but the shared bun global at ${s.dir} changed other packages:`
348
+ : `[FAILED] the install of jeo-code@${version} did not finish cleanly, and the shared bun global at ${s.dir} changed other packages:`,
349
+ );
350
+ for (const change of s.changes) console.error(` ${formatChangeLine(change)}`);
351
+ console.error("jeo does not repair these. To put them back run:");
352
+ for (const command of formatRestoreCommands(s.changes)) console.error(` ${command}`);
353
+ if (installed) console.error("Messenger runtimes were not refreshed; run 'jeo messenger update' after restoring.");
354
+ }
355
+
162
356
  export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Promise<void> {
163
357
  const isHelp = args.includes("--help") || args.includes("-h");
164
358
  const hasInstall = args.includes("--install");
165
359
  const hasCheck = args.includes("--check");
166
360
  const hasJson = args.includes("--json");
167
361
  const hasStrict = args.includes("--strict");
362
+ const allowUnlocked = args.includes("--allow-unlocked-global");
168
363
 
169
- const KNOWN_FLAGS = new Set(["--check", "--install", "--json", "--strict", "--version", "-h", "--help"]);
364
+ const KNOWN_FLAGS = new Set(["--check", "--install", "--json", "--strict", "--allow-unlocked-global", "--version", "-h", "--help"]);
170
365
  // A semver-ish shape: MAJOR.MINOR.PATCH with optional -prerelease and/or +build metadata,
171
366
  // matching what the npm registry actually publishes (and what activeVersion() parses back out).
172
367
  const SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
@@ -317,13 +512,35 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
317
512
  : `jeo-code is already up-to-date (${current}).`);
318
513
  }
319
514
  } else {
515
+ const shared = beginShared(deps, { hasJson, allowUnlocked });
516
+ if (shared.kind === "refused") {
517
+ if (hasJson) {
518
+ console.log(JSON.stringify({
519
+ current,
520
+ latest,
521
+ upToDate: false,
522
+ installed: false,
523
+ error: shared.error,
524
+ preflight: shared.preflight
525
+ }));
526
+ } else {
527
+ for (const line of shared.lines) console.error(line);
528
+ }
529
+ process.exitCode = 1;
530
+ return;
531
+ }
320
532
  if (!hasJson) {
321
533
  console.log(pinnedVersion
322
534
  ? `Installing pinned version: ${current} -> ${latest}...`
323
535
  : `Installing update: ${current} -> ${latest}...`);
324
536
  }
537
+ // Settled exactly once: right after the installer returns, or in the catch when it threw
538
+ // (a throwing installer may have half-run). Messenger refresh never happens before this.
539
+ let settled: SharedSettled | null = null;
325
540
  try {
326
541
  const result = await deps.install(latest ?? undefined);
542
+ settled = settleShared(shared);
543
+ const drift = settled.changes.length > 0;
327
544
  if (result.success) {
328
545
  // A package manager's zero exit does not prove the user's `jeo` moved: npm's
329
546
  // global prefix is frequently a different directory than the one PATH resolves,
@@ -333,25 +550,33 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
333
550
  const active = deps.activeVersion?.();
334
551
  const stale = !!(active && latest && compareVersions(active, latest) < 0);
335
552
  if (hasJson) {
336
- const messengersRefreshed = stale ? null : (deps.refreshMessengers?.(true) ?? null);
553
+ const messengersRefreshed = stale || drift ? null : (deps.refreshMessengers?.(true) ?? null);
337
554
  console.log(JSON.stringify({
338
555
  current,
339
556
  latest,
340
557
  upToDate: false,
341
558
  installed: !stale,
342
559
  ...(messengersRefreshed === null ? {} : { messengersRefreshed }),
343
- ...(stale ? { activeVersion: active, error: "active jeo still reports an older version" } : {}),
560
+ ...(stale
561
+ ? { activeVersion: active, error: "active jeo still reports an older version" }
562
+ : drift ? { error: settled.driftError } : {}),
563
+ ...sharedJson(settled),
344
564
  }));
345
- if (stale) process.exitCode = 1;
565
+ if (stale || drift) process.exitCode = 1;
346
566
  } else if (stale) {
347
567
  console.error(`[FAILED] Installed jeo-code@${latest}, but the active 'jeo' on PATH still reports ${active}.`);
348
568
  console.error(`The install landed in a prefix your PATH does not use. Fix it with one of:`);
349
569
  console.error(` bun install -g jeo-code@${latest} --force`);
350
570
  console.error(` npm install -g jeo-code@${latest}`);
351
571
  console.error(`Then confirm with: jeo --version`);
572
+ if (drift) printSharedDrift(settled, latest, true);
573
+ process.exitCode = 1;
574
+ } else if (drift) {
575
+ printSharedDrift(settled, latest, true);
352
576
  process.exitCode = 1;
353
577
  } else {
354
578
  console.log(`Successfully installed jeo-code@${latest}`);
579
+ if (settled.summary) console.log(settled.summary);
355
580
  deps.refreshMessengers?.(false);
356
581
  deps.showWhatsNew?.();
357
582
  }
@@ -363,17 +588,24 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
363
588
  const recovered = deps.activeVersion?.();
364
589
  if (recovered && latest && compareVersions(recovered, latest) >= 0) {
365
590
  if (hasJson) {
366
- const messengersRefreshed = deps.refreshMessengers?.(true) ?? null;
591
+ const messengersRefreshed = drift ? null : (deps.refreshMessengers?.(true) ?? null);
367
592
  console.log(JSON.stringify({
368
593
  current,
369
594
  latest,
370
595
  upToDate: false,
371
596
  installed: true,
372
597
  recovered: true,
373
- ...(messengersRefreshed === null ? {} : { messengersRefreshed })
598
+ ...(messengersRefreshed === null ? {} : { messengersRefreshed }),
599
+ ...(drift ? { error: settled.driftError } : {}),
600
+ ...sharedJson(settled),
374
601
  }));
602
+ if (drift) process.exitCode = 1;
603
+ } else if (drift) {
604
+ printSharedDrift(settled, latest, true);
605
+ process.exitCode = 1;
375
606
  } else {
376
607
  console.log(`Successfully installed jeo-code@${latest} (package manager reported an error, but the runtime verified clean).`);
608
+ if (settled.summary) console.log(settled.summary);
377
609
  deps.refreshMessengers?.(false);
378
610
  deps.showWhatsNew?.();
379
611
  }
@@ -384,26 +616,35 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
384
616
  latest,
385
617
  upToDate: false,
386
618
  installed: false,
387
- error: "Installation failed"
619
+ error: "Installation failed",
620
+ ...sharedJson(settled),
388
621
  }));
389
622
  } else {
390
623
  console.error("Failed to install update.");
624
+ if (result.manual && result.manual.length > 0) {
625
+ console.error("Try manually:");
626
+ for (const line of result.manual) console.error(` ${line}`);
627
+ }
628
+ if (drift) printSharedDrift(settled, latest, false);
391
629
  }
392
630
  process.exitCode = 1;
393
631
  }
394
632
  }
395
- } catch (installErr: any) {
396
- const installErrMsg = installErr.message || String(installErr);
633
+ } catch (installErr) {
634
+ settled ??= settleShared(shared);
635
+ const installErrMsg = errorText(installErr);
397
636
  if (hasJson) {
398
637
  console.log(JSON.stringify({
399
638
  current,
400
639
  latest,
401
640
  upToDate: false,
402
641
  installed: false,
403
- error: `Installation error: ${installErrMsg}`
642
+ error: `Installation error: ${installErrMsg}`,
643
+ ...sharedJson(settled),
404
644
  }));
405
645
  } else {
406
646
  console.error(`Failed to install update: ${installErrMsg}`);
647
+ if (settled.changes.length > 0) printSharedDrift(settled, latest, false);
407
648
  }
408
649
  process.exitCode = 1;
409
650
  }
@@ -454,5 +695,6 @@ function printUsage() {
454
695
  console.log(" (e.g. --version 1.2.3); combine with --install to apply it");
455
696
  console.log(" --json Output result in JSON format");
456
697
  console.log(" --strict Exit with code 1 on network/registry errors");
698
+ console.log(" --allow-unlocked-global Proceed even if the shared bun global bun.lock does not cover its other packages (drift is still reported)");
457
699
  console.log(" -h, --help Show this help message");
458
700
  }
@@ -7,7 +7,7 @@ import type {
7
7
  MessengerMessage,
8
8
  MessengerReply,
9
9
  } from "./types";
10
- import { MessengerFatalError } from "./types";
10
+ import { MessengerFatalError, MessengerUnverifiedReplyError } from "./types";
11
11
 
12
12
  const MAX_MESSAGE_LENGTH = 32_000;
13
13
  const MAX_QUEUED_MESSAGES = 100;
@@ -276,8 +276,9 @@ export interface MessengerGatewayOptions {
276
276
  /** Authenticated human controls bypass model queues, including busy sessions. */
277
277
  sandboxControl?: (message: MessengerMessage, signal: AbortSignal) => Promise<{ handled: boolean; output: string }>;
278
278
  /**
279
- * Operator log for a request that failed before or during delivery. Users only ever see the generic failure
280
- * text, so this is the one place the cause is observable; it receives the conversation, never message text.
279
+ * Operator log for a request that failed before or during delivery. Users see the generic failure text, except
280
+ * an unverified completion, whose marked answer is delivered; this is the one place the cause is observable,
281
+ * and it receives the conversation, never message text.
281
282
  */
282
283
  onRequestError?: (message: MessengerMessage, error: unknown, stage: "prepare" | "reply" | "deliver") => void;
283
284
  }
@@ -372,9 +373,11 @@ export function createMessengerGateway(
372
373
  }
373
374
  if (signal.aborted) return;
374
375
  reportError(message, error, "reply");
375
- text = error instanceof DiscordModelSelectionError
376
- ? "Discord could not verify a model. Ask the operator to configure an explicit Discord model, check provider credentials and live model discovery, then restart the messenger service."
377
- : "The request could not be completed. Please try again.";
376
+ text = error instanceof MessengerUnverifiedReplyError
377
+ ? error.reply
378
+ : error instanceof DiscordModelSelectionError
379
+ ? "Discord could not verify a model. Ask the operator to configure an explicit Discord model, check provider credentials and live model discovery, then restart the messenger service."
380
+ : "The request could not be completed. Please try again.";
378
381
  }
379
382
  if (!signal.aborted && text.trim()) {
380
383
  // A failed send may already have delivered a chunk: never retry the entire reply.
@@ -1,4 +1,5 @@
1
1
  import type { MessengerActivityReporter } from "./types";
2
+ import { MessengerUnverifiedReplyError } from "./types";
2
3
 
3
4
  interface WorkerProcess {
4
5
  stdin: { write(data: string): unknown; flush(): unknown; end(): unknown };
@@ -15,6 +16,8 @@ interface Pending {
15
16
  reject(error: Error): void;
16
17
  report?: MessengerActivityReporter;
17
18
  result?: string;
19
+ /** The result is an UNVERIFIED-marked answer: the turn rejects with it while the worker keeps serving. */
20
+ unverified?: boolean;
18
21
  written?: boolean;
19
22
  }
20
23
 
@@ -159,7 +162,8 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
159
162
  worker.idleTimer = setTimeout(() => retire(key, worker), idleTimeoutMs);
160
163
  worker.idleTimer.unref();
161
164
  }
162
- pending.resolve(pending.result);
165
+ if (pending.unverified) pending.reject(new MessengerUnverifiedReplyError(pending.result));
166
+ else pending.resolve(pending.result);
163
167
  }
164
168
 
165
169
  async function readLines(stream: ReadableStream<Uint8Array>, handle: (line: string) => void) {
@@ -234,6 +238,17 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
234
238
  const pending = worker.pending;
235
239
  if (!pending) return;
236
240
  if (line === "JEO_MESSENGER_ERROR") { retire(key, worker); return; }
241
+ // An exhausted completion gate is a controlled turn end: deliver the marked answer, keep the worker.
242
+ if (line.startsWith("JEO_MESSENGER_UNVERIFIED ")) {
243
+ try {
244
+ const text: unknown = JSON.parse(line.slice("JEO_MESSENGER_UNVERIFIED ".length));
245
+ if (typeof text !== "string" || !text.trim()) throw new Error("Invalid response");
246
+ pending.result = text;
247
+ pending.unverified = true;
248
+ finishTurn(key, worker, pending);
249
+ } catch { retire(key, worker); }
250
+ return;
251
+ }
237
252
  if (!line.startsWith("JEO_MESSENGER_RESULT ")) return;
238
253
  try {
239
254
  const text: unknown = JSON.parse(line.slice("JEO_MESSENGER_RESULT ".length));
@@ -78,6 +78,14 @@ export class MessengerFatalError extends Error {
78
78
  }
79
79
  }
80
80
 
81
+ /** The agent turn ended without verified completion; `reply` is the UNVERIFIED-marked answer for the user. */
82
+ export class MessengerUnverifiedReplyError extends Error {
83
+ constructor(readonly reply: string) {
84
+ super("Messenger agent turn ended without verified completion");
85
+ this.name = "MessengerUnverifiedReplyError";
86
+ }
87
+ }
88
+
81
89
  export function isRecord(value: unknown): value is Record<string, unknown> {
82
90
  return typeof value === "object" && value !== null && !Array.isArray(value);
83
91
  }