jeo-code 0.12.25 → 0.12.27
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 +1 -1
- package/src/agent/AGENTS.md +3 -2
- package/src/agent/engine.ts +140 -22
- package/src/agent/loop-guards.ts +391 -46
- package/src/agent/sandbox/docker.ts +15 -1
- package/src/agent/sandbox/hands-source.text +731 -731
- package/src/agent/snapshot-target.ts +188 -0
- package/src/agent/workspace-snapshot.ts +163 -24
- package/src/ai/types.ts +12 -0
- package/src/commands/launch.ts +2 -1
|
@@ -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,11 +12,31 @@
|
|
|
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
|
-
import { constants } from "node:fs";
|
|
36
|
+
import { constants, realpathSync } from "node:fs";
|
|
17
37
|
import type { BigIntStats, Dirent } from "node:fs";
|
|
18
38
|
import type { FileHandle } from "node:fs/promises";
|
|
39
|
+
import * as os from "node:os";
|
|
19
40
|
import * as path from "node:path";
|
|
20
41
|
|
|
21
42
|
export interface WorkspaceSnapshotLimits {
|
|
@@ -25,7 +46,8 @@ export interface WorkspaceSnapshotLimits {
|
|
|
25
46
|
maxMs: number;
|
|
26
47
|
/** Files with mtime within this window of the snapshot start are content-hashed too. */
|
|
27
48
|
racyWindowMs: number;
|
|
28
|
-
/** 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). */
|
|
29
51
|
racyMaxFileBytes: number;
|
|
30
52
|
racyMaxTotalBytes: number;
|
|
31
53
|
}
|
|
@@ -42,7 +64,14 @@ export const WORKSPACE_SNAPSHOT_LIMITS: Readonly<WorkspaceSnapshotLimits> = Obje
|
|
|
42
64
|
export const WORKSPACE_SNAPSHOT_EXCLUDED_DIRS: ReadonlySet<string> = new Set([".git", ".jeo", "node_modules"]);
|
|
43
65
|
|
|
44
66
|
export type WorkspaceSnapshot =
|
|
45
|
-
| {
|
|
67
|
+
| {
|
|
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;
|
|
71
|
+
/** Relative path -> entry fingerprint, so a mismatch can name WHICH paths changed
|
|
72
|
+
* (`snapshotChangedPaths`). Optional so hand-built snapshots and mocks stay valid. */
|
|
73
|
+
paths?: ReadonlyMap<string, bigint>;
|
|
74
|
+
}
|
|
46
75
|
| { ok: false; reason: "error" | "cap"; detail: string };
|
|
47
76
|
|
|
48
77
|
/** `racySinceMs`: anchor for the racy content-hash predicate. The engine passes the BEFORE snapshot's
|
|
@@ -75,6 +104,8 @@ async function chunked<T>(items: readonly T[], run: (item: T) => Promise<void>):
|
|
|
75
104
|
|
|
76
105
|
interface Dir { rel: string; abs: string }
|
|
77
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 }
|
|
78
109
|
|
|
79
110
|
export async function snapshotWorkspace(
|
|
80
111
|
cwd: string,
|
|
@@ -87,26 +118,31 @@ export async function snapshotWorkspace(
|
|
|
87
118
|
const racyThresholdNs = BigInt(Math.floor(anchorMs - limits.racyWindowMs)) * 1_000_000n;
|
|
88
119
|
let digest = 0n;
|
|
89
120
|
let entries = 0;
|
|
90
|
-
let
|
|
121
|
+
let degraded = 0;
|
|
91
122
|
|
|
92
|
-
const
|
|
123
|
+
const paths = new Map<string, bigint>();
|
|
124
|
+
const record = (entry: string, rel: string): void => {
|
|
125
|
+
const hash = BigInt(Bun.hash.wyhash(entry));
|
|
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;
|
|
129
|
+
paths.set(rel, hash);
|
|
130
|
+
};
|
|
93
131
|
const checkCaps = (): void => {
|
|
94
132
|
if (entries > limits.maxEntries) stop("cap", `workspace has more than ${limits.maxEntries} entries`);
|
|
95
133
|
if (performance.now() - startedAt > limits.maxMs) stop("cap", `workspace snapshot exceeded ${limits.maxMs} ms`);
|
|
96
134
|
};
|
|
97
135
|
|
|
98
|
-
/** Bounded
|
|
99
|
-
|
|
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"> => {
|
|
100
139
|
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
140
|
let handle: FileHandle;
|
|
105
141
|
try { handle = await fs.open(leaf.abs, RACY_OPEN_FLAGS); }
|
|
106
|
-
catch (error) {
|
|
142
|
+
catch (error) { return vanished(error) ? "gone" : "unhashed"; }
|
|
107
143
|
try {
|
|
108
144
|
const opened = await handle.stat({ bigint: true });
|
|
109
|
-
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";
|
|
110
146
|
// One spare byte detects growth after lstat without reading past the cap.
|
|
111
147
|
const buffer = new Uint8Array(size + 1);
|
|
112
148
|
let read = 0;
|
|
@@ -114,14 +150,39 @@ export async function snapshotWorkspace(
|
|
|
114
150
|
const { bytesRead } = await handle.read(buffer, read, buffer.length - read, read);
|
|
115
151
|
if (bytesRead === 0) break;
|
|
116
152
|
read += bytesRead;
|
|
117
|
-
if (read > size)
|
|
153
|
+
if (read > size) return "unhashed";
|
|
118
154
|
}
|
|
119
155
|
return Bun.hash.wyhash(buffer.subarray(0, read));
|
|
156
|
+
} catch {
|
|
157
|
+
return "unhashed";
|
|
120
158
|
} finally {
|
|
121
159
|
await handle.close().catch(() => {});
|
|
122
160
|
}
|
|
123
161
|
};
|
|
124
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
|
+
|
|
125
186
|
const fingerprint = async (leaf: Leaf, queue: Dir[]): Promise<void> => {
|
|
126
187
|
let stat: BigIntStats;
|
|
127
188
|
try { stat = await fs.lstat(leaf.abs, { bigint: true }); }
|
|
@@ -132,7 +193,7 @@ export async function snapshotWorkspace(
|
|
|
132
193
|
if (stat.isDirectory()) {
|
|
133
194
|
// Unreported or raced dirent type: a real directory is never fingerprinted by metadata.
|
|
134
195
|
if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(leaf.dirent.name)) { entries -= 1; return; }
|
|
135
|
-
record(`d:${leaf.rel}
|
|
196
|
+
record(`d:${leaf.rel}`, leaf.rel);
|
|
136
197
|
queue.push(leaf);
|
|
137
198
|
return;
|
|
138
199
|
}
|
|
@@ -145,19 +206,32 @@ export async function snapshotWorkspace(
|
|
|
145
206
|
return stop("error", `cannot read link ${leaf.rel}: ${errorMessage(error)}`);
|
|
146
207
|
}
|
|
147
208
|
} else if (kind === "f" && leaf.dirent.isFile() && stat.mtimeNs >= racyThresholdNs) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
entry += `:h${hash}`;
|
|
209
|
+
racy.push({ leaf, stat, entry, hashable: false });
|
|
210
|
+
return;
|
|
151
211
|
}
|
|
152
|
-
record(entry);
|
|
212
|
+
record(entry, leaf.rel);
|
|
153
213
|
};
|
|
154
214
|
|
|
155
215
|
const list = async (dir: Dir, next: Dir[], leaves: Leaf[]): Promise<void> => {
|
|
156
216
|
let dirents: Dirent[];
|
|
157
217
|
try { dirents = await fs.readdir(dir.abs, { withFileTypes: true }); }
|
|
158
218
|
catch (error) {
|
|
159
|
-
if (dir.rel
|
|
160
|
-
|
|
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)}`);
|
|
161
235
|
}
|
|
162
236
|
for (const dirent of dirents) {
|
|
163
237
|
const rel = dir.rel ? `${dir.rel}/${dirent.name}` : dirent.name;
|
|
@@ -165,7 +239,7 @@ export async function snapshotWorkspace(
|
|
|
165
239
|
if (dirent.isDirectory()) {
|
|
166
240
|
if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(dirent.name)) continue;
|
|
167
241
|
entries += 1;
|
|
168
|
-
record(`d:${rel}
|
|
242
|
+
record(`d:${rel}`, rel);
|
|
169
243
|
next.push(child);
|
|
170
244
|
} else {
|
|
171
245
|
entries += 1;
|
|
@@ -192,7 +266,9 @@ export async function snapshotWorkspace(
|
|
|
192
266
|
}
|
|
193
267
|
level = next;
|
|
194
268
|
}
|
|
195
|
-
|
|
269
|
+
await hashRacy();
|
|
270
|
+
checkCaps();
|
|
271
|
+
return { ok: true, digest, entries, degraded, elapsedMs: performance.now() - startedAt, startedAtMs, paths };
|
|
196
272
|
} catch (error) {
|
|
197
273
|
if (error instanceof SnapshotStop) return error.result;
|
|
198
274
|
return { ok: false, reason: "error", detail: errorMessage(error) };
|
|
@@ -207,3 +283,66 @@ export function workspaceSnapshotChanged(before: WorkspaceSnapshot, after: Works
|
|
|
207
283
|
if (!before.ok || !after.ok) return true;
|
|
208
284
|
return before.digest !== after.digest || before.entries !== after.entries;
|
|
209
285
|
}
|
|
286
|
+
|
|
287
|
+
/** Result of comparing two snapshots path by path (sorted, relative to the workspace root). */
|
|
288
|
+
export interface SnapshotPathChange {
|
|
289
|
+
added: string[];
|
|
290
|
+
removed: string[];
|
|
291
|
+
modified: string[];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Paths that differ between two successful snapshots. Null when either side cannot say (a failed
|
|
296
|
+
* snapshot, or a hand-built one without a per-path map) — callers then fall back to the bare
|
|
297
|
+
* digest verdict. Directories are reported by name too; their mtimes are not fingerprinted.
|
|
298
|
+
*/
|
|
299
|
+
export function snapshotChangedPaths(before: WorkspaceSnapshot, after: WorkspaceSnapshot): SnapshotPathChange | null {
|
|
300
|
+
if (!before.ok || !after.ok || !before.paths || !after.paths) return null;
|
|
301
|
+
const added: string[] = [];
|
|
302
|
+
const modified: string[] = [];
|
|
303
|
+
const removed: string[] = [];
|
|
304
|
+
for (const [rel, hash] of after.paths) {
|
|
305
|
+
const prev = before.paths.get(rel);
|
|
306
|
+
if (prev === undefined) added.push(rel);
|
|
307
|
+
else if (prev !== hash) modified.push(rel);
|
|
308
|
+
}
|
|
309
|
+
for (const rel of before.paths.keys()) if (!after.paths.has(rel)) removed.push(rel);
|
|
310
|
+
return { added: added.sort(), removed: removed.sort(), modified: modified.sort() };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** `modified a/b.json; added c.txt (+3 more)` — at most `limit` paths, for notices and gate messages. */
|
|
314
|
+
export function formatSnapshotChange(change: SnapshotPathChange, limit = 5): string {
|
|
315
|
+
const items = [
|
|
316
|
+
...change.modified.map(p => `modified ${p}`),
|
|
317
|
+
...change.added.map(p => `added ${p}`),
|
|
318
|
+
...change.removed.map(p => `removed ${p}`),
|
|
319
|
+
];
|
|
320
|
+
if (items.length === 0) return "no path-level difference";
|
|
321
|
+
const shown = items.slice(0, limit).join("; ");
|
|
322
|
+
return items.length > limit ? `${shown} (+${items.length - limit} more)` : shown;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const realOrResolved = (target: string): string => {
|
|
326
|
+
try { return realpathSync(target); } catch { return path.resolve(target); }
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
function accountHomedir(): string {
|
|
330
|
+
try { return os.userInfo().homedir; } catch { return os.homedir(); }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function defaultScratchRoots(cwd: string): string[] {
|
|
334
|
+
// `os.homedir()` / `os.tmpdir()` follow $HOME / $TMPDIR, so the account's real home is listed too.
|
|
335
|
+
return [os.homedir(), accountHomedir(), path.parse(path.resolve(cwd)).root, os.tmpdir(), "/tmp", "/var/tmp"];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* True when `cwd` IS a shared scratch root — `$HOME`, the filesystem root, or a system temp directory —
|
|
340
|
+
* rather than a directory inside one. Those roots are written by unrelated processes (daemon heartbeats,
|
|
341
|
+
* logs, other agents), are often too large or partly unreadable to snapshot, and hold no project whose
|
|
342
|
+
* verifier could ever run, so a snapshot diff there cannot be attributed to one shell call. A project
|
|
343
|
+
* directory nested under them (a `mkdtemp` dir, `~/work/app`) is NOT a scratch root and stays gated.
|
|
344
|
+
*/
|
|
345
|
+
export function isSharedScratchRoot(cwd: string, roots: readonly string[] = defaultScratchRoots(cwd)): boolean {
|
|
346
|
+
const here = realOrResolved(cwd);
|
|
347
|
+
return roots.some(root => realOrResolved(root) === here);
|
|
348
|
+
}
|
package/src/ai/types.ts
CHANGED
|
@@ -81,6 +81,18 @@ export interface ToolResultRecord {
|
|
|
81
81
|
id: string;
|
|
82
82
|
output: string;
|
|
83
83
|
isError: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Host-side workspace evidence for a shell/computer call. Never sent to a provider; persisted with
|
|
86
|
+
* the transcript so a completion-gate rejection can be audited afterwards. `mutation` is whether the
|
|
87
|
+
* snapshot bracket counted the call as a possible file change, `changed` names the paths that differed,
|
|
88
|
+
* and `inconclusive` is why the bracket could not measure (cap or error; counted as a change). `skipped: "scratch"`
|
|
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.
|
|
94
|
+
*/
|
|
95
|
+
workspace?: { mutation: boolean; changed?: string; inconclusive?: string; skipped?: "scratch"; target?: string };
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
export interface Usage {
|
package/src/commands/launch.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { emitKeypressEvents } from "node:readline";
|
|
|
3
3
|
import { StringDecoder } from "node:string_decoder";
|
|
4
4
|
import { PassThrough } from "node:stream";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
+
import { MUTATION_NUDGE_PREFIX } from "../agent/loop-guards";
|
|
6
7
|
import { runAgentLoop, DEFAULT_TOOLS, SANDBOX_MEDIATED_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, KARPATHY_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, isSafetyFallbackBail, stripSafetyFallbackTag, type AgentLoopEvents } from "../agent/engine";
|
|
7
8
|
import { computerSupervisor } from "../agent/computer-supervisor";
|
|
8
9
|
import { executeComputerAction } from "./computer";
|
|
@@ -2001,7 +2002,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2001
2002
|
const carriedPending = turnPendingMutation || (goalState !== null && pendingMutationVerification);
|
|
2002
2003
|
if ((carriedPending || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
|
|
2003
2004
|
return (
|
|
2004
|
-
|
|
2005
|
+
`${MUTATION_NUDGE_PREFIX} ` +
|
|
2005
2006
|
`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.`
|
|
2006
2007
|
);
|
|
2007
2008
|
}
|