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.
- package/CHANGELOG.md +28 -1
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +1 -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/launch.ts +32 -13
- package/src/commands/messenger.ts +27 -7
- package/src/commands/update.ts +30 -1
- package/src/messenger/discord-setup.ts +42 -72
- package/src/messenger/gateway.ts +20 -8
- package/src/messenger/launcher.ts +68 -0
- package/src/messenger/runtime.ts +255 -0
- package/src/messenger/service.ts +40 -2
- package/src/messenger/session-pool.ts +50 -4
- package/src/messenger/types.ts +8 -0
- package/src/util/slack.ts +40 -0
package/src/agent/task-tool.ts
CHANGED
|
@@ -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++) {
|
|
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);
|
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
2519
|
-
|
|
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 (
|
|
2758
|
-
|
|
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:
|
|
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
|
|
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
|
|
225
|
-
|
|
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
|
}
|
package/src/commands/update.ts
CHANGED
|
@@ -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 {
|