@nledford/omp-coord 0.4.0 → 0.4.2

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.
@@ -3,10 +3,13 @@
3
3
  * queue (`omp-coord run <queue> -- sh -c '<command>'`), so it waits for a slot inside the bash call instead of
4
4
  * starting at once. omp's `tool_call` check must answer within 30 s, so waiting there is not an option.
5
5
  */
6
- /** What happens to an agent's bash command that matches `pattern`: refused with a reason, or run through a queue. */
6
+ /**
7
+ * What happens to an agent's bash command that matches `pattern`: refused with a reason, or run through a queue.
8
+ * `agents: "sub"` limits the rule to subagents' commands (the main agent's run as if it were not there); the default is all.
9
+ */
7
10
  export type CommandRule =
8
- | { pattern: RegExp; action: "refuse"; reason: string }
9
- | { pattern: RegExp; action: "queue"; queue: string; lane: string | undefined };
11
+ | { pattern: RegExp; action: "refuse"; reason: string; agents?: "all" | "sub" }
12
+ | { pattern: RegExp; action: "queue"; queue: string; lane: string | undefined; agents?: "all" | "sub" };
10
13
 
11
14
  export type CommandDecision =
12
15
  | { kind: "allow" }
@@ -33,16 +36,22 @@ export interface Runner {
33
36
  */
34
37
  export const MAX_CHECKED_COMMAND = 32_768;
35
38
 
36
- export function decideCommand(rules: CommandRule[], command: string, runner: Runner): CommandDecision {
39
+ export function decideCommand(
40
+ rules: CommandRule[],
41
+ command: string,
42
+ runner: Runner,
43
+ agentKind: "main" | "sub" = "sub",
44
+ ): CommandDecision {
37
45
  // Already queued: a command that runs `omp-coord run`, or one this extension rewrote.
38
46
  if (/(^|[;&|(\s'"/])omp-coord'?\s+'?run\b/.test(command) || runner.argv.every(a => command.includes(a)))
39
47
  return { kind: "allow" };
40
- if (command.length > MAX_CHECKED_COMMAND && rules.some(r => r.action === "refuse"))
48
+ const applies = (r: CommandRule) => r.agents !== "sub" || agentKind === "sub";
49
+ if (command.length > MAX_CHECKED_COMMAND && rules.some(r => r.action === "refuse" && applies(r)))
41
50
  return {
42
51
  kind: "refuse",
43
52
  reason: `the command is ${command.length} characters, over the ${MAX_CHECKED_COMMAND} that omp-coord's refuse rules can check: create the file with the write tool and run it from a short command`,
44
53
  };
45
- const rule = rules.find(r => r.pattern.test(command));
54
+ const rule = rules.find(r => applies(r) && r.pattern.test(command));
46
55
  if (!rule) return { kind: "allow" };
47
56
  if (rule.action === "refuse") return { kind: "refuse", reason: rule.reason };
48
57
  const wrapped = [
@@ -67,6 +67,8 @@ export interface QueueDay {
67
67
  /** Ended with a nonzero exit code, a signal, or a timeout. */
68
68
  failed: number;
69
69
  timedOut: number;
70
+ /** Present when any failed job of the day carried `--meta` tags. */
71
+ ofFailures?: { repeats: number; intended: number };
70
72
  }
71
73
 
72
74
  export interface DayStats {
@@ -80,7 +82,16 @@ export interface DayStats {
80
82
  queues: Record<string, QueueDay>;
81
83
  }
82
84
 
85
+ /**
86
+ * Per queue and day: failures that repeat an unchanged failure (a failed job with `meta.fp` equal to that of an
87
+ * earlier failed job of the same queue in the window, with no successful job of that `fp` in between) and failures
88
+ * marked `meta.intent == "failfirst"`.
89
+ */
83
90
  export function stats(events: BoardEvent[], since: number): { days: DayStats[] } {
91
+ // Per queue: fingerprints whose latest outcome was a failure.
92
+ const failing = new Map<string, Set<string>>();
93
+ // Tags seen at queue time, for a job_ended that lost them (an older omp-coord still running rewrote the job without them).
94
+ const queuedMeta = new Map<string, unknown>();
84
95
  const days = new Map<string, DayStats & { waits: Record<string, number[]> }>();
85
96
  const dayOf = (at: number) => {
86
97
  const d = new Date(at);
@@ -96,6 +107,11 @@ export function stats(events: BoardEvent[], since: number): { days: DayStats[] }
96
107
  (s.queues[q] ??= { started: 0, avgWaitMs: 0, maxWaitMs: 0, ended: 0, failed: 0, timedOut: 0 });
97
108
 
98
109
  for (const e of events) {
110
+ // Tags are indexed from the whole log, so a job queued before the window still explains its end inside it.
111
+ if (e.type === "job_queued") {
112
+ if (e.data.meta === undefined) queuedMeta.delete(String(e.data.job));
113
+ else queuedMeta.set(String(e.data.job), e.data.meta);
114
+ }
99
115
  if (e.at < since) continue;
100
116
  const s = dayOf(e.at);
101
117
  const n = (k: string) => (typeof e.data[k] === "number" ? (e.data[k] as number) : 0);
@@ -117,8 +133,30 @@ export function stats(events: BoardEvent[], since: number): { days: DayStats[] }
117
133
  if (e.type === "job_ended") {
118
134
  const q = queueOf(s, String(e.data.queue));
119
135
  q.ended++;
120
- if (e.data.exitCode !== 0 || e.data.timedOut === true) q.failed++;
136
+ const failed = e.data.exitCode !== 0 || e.data.timedOut === true;
137
+ if (failed) q.failed++;
121
138
  if (e.data.timedOut === true) q.timedOut++;
139
+ const jobId = String(e.data.job);
140
+ const meta = e.data.meta ?? queuedMeta.get(jobId);
141
+ queuedMeta.delete(jobId);
142
+ if (typeof meta === "object" && meta !== null) {
143
+ const tags = meta as Record<string, unknown>;
144
+ const fp = typeof tags.fp === "string" ? tags.fp : undefined;
145
+ const queueName = String(e.data.queue);
146
+ const set = failing.get(queueName) ?? new Set<string>();
147
+ failing.set(queueName, set);
148
+ if (failed) {
149
+ const counts = q.ofFailures ?? { repeats: 0, intended: 0 };
150
+ q.ofFailures = counts;
151
+ if (fp !== undefined) {
152
+ if (set.has(fp)) counts.repeats++;
153
+ set.add(fp);
154
+ }
155
+ if (tags.intent === "failfirst") counts.intended++;
156
+ } else if (fp !== undefined) {
157
+ set.delete(fp);
158
+ }
159
+ }
122
160
  }
123
161
  }
124
162
  return {
@@ -159,6 +159,7 @@ export interface BoardEvent {
159
159
  | "claimed"
160
160
  | "released"
161
161
  | "claim_refused"
162
+ | "write_refused"
162
163
  | "note_added";
163
164
  /** The omp session concerned; null for build jobs started outside any omp session. */
164
165
  sessionId: string | null;
@@ -180,6 +181,8 @@ export interface Job {
180
181
  priority: number;
181
182
  /** Jobs with the same key are duplicates (e.g. "<checkout> ci"). */
182
183
  key: string | undefined;
184
+ /** Tags the caller gave (`--meta k=v`), kept in the event log; undefined when none. */
185
+ meta?: Record<string, string> | undefined;
183
186
  /** What runs, for display. */
184
187
  command: string;
185
188
  /** The runner's liveness socket and process id. */
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Protected checkouts: what agents may not change in a project's main checkout. A relative path in the file tools
3
+ * (`edit`, `write`, `ast_edit`, `apply_patch`) resolves against omp's own folder, not against the `cwd` an agent
4
+ * gives its bash calls, so an agent told to work in a worktree edits the main checkout the moment it types
5
+ * `crates/a.rs` instead of the worktree's absolute path; and a bash call without a `cwd` runs in that same folder, so
6
+ * its `git checkout` changes the main checkout too. A refusal names the resolved path, so the agent sees the mistake
7
+ * before anything changes.
8
+ *
9
+ * File paths are judged as written and as omp is likely to read them (decorations such as a quoted path or an
10
+ * `[Update File: …]` wrapper removed): a call is refused when either lies in the checkout, because omp's own reading
11
+ * differs per tool and a careless agent's decorated path should stop it, not slip by. A shell command's file writes
12
+ * are not judged (the `commands` rules cover the in-place rewriters); its state-changing `git` is (`gitChange`).
13
+ * Pure path arithmetic; what the disk says (symlinks, nested checkouts) is passed in.
14
+ */
15
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
16
+ import { type Board, emit } from "./model.ts";
17
+
18
+ export interface WriteGuard {
19
+ /** Absolute folders of the checkouts to protect. A linked worktree (or any folder with its own `.git`) below one is not part of it. */
20
+ roots: string[];
21
+ /** Folders and files below a root (relative to it) that stay writable. */
22
+ allow: string[];
23
+ /** Whose writes are judged: subagents only, or the main agent too. */
24
+ agents: "sub" | "all";
25
+ }
26
+
27
+ export interface GuardFacts {
28
+ /** omp's folder for the agent: where a relative path in a file tool resolves. */
29
+ cwd: string;
30
+ home: string;
31
+ /** The path with symbolic links resolved (a file that does not exist yet: its nearest folder that does, plus the rest). */
32
+ canonical: (path: string) => string;
33
+ /** Whether the folder has a `.git` of its own: a checkout's top. */
34
+ hasGitEntry: (dir: string) => boolean;
35
+ }
36
+
37
+ export type WriteDecision = { kind: "allow" } | { kind: "refuse"; reason: string; shown?: string[] };
38
+
39
+ /** Tools that write files, each with how its arguments name them (see `writeTargets`). */
40
+ const WRITE_TOOLS: Record<string, true> = { edit: true, write: true, ast_edit: true, apply_patch: true };
41
+
42
+ /**
43
+ * The paths a file tool call would change: each as the agent wrote it, and again with the decorations omp strips
44
+ * removed when that differs. Undefined for a tool that writes no files.
45
+ */
46
+ export function writeTargets(toolName: string, input: Record<string, unknown>): string[] | undefined {
47
+ if (typeof input !== "object" || input === null) return Object.hasOwn(WRITE_TOOLS, toolName) ? [] : undefined;
48
+ if (!Object.hasOwn(WRITE_TOOLS, toolName)) return undefined;
49
+ const written: string[] = [];
50
+ if (toolName === "write") {
51
+ const path = input.path;
52
+ if (typeof path !== "string") return [];
53
+ // `xd://<tool>` runs that tool with the written JSON as its arguments.
54
+ const device = /^xd:\/\/(edit|write|ast_edit|apply_patch)\/?$/i.exec(path);
55
+ if (device && typeof input.content === "string") {
56
+ try {
57
+ const args: unknown = JSON.parse(input.content);
58
+ if (typeof args === "object" && args !== null && !Array.isArray(args))
59
+ return writeTargets((device[1] as string).toLowerCase(), args as Record<string, unknown>) ?? [];
60
+ } catch {
61
+ // not JSON: the device refuses it itself
62
+ }
63
+ return [];
64
+ }
65
+ written.push(path);
66
+ } else if (toolName === "ast_edit") {
67
+ const paths = Array.isArray(input.paths) ? input.paths.filter((p): p is string => typeof p === "string") : [];
68
+ for (const p of paths) {
69
+ // a glob is judged by what comes before its first wildcard, cut back to a folder
70
+ const prefix = p.replace(/^file:\/\//i, "").replace(/[*?[{][\s\S]*$/, "");
71
+ written.push(prefix === "" ? "." : prefix);
72
+ }
73
+ } else {
74
+ const text = [input.input, input.patch].find((t): t is string => typeof t === "string") ?? "";
75
+ // replace mode names its file in `path`; patch mode may move one with `rename`
76
+ for (const key of ["path", "rename", "move_to"]) if (typeof input[key] === "string") written.push(input[key]);
77
+ if (Array.isArray(input.edits))
78
+ for (const e of input.edits)
79
+ if (typeof e === "object" && e !== null)
80
+ for (const key of ["path", "rename", "move_to"]) {
81
+ const v = (e as Record<string, unknown>)[key];
82
+ if (typeof v === "string") written.push(v);
83
+ }
84
+ for (const line of text.split("\n")) {
85
+ // hashline mode: `[path#TAG]`; the path runs to the last `]` (a file may be named `[slug]`)
86
+ const header = /^\[(.+)\]\s*$/.exec(line);
87
+ if (header) written.push(header[1] as string);
88
+ // `MV DEST`: resolved like a header
89
+ const move = /^\s*MV\s+(.+?)\s*$/.exec(line);
90
+ if (move) written.push(move[1] as string);
91
+ // apply_patch and sloppy modes: `*** Update File: path`, `*** Move to: path`, `*** Edit File path all`
92
+ const patch =
93
+ /^\s*\*{3}\s*(?:(?:Update|Add|Delete)\s+File|Move\s+to|Edit\s+File)\s*:?\s*(.+?)(?:\s+all)?\s*$/i.exec(line);
94
+ if (patch) written.push(patch[1] as string);
95
+ }
96
+ }
97
+ const targets = new Set<string>();
98
+ for (const w of written) {
99
+ for (const p of [w, cleanPath(w)]) if (p !== "" && !/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(p)) targets.add(p);
100
+ }
101
+ return [...targets];
102
+ }
103
+
104
+ /**
105
+ * The path as omp is likely to read it: unquoted, without a `[…#TAG]` wrapper, a trailing `#TAG`, the `Update File:`
106
+ * noise of a copied header or a leading `@`.
107
+ */
108
+ function cleanPath(raw: string): string {
109
+ let p = raw.trim();
110
+ const stripFile = (s: string): string => {
111
+ if (!/^file:\/\//i.test(s)) return s;
112
+ const rest = s.replace(/^file:\/\/(?:localhost(?=\/))?/i, "");
113
+ try {
114
+ return decodeURIComponent(rest);
115
+ } catch {
116
+ return rest;
117
+ }
118
+ };
119
+ p = stripFile(p);
120
+ const wrapped = /^\[(.+)\]$/.exec(p);
121
+ if (wrapped) p = (wrapped[1] as string).trim();
122
+ p = p.replace(/^\**\s*(?:(?:Update|Add|Delete|Edit)\s+File|Move\s+to|Update|Move)\s*:?\s*\**\s*/i, "");
123
+ p = p.replace(/#[0-9A-Fa-f]{2,8}$/, "");
124
+ const quoted = /^(["'])(.*)\1$/.exec(p);
125
+ if (quoted) p = quoted[2] as string;
126
+ return stripFile(p.replace(/^@(?=[/~]|file:\/\/)/i, ""));
127
+ }
128
+
129
+ export function decideWrite(
130
+ guard: WriteGuard,
131
+ agentKind: "main" | "sub",
132
+ toolName: string,
133
+ targets: string[],
134
+ facts: GuardFacts,
135
+ ): WriteDecision {
136
+ if (guard.agents === "sub" && agentKind !== "sub") return { kind: "allow" };
137
+ const roots = guard.roots.map(r => facts.canonical(resolve(r)));
138
+ for (const target of targets) {
139
+ const absolute = resolve(facts.cwd, target.startsWith("~/") ? join(facts.home, target.slice(2)) : target);
140
+ const path = facts.canonical(absolute);
141
+ for (const root of roots) {
142
+ // a target that holds the root (`ast_edit` on `..`) would change it too
143
+ const holdsRoot = path !== root && inside(root, path);
144
+ if (!holdsRoot && (!inside(path, root) || nested(path, root, facts) || allowed(path, root, guard.allow)))
145
+ continue;
146
+ const how =
147
+ isAbsolute(target) || target.startsWith("~/")
148
+ ? `${target} is in the main checkout ${root}`
149
+ : `${target} resolves to ${path} in the main checkout ${root}: a relative path in ${toolName} resolves against omp's folder, not the cwd of your bash calls`;
150
+ return {
151
+ kind: "refuse",
152
+ reason: `${toolName} refused: ${how}. Agents work in a worktree of their own: give the file's absolute path there (<your worktree>/${relative(root, path)}), and check the brief for the worktree's path.`,
153
+ };
154
+ }
155
+ }
156
+ return { kind: "allow" };
157
+ }
158
+
159
+ /** `git` subcommands that change a checkout's files, index or HEAD (`branch` is left out: most uses only read). */
160
+ const GIT_CHANGES: Record<string, true> = {
161
+ checkout: true,
162
+ switch: true,
163
+ reset: true,
164
+ restore: true,
165
+ clean: true,
166
+ rebase: true,
167
+ merge: true,
168
+ commit: true,
169
+ pull: true,
170
+ "cherry-pick": true,
171
+ revert: true,
172
+ am: true,
173
+ add: true,
174
+ rm: true,
175
+ mv: true,
176
+ apply: true,
177
+ bisect: true,
178
+ };
179
+
180
+ /** How deep below a root a linked worktree is looked for: they sit one or two levels down (`.claude/worktrees/x`). */
181
+ const MAX_NEST_DEPTH = 8;
182
+
183
+ /** Words that start a command's real name without being it: shell keywords, and wrappers with the options they take. */
184
+ const KEYWORDS = new Set([
185
+ "if",
186
+ "then",
187
+ "elif",
188
+ "else",
189
+ "do",
190
+ "while",
191
+ "until",
192
+ "!",
193
+ "{",
194
+ "}",
195
+ "time",
196
+ "command",
197
+ "exec",
198
+ "nohup",
199
+ ]);
200
+ const WRAPPERS: Record<string, string[]> = {
201
+ sudo: ["-u", "-g", "-C", "-h", "-p", "-r", "-t", "-U", "-D", "-T", "--chdir"],
202
+ env: ["-u", "-C", "-S", "-P", "--chdir"],
203
+ nice: ["-n"],
204
+ timeout: ["-s", "-k", "--signal", "--kill-after"],
205
+ xargs: ["-n", "-I", "-P", "-d", "-L", "-E", "-s", "-a"],
206
+ };
207
+
208
+ /** Where a `cd` and the like lead: `from` is where the command runs, undefined when that is not known. */
209
+ function moved(from: string | undefined, target: string, facts: GuardFacts): string | undefined {
210
+ if (target === "-" || /[$`*?[{%]|^~[^/]/.test(target)) return undefined;
211
+ if (target === "") return from;
212
+ if (target === "~") return facts.home;
213
+ if (target.startsWith("~/")) return resolve(facts.home, target.slice(2));
214
+ if (isAbsolute(target)) return resolve(target);
215
+ return from === undefined ? undefined : resolve(from, target);
216
+ }
217
+
218
+ /** The folder a call's `cwd` names, as omp reads it (`~` expanded, relative to omp's own folder). */
219
+ export function resolveCwd(base: string, cwd: string, home: string): string {
220
+ if (cwd === "~") return home;
221
+ return cwd.startsWith("~/") ? resolve(home, cwd.slice(2)) : resolve(base, cwd);
222
+ }
223
+
224
+ /**
225
+ * A `git` command in the bash command that changes a protected checkout. The folder it runs in follows the call's
226
+ * `cwd`, `cd` and `pushd` (a subshell's change ends with it), `git -C`, `--work-tree` and `GIT_WORK_TREE=`, through
227
+ * `&&`, `||`, `;`, `|`, newlines, `if`/`for`/`while` bodies, `sudo`/`env`/`nice`/`timeout`/`xargs` wrappers and
228
+ * `sh -c '…'`; quotes are respected and heredoc bodies skipped. A folder it cannot work out (a variable, `cd -`,
229
+ * after `popd`) is not judged. Like every rule that reads command text it stops the usual spellings, not a determined
230
+ * dodge.
231
+ */
232
+ export function gitChange(
233
+ guard: WriteGuard,
234
+ agentKind: "main" | "sub",
235
+ command: string,
236
+ cwd: string,
237
+ facts: GuardFacts,
238
+ ): WriteDecision {
239
+ if (guard.agents === "sub" && agentKind !== "sub") return { kind: "allow" };
240
+ const roots = guard.roots.map(r => facts.canonical(resolve(r)));
241
+ const judge = (sub: string, dir: string): WriteDecision | undefined => {
242
+ const path = facts.canonical(dir);
243
+ for (const root of roots) {
244
+ if (!inside(path, root) || nested(path, root, facts) || allowed(path, root, guard.allow)) continue;
245
+ return {
246
+ kind: "refuse",
247
+ reason: `git ${sub} refused: it runs in ${path}, inside the main checkout ${root}, which no agent changes (a bash call without a cwd runs there). Run it in your own worktree: give the call that worktree's absolute path as its cwd, or use \`cd <your worktree> && git …\` or \`git -C <your worktree> …\`.`,
248
+ shown: [`git ${sub} in ${path}`],
249
+ };
250
+ }
251
+ return undefined;
252
+ };
253
+ return (
254
+ scanCommands(command, cwd, facts, 0, (words, here) => {
255
+ let dir = here;
256
+ let sub: string | undefined;
257
+ for (let i = 0; i < words.length; i++) {
258
+ const w = words[i] as string;
259
+ if (w === "-C" || w === "--work-tree") dir = moved(dir, words[++i] ?? "", facts);
260
+ else if (w.startsWith("--work-tree=")) dir = moved(dir, w.slice("--work-tree=".length), facts);
261
+ else if (w === "-c" || w === "--git-dir" || w === "--namespace" || w === "--exec-path") i++;
262
+ else if (!w.startsWith("-")) {
263
+ sub = w;
264
+ break;
265
+ }
266
+ }
267
+ return sub === undefined || !Object.hasOwn(GIT_CHANGES, sub) || dir === undefined ? undefined : judge(sub, dir);
268
+ }) ?? { kind: "allow" }
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Walks the commands of a shell script text in order, calling `onGit` with the words after `git` and the folder it runs
274
+ * in; stops at the first answer. Tracks `cd`/`pushd`/`popd` and subshells.
275
+ */
276
+ function scanCommands(
277
+ script: string,
278
+ start: string | undefined,
279
+ facts: GuardFacts,
280
+ depth: number,
281
+ onGit: (words: string[], here: string | undefined) => WriteDecision | undefined,
282
+ ): WriteDecision | undefined {
283
+ let here = start;
284
+ const stack: { here: string | undefined; outer?: { words: string[]; word: string | undefined }; dq?: boolean }[] = [];
285
+ let found: WriteDecision | undefined;
286
+ const run = (all: string[]): void => {
287
+ const words = [...all];
288
+ let base = here;
289
+ let treeFromEnv: string | undefined;
290
+ let replace: string | undefined;
291
+ for (;;) {
292
+ const first = words[0];
293
+ if (first === undefined) return;
294
+ const assign = /^[A-Za-z_]\w*=([\s\S]*)$/.exec(first);
295
+ if (assign) {
296
+ if (first.startsWith("GIT_WORK_TREE=")) treeFromEnv = assign[1];
297
+ words.shift();
298
+ } else if (first === "command" || first === "exec" || first === "time") {
299
+ words.shift();
300
+ while (words[0]?.startsWith("-")) words.splice(0, first === "exec" && words[0] === "-a" ? 2 : 1);
301
+ } else if (KEYWORDS.has(first)) words.shift();
302
+ else if (Object.hasOwn(WRAPPERS, basename(first))) {
303
+ const name = basename(first);
304
+ words.shift();
305
+ const valued = WRAPPERS[name] as string[];
306
+ while (words[0]?.startsWith("-") && words[0] !== "--") {
307
+ const opt = words[0];
308
+ if (name === "xargs") {
309
+ if (opt === "-I") replace = words[1];
310
+ else if (opt.startsWith("-I") && opt.length > 2) replace = opt.slice(2);
311
+ else if (opt === "-i" || opt === "--replace") replace = "{}";
312
+ else if (opt.startsWith("-i") && opt.length > 2) replace = opt.slice(2);
313
+ else if (opt.startsWith("--replace=")) replace = opt.slice("--replace=".length);
314
+ }
315
+ const chdir = opt.startsWith("--chdir=")
316
+ ? opt.slice("--chdir=".length)
317
+ : opt === "--chdir" || (name === "env" && opt === "-C") || (name === "sudo" && opt === "-D")
318
+ ? (words[1] ?? "")
319
+ : (name === "env" && opt.startsWith("-C")) || (name === "sudo" && opt.startsWith("-D"))
320
+ ? opt.slice(2) || undefined
321
+ : undefined;
322
+ if (chdir !== undefined) base = moved(base, chdir, facts);
323
+ words.splice(0, valued.includes(opt) ? 2 : 1);
324
+ }
325
+ if (words[0] === "--") words.shift();
326
+ if (name === "timeout") words.shift();
327
+ } else break;
328
+ }
329
+ if (replace) for (let k = 0; k < words.length; k++) words[k] = (words[k] as string).split(replace).join("$");
330
+ const head = words.shift() as string;
331
+ if (head === "cd" || head === "pushd")
332
+ here = moved(here, words.filter(w => w === "-" || !w.startsWith("-"))[0] ?? "~", facts);
333
+ else if (head === "popd") here = undefined;
334
+ else if (/^(?:sh|bash|zsh|dash)$/.test(basename(head)) && depth < 2) {
335
+ const at = words.findIndex(w => /^-[A-Za-z]*c[A-Za-z]*$/.test(w));
336
+ const body = at >= 0 ? words[at + 1] : undefined;
337
+ if (body !== undefined) found ??= scanCommands(body, base, facts, depth + 1, onGit);
338
+ } else if (basename(head) === "git")
339
+ found ??= onGit(words, treeFromEnv === undefined ? base : moved(base, treeFromEnv, facts));
340
+ };
341
+
342
+ let words: string[] = [];
343
+ let word: string | undefined;
344
+ const endWord = (): void => {
345
+ if (word !== undefined) words.push(word);
346
+ word = undefined;
347
+ };
348
+ const endCommand = (): void => {
349
+ endWord();
350
+ if (words.length > 0) run(words);
351
+ words = [];
352
+ };
353
+ const pending: { name: string; strip: boolean }[] = [];
354
+ let backtick = false;
355
+ let inDq = false;
356
+ let i = 0;
357
+ const n = script.length;
358
+ while (i < n && found === undefined) {
359
+ const c = script[i] as string;
360
+ if (inDq) {
361
+ // inside double quotes: text, except that `$(` starts a substitution whose own quotes are fresh
362
+ if (c === '"') {
363
+ inDq = false;
364
+ i++;
365
+ } else if (c === "\\" && i + 1 < n) {
366
+ const next = script[i + 1] as string;
367
+ word = (word ?? "") + ('"\\$`'.includes(next) ? next : `\\${next}`);
368
+ i += 2;
369
+ } else if (c === "$" && script[i + 1] === "(") {
370
+ stack.push({ here, outer: { words, word }, dq: true });
371
+ words = [];
372
+ word = undefined;
373
+ inDq = false;
374
+ i += 2;
375
+ } else if (c === "`" && !backtick) {
376
+ stack.push({ here, outer: { words, word }, dq: true });
377
+ words = [];
378
+ word = undefined;
379
+ inDq = false;
380
+ backtick = true;
381
+ i++;
382
+ } else {
383
+ word = (word ?? "") + c;
384
+ i++;
385
+ }
386
+ continue;
387
+ }
388
+ if (c === "\\" && script[i + 1] === "\n") i += 2;
389
+ else if (c === "\\") {
390
+ word = (word ?? "") + (script[i + 1] ?? "");
391
+ i += 2;
392
+ } else if (c === "'") {
393
+ const end = script.indexOf("'", i + 1);
394
+ word = (word ?? "") + script.slice(i + 1, end < 0 ? n : end);
395
+ i = end < 0 ? n : end + 1;
396
+ } else if (c === '"') {
397
+ inDq = true;
398
+ word = word ?? "";
399
+ i++;
400
+ } else if (c === " " || c === "\t" || c === "\r") {
401
+ endWord();
402
+ i++;
403
+ } else if (c === "\n") {
404
+ endCommand();
405
+ i++;
406
+ // a heredoc's body runs from the next line to its delimiter line
407
+ for (const h of pending.splice(0)) {
408
+ for (;;) {
409
+ if (i >= n) break;
410
+ const eol = script.indexOf("\n", i);
411
+ const line = script.slice(i, eol < 0 ? n : eol);
412
+ i = eol < 0 ? n : eol + 1;
413
+ if ((h.strip ? line.replace(/^\t+/, "") : line) === h.name) break;
414
+ }
415
+ }
416
+ } else if (c === "#" && word === undefined) {
417
+ const eol = script.indexOf("\n", i);
418
+ i = eol < 0 ? n : eol;
419
+ } else if (c === "<" && script[i + 1] === "<" && script[i + 2] !== "<") {
420
+ i += 2;
421
+ const strip = script[i] === "-";
422
+ if (strip) i++;
423
+ while (script[i] === " " || script[i] === "\t") i++;
424
+ let name = "";
425
+ while (i < n && !/[\s;&|<>()]/.test(script[i] as string)) {
426
+ const d = script[i] as string;
427
+ if (d === "'" || d === '"') {
428
+ const end = script.indexOf(d, i + 1);
429
+ name += script.slice(i + 1, end < 0 ? n : end);
430
+ i = end < 0 ? n : end + 1;
431
+ } else if (d === "\\") {
432
+ name += script[i + 1] ?? "";
433
+ i += 2;
434
+ } else {
435
+ name += d;
436
+ i++;
437
+ }
438
+ }
439
+ if (name !== "") pending.push({ name, strip });
440
+ } else if (c === "$" && script[i + 1] === "(") {
441
+ // a substitution is scanned as commands of its own; the outer word gets a `$` placeholder (an unknown value)
442
+ stack.push({ here, outer: { words, word } });
443
+ words = [];
444
+ word = undefined;
445
+ i += 2;
446
+ } else if (c === "`") {
447
+ if (backtick) {
448
+ endCommand();
449
+ const frame = stack.pop();
450
+ here = frame?.here ?? here;
451
+ words = frame?.outer?.words ?? words;
452
+ word = `${frame?.outer?.word ?? ""}$`;
453
+ if (frame?.dq) inDq = true;
454
+ } else {
455
+ stack.push({ here, outer: { words, word } });
456
+ words = [];
457
+ word = undefined;
458
+ }
459
+ backtick = !backtick;
460
+ i++;
461
+ } else if (c === "(") {
462
+ endCommand();
463
+ stack.push({ here });
464
+ i++;
465
+ } else if (c === ")") {
466
+ endCommand();
467
+ const frame = stack.pop();
468
+ here = frame?.here ?? here;
469
+ if (frame?.dq) inDq = true;
470
+ if (frame?.outer) {
471
+ words = frame.outer.words;
472
+ word = `${frame.outer.word ?? ""}$`;
473
+ }
474
+ i++;
475
+ } else if (c === ";" || c === "|" || c === "&") {
476
+ endCommand();
477
+ i += script[i + 1] === c && c !== ";" ? 2 : 1;
478
+ } else {
479
+ word = (word ?? "") + c;
480
+ i++;
481
+ }
482
+ }
483
+ if (found === undefined) endCommand();
484
+ return found;
485
+ }
486
+
487
+ function inside(path: string, root: string): boolean {
488
+ return path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
489
+ }
490
+
491
+ /**
492
+ * The path is, or lies in, a folder below the root that is a checkout of its own (a linked worktree): only the first
493
+ * `MAX_NEST_DEPTH` levels below the root are looked at, so a path of thousands of levels costs a few checks.
494
+ */
495
+ function nested(path: string, root: string, facts: GuardFacts): boolean {
496
+ const parts = relative(root, path).split(sep).filter(Boolean);
497
+ for (let i = 1; i <= Math.min(parts.length, MAX_NEST_DEPTH); i++)
498
+ if (facts.hasGitEntry(join(root, ...parts.slice(0, i)))) return true;
499
+ return false;
500
+ }
501
+
502
+ function allowed(path: string, root: string, allow: string[]): boolean {
503
+ const rel = relative(root, path);
504
+ return allow.some(a => {
505
+ const prefix = a.replace(/\/+$/, "");
506
+ return rel === prefix || rel.startsWith(`${prefix}/`);
507
+ });
508
+ }
509
+
510
+ /**
511
+ * A path with symbolic links resolved where it exists, built from a resolver for the part that does. A folder that
512
+ * exists has all its parents, so the deepest one is found by bisection: a path of thousands of levels costs a few
513
+ * lookups.
514
+ */
515
+ export function canonicalWith(real: (p: string) => string | undefined): (path: string) => string {
516
+ return (path: string): string => {
517
+ const parts = path.split(sep).filter(Boolean);
518
+ const prefix = (k: number): string => sep + parts.slice(0, k).join(sep);
519
+ let lo = 0;
520
+ let hi = parts.length;
521
+ while (lo < hi) {
522
+ const mid = (lo + hi + 1) >> 1;
523
+ if (real(prefix(mid)) !== undefined) lo = mid;
524
+ else hi = mid - 1;
525
+ }
526
+ return join(real(prefix(lo)) ?? prefix(lo), ...parts.slice(lo));
527
+ };
528
+ }
529
+
530
+ /** Records a refused write or command for the event log: the agent, the tool and what the agent wrote. */
531
+ export function writeRefused(
532
+ board: Board,
533
+ sessionId: string,
534
+ w: { agentId: string; tool: string; paths: string[]; now: number },
535
+ ): void {
536
+ emit(board, w.now, "write_refused", sessionId, { agentId: w.agentId, tool: w.tool, paths: w.paths });
537
+ }