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/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
|
@@ -235,7 +235,7 @@ export const COMMANDS: readonly CommandSpec[] = [
|
|
|
235
235
|
{
|
|
236
236
|
name: "update",
|
|
237
237
|
summary: "Update jeo-code to the latest npm release (bare = install; --check only checks).",
|
|
238
|
-
usage: "update [--check|--install] [--json] [--strict] [--version <semver>]",
|
|
238
|
+
usage: "update [--check|--install] [--json] [--strict] [--version <semver>] [--allow-unlocked-global]",
|
|
239
239
|
loader: async () => {
|
|
240
240
|
const m = await import("../commands/update");
|
|
241
241
|
return args => m.runUpdateCommand(args);
|
package/src/commands/doctor.ts
CHANGED
|
@@ -14,6 +14,9 @@ import { visibleWidth, truncateToWidth } from "../tui/components/width";
|
|
|
14
14
|
import chalk from "chalk";
|
|
15
15
|
import { extractChatgptAccountId, CODEX_RESPONSES_URL } from "../ai/providers/openai-responses";
|
|
16
16
|
import { resolveTierModel, tierModelPool, cheapestCredentialed } from "../agent/prompt-router";
|
|
17
|
+
import { DEFAULT_SIZE_CAP_MS, collectSharedInstallReport, type SharedInstallReport } from "../util/shared-install-report";
|
|
18
|
+
import { formatBytes } from "../tui/components/session-picker";
|
|
19
|
+
import { compareVersions } from "./update";
|
|
17
20
|
|
|
18
21
|
interface ProbeResult {
|
|
19
22
|
status: "ok" | "fail" | "skipped";
|
|
@@ -219,7 +222,93 @@ function providerTableLines(
|
|
|
219
222
|
return renderMarkdownTables(md, { unicode, maxColWidth: detailBudget }).split("\n");
|
|
220
223
|
}
|
|
221
224
|
|
|
222
|
-
export
|
|
225
|
+
export interface DoctorDeps {
|
|
226
|
+
/** Produces the shared bun global report; tests inject it, the default scans the real machine (memoized). */
|
|
227
|
+
sharedInstall?: () => SharedInstallReport | Promise<SharedInstallReport>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const SHARED_INSTALL_TTL_MS = 30_000;
|
|
231
|
+
let sharedInstallMemo: { key: string; at: number; report: SharedInstallReport } | undefined;
|
|
232
|
+
|
|
233
|
+
/** The real scan, memoized for 30 s per environment so repeated doctor runs in one process walk the global dir once. */
|
|
234
|
+
function defaultSharedInstall(): SharedInstallReport {
|
|
235
|
+
const key = [process.env.HOME, process.env.BUN_INSTALL, process.env.BUN_INSTALL_GLOBAL_DIR, process.env.XDG_CONFIG_HOME, process.env.PATH, process.platform].join("|");
|
|
236
|
+
if (sharedInstallMemo?.key === key && Date.now() - sharedInstallMemo.at < SHARED_INSTALL_TTL_MS) return sharedInstallMemo.report;
|
|
237
|
+
const report = collectSharedInstallReport();
|
|
238
|
+
sharedInstallMemo = { key, at: Date.now(), report };
|
|
239
|
+
return report;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ORPHAN_KIND_NOTE: Record<"directory" | "symlink" | "dangling-symlink", string> = {
|
|
243
|
+
directory: "possible orphan",
|
|
244
|
+
symlink: "possible orphan, symlink",
|
|
245
|
+
"dangling-symlink": "possible orphan, dangling symlink",
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** Human text for the shared install section. Warnings are yellow, an ok lockfile green; paths are never truncated. */
|
|
249
|
+
function sharedInstallLines(report: SharedInstallReport): string[] {
|
|
250
|
+
const lines: string[] = [];
|
|
251
|
+
const { manifest, lockfile, leftovers, sizes } = report;
|
|
252
|
+
if (report.skipped !== null) {
|
|
253
|
+
lines.push(`Shared install: no usable bun global (${report.skipped}); lockfile, leftover and size checks skipped.`);
|
|
254
|
+
} else {
|
|
255
|
+
lines.push(`Shared install (${report.globalDir ?? "unknown"}):`);
|
|
256
|
+
}
|
|
257
|
+
if (manifest !== null) lines.push(` packages: ${manifest.dependencies} in manifest (${manifest.external} besides jeo-code)`);
|
|
258
|
+
if (lockfile !== null) {
|
|
259
|
+
if (lockfile.status === "ok") {
|
|
260
|
+
const total = manifest?.external ?? lockfile.uncovered.length;
|
|
261
|
+
lines.push(chalk.green(` bun.lock: ok (${total - lockfile.uncovered.length} of ${total} covered)`));
|
|
262
|
+
} else if (lockfile.status === "not-required") {
|
|
263
|
+
lines.push(" bun.lock: not required (jeo-code is the only package)");
|
|
264
|
+
} else if (lockfile.status === "missing") {
|
|
265
|
+
lines.push(chalk.yellow(" bun.lock: MISSING - jeo update will refuse until it is restored (or pass --allow-unlocked-global)"));
|
|
266
|
+
} else if (lockfile.status === "incomplete") {
|
|
267
|
+
lines.push(chalk.yellow(` bun.lock: INCOMPLETE - no resolved entry for: ${lockfile.uncovered.join(", ")}`));
|
|
268
|
+
} else {
|
|
269
|
+
lines.push(chalk.yellow(` bun.lock: UNREADABLE${lockfile.detail ? ` (${lockfile.detail})` : ""}`));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (report.jeoOnPath.length === 0) {
|
|
273
|
+
lines.push(" jeo on PATH: none found");
|
|
274
|
+
} else {
|
|
275
|
+
const activeVersion = report.jeoOnPath.find(entry => entry.active)?.version ?? null;
|
|
276
|
+
lines.push(" jeo on PATH (first = active):");
|
|
277
|
+
for (const entry of report.jeoOnPath) {
|
|
278
|
+
const marker = entry.active ? "*" : " ";
|
|
279
|
+
const text = ` ${marker} ${entry.path} ${entry.version ?? "(version unknown)"}${entry.active ? " (active)" : ""}`;
|
|
280
|
+
if (entry.stale && entry.version !== null && activeVersion !== null) {
|
|
281
|
+
const direction = compareVersions(entry.version, activeVersion) < 0 ? "older" : "newer";
|
|
282
|
+
lines.push(chalk.yellow(`${text} [stale: ${direction} than active ${activeVersion}]`));
|
|
283
|
+
} else {
|
|
284
|
+
lines.push(text);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (leftovers !== null) {
|
|
289
|
+
if (leftovers.backupFiles.length === 0 && leftovers.possibleOrphans.length === 0 && leftovers.orphanScanComplete) {
|
|
290
|
+
lines.push(" leftovers: none");
|
|
291
|
+
} else {
|
|
292
|
+
lines.push(" leftovers (not touched):");
|
|
293
|
+
for (const file of leftovers.backupFiles) lines.push(chalk.yellow(` file ${file}`));
|
|
294
|
+
for (const orphan of leftovers.possibleOrphans) lines.push(chalk.yellow(` orphan ${orphan.name} (${ORPHAN_KIND_NOTE[orphan.kind]})`));
|
|
295
|
+
if (!leftovers.orphanScanComplete) lines.push(chalk.yellow(" orphan scan stopped at its cap; no orphans reported"));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (sizes !== null) {
|
|
299
|
+
const top = `top ${sizes.top.length}: ${sizes.top.map(p => `${p.name} ${formatBytes(p.bytes)}`).join(", ")}`;
|
|
300
|
+
if (sizes.capped === null) {
|
|
301
|
+
lines.push(` size: ${formatBytes(sizes.totalBytes)} total${sizes.top.length > 0 ? `; ${top}` : ""}`);
|
|
302
|
+
} else {
|
|
303
|
+
const cap = sizes.capped === "time" ? `time cap (${DEFAULT_SIZE_CAP_MS} ms)` : `entry cap (${sizes.scannedEntries} entries)`;
|
|
304
|
+
lines.push(chalk.yellow(` size scan stopped at the ${cap}; figures are lower bounds`));
|
|
305
|
+
lines.push(` at least ${formatBytes(sizes.totalBytes)} scanned${sizes.top.length > 0 ? `; ${top}` : ""}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return lines;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function runDoctorCommand(args: string[] = [], deps: DoctorDeps = {}): Promise<void> {
|
|
223
312
|
const strict = args.includes("--strict");
|
|
224
313
|
const json = args.includes("--json");
|
|
225
314
|
const config = await readGlobalConfig();
|
|
@@ -379,6 +468,14 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
379
468
|
? defaultProbe?.result.status === "skipped"
|
|
380
469
|
: defaultProbe?.result.status === "ok";
|
|
381
470
|
|
|
471
|
+
// Shared bun global diagnostics: read-only, never affects `ready` or the exit code.
|
|
472
|
+
let sharedInstall: SharedInstallReport | { error: string };
|
|
473
|
+
try {
|
|
474
|
+
sharedInstall = await (deps.sharedInstall ?? defaultSharedInstall)();
|
|
475
|
+
} catch (err) {
|
|
476
|
+
sharedInstall = { error: err instanceof Error ? err.message : String(err) };
|
|
477
|
+
}
|
|
478
|
+
|
|
382
479
|
// --- JSON output mode (CI / scripting) ---
|
|
383
480
|
if (json) {
|
|
384
481
|
const report = {
|
|
@@ -402,6 +499,7 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
402
499
|
oauth: oauthHealth,
|
|
403
500
|
ready,
|
|
404
501
|
...(routingEnabled ? { routing: { enabled: true, smolConfigured, preview: routingPreview, ...(routingNotes.length ? { notes: routingNotes } : {}) } } : {}),
|
|
502
|
+
sharedInstall,
|
|
405
503
|
|
|
406
504
|
};
|
|
407
505
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -490,6 +588,13 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
490
588
|
console.log("");
|
|
491
589
|
}
|
|
492
590
|
|
|
591
|
+
if ("error" in sharedInstall) {
|
|
592
|
+
console.log(chalk.yellow(`Shared install: check failed (${sharedInstall.error})`));
|
|
593
|
+
} else {
|
|
594
|
+
for (const line of sharedInstallLines(sharedInstall)) console.log(line);
|
|
595
|
+
}
|
|
596
|
+
console.log("");
|
|
597
|
+
|
|
493
598
|
// Final verdict
|
|
494
599
|
if (ready) {
|
|
495
600
|
console.log(`${chalk.green("[READY]")} Default model '${config.defaultModel}' is reachable.`);
|
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) {
|