jeo-code 0.12.18 → 0.12.21

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
@@ -122,7 +122,7 @@ export const COMMANDS: readonly CommandSpec[] = [
122
122
  {
123
123
  name: "messenger",
124
124
  summary: "Run or configure the bidirectional Discord, Telegram, and Slack agent gateway.",
125
- usage: "messenger [setup|check|run] --config <file> [--channel ID] [--user ID] [--tools LIST] [--artifacts] [--service]",
125
+ usage: "messenger [setup|check|run|update] --config <file> [--channel ID] [--user ID] [--tools LIST] [--artifacts] [--service]",
126
126
  loader: async () => {
127
127
  const m = await import("../commands/messenger");
128
128
  return args => m.runMessengerCommand(args);
@@ -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) {
@@ -6,6 +6,7 @@ import { Writable } from "node:stream";
6
6
  import { loadMessengerConfig, parseMessengerSandbox, validateMessengerEnvironment, validateMessengerStorage } from "../messenger/config";
7
7
  import { createDiscordAdapter } from "../messenger/discord";
8
8
  import { setupDiscordMessenger } from "../messenger/discord-setup";
9
+ import { refreshMessengerProfiles } from "../messenger/runtime";
9
10
  import { messengerArtifactPrompt } from "../messenger/artifacts";
10
11
  import { createMessengerGateway, messengerSessionRoute } from "../messenger/gateway";
11
12
  import { createMessengerSessionPool } from "../messenger/session-pool";
@@ -61,11 +62,12 @@ async function sendSandboxControl(config: MessengerConfig, id: string, userId: s
61
62
  return promise;
62
63
  }
63
64
 
65
+ type MessengerAction = "setup" | "check" | "run" | "update";
64
66
  type Flags = { config?: string; channel: string[]; user: string[]; tools?: string; artifacts: boolean; service: boolean; noDm: boolean };
65
67
 
66
- function parse(args: string[]): { action: "setup" | "check" | "run"; flags: Flags } {
68
+ function parse(args: string[]): { action: MessengerAction; flags: Flags } {
67
69
  const action = args.shift();
68
- if (action !== "setup" && action !== "check" && action !== "run") throw new Error("Usage: jeo messenger setup|check|run --config <file>");
70
+ if (action !== "setup" && action !== "check" && action !== "run" && action !== "update") throw new Error("Usage: jeo messenger setup|check|run --config <file> | jeo messenger update [--config <file>]");
69
71
  const flags: Flags = { channel: [], user: [], artifacts: false, service: false, noDm: false };
70
72
  for (let index = 0; index < args.length; index++) {
71
73
  const arg = args[index]!;
@@ -80,7 +82,7 @@ function parse(args: string[]): { action: "setup" | "check" | "run"; flags: Flag
80
82
  else flags[arg === "--channel" ? "channel" : "user"].push(...value.split(",").filter(Boolean));
81
83
  } else throw new Error(`Unknown messenger option: ${arg}`);
82
84
  }
83
- if (!flags.config) throw new Error("Messenger requires --config <file>");
85
+ if (!flags.config && action !== "update") throw new Error("Messenger requires --config <file>");
84
86
  return { action, flags };
85
87
  }
86
88
 
@@ -113,11 +115,20 @@ function routeUuid(config: MessengerConfig, message: Parameters<MessengerReply>[
113
115
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
114
116
  }
115
117
 
118
+ export interface MessengerCliReplyOptions {
119
+ /** Operator log line sink (gateway stderr); receives bounded worker diagnostics, never message text or tokens. */
120
+ log?: (line: string) => void;
121
+ }
122
+
116
123
  /** Run each conversation in its own reusable agent process. */
117
- export function createMessengerCliReply(config: MessengerConfig, spawn: typeof Bun.spawn = Bun.spawn, selectModel: typeof selectDiscordModel = selectDiscordModel): MessengerReply & { close(): Promise<void>; sandboxControl: NonNullable<MessengerGatewayOptions["sandboxControl"]> } {
124
+ export function createMessengerCliReply(config: MessengerConfig, spawn: typeof Bun.spawn = Bun.spawn, selectModel: typeof selectDiscordModel = selectDiscordModel, options: MessengerCliReplyOptions = {}): MessengerReply & { close(): Promise<void>; sandboxControl: NonNullable<MessengerGatewayOptions["sandboxControl"]> } {
118
125
  const sandbox = config.sandbox !== undefined || config.platforms.discord ? parseMessengerSandbox(config.sandbox) : undefined;
119
126
  // Launch may inherit a global sandbox even when this platform has no local override.
120
- const pool = createMessengerSessionPool(spawn, undefined, undefined, undefined, { requireSandboxCleanup: true });
127
+ const log = options.log;
128
+ const pool = createMessengerSessionPool(spawn, undefined, undefined, undefined, {
129
+ requireSandboxCleanup: true,
130
+ ...(log ? { onDiagnostic: (key: string, line: string) => log(`worker ${key.slice(0, 8)}: ${line}`) } : {}),
131
+ });
121
132
  const reply: MessengerReply = async (message, signal, onActivity) => {
122
133
  if (signal.aborted) throw new Error("Messenger request aborted");
123
134
  await validateMessengerStorage(config);
@@ -208,6 +219,12 @@ export async function runMessengerCommand(args: string[]): Promise<void> {
208
219
  } finally { process.off("SIGINT", stop); process.off("SIGTERM", stop); io.close(); }
209
220
  return;
210
221
  }
222
+ if (action === "update") {
223
+ // Re-pin every registered profile (or the named one) to the jeo running this command, then restart its service.
224
+ const { failed } = await refreshMessengerProfiles(flags.config ? [flags.config] : undefined, line => process.stdout.write(`${line}\n`));
225
+ if (failed) process.exitCode = 1;
226
+ return;
227
+ }
211
228
  let config: MessengerConfig;
212
229
  try { config = await loadMessengerConfig(flags.config!); validateMessengerEnvironment(config); }
213
230
  catch (error) { throw new Error(error instanceof Error ? error.message : "Messenger configuration or required environment is invalid"); }
@@ -221,7 +238,10 @@ export async function runMessengerCommand(args: string[]): Promise<void> {
221
238
  const controller = new AbortController();
222
239
  const stop = () => controller.abort();
223
240
  process.once("SIGINT", stop); process.once("SIGTERM", stop);
224
- const reply = createMessengerCliReply(config);
225
- try { process.stdout.write(`Starting messenger: ${platforms.join(", ")}. Press Ctrl+C to stop.\n`); await createMessengerGateway(config, adapters, reply, { concurrency: 4, sandboxControl: reply.sandboxControl }).run(controller.signal); }
241
+ const log = (line: string): void => { process.stderr.write(`[jeo messenger ${new Date().toISOString()}] ${line}\n`); };
242
+ const reply = createMessengerCliReply(config, undefined, undefined, { log });
243
+ const onRequestError: NonNullable<MessengerGatewayOptions["onRequestError"]> = (message, error, stage) =>
244
+ log(`${message.platform} request failed at ${stage} (conversation ${routeUuid(config, message).slice(0, 8)}): ${error instanceof Error ? error.message : String(error)}`);
245
+ try { process.stdout.write(`Starting messenger: ${platforms.join(", ")}. Press Ctrl+C to stop.\n`); await createMessengerGateway(config, adapters, reply, { concurrency: 4, sandboxControl: reply.sandboxControl, onRequestError }).run(controller.signal); }
226
246
  finally { controller.abort(); await reply.close(); process.off("SIGINT", stop); process.off("SIGTERM", stop); }
227
247
  }
@@ -1,4 +1,6 @@
1
+ import { existsSync } from "node:fs";
1
2
  import pkg from "../../package.json";
3
+ import { messengerProfilesPath } from "../messenger/runtime";
2
4
 
3
5
  /**
4
6
  * Compares two semver-ish version strings.
@@ -38,6 +40,13 @@ export interface UpdateDeps {
38
40
  install: (version?: string) => Promise<{ success: boolean; stdout?: string; stderr?: string }>;
39
41
  /** Display release notes after a successful self-update (best-effort, no-op in tests). */
40
42
  showWhatsNew?: () => void;
43
+ /**
44
+ * Re-pin registered messenger profiles to the freshly installed jeo and restart their services
45
+ * (`jeo messenger update` of the NEW binary). Returns null when no profile is registered, else whether
46
+ * every profile was refreshed. `quiet` keeps the child's output off stdout for `--json`. Optional in
47
+ * tests; failures never fail the update.
48
+ */
49
+ refreshMessengers?: (quiet: boolean) => boolean | null;
41
50
  /** Version of the `jeo` actually on PATH, read after install to catch a silent no-op
42
51
  * (installed but PATH still points at a different/older binary). Optional in tests. */
43
52
  activeVersion?: () => string | null;
@@ -129,6 +138,20 @@ export const defaultDeps: UpdateDeps = {
129
138
  } catch {
130
139
  // Notes are a courtesy; a spawn failure must never fail the update.
131
140
  }
141
+ },
142
+ refreshMessengers: quiet => {
143
+ // Only profiles registered by `jeo messenger setup` have runtimes to move; everyone else sees nothing.
144
+ if (!existsSync(messengerProfilesPath())) return null;
145
+ try {
146
+ if (!quiet) console.log("Refreshing messenger runtimes...");
147
+ // The NEW binary knows its own launcher format and version, so it performs the refresh.
148
+ const proc = Bun.spawnSync(["jeo", "messenger", "update"], quiet ? { stdout: "ignore", stderr: "ignore" } : { stdout: "inherit", stderr: "inherit" });
149
+ if (!proc.success && !quiet) console.error("Messenger runtimes were not all refreshed; run 'jeo messenger update' to retry.");
150
+ return proc.success;
151
+ } catch {
152
+ if (!quiet) console.error("Messenger runtimes were not refreshed; run 'jeo messenger update' to retry.");
153
+ return false;
154
+ }
132
155
  }
133
156
  };
134
157
 
@@ -310,11 +333,13 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
310
333
  const active = deps.activeVersion?.();
311
334
  const stale = !!(active && latest && compareVersions(active, latest) < 0);
312
335
  if (hasJson) {
336
+ const messengersRefreshed = stale ? null : (deps.refreshMessengers?.(true) ?? null);
313
337
  console.log(JSON.stringify({
314
338
  current,
315
339
  latest,
316
340
  upToDate: false,
317
341
  installed: !stale,
342
+ ...(messengersRefreshed === null ? {} : { messengersRefreshed }),
318
343
  ...(stale ? { activeVersion: active, error: "active jeo still reports an older version" } : {}),
319
344
  }));
320
345
  if (stale) process.exitCode = 1;
@@ -327,6 +352,7 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
327
352
  process.exitCode = 1;
328
353
  } else {
329
354
  console.log(`Successfully installed jeo-code@${latest}`);
355
+ deps.refreshMessengers?.(false);
330
356
  deps.showWhatsNew?.();
331
357
  }
332
358
  } else {
@@ -337,15 +363,18 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
337
363
  const recovered = deps.activeVersion?.();
338
364
  if (recovered && latest && compareVersions(recovered, latest) >= 0) {
339
365
  if (hasJson) {
366
+ const messengersRefreshed = deps.refreshMessengers?.(true) ?? null;
340
367
  console.log(JSON.stringify({
341
368
  current,
342
369
  latest,
343
370
  upToDate: false,
344
371
  installed: true,
345
- recovered: true
372
+ recovered: true,
373
+ ...(messengersRefreshed === null ? {} : { messengersRefreshed })
346
374
  }));
347
375
  } else {
348
376
  console.log(`Successfully installed jeo-code@${latest} (package manager reported an error, but the runtime verified clean).`);
377
+ deps.refreshMessengers?.(false);
349
378
  deps.showWhatsNew?.();
350
379
  }
351
380
  } else {