jeo-code 0.12.26 → 0.12.28
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 +15 -0
- 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 +1 -1
- package/src/agent/AGENTS.md +3 -2
- package/src/agent/engine.ts +82 -29
- package/src/agent/loop-guards.ts +366 -42
- package/src/agent/sandbox/docker.ts +15 -1
- package/src/agent/sandbox/hands-source.text +705 -705
- package/src/agent/snapshot-target.ts +188 -0
- package/src/agent/workspace-snapshot.ts +84 -19
- package/src/ai/types.ts +6 -2
- package/src/commands/update.ts +32 -11
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which directory the engine's before/after workspace snapshot should measure for ONE shell call.
|
|
3
|
+
*
|
|
4
|
+
* A shared scratch cwd ($HOME, `/`, a system temp dir) is never bracketed itself: unrelated processes write there, so
|
|
5
|
+
* a digest change cannot be blamed on the call. But a model often sits in such a cwd and edits a real project
|
|
6
|
+
* through it (`cd ~/work/app && sed -i …`, or a bash `cwd`/`subdir` argument), and that edit would otherwise be
|
|
7
|
+
* invisible to the done gate. This resolves the PROJECT the call is aimed at (the outermost directory with a project
|
|
8
|
+
* marker above the target, so `cd app/pkg && echo X > ../src/a.ts` measures `app`) so the bracket can measure it.
|
|
9
|
+
*
|
|
10
|
+
* Everything here fails SAFE: an unresolvable, nonexistent, scratch, marker-less or ambiguous target means "no nested
|
|
11
|
+
* target" (the caller keeps today's scratch skip), and nothing throws.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { isSharedScratchRoot } from "./workspace-snapshot";
|
|
17
|
+
|
|
18
|
+
/** Leading `cd a && cd b; …` hops followed at most this far; anything beyond is simply not modelled. */
|
|
19
|
+
const MAX_CD_HOPS = 8;
|
|
20
|
+
|
|
21
|
+
/** How far above a target the project-marker search climbs before giving up. */
|
|
22
|
+
const MAX_MARKER_LEVELS = 12;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Files/dirs whose presence says "this is a project". A nested target is bracketed only when it (or an ancestor below
|
|
26
|
+
* the scratch root) has one: `cd ~/.cache && ls` or `cd /var/log && tail x` is ambient state, not a project, and a
|
|
27
|
+
* bracket there measures unrelated churn (or hits the entry cap) instead of the call's own writes. The OUTERMOST such
|
|
28
|
+
* ancestor is the bracketed root, because a write anywhere in the project (`../src/a.ts`) belongs to the same project.
|
|
29
|
+
*/
|
|
30
|
+
const PROJECT_MARKERS: readonly string[] = [
|
|
31
|
+
".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", "setup.py", "Makefile", "pom.xml",
|
|
32
|
+
"build.gradle", "build.gradle.kts", "Gemfile", "composer.json", "deno.json", "CMakeLists.txt", ".jeo",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A leading literal `cd <path>` terminated by `&&`, `;` or a newline. The path is single-quoted, double-quoted
|
|
37
|
+
* without expansion characters, or a bare token drawn from a whitelist (so `$`, backticks, globs, braces, escapes
|
|
38
|
+
* and `||`/`|`/`&` never parse): when it does not match, the hop is not modelled rather than guessed.
|
|
39
|
+
*/
|
|
40
|
+
const CD_HOP =
|
|
41
|
+
/^\s*cd[ \t]+(?:'([^']*)'|"([^"$`\\]*)"|([A-Za-z0-9_./~@%+:,][A-Za-z0-9_./~@%+:,-]*))[ \t]*(?:&&|;|\r?\n)/;
|
|
42
|
+
|
|
43
|
+
const realOrResolved = (target: string): string => {
|
|
44
|
+
try { return realpathSync(target); } catch { return path.resolve(target); }
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isDirectory = (target: string): boolean => {
|
|
48
|
+
try { return statSync(target).isDirectory(); } catch { return false; }
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const accountHomedir = (): string => {
|
|
52
|
+
try { return os.userInfo().homedir; } catch { return os.homedir(); }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** True when `child` is `parent` or lies inside it (both already resolved). */
|
|
56
|
+
const within = (parent: string, child: string): boolean => {
|
|
57
|
+
const rel = path.relative(parent, child);
|
|
58
|
+
return rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The roots the climb must not enter and a bracketed root must not cover: the cwd plus the declared/default roots. */
|
|
62
|
+
function scratchRootList(cwd: string, scratchRoots: readonly string[] | undefined): string[] {
|
|
63
|
+
return scratchRoots
|
|
64
|
+
? [cwd, ...scratchRoots]
|
|
65
|
+
: [cwd, os.homedir(), accountHomedir(), os.tmpdir(), "/tmp", "/var/tmp"];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when `target` is a scratch root or CONTAINS one (`/Users` above `$HOME`, `/` above everything). Bracketing an
|
|
70
|
+
* ancestor of a scratch root walks the very tree that made the root unmeasurable (a read-only `cd .. && ls` would hit
|
|
71
|
+
* the entry cap and count as a mutation), so it is skipped exactly like the root itself.
|
|
72
|
+
*/
|
|
73
|
+
function coversScratchRoot(target: string, roots: readonly string[]): boolean {
|
|
74
|
+
const here = realOrResolved(target);
|
|
75
|
+
return roots.some(root => within(here, realOrResolved(root)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The OUTERMOST ancestor-or-self of `target` holding a project marker, or null. The climb stops BEFORE any scratch
|
|
80
|
+
* root (a `package.json` in `$HOME` must not turn every directory under it into a project) and at the filesystem
|
|
81
|
+
* root, and is bounded to MAX_MARKER_LEVELS. Outermost, not nearest: the deepest directory of a project (`app/pkg`,
|
|
82
|
+
* `/opt/homebrew/var/log`) sees only part of what a command can write, and a bracket on a log directory blames the call
|
|
83
|
+
* for its ambient writers; the project root is where an edit anywhere in the tree shows up.
|
|
84
|
+
*/
|
|
85
|
+
function projectRoot(target: string, roots: readonly string[]): string | null {
|
|
86
|
+
let current = realOrResolved(target);
|
|
87
|
+
let root: string | null = null;
|
|
88
|
+
for (let level = 0; level < MAX_MARKER_LEVELS; level++) {
|
|
89
|
+
const parent = path.dirname(current);
|
|
90
|
+
if (parent === current || isSharedScratchRoot(current, roots)) break;
|
|
91
|
+
if (PROJECT_MARKERS.some(marker => existsSync(path.join(current, marker)))) root = current;
|
|
92
|
+
current = parent;
|
|
93
|
+
}
|
|
94
|
+
return root;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The `a.env` map bash receives, reduced to the string entries the tool actually forwards. */
|
|
98
|
+
function envValue(args: Record<string, any> | undefined, key: string): string | undefined {
|
|
99
|
+
const env = args?.env;
|
|
100
|
+
const own = env && typeof env === "object" && !Array.isArray(env) ? env[key] : undefined;
|
|
101
|
+
return typeof own === "string" ? own : process.env[key];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One `cd` hop from `dir`. Null when the hop cannot be resolved with certainty: `~user`, a relative path under a
|
|
106
|
+
* set `CDPATH` (bash would search elsewhere), or a target that is not an existing directory.
|
|
107
|
+
*/
|
|
108
|
+
function applyHop(dir: string, raw: string, quoted: boolean, args: Record<string, any> | undefined): string | null {
|
|
109
|
+
let target = raw;
|
|
110
|
+
if (!quoted && raw.startsWith("~")) {
|
|
111
|
+
if (raw !== "~" && !raw.startsWith("~/")) return null;
|
|
112
|
+
const home = envValue(args, "HOME") || os.homedir();
|
|
113
|
+
target = path.join(home, raw.slice(1));
|
|
114
|
+
} else if (
|
|
115
|
+
!path.isAbsolute(raw) && !/^\.\.?(?:\/|$)/.test(raw) && (envValue(args, "CDPATH") ?? "") !== ""
|
|
116
|
+
) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const next = path.resolve(dir, target);
|
|
120
|
+
return isDirectory(next) ? next : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The directory the snapshot bracket should measure for this ONE call.
|
|
125
|
+
*
|
|
126
|
+
* - Default: `cwd`, unchanged. A non-scratch cwd always brackets itself (targets outside it are not followed), and
|
|
127
|
+
* so does every tool other than `bash`.
|
|
128
|
+
* - `bash` from a shared scratch cwd: resolve the effective run dir from `args.cwd ?? args.subdir` (the same
|
|
129
|
+
* precedence the bash tool uses), then follow a LEADING literal `cd <path> &&` / `cd <path>;` chain from there.
|
|
130
|
+
* The last existing directory reached is the TARGET; the bracketed directory is its project root (outermost
|
|
131
|
+
* ancestor-or-self with a marker), unless that root is a scratch root or contains one.
|
|
132
|
+
*
|
|
133
|
+
* Returns `cwd` itself (same string) when there is no nested target; any other return value is the absolute real path
|
|
134
|
+
* of the project root the caller should measure instead. A directory the command creates itself does not exist yet
|
|
135
|
+
* and so yields no target — an accepted gap, since guessing a path that may never exist would measure the wrong
|
|
136
|
+
* tree. A target with no project marker (see PROJECT_MARKERS) is not a project either, and is treated like no target.
|
|
137
|
+
*/
|
|
138
|
+
export function snapshotTarget(
|
|
139
|
+
cwd: string,
|
|
140
|
+
tool: string,
|
|
141
|
+
args: Record<string, any> | undefined,
|
|
142
|
+
scratchRoots?: readonly string[],
|
|
143
|
+
): string {
|
|
144
|
+
try {
|
|
145
|
+
if (tool !== "bash" || !isSharedScratchRoot(cwd, scratchRoots)) return cwd;
|
|
146
|
+
const sub = typeof args?.cwd === "string" ? args.cwd : typeof args?.subdir === "string" ? args.subdir : "";
|
|
147
|
+
let dir = sub ? path.resolve(cwd, sub) : cwd;
|
|
148
|
+
// The bash tool spawns in this directory; if it is missing the call fails before touching anything.
|
|
149
|
+
if (!isDirectory(dir)) return cwd;
|
|
150
|
+
let rest = String(args?.command ?? args?.cmd ?? "");
|
|
151
|
+
for (let hop = 0; hop < MAX_CD_HOPS; hop++) {
|
|
152
|
+
const m = CD_HOP.exec(rest);
|
|
153
|
+
if (!m) break;
|
|
154
|
+
const next = applyHop(dir, m[1] ?? m[2] ?? m[3], m[3] === undefined, args);
|
|
155
|
+
if (next === null) break;
|
|
156
|
+
dir = next;
|
|
157
|
+
rest = rest.slice(m[0].length);
|
|
158
|
+
}
|
|
159
|
+
const roots = scratchRootList(cwd, scratchRoots);
|
|
160
|
+
const root = projectRoot(dir, roots);
|
|
161
|
+
// The climb never enters a scratch root, but a marker above one (`/var/.git` over `/var/tmp`) still yields a
|
|
162
|
+
// root that covers it: that is as unmeasurable as the scratch root itself.
|
|
163
|
+
if (root === null || isSharedScratchRoot(root, roots) || coversScratchRoot(root, roots)) return cwd;
|
|
164
|
+
return root;
|
|
165
|
+
} catch {
|
|
166
|
+
return cwd;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The form persisted as `workspace.target`: relative to `cwd` when the target lies inside it, else the fixed token
|
|
172
|
+
* `<outside cwd>` — an absolute path outside the workspace has no business in a transcript. Compared by real path, so
|
|
173
|
+
* a cwd reached through a symlink (macOS `/tmp`) still counts as containing its own subdirectories.
|
|
174
|
+
*/
|
|
175
|
+
export function describeSnapshotTarget(cwd: string, target: string): string {
|
|
176
|
+
const base = realOrResolved(cwd);
|
|
177
|
+
const real = realOrResolved(target);
|
|
178
|
+
const rel = path.relative(base, real);
|
|
179
|
+
return rel !== "" && within(base, real) ? rel : "<outside cwd>";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Human-facing name for notices: the relative path inside `cwd`, otherwise only the directory's basename. */
|
|
183
|
+
export function snapshotTargetLabel(cwd: string, target: string): string {
|
|
184
|
+
const base = realOrResolved(cwd);
|
|
185
|
+
const real = realOrResolved(target);
|
|
186
|
+
const rel = path.relative(base, real);
|
|
187
|
+
return rel !== "" && within(base, real) ? rel : path.basename(real);
|
|
188
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
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).
|
|
4
|
-
*
|
|
3
|
+
* counts the call as a mutation only when they differ (workspaceSnapshotChanged). A snapshot that
|
|
4
|
+
* cannot be taken is `ok:false` and always counts as a mutation (safe side): an unreadable ROOT, the
|
|
5
|
+
* entry/time caps, and any readdir/lstat/readlink error not listed under "degraded" below.
|
|
5
6
|
*
|
|
6
7
|
* Entry strings folded into an order-independent digest:
|
|
7
8
|
* - directory: `d:<rel>` (presence only; directory mtimes are ignored so creating an excluded
|
|
@@ -11,6 +12,25 @@
|
|
|
11
12
|
* - regular file with mtime >= anchor - racyWindowMs: `:h<wyhash(content)>` (racy-clean guard
|
|
12
13
|
* against coarse timestamps; the after-snapshot reuses the before-snapshot's anchor)
|
|
13
14
|
* - FIFO/socket/device: stat only, never opened
|
|
15
|
+
*
|
|
16
|
+
* Degraded entries keep the snapshot `ok:true`; `degraded` counts them. Both cases lose nothing
|
|
17
|
+
* attributable to a command, so they must not make every bash call look mutating:
|
|
18
|
+
* - a NON-root directory whose listing is denied (EACCES/EPERM): it is keyed
|
|
19
|
+
* `u:<rel>:<mode>:<ctimeNs>:<ino>` (lstat of the directory itself) instead of `d:<rel>` (replacing
|
|
20
|
+
* it in the digest and the path map) and its children are skipped. A same-user process CAN change
|
|
21
|
+
* such a directory (chmod it open, write, chmod it shut), but every entry creation/removal/rename
|
|
22
|
+
* inside it and every chmod updates the directory's own ctime, and the mode is recorded, so the
|
|
23
|
+
* marker flips; so does becoming listable. ENOENT/ENOTDIR (vanished) is skipped as before; any
|
|
24
|
+
* other readdir code stays a failure. Residual, verified only for the cases above: a change that
|
|
25
|
+
* leaves mode, ctime and inode identical (e.g. a rewrite of an existing child's CONTENT inside a
|
|
26
|
+
* `-wx` directory does not touch the directory's ctime) is not seen.
|
|
27
|
+
* - a racy regular file that is over racyMaxFileBytes, would push the hashed total over
|
|
28
|
+
* racyMaxTotalBytes, cannot be opened/read, or changes while being read: stat-only fingerprint plus
|
|
29
|
+
* `:x` (unhashed). Racy files are hashed after the walk in path order, first-fit against the total
|
|
30
|
+
* budget, so the before and after snapshots of an unchanged workspace hash the same files.
|
|
31
|
+
* Residual risk: a same-size rewrite of such a file inside one timestamp tick of the bracket anchor
|
|
32
|
+
* goes unseen. Nanosecond mtime/ctime (APFS, ext4) make that negligible; files within the caps are
|
|
33
|
+
* still content-hashed.
|
|
14
34
|
*/
|
|
15
35
|
import * as fs from "node:fs/promises";
|
|
16
36
|
import { constants, realpathSync } from "node:fs";
|
|
@@ -26,7 +46,8 @@ export interface WorkspaceSnapshotLimits {
|
|
|
26
46
|
maxMs: number;
|
|
27
47
|
/** Files with mtime within this window of the snapshot start are content-hashed too. */
|
|
28
48
|
racyWindowMs: number;
|
|
29
|
-
/** Per-file and per-snapshot byte caps for racy content hashing.
|
|
49
|
+
/** Per-file and per-snapshot byte caps for racy content hashing. A file over either cap is not
|
|
50
|
+
* hashed: it degrades to a stat-only `:x` entry (the total bounds hashing work, first-fit). */
|
|
30
51
|
racyMaxFileBytes: number;
|
|
31
52
|
racyMaxTotalBytes: number;
|
|
32
53
|
}
|
|
@@ -45,6 +66,8 @@ export const WORKSPACE_SNAPSHOT_EXCLUDED_DIRS: ReadonlySet<string> = new Set([".
|
|
|
45
66
|
export type WorkspaceSnapshot =
|
|
46
67
|
| {
|
|
47
68
|
ok: true; digest: bigint; entries: number; elapsedMs: number; startedAtMs: number;
|
|
69
|
+
/** Entries recorded without full fidelity (unlistable directory, unhashed racy file). */
|
|
70
|
+
degraded?: number;
|
|
48
71
|
/** Relative path -> entry fingerprint, so a mismatch can name WHICH paths changed
|
|
49
72
|
* (`snapshotChangedPaths`). Optional so hand-built snapshots and mocks stay valid. */
|
|
50
73
|
paths?: ReadonlyMap<string, bigint>;
|
|
@@ -81,6 +104,8 @@ async function chunked<T>(items: readonly T[], run: (item: T) => Promise<void>):
|
|
|
81
104
|
|
|
82
105
|
interface Dir { rel: string; abs: string }
|
|
83
106
|
interface Leaf extends Dir { dirent: Dirent }
|
|
107
|
+
/** A racy file waiting for the post-walk hashing pass. */
|
|
108
|
+
interface RacyCandidate { leaf: Leaf; stat: BigIntStats; entry: string; hashable: boolean }
|
|
84
109
|
|
|
85
110
|
export async function snapshotWorkspace(
|
|
86
111
|
cwd: string,
|
|
@@ -93,12 +118,14 @@ export async function snapshotWorkspace(
|
|
|
93
118
|
const racyThresholdNs = BigInt(Math.floor(anchorMs - limits.racyWindowMs)) * 1_000_000n;
|
|
94
119
|
let digest = 0n;
|
|
95
120
|
let entries = 0;
|
|
96
|
-
let
|
|
121
|
+
let degraded = 0;
|
|
97
122
|
|
|
98
123
|
const paths = new Map<string, bigint>();
|
|
99
124
|
const record = (entry: string, rel: string): void => {
|
|
100
125
|
const hash = BigInt(Bun.hash.wyhash(entry));
|
|
101
|
-
|
|
126
|
+
// A path recorded twice (the `u:` marker over its directory's `d:` entry) replaces the first, so
|
|
127
|
+
// digest stays the sum of the path map. BigInt `&` on a negative value wraps as two's complement.
|
|
128
|
+
digest = (digest - (paths.get(rel) ?? 0n) + hash) & DIGEST_MASK;
|
|
102
129
|
paths.set(rel, hash);
|
|
103
130
|
};
|
|
104
131
|
const checkCaps = (): void => {
|
|
@@ -106,18 +133,16 @@ export async function snapshotWorkspace(
|
|
|
106
133
|
if (performance.now() - startedAt > limits.maxMs) stop("cap", `workspace snapshot exceeded ${limits.maxMs} ms`);
|
|
107
134
|
};
|
|
108
135
|
|
|
109
|
-
/** Bounded
|
|
110
|
-
|
|
136
|
+
/** Bounded content hash of a regular file. `"gone"`: vanished before it could be opened. `"unhashed"`:
|
|
137
|
+
* any other failure (permissions, swapped inode, grew or errored while read); the caller degrades. */
|
|
138
|
+
const racyHash = async (leaf: Leaf, stat: BigIntStats): Promise<bigint | "gone" | "unhashed"> => {
|
|
111
139
|
const size = Number(stat.size);
|
|
112
|
-
if (size > limits.racyMaxFileBytes) stop("error", `racy file too large: ${leaf.rel} (${size} bytes)`);
|
|
113
|
-
racyBytes += size;
|
|
114
|
-
if (racyBytes > limits.racyMaxTotalBytes) stop("error", `racy files too large: more than ${limits.racyMaxTotalBytes} bytes`);
|
|
115
140
|
let handle: FileHandle;
|
|
116
141
|
try { handle = await fs.open(leaf.abs, RACY_OPEN_FLAGS); }
|
|
117
|
-
catch (error) {
|
|
142
|
+
catch (error) { return vanished(error) ? "gone" : "unhashed"; }
|
|
118
143
|
try {
|
|
119
144
|
const opened = await handle.stat({ bigint: true });
|
|
120
|
-
if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev)
|
|
145
|
+
if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) return "unhashed";
|
|
121
146
|
// One spare byte detects growth after lstat without reading past the cap.
|
|
122
147
|
const buffer = new Uint8Array(size + 1);
|
|
123
148
|
let read = 0;
|
|
@@ -125,14 +150,39 @@ export async function snapshotWorkspace(
|
|
|
125
150
|
const { bytesRead } = await handle.read(buffer, read, buffer.length - read, read);
|
|
126
151
|
if (bytesRead === 0) break;
|
|
127
152
|
read += bytesRead;
|
|
128
|
-
if (read > size)
|
|
153
|
+
if (read > size) return "unhashed";
|
|
129
154
|
}
|
|
130
155
|
return Bun.hash.wyhash(buffer.subarray(0, read));
|
|
156
|
+
} catch {
|
|
157
|
+
return "unhashed";
|
|
131
158
|
} finally {
|
|
132
159
|
await handle.close().catch(() => {});
|
|
133
160
|
}
|
|
134
161
|
};
|
|
135
162
|
|
|
163
|
+
/** Racy files are collected during the walk and hashed afterwards in path order (see header). */
|
|
164
|
+
const racy: RacyCandidate[] = [];
|
|
165
|
+
const hashRacy = async (): Promise<void> => {
|
|
166
|
+
racy.sort((a, b) => (a.leaf.rel < b.leaf.rel ? -1 : 1));
|
|
167
|
+
let racyBytes = 0;
|
|
168
|
+
for (const candidate of racy) {
|
|
169
|
+
const size = Number(candidate.stat.size);
|
|
170
|
+
candidate.hashable = size <= limits.racyMaxFileBytes && racyBytes + size <= limits.racyMaxTotalBytes;
|
|
171
|
+
if (candidate.hashable) racyBytes += size;
|
|
172
|
+
}
|
|
173
|
+
await chunked(racy, async candidate => {
|
|
174
|
+
checkCaps();
|
|
175
|
+
const hash = candidate.hashable ? await racyHash(candidate.leaf, candidate.stat) : "unhashed";
|
|
176
|
+
if (hash === "gone") { entries -= 1; return; }
|
|
177
|
+
if (hash === "unhashed") {
|
|
178
|
+
degraded += 1;
|
|
179
|
+
record(`${candidate.entry}:x`, candidate.leaf.rel);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
record(`${candidate.entry}:h${hash}`, candidate.leaf.rel);
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
136
186
|
const fingerprint = async (leaf: Leaf, queue: Dir[]): Promise<void> => {
|
|
137
187
|
let stat: BigIntStats;
|
|
138
188
|
try { stat = await fs.lstat(leaf.abs, { bigint: true }); }
|
|
@@ -156,9 +206,8 @@ export async function snapshotWorkspace(
|
|
|
156
206
|
return stop("error", `cannot read link ${leaf.rel}: ${errorMessage(error)}`);
|
|
157
207
|
}
|
|
158
208
|
} else if (kind === "f" && leaf.dirent.isFile() && stat.mtimeNs >= racyThresholdNs) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
entry += `:h${hash}`;
|
|
209
|
+
racy.push({ leaf, stat, entry, hashable: false });
|
|
210
|
+
return;
|
|
162
211
|
}
|
|
163
212
|
record(entry, leaf.rel);
|
|
164
213
|
};
|
|
@@ -167,8 +216,22 @@ export async function snapshotWorkspace(
|
|
|
167
216
|
let dirents: Dirent[];
|
|
168
217
|
try { dirents = await fs.readdir(dir.abs, { withFileTypes: true }); }
|
|
169
218
|
catch (error) {
|
|
170
|
-
if (dir.rel
|
|
171
|
-
|
|
219
|
+
if (dir.rel === "") return stop("error", `cannot read directory .: ${errorMessage(error)}`);
|
|
220
|
+
if (vanished(error)) return;
|
|
221
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
222
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
223
|
+
// chmod-open/write/chmod-shut leaves the same mode but a newer ctime, so identity must be part of the marker.
|
|
224
|
+
let stat: BigIntStats;
|
|
225
|
+
try { stat = await fs.lstat(dir.abs, { bigint: true }); }
|
|
226
|
+
catch (statError) {
|
|
227
|
+
if (vanished(statError)) return;
|
|
228
|
+
return stop("error", `cannot stat ${dir.rel}: ${errorMessage(statError)}`);
|
|
229
|
+
}
|
|
230
|
+
degraded += 1;
|
|
231
|
+
record(`u:${dir.rel}:${stat.mode}:${stat.ctimeNs}:${stat.ino}`, dir.rel);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
return stop("error", `cannot read directory ${dir.rel}: ${errorMessage(error)}`);
|
|
172
235
|
}
|
|
173
236
|
for (const dirent of dirents) {
|
|
174
237
|
const rel = dir.rel ? `${dir.rel}/${dirent.name}` : dirent.name;
|
|
@@ -203,7 +266,9 @@ export async function snapshotWorkspace(
|
|
|
203
266
|
}
|
|
204
267
|
level = next;
|
|
205
268
|
}
|
|
206
|
-
|
|
269
|
+
await hashRacy();
|
|
270
|
+
checkCaps();
|
|
271
|
+
return { ok: true, digest, entries, degraded, elapsedMs: performance.now() - startedAt, startedAtMs, paths };
|
|
207
272
|
} catch (error) {
|
|
208
273
|
if (error instanceof SnapshotStop) return error.result;
|
|
209
274
|
return { ok: false, reason: "error", detail: errorMessage(error) };
|
package/src/ai/types.ts
CHANGED
|
@@ -86,9 +86,13 @@ export interface ToolResultRecord {
|
|
|
86
86
|
* the transcript so a completion-gate rejection can be audited afterwards. `mutation` is whether the
|
|
87
87
|
* snapshot bracket counted the call as a possible file change, `changed` names the paths that differed,
|
|
88
88
|
* and `inconclusive` is why the bracket could not measure (cap or error; counted as a change). `skipped: "scratch"`
|
|
89
|
-
* means the cwd is a shared scratch root
|
|
89
|
+
* means nothing was measured: the cwd is a shared scratch root where calls are deliberately not bracketed, or (with
|
|
90
|
+
* `target`) a nested project root was already unmeasurable BEFORE the call and failed open.
|
|
91
|
+
* `target` is set only when the bracket aimed at a nested project root instead of the cwd (a bash call from a
|
|
92
|
+
* scratch cwd; the outermost marker directory above the call's target): relative to the cwd when inside it, else
|
|
93
|
+
* the fixed token `<outside cwd>`; `changed` paths are relative to that directory.
|
|
90
94
|
*/
|
|
91
|
-
workspace?: { mutation: boolean; changed?: string; inconclusive?: string; skipped?: "scratch" };
|
|
95
|
+
workspace?: { mutation: boolean; changed?: string; inconclusive?: string; skipped?: "scratch"; target?: string };
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
export interface Usage {
|
package/src/commands/update.ts
CHANGED
|
@@ -142,9 +142,15 @@ export function runInstall(version: string | undefined, io: InstallIo): InstallR
|
|
|
142
142
|
// A zero exit is NOT proof the user's `jeo` moved: npm's global prefix is
|
|
143
143
|
// often a different directory than the bun prefix PATH actually resolves,
|
|
144
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
|
-
|
|
147
|
-
|
|
145
|
+
// Only stop once the binary on PATH really reports the requested version —
|
|
146
|
+
// unless the active version cannot be read at all (null). A null means
|
|
147
|
+
// `jeo --version` could not even run (e.g. a Windows `.cmd`/`.exe` shim
|
|
148
|
+
// the bare spawn cannot resolve); re-running the installer cannot fix an
|
|
149
|
+
// unreadable binary, so the installer's zero exit is trusted. A positively
|
|
150
|
+
// stale version (a real older string) is what retries and fails the update.
|
|
151
|
+
const active = io.activeVersion();
|
|
152
|
+
if (!version || active === null || active === version) return { success: true };
|
|
153
|
+
lastErr = `${cmd[0]} reported success but the active 'jeo' is still ${active ?? "unknown"}`;
|
|
148
154
|
continue;
|
|
149
155
|
}
|
|
150
156
|
lastErr = `${cmd[0]} exited with code ${proc.exitCode}`;
|
|
@@ -155,17 +161,32 @@ export function runInstall(version: string | undefined, io: InstallIo): InstallR
|
|
|
155
161
|
return { success: false, stderr: lastErr, manual: manualInstallLines(target, origin) };
|
|
156
162
|
}
|
|
157
163
|
|
|
164
|
+
/**
|
|
165
|
+
* How to ask the `jeo` on PATH for its version. On Windows the bun-global bin
|
|
166
|
+
* can be a `jeo.exe` stub or a `cmd` shim, which a bare `["jeo", ...]` spawn
|
|
167
|
+
* often cannot resolve, so a `/c` form is tried as well. Pure so both shapes
|
|
168
|
+
* are unit-testable without spawning.
|
|
169
|
+
*/
|
|
170
|
+
export function jeoVersionAttempts(platform: NodeJS.Platform): string[][] {
|
|
171
|
+
const direct = ["jeo", "--version"];
|
|
172
|
+
if (platform !== "win32") return [direct];
|
|
173
|
+
return [direct, ["jeo.exe", "--version"], ["cmd", "/d", "/s", "/c", "jeo --version"]];
|
|
174
|
+
}
|
|
175
|
+
|
|
158
176
|
/** Version of the `jeo` binary PATH actually resolves, or null when unreadable. */
|
|
159
177
|
function readActiveVersion(): string | null {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
178
|
+
for (const argv of jeoVersionAttempts(process.platform)) {
|
|
179
|
+
try {
|
|
180
|
+
const proc = Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe" });
|
|
181
|
+
if (!proc.success) continue;
|
|
182
|
+
const out = new TextDecoder().decode(proc.stdout);
|
|
183
|
+
const m = out.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
|
|
184
|
+
if (m) return m[1];
|
|
185
|
+
} catch {
|
|
186
|
+
// Try the next spelling; unreadable is reported only when every one fails.
|
|
187
|
+
}
|
|
168
188
|
}
|
|
189
|
+
return null;
|
|
169
190
|
}
|
|
170
191
|
export const defaultDeps: UpdateDeps = {
|
|
171
192
|
fetchJson: async (url: string, options?: { signal?: AbortSignal }) => {
|