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.
- package/CHANGELOG.md +22 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +2 -1
- package/src/agent/AGENTS.md +2 -1
- package/src/agent/engine.ts +55 -11
- package/src/agent/loop-guards.ts +101 -21
- package/src/agent/sandbox/hands-source.text +590 -590
- package/src/agent/task-tool.ts +4 -1
- package/src/agent/workspace-snapshot.ts +209 -0
- package/src/cli/runner.ts +1 -1
- package/src/commands/doctor.ts +106 -1
- package/src/commands/launch.ts +32 -13
- package/src/commands/update.ts +284 -42
- package/src/messenger/gateway.ts +9 -6
- package/src/messenger/session-pool.ts +16 -1
- package/src/messenger/types.ts +8 -0
- package/src/util/shared-global.ts +380 -0
- package/src/util/shared-install-report.ts +315 -0
package/src/commands/update.ts
CHANGED
|
@@ -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<
|
|
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
|
|
62
|
-
* npm
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
396
|
-
|
|
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
|
}
|
package/src/messenger/gateway.ts
CHANGED
|
@@ -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
|
|
280
|
-
*
|
|
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
|
|
376
|
-
?
|
|
377
|
-
:
|
|
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.
|
|
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));
|
package/src/messenger/types.ts
CHANGED
|
@@ -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
|
}
|