@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.
@@ -7,7 +7,7 @@
7
7
  "name": "omp-coord",
8
8
  "source": "./",
9
9
  "description": "Shared subagent caps, build and test queues, claims, pause, a decision log and messages across omp sessions working in one project on one machine.",
10
- "version": "0.4.0",
10
+ "version": "0.4.2",
11
11
  "author": { "name": "Nathaniel Ledford" },
12
12
  "homepage": "https://github.com/nledford/omp-coord",
13
13
  "repository": "https://github.com/nledford/omp-coord",
package/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.2] - 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - `omp-coord run --meta k=v` (repeatable, at most 8; the key matches `[a-z][a-z0-9_-]{0,31}`, the value is at most 200 characters on one line) tags a job. The tags go into the `job_queued` and `job_ended` events as `meta`, and `job_ended` now also carries the job's `key`. A script can detect support with `omp-coord --help 2>&1 | grep -q -- '--meta'`. The jobs table gains a nullable `meta` column (no schema version bump).
14
+ - `omp-coord stats` and `/coord stats` add `; of the failures: N repeat an unchanged failure, M intended` to a queue's line when a failed job of that day had tags: a repeat is a failed job whose `fp` tag equals that of an earlier failed job in the same queue, with no successful job of that `fp` in between; intended is `intent=failfirst`.
15
+
16
+ ## [0.4.1] - 2026-10-30
17
+
18
+ ### Added
19
+
20
+ - `protect` in `.omp-coord.json`: the extension refuses `edit`, `write`, `ast_edit` and `apply_patch` calls (also through `xd://`) whose target lies in a protected checkout and not in a linked worktree below it, and a bash command whose state-changing `git` (`checkout`, `reset`, `commit`, `clean`, `rebase` and the like) runs there, naming the resolved path or folder. A relative path in the file tools resolves against omp's own folder, not the `cwd` of an agent's bash calls, and a bash call without a `cwd` runs there too, so an agent told to work in a worktree edited or reset the main checkout. Known gaps, in [protect](docs/protect.md#what-it-cannot-see): a shell command's file writes (`cat >`, `cp`, `mv`) and `eval`, the `eval` tool and `lsp` write without a file-tool call to judge; a git folder the shell computes (`cd "$W"`, `git -C $(pwd)`) is not judged; the guard fails open when its own check throws. Subagents only unless `protect.agents` is `"all"`; each refusal is a `write_refused` event naming the agent. Without `protect` nothing changes. The guard reads paths and command text, so it stops the usual forms, not a determined dodge; what it cannot see is listed in [protect](docs/protect.md).
21
+ - A `commands` rule may set `"agents": "sub"` to apply to subagents' commands only (default `"all"`). `examples/omp-coord.json` has three such `refuse` rules for in-place rewrites (`sed -i`, `perl -i`, an inline Python script that writes a file), which agents used instead of the edit tool; copy them into your file to use them.
22
+
9
23
  ## [0.4.0] - 2026-10-28
10
24
 
11
25
  ### Added
@@ -106,7 +120,9 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
106
120
  - An SQLite event log per scope, with retention.
107
121
  - Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
108
122
 
109
- [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.4.0...HEAD
123
+ [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.4.2...HEAD
124
+ [0.4.2]: https://github.com/nledford/omp-coord/compare/v0.4.1...v0.4.2
125
+ [0.4.1]: https://github.com/nledford/omp-coord/compare/v0.4.0...v0.4.1
110
126
  [0.4.0]: https://github.com/nledford/omp-coord/compare/v0.3.2...v0.4.0
111
127
  [0.3.2]: https://github.com/nledford/omp-coord/compare/v0.3.1...v0.3.2
112
128
  [0.3.1]: https://github.com/nledford/omp-coord/compare/v0.3.0...v0.3.1
package/docs/README.md CHANGED
@@ -10,6 +10,7 @@ How it works:
10
10
  - [Agent slots and the line](agent-slots.md): caps, holds, revivals, agents started by subagents.
11
11
  - [Build and test queues](queues.md): `omp-coord run`, order, lanes, time limits, agents' commands.
12
12
  - [Working across sessions](coordination.md): messages, claims, pause, the decision log, ports, crash recovery.
13
+ - [Protected checkouts](protect.md): refusing file-tool writes into the main checkout, and in-place rewrites by shell commands.
13
14
 
14
15
  Reference:
15
16
 
package/docs/commands.md CHANGED
@@ -26,7 +26,7 @@
26
26
  ## `omp-coord`
27
27
 
28
28
  ```sh
29
- omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
29
+ omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command> [args…]
30
30
  omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
31
31
  omp-coord pause [reason…] | resume | doctor [--fix]
32
32
  ```
@@ -14,7 +14,8 @@
14
14
  | `agents.startTimeoutSeconds` | `120` | How long an approved spawn may take to start before its slot frees. |
15
15
  | `agents.queueSpawns` | `true` | While the session is the only one in the scope, keep omp's per-session spawn queue (`task.maxConcurrency`) at what the caps allow it, so spawns past the cap wait and start by themselves instead of being refused ([agent slots](agent-slots.md#spawns-wait-their-turn)); with other sessions, or paused, spawns over the cap are refused as before. A `task.maxConcurrency` you set yourself stays the ceiling. `false` keeps the refusals always. |
16
16
  | `messages.perMinute` | `10` | Messages one session may send per minute. It slows two sessions that keep answering each other; it does not stop them. Each delivered message starts a turn in an idle session. |
17
- | `commands` | `[]` | Rules for agents' bash commands, first match wins: `{ "pattern": "<regex>", "action": "refuse", "reason": "…" }`, or `{ "pattern", "action": "queue", "queue": "<name>", "lane"? }`. The user's own `!` commands are not affected. Nothing is refused unless a rule is listed; `examples/omp-coord.json` has ready-made `refuse` rules, including two that protect git's rerere cache ([queues](queues.md#example-protecting-gits-rerere-cache)). |
17
+ | `commands` | `[]` | Rules for agents' bash commands, first match wins: `{ "pattern": "<regex>", "action": "refuse", "reason": "…" }`, or `{ "pattern", "action": "queue", "queue": "<name>", "lane"? }`; `"agents": "sub"` on a rule applies it to subagents' commands only (default `"all"`). The user's own `!` commands are not affected. Nothing is refused unless a rule is listed; `examples/omp-coord.json` has ready-made `refuse` rules, including two that protect git's rerere cache ([queues](queues.md#example-protecting-gits-rerere-cache)) and three that refuse in-place rewrites by `sed -i`, `perl -i` and inline Python ([protect](protect.md#in-place-rewrites-by-shell)). |
18
+ | `protect` | none | `{ "roots": ["."], "allow": [], "agents": "sub" }`: checkouts the file tools (`edit`, `write`, `ast_edit`, `apply_patch`) may not write to and where a bash command's state-changing `git` (`checkout`, `reset`, `commit`, `clean`, `rebase` and the like) may not run, so an agent that types a relative path does not change the main checkout instead of its worktree ([protect](protect.md)). `roots` are relative to the config file's folder; `allow` entries are files or folders below a root, relative to it (no leading `/`, no `..`); `agents` is `"sub"` (default) or `"all"`. |
18
19
  | `queues.<name>.slots` | required | How many jobs of this queue run at once, across all processes in the scope. Queue names use letters, digits, `.`, `_`, `-`. |
19
20
  | `queues.<name>.order` | `"fifo"` | `"fifo"`, `"fair-share"` or `"priority"` (see [queues](queues.md#order)). |
20
21
  | `queues.<name>.lanes` | `["normal"]` | Lane names, first served first; the last is the default. |
package/docs/event-log.md CHANGED
@@ -25,8 +25,8 @@ These tables are rebuilt as sessions come and go; their layout is not part of th
25
25
  `events` is an append-only log: `at` (ms since epoch), `type`, `session_id` (null for jobs from outside omp), `label`, and `data` (JSON). Types:
26
26
 
27
27
  - Sessions and agents: `session_joined`, `session_left`, `spawn_approved`, `spawn_refused` (with `position`, `used`, `cap`, or `nested` and `reason`), `run_started` (with `revived`), `run_ended` (with `ranMs`), `start_expired`, `hold_granted`, `hold_used` and `hold_expired` (with `waitedMs` since the first refusal), `message_sent` (recipients and length, never the text).
28
- - Jobs: `job_queued` (with `queue`, `lane`, `priority`, `key`, and `agent` when recorded), `job_refused` (with `reason`), `job_started` (with `waitedMs`), `job_ended` (with `exitCode`, `ranMs`, and `timedOut` when the time limit stopped it), `job_withdrawn` (left the line before starting), `job_superseded`, `job_lost` (its runner died), `job_reprioritized`.
29
- - Control: `paused` (with `reason`), `resumed` (with `pausedMs`), `claimed`, `released`, `claim_refused` (with `holder`), `note_added` (note number and length).
28
+ - Jobs: `job_queued` (with `queue`, `lane`, `priority`, `key`, and `agent` and `meta` when recorded), `job_refused` (with `reason`), `job_started` (with `waitedMs`), `job_ended` (with `exitCode`, `ranMs`, `key` and `meta` when the job had them, and `timedOut` when the time limit stopped it), `job_withdrawn` (left the line before starting), `job_superseded`, `job_lost` (its runner died), `job_reprioritized`.
29
+ - Control: `paused` (with `reason`), `resumed` (with `pausedMs`), `claimed`, `released`, `claim_refused` (with `holder`), `write_refused` (a file-tool write or a state-changing `git` command refused in a protected checkout: `agentId`, `tool` (`bash` for git), `paths` as the agent wrote them, empty for git), `note_added` (note number and length).
30
30
 
31
31
  `notes` is the decision log: `id`, `at`, `label`, `author`, `text`.
32
32
 
@@ -0,0 +1,43 @@
1
+ # Protected checkouts
2
+
3
+ Agents are told to work in a worktree of their own and to leave the project's main checkout alone, but nothing stops them: **a relative path in `edit`, `write`, `ast_edit` and `read` resolves against omp's own folder, not against the `cwd` an agent gives its bash calls, and a bash call without a `cwd` runs in that same folder.** An agent whose brief says "work in `/repo/.claude/worktrees/fix`" and who types `crates/a.rs` into `edit` changes `/repo/crates/a.rs`, the main checkout, while its `git status` in the worktree shows nothing; one that runs `git checkout -- .` without a `cwd` resets it. A `protect` section makes omp-coord refuse both before they happen.
4
+
5
+ ```json
6
+ "protect": { "roots": ["."], "allow": ["notes"], "agents": "sub" }
7
+ ```
8
+
9
+ | Field | Default | Meaning |
10
+ |---|---|---|
11
+ | `protect.roots` | required | Folders of the checkouts to protect, relative to the config file's folder (`"."` is the folder holding `.omp-coord.json`) or absolute. |
12
+ | `protect.allow` | `[]` | Files and folders below a root, relative to it, that stay writable. |
13
+ | `protect.agents` | `"sub"` | Whose writes are judged: `"sub"` for subagents only (the main agent edits the checkout as before), or `"all"`. |
14
+
15
+ ## What is refused
16
+
17
+ A call of `edit`, `write`, `ast_edit` or `apply_patch` whose target, after `~` and `..` are resolved against omp's folder and symbolic links are followed, lies inside a root and is not inside a linked worktree below it (a folder with a `.git` of its own, such as `<root>/.claude/worktrees/<name>`, including the worktree's top folder), nor under an `allow` entry. A target that holds a root (`ast_edit` on `..` or `/`) is refused too. One protected target refuses the whole call. The refusal names the resolved path, says that a relative path resolves against omp's folder, and gives the path relative to the root to put after the worktree's own path, so the agent corrects the call instead of finding the change later. Failing to write the event below never lets a refused call through.
18
+
19
+ - A symbolic link is judged by where it points: `AGENTS.local.md` in the checkout, a link to a file of another repository, is writable; a link from elsewhere into the checkout is not.
20
+ - `write` to `xd://edit`, `xd://write`, `xd://ast_edit` and `xd://apply_patch` (any case) is judged as the tool whose arguments it carries.
21
+ - Every way `edit` names a file counts: a `[path#TAG]` header (the path runs to the last `]`, so `[slug]` folders work), an `MV` destination (resolved like a header), `path`, `rename` and `move_to` (also in `edits`), and the `*** Update File:`, `*** Add File:`, `*** Delete File:`, `*** Move to:` and `*** Edit File:` lines of the patch modes.
22
+ - omp reads a path differently per tool (it unquotes a header but not a `write` path, drops a `[…#TAG]` wrapper or a copied `Update File:` and a leading `@`), so each path is judged as written and with those decorations removed, and the call is refused when either lies in the checkout. A decorated path to a worktree can therefore be refused (`write` with `"/repo/.claude/worktrees/x/a.rs"` in quotes): write it plainly.
23
+ - For `ast_edit`, a glob is judged by what comes before its first wildcard.
24
+ - Each refusal is a `write_refused` event (`agentId`, `tool`, `paths`), so the log names the agent that tried ([event log](event-log.md)).
25
+
26
+ ## State-changing git
27
+
28
+ A bash command whose `git` runs in a protected checkout is refused when its subcommand changes files, the index, HEAD or a branch: `checkout`, `switch`, `reset`, `restore`, `clean`, `rebase`, `merge`, `commit`, `pull`, `cherry-pick`, `revert`, `am`, `add`, `rm`, `mv` and `apply`. Reading (`status`, `diff`, `log`, `show`, `branch`), `fetch`, `push` and `worktree` stay allowed. Where a command runs follows the call's `cwd` (omp's folder when none is given), then each `cd` and `pushd`, `git -C <dir>` and `--work-tree`, through `&&`, `||`, `;`, `|` and subshells; a folder it cannot work out (`cd "$W"`, `cd -`) is not judged. Heredoc bodies are skipped. So `cd /repo && git checkout -q -- .` is refused from any folder, and `cd /repo/.claude/worktrees/x && git checkout -- a.rs` or `git -C /repo/.claude/worktrees/x reset` is not. An agent that really must run git there passes a worktree as `cwd`.
29
+
30
+ `read` is not refused: reading the main checkout is harmless, but it is the first step of the mistake, because the agent sees a file and edits what it just read. The refusal of the edit is what corrects it.
31
+
32
+ ## What it cannot see
33
+
34
+ - **Bash writes.** A shell command's file writes are not parsed: `cat > crates/a.rs`, `cp`, `mv`, `awk` with redirects and a script all get through (the `commands` rules below refuse the in-place rewriters); the rest is for [claims](coordination.md) and the agent's own `git status`. Git commands whose folder the shell computes (`cd "$W"`, `git -C $(pwd)`, a `cd` to a substitution's output, an `xargs -I{}` placeholder) and `eval` are not judged; a substitution's own commands are, quotes inside a double-quoted `$(…)` included. `xargs git …`, `sh -c '…'` (`-c`, `-lc`, `-ec`…), `sudo`/`env`/`nice`/`timeout` wrappers (also by path), `env -C`/`--chdir` and `sudo -D` are followed. Not handled: `$'…'` strings with escaped quotes, a stray `)` in a `case` pattern, and the cost of resolving folders on a real disk is not cached.
35
+ - **Eval and `lsp`.** Python and JavaScript run by the `eval` tool, and the `lsp` tool's renames and code actions, write files without a file-tool call to judge.
36
+ - A worktree that does not exist yet is the checkout's own folder to this guard (a folder below the root without a `.git` of its own), and a worktree outside every root is never judged.
37
+ - The guard fails open: a check that throws lets the call run, with a notice.
38
+
39
+ ## In-place rewrites by shell
40
+
41
+ A related slip: an agent that holds the text to change makes the edit with `sed -i`, `perl -pi` or an inline Python script (`python3 - <<'E' … open(p,'w') …`) instead of the edit tool. Nothing shows what the command matched, whether the file is the one meant, or that it changed at all. `examples/omp-coord.json` has three `refuse` rules for them (`sed -i` and `gsed -i`, `perl -i`, and Python run inline with `-c`, `-` or a heredoc whose code opens a file for writing, appending or creating, or calls `write_text` or `write_bytes`), each with `"agents": "sub"` so the main agent is not judged. A `commands` rule with `agents: "sub"` applies to subagents' commands only; the default is `"all"`.
42
+
43
+ The way out is deliberate and visible: a script saved to a file with the write tool and run from a short command (`python3 /path/script.py`, `bash script.sh`) is not judged, and neither is anything a `just` recipe or other program writes. The rules read command text like every rule here ([agents' commands](queues.md#agents-commands)), so `echo "sed -i is refused"` and a commit message that mentions it are refused too, and `awk` with redirects, `tee`, `ed`, `cat <<'PY' | python3` and `perl -pe '…' -i file` get through.
package/docs/queues.md CHANGED
@@ -3,7 +3,7 @@
3
3
  `omp-coord run <queue> -- <command>` waits for one of the queue's slots, shared by everything on this machine in the same scope, then runs the command. `just` recipes, scripts and terminals call it; agents' matching bash commands are rewritten to go through it. Queues are set under `queues` in the [configuration](configuration.md).
4
4
 
5
5
  ```sh
6
- omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
6
+ omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command> [args…]
7
7
  ```
8
8
 
9
9
  ## Running a job
@@ -14,6 +14,7 @@ The command runs in its own process group and session, so a time limit stops eve
14
14
 
15
15
  - **Exit codes**: the command's own, `75` when the job was not run (a refused or superseded duplicate), `124` when its time limit stopped it, `2` for a usage or configuration error.
16
16
  - **Owner**: jobs belong to the omp session in `OMP_COORD_SESSION` (set by the extension, so commands an agent runs inherit it) or `--owner`; otherwise to no session ("outside omp").
17
+ - **Tags**: `--meta k=v` (repeatable, at most 8; a repeated key: the last wins) records tags on the job's `job_queued` and `job_ended` events. A key matches `[a-z][a-z0-9_-]{0,31}`; a value is at most 200 characters on one line. Scripts can tag a job with a fingerprint of the code state (`fp`) and mark a deliberate failure (`intent=failfirst`); `omp-coord stats` then adds `; of the failures: N repeat an unchanged failure, M intended` to the queue's line. A repeat is a failed job whose `fp` equals that of an earlier failed job in the same queue inside the stats window, with no successful job of that `fp` in between. Detect support with `omp-coord --help 2>&1 | grep -q -- '--meta'`.
17
18
  - **Duplicates**: jobs with the same `--key` (for example `"<checkout> ci"`) are duplicates. One that duplicates a running job is refused; one that duplicates a waiting job is refused or, with `onDuplicate: "supersede"`, replaces it in its place in line.
18
19
 
19
20
  ## Order
@@ -32,6 +32,24 @@
32
32
  "action": "refuse",
33
33
  "reason": "git's rerere cache is shared by every worktree and session, and deleting it loses other sessions' recorded resolutions: to keep a throwaway build's resolutions out, run that command with `git -c rerere.enabled=false …`"
34
34
  },
35
+ {
36
+ "pattern": "(?<![\\w.-])g?sed(?:\\s+-\\S+){0,8}?\\s+(?:-[A-Za-z]*i\\S*|--in-place\\S*)(?=\\s|$)",
37
+ "action": "refuse",
38
+ "agents": "sub",
39
+ "reason": "sed -i rewrites files in place without showing what changed: use the edit tool (read the lines first). For one rewrite across many files, save a script with the write tool and run it from a short command. If the text only mentions it (a commit message, a PR body), put it in a file with the write tool and pass the file"
40
+ },
41
+ {
42
+ "pattern": "(?<![\\w.-])perl(?:\\s+-\\S+){0,8}?\\s+-(?![IMmdDwWxX])\\w*i\\S*(?=\\s|$)",
43
+ "action": "refuse",
44
+ "agents": "sub",
45
+ "reason": "perl -i rewrites files in place without showing what changed: use the edit tool (read the lines first). For one rewrite across many files, save a script with the write tool and run it from a short command. If the text only mentions it (a commit message, a PR body), put it in a file with the write tool and pass the file"
46
+ },
47
+ {
48
+ "pattern": "(?<![\\w.-])python[0-9.]*(?=((?:\\s+-(?![c-]\\b)(?![\\s<])\\S*(?:\\s+(?![-<])[A-Za-z][\\w.]*)?){0,8}))\\1\\s+(?:-c\\b|-(?=\\s*<<)|<<|-\\s)(?=[\\s\\S]*?(?:\\.write_(?:text|bytes)\\(|\\bopen\\((?:[^,()\\n]|\\([^()\\n]*\\))*,\\s*(?:mode\\s*=\\s*)?['\"][wax]))",
49
+ "action": "refuse",
50
+ "agents": "sub",
51
+ "reason": "an inline Python script that writes a file edits it without showing what changed: use the edit tool (read the lines first). For one rewrite across many files, save the script with the write tool and run it from a short command (`python3 /path/script.py`). If the text only mentions it (a commit message, a PR body), put it in a file with the write tool and pass the file"
52
+ },
35
53
  { "pattern": "(^|[;&|(\\s])(npm|pnpm|yarn|bun)\\s+(run\\s+)?test\\b", "action": "queue", "queue": "test" },
36
54
  { "pattern": "(^|[;&|(\\s])(npm|pnpm|yarn|bun)\\s+run\\s+build\\b", "action": "queue", "queue": "build" },
37
55
  {
@@ -49,6 +67,7 @@
49
67
  "ports": { "from": 20000, "to": 20999, "perSession": 200, "perAgent": 10 },
50
68
  "scratch": ".omp-coord-scratch"
51
69
  },
70
+ "protect": { "roots": ["."], "allow": [], "agents": "sub" },
52
71
  "messages": { "perMinute": 10 },
53
72
  "history": { "retentionDays": 30, "maxEvents": 100000 }
54
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nledford/omp-coord",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "files": [
5
5
  "src",
6
6
  "docs",
@@ -4,7 +4,7 @@
4
4
  * terminals, or agents' bash calls (which the extension rewrites to go through here when a rule queues them), and
5
5
  * reports on the scope.
6
6
  *
7
- * omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
7
+ * omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command> [args…]
8
8
  * omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
9
9
  * omp-coord pause [reason…] | resume | doctor [--fix]
10
10
  *
@@ -24,7 +24,7 @@ import { doctor } from "../../app/doctor.ts";
24
24
  import { EXIT_TIMED_OUT, runJob } from "../../app/job-runner.ts";
25
25
  import { type Config, ConfigError, findConfigFile, loadConfig } from "../../config.ts";
26
26
  import { statusView } from "../../domain/overview.ts";
27
- import { MAX_PRIORITY } from "../../domain/queues.ts";
27
+ import { MAX_PRIORITY, parseMeta } from "../../domain/queues.ts";
28
28
  import { agentOfOmpSession } from "../../domain/sessions.ts";
29
29
  import { osLoad } from "../os/load.ts";
30
30
  import { groupRunning } from "../os/process-group.ts";
@@ -45,7 +45,7 @@ const KILL_GRACE_MS = 10_000;
45
45
  const DAY = 86_400_000;
46
46
 
47
47
  const USAGE = `usage:
48
- omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command…>
48
+ omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command…>
49
49
  omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
50
50
  omp-coord pause [reason…] | resume | doctor [--fix]`;
51
51
 
@@ -206,6 +206,12 @@ async function run(args: string[]): Promise<number> {
206
206
  if (!Number.isSafeInteger(priority) || Math.abs(priority) > MAX_PRIORITY) {
207
207
  throw new UsageError(`omp-coord run: --priority must be a whole number from -${MAX_PRIORITY} to ${MAX_PRIORITY}`);
208
208
  }
209
+ let meta: ReturnType<typeof parseMeta>;
210
+ try {
211
+ meta = parseMeta(values.meta ?? []);
212
+ } catch (error) {
213
+ throw new UsageError(`omp-coord run: ${error instanceof Error ? error.message : String(error)}`);
214
+ }
209
215
  const timeoutMs = values.timeout === undefined ? undefined : parseDuration(values.timeout);
210
216
  const command = args.slice(split + 1);
211
217
 
@@ -258,6 +264,7 @@ async function run(args: string[]): Promise<number> {
258
264
  ...(values.lane === undefined ? {} : { lane: values.lane }),
259
265
  priority,
260
266
  ...(values.key === undefined ? {} : { key: values.key }),
267
+ ...(meta === undefined ? {} : { meta }),
261
268
  command: command.join(" "),
262
269
  pid: process.pid,
263
270
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
@@ -293,6 +300,7 @@ function parseRunFlags(args: string[]) {
293
300
  key: { type: "string" },
294
301
  owner: { type: "string" },
295
302
  timeout: { type: "string" },
303
+ meta: { type: "string", multiple: true },
296
304
  },
297
305
  });
298
306
  }
@@ -10,15 +10,25 @@
10
10
  * one registration, and every binding is kept so a message can reach the subagent it is for.
11
11
  */
12
12
  import { randomBytes } from "node:crypto";
13
- import { existsSync, mkdirSync } from "node:fs";
13
+ import { existsSync, mkdirSync, realpathSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { join, resolve } from "node:path";
16
16
  import { Coordinator } from "../../app/coordinator.ts";
17
17
  import type { Listener } from "../../app/ports.ts";
18
18
  import { type Config, ConfigError, findConfigFile, loadConfig } from "../../config.ts";
19
19
  import { spawnAllowance } from "../../domain/agents.ts";
20
- import { decideCommand } from "../../domain/commands.ts";
20
+ import { decideCommand, MAX_CHECKED_COMMAND } from "../../domain/commands.ts";
21
21
  import type { Claim } from "../../domain/model.ts";
22
+ import {
23
+ canonicalWith,
24
+ decideWrite,
25
+ type GuardFacts,
26
+ gitChange,
27
+ resolveCwd,
28
+ type WriteDecision,
29
+ type WriteGuard,
30
+ writeTargets,
31
+ } from "../../domain/protect.ts";
22
32
  import { osLoad } from "../os/load.ts";
23
33
  import { BoardStore, stateDir } from "../sqlite/store.ts";
24
34
  import {
@@ -31,7 +41,7 @@ import {
31
41
  formatStatus,
32
42
  } from "../text/report.ts";
33
43
  import { startBeacon, unixLiveness } from "../unix/beacon.ts";
34
- import type { ExtensionAPI, ExtensionContext, Timer, ToolResult } from "./api.ts";
44
+ import type { ExtensionAPI, ExtensionContext, Timer, ToolCallEvent, ToolResult } from "./api.ts";
35
45
  import { loadSettingHandle, type SpawnQueue, spawnQueue } from "./spawn-queue.ts";
36
46
 
37
47
  /**
@@ -194,14 +204,15 @@ export default function ompCoord(pi: ExtensionAPI): void {
194
204
 
195
205
  pi.on("tool_call", (event, ctx) => {
196
206
  const rt = shared.runtime;
197
- if (!rt || event.toolName !== "bash") return undefined;
198
- const command = event.input.command;
207
+ if (!rt) return undefined;
208
+ if (event.toolName !== "bash") return guardWrite(rt, event, ctx);
209
+ const command = event.input?.command;
199
210
  if (typeof command !== "string") return undefined;
200
211
  // the id must be on the board before the command can reach `omp-coord run`, whatever event announced a switch
201
212
  if (ctx.agent.kind === "main") syncMainSession(rt, ctx);
202
213
 
203
214
  try {
204
- const cwd = typeof event.input.cwd === "string" ? resolve(ctx.cwd, event.input.cwd) : ctx.cwd;
215
+ const cwd = typeof event.input?.cwd === "string" ? resolve(ctx.cwd, event.input.cwd) : ctx.cwd;
205
216
  const held = rt.coordinator.checkCommand({
206
217
  sessionId: rt.sessionId,
207
218
  command,
@@ -220,12 +231,15 @@ export default function ompCoord(pi: ExtensionAPI): void {
220
231
  } catch (error) {
221
232
  notify(rt, `omp-coord could not check claims: ${describe(error)}`, "error");
222
233
  }
223
-
224
- const decision = decideCommand(rt.config.commands, command, {
225
- ...RUNNER,
226
- owner: rt.sessionId,
227
- config: rt.config.source,
228
- });
234
+ const gitHeld = guardGit(rt, ctx, command, event.input?.cwd);
235
+ if (gitHeld) return gitHeld;
236
+
237
+ const decision = decideCommand(
238
+ rt.config.commands,
239
+ command,
240
+ { ...RUNNER, owner: rt.sessionId, config: rt.config.source },
241
+ ctx.agent.kind,
242
+ );
229
243
  if (decision.kind === "refuse") return { block: true, reason: `omp-coord: ${decision.reason}` };
230
244
  // Only a queued command is rewritten. Which agent runs it needs no rewrite: omp gives every bash call its agent's
231
245
  // session id (`PI_SESSION_ID`), and the board maps that id to the agent (`session_start`, `agent_start`).
@@ -709,6 +723,85 @@ function notify(rt: Runtime, message: string, type: "info" | "warning" | "error"
709
723
  if (rt.ctx.hasUI) rt.ctx.ui.notify(message, type);
710
724
  }
711
725
 
726
+ /**
727
+ * The `protect` guard for the file tools (`edit`, `write`, `ast_edit`, `apply_patch`): a write into a protected
728
+ * checkout is blocked with a reason naming the resolved path.
729
+ */
730
+ function guardWrite(
731
+ rt: Runtime,
732
+ event: ToolCallEvent,
733
+ ctx: ExtensionContext,
734
+ ): { block: true; reason: string } | undefined {
735
+ if (!rt.config.protect) return undefined;
736
+ let targets: string[] | undefined;
737
+ try {
738
+ targets = writeTargets(event.toolName, event.input ?? {});
739
+ } catch (error) {
740
+ notify(rt, `omp-coord could not read a ${event.toolName} call: ${describe(error)}`, "error");
741
+ return undefined;
742
+ }
743
+ if (!targets || targets.length === 0) return undefined;
744
+ return refuseProtected(rt, ctx, event.toolName, targets, ctx.cwd, (guard, facts) =>
745
+ decideWrite(guard, ctx.agent.kind, event.toolName, targets, facts),
746
+ );
747
+ }
748
+
749
+ /**
750
+ * The `protect` guard for a bash command's state-changing `git`: it must not run in a protected checkout. A command
751
+ * over the length cap is left to `decideCommand`, which refuses it while a refuse rule exists.
752
+ */
753
+ function guardGit(
754
+ rt: Runtime,
755
+ ctx: ExtensionContext,
756
+ command: string,
757
+ inputCwd: unknown,
758
+ ): { block: true; reason: string } | undefined {
759
+ if (!rt.config.protect || command.length > MAX_CHECKED_COMMAND || !/\bgit\b/.test(command)) return undefined;
760
+ const cwd = typeof inputCwd === "string" ? resolveCwd(ctx.cwd, inputCwd, homedir()) : ctx.cwd;
761
+ return refuseProtected(rt, ctx, "bash", [], cwd, (guard, facts) =>
762
+ gitChange(guard, ctx.agent.kind, command, cwd, facts),
763
+ );
764
+ }
765
+
766
+ /**
767
+ * Judges a call with the disk's facts and blocks it on a refusal. Like every guard here it fails open: a check that
768
+ * throws lets the call run; but once the decision is "refuse", failing to log it never lets the call through.
769
+ */
770
+ function refuseProtected(
771
+ rt: Runtime,
772
+ ctx: ExtensionContext,
773
+ tool: string,
774
+ shown: string[],
775
+ cwd: string,
776
+ judge: (guard: WriteGuard, facts: GuardFacts) => WriteDecision,
777
+ ): { block: true; reason: string } | undefined {
778
+ const guard = rt.config.protect;
779
+ if (!guard) return undefined;
780
+ let decision: WriteDecision;
781
+ try {
782
+ decision = judge(guard, {
783
+ cwd,
784
+ home: homedir(),
785
+ canonical: canonicalWith(p => {
786
+ try {
787
+ return realpathSync(p);
788
+ } catch {
789
+ return undefined;
790
+ }
791
+ }),
792
+ hasGitEntry: dir => existsSync(join(dir, ".git")),
793
+ });
794
+ } catch (error) {
795
+ notify(rt, `omp-coord could not check a ${tool} call: ${describe(error)}`, "error");
796
+ return undefined;
797
+ }
798
+ if (decision.kind === "allow") return undefined;
799
+ guarded(rt, "record a refused call", () =>
800
+ rt.coordinator.recordWriteRefused(rt.sessionId, ctx.agent.id, tool, decision.shown ?? shown),
801
+ );
802
+ return { block: true, reason: `omp-coord: ${decision.reason}` };
803
+ }
804
+
712
805
  function claimKind(word: string): Claim["kind"] | undefined {
713
806
  const k = word.trim().toLowerCase();
714
807
  return CLAIM_KINDS.find(x => x === k);
@@ -81,6 +81,7 @@ CREATE TABLE jobs (
81
81
  key TEXT,
82
82
  command TEXT NOT NULL,
83
83
  agent TEXT,
84
+ meta TEXT,
84
85
  socket TEXT NOT NULL,
85
86
  pid INTEGER NOT NULL,
86
87
  state TEXT NOT NULL CHECK (state IN ('waiting', 'running', 'superseded')),
@@ -258,6 +259,7 @@ export class BoardStore implements BoardRepository {
258
259
  .all()
259
260
  .some(c => c.name === column);
260
261
  if (!has("jobs", "agent")) this.#db.exec("ALTER TABLE jobs ADD COLUMN agent TEXT");
262
+ if (!has("jobs", "meta")) this.#db.exec("ALTER TABLE jobs ADD COLUMN meta TEXT");
261
263
  if (!has("slots", "unqueued"))
262
264
  this.#db.exec("ALTER TABLE slots ADD COLUMN unqueued TEXT CHECK (unqueued IN ('revived', 'eval'))");
263
265
  if (!has("sessions", "omp_session")) this.#db.exec("ALTER TABLE sessions ADD COLUMN omp_session TEXT");
@@ -369,6 +371,7 @@ export class BoardStore implements BoardRepository {
369
371
  key: string | null;
370
372
  command: string;
371
373
  agent: string | null;
374
+ meta: string | null;
372
375
  socket: string;
373
376
  pid: number;
374
377
  state: "waiting" | "running" | "superseded";
@@ -387,6 +390,7 @@ export class BoardStore implements BoardRepository {
387
390
  key: r.key ?? undefined,
388
391
  command: r.command,
389
392
  agent: r.agent ?? undefined,
393
+ meta: r.meta === null ? undefined : (JSON.parse(r.meta) as Record<string, string>),
390
394
  socket: r.socket,
391
395
  pid: r.pid,
392
396
  state: r.state,
@@ -499,7 +503,7 @@ export class BoardStore implements BoardRepository {
499
503
  const sent = db.query("INSERT INTO sent (session_id, at) VALUES (?, ?)");
500
504
  for (const x of board.sent) sent.run(x.sessionId, x.at);
501
505
  const job = db.query(
502
- "INSERT INTO jobs (id, queue, owner, lane, priority, key, command, agent, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
506
+ "INSERT INTO jobs (id, queue, owner, lane, priority, key, command, agent, meta, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
503
507
  );
504
508
  for (const j of board.jobs) {
505
509
  job.run(
@@ -511,6 +515,7 @@ export class BoardStore implements BoardRepository {
511
515
  j.key ?? null,
512
516
  j.command,
513
517
  j.agent ?? null,
518
+ j.meta === undefined ? null : JSON.stringify(j.meta),
514
519
  j.socket,
515
520
  j.pid,
516
521
  j.state,
@@ -212,7 +212,7 @@ export function formatStats(days: DayStats[]): string {
212
212
  );
213
213
  for (const [q, s] of Object.entries(d.queues)) {
214
214
  lines.push(
215
- ` ${q}: ${s.started} started, wait avg ${minutes(s.avgWaitMs)} / max ${minutes(s.maxWaitMs)}; ${s.ended} ended, ${s.failed} failed${s.timedOut ? ` (${s.timedOut} timed out)` : ""}`,
215
+ ` ${q}: ${s.started} started, wait avg ${minutes(s.avgWaitMs)} / max ${minutes(s.maxWaitMs)}; ${s.ended} ended, ${s.failed} failed${s.timedOut ? ` (${s.timedOut} timed out)` : ""}${s.ofFailures ? `; of the failures: ${s.ofFailures.repeats} repeat an unchanged failure, ${s.ofFailures.intended} intended` : ""}`,
216
216
  );
217
217
  }
218
218
  }
@@ -30,6 +30,7 @@ import { type SendResult, sendMessage, takeInbox } from "../domain/messages.ts";
30
30
  import type { Board, Claim, Note } from "../domain/model.ts";
31
31
  import { addNote, type NoteResult } from "../domain/notes.ts";
32
32
  import { type AgentPortsResult, agentPorts } from "../domain/ports.ts";
33
+ import { writeRefused } from "../domain/protect.ts";
33
34
  import { dropJobs, type EnqueueResult, schedule, setJobPriority } from "../domain/queues.ts";
34
35
  import { bindOmpSession, registerSession, removeSession } from "../domain/sessions.ts";
35
36
  import type { BoardRepository, Clock, Liveness, LoadProbe } from "./ports.ts";
@@ -137,6 +138,11 @@ export class Coordinator {
137
138
  return conflict;
138
139
  }
139
140
 
141
+ /** Records that a file tool's write was refused by the `protect` guard (for the event log). */
142
+ recordWriteRefused(sessionId: string, agentId: string, tool: string, paths: string[]): void {
143
+ this.repo.transact(b => writeRefused(b, sessionId, { agentId, tool, paths, now: this.clock.now() }));
144
+ }
145
+
140
146
  note(sessionId: string | null, author: string, text: string): NoteResult {
141
147
  return this.repo.transact(b => addNote(b, { sessionId, author, text, now: this.clock.now() }));
142
148
  }
@@ -155,7 +161,7 @@ export class Coordinator {
155
161
  }
156
162
 
157
163
  stats(sinceMs: number): DayStats[] {
158
- return stats(this.repo.events(this.clock.now() - sinceMs), 0).days;
164
+ return stats(this.repo.events(), this.clock.now() - sinceMs).days;
159
165
  }
160
166
 
161
167
  requestSpawn(req: Omit<SpawnRequest, "now">): SpawnDecision {
@@ -22,6 +22,8 @@ export interface JobSpec {
22
22
  lane?: string;
23
23
  priority: number;
24
24
  key?: string;
25
+ /** Tags for the event log. */
26
+ meta?: Record<string, string>;
25
27
  /** For display. */
26
28
  command: string;
27
29
  pid: number;
package/src/config.ts CHANGED
@@ -5,11 +5,12 @@
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
- import { dirname, join, resolve } from "node:path";
8
+ import { dirname, join, posix, resolve } from "node:path";
9
9
  import type { CommandRule } from "./domain/commands.ts";
10
10
  import type { LoadLimit } from "./domain/limits.ts";
11
11
  import type { AgentPolicy } from "./domain/model.ts";
12
12
  import type { PortPolicy } from "./domain/ports.ts";
13
+ import type { WriteGuard } from "./domain/protect.ts";
13
14
  import type { QueuePolicy, Queues } from "./domain/queues.ts";
14
15
 
15
16
  export const CONFIG_FILE = ".omp-coord.json";
@@ -24,6 +25,8 @@ export interface Config {
24
25
  agents: AgentPolicy;
25
26
  /** Rules for agents' bash commands, first match wins. */
26
27
  commands: CommandRule[];
28
+ /** Checkouts the agents' file tools may not write to; undefined when no `protect` is configured. */
29
+ protect: WriteGuard | undefined;
27
30
  /** Build and test queues, machine-wide within the scope. */
28
31
  queues: Queues;
29
32
  /** Port ranges and scratch folders handed to sessions and their agents. */
@@ -135,8 +138,11 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
135
138
  } catch (e) {
136
139
  return fail(`${field}.pattern`, `is not a valid regular expression (${e instanceof Error ? e.message : e})`);
137
140
  }
141
+ const agentsText = rule.agents ?? "all";
142
+ if (agentsText !== "all" && agentsText !== "sub") fail(`${field}.agents`, `must be "all" or "sub"`);
143
+ const agents = agentsText as "all" | "sub";
138
144
  if (rule.action === "refuse")
139
- return { pattern, action: "refuse", reason: str(rule.reason, `${field}.reason`, fail) };
145
+ return { pattern, action: "refuse", reason: str(rule.reason, `${field}.reason`, fail), agents };
140
146
  if (rule.action !== "queue") return fail(`${field}.action`, `must be "refuse" or "queue"`);
141
147
  const queue = str(rule.queue, `${field}.queue`, fail);
142
148
  const policy = queues[queue];
@@ -144,9 +150,40 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
144
150
  const lane = rule.lane === undefined ? undefined : str(rule.lane, `${field}.lane`, fail);
145
151
  if (lane !== undefined && !policy.lanes.includes(lane))
146
152
  fail(`${field}.lane`, `names no lane of queue ${queue} (${policy.lanes.join(", ")})`);
147
- return { pattern, action: "queue", queue, lane };
153
+ return { pattern, action: "queue", queue, lane, agents };
148
154
  });
149
155
 
156
+ let protect: WriteGuard | undefined;
157
+ if (root.protect !== undefined) {
158
+ const p = obj(root.protect, "protect", fail);
159
+ const roots = strList(p.roots, "protect.roots", fail);
160
+ if (roots.length === 0) fail("protect.roots", "must list at least one folder");
161
+ const agentsText = p.agents ?? "sub";
162
+ if (agentsText !== "sub" && agentsText !== "all") fail("protect.agents", `must be "sub" or "all"`);
163
+ protect = {
164
+ roots: roots.map(r => resolve(dirname(source), r)),
165
+ allow: strList(p.allow ?? [], "protect.allow", fail).map((a, i) => {
166
+ const entry = a.replace(/^(?:\.\/)+/, "");
167
+ const bare = entry.replace(/\/+$/, "");
168
+ if (
169
+ bare === "" ||
170
+ bare.startsWith("/") ||
171
+ bare.includes("\\") ||
172
+ posix.normalize(bare) !== bare ||
173
+ bare === "." ||
174
+ bare.startsWith("../") ||
175
+ bare === ".."
176
+ )
177
+ fail(
178
+ `protect.allow[${i}]`,
179
+ "must be a file or folder below a root, written relative to it with / separators (no leading /, no .. or . parts, no doubled /)",
180
+ );
181
+ return entry;
182
+ }),
183
+ agents: agentsText as WriteGuard["agents"],
184
+ };
185
+ }
186
+
150
187
  const resources = obj(root.resources ?? {}, "resources", fail);
151
188
  let ports: PortPolicy | undefined;
152
189
  if (resources.ports !== undefined) {
@@ -185,6 +222,7 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
185
222
  messagesPerMinute: count(messages.perMinute ?? 10, "messages.perMinute", fail),
186
223
  },
187
224
  commands,
225
+ protect,
188
226
  queues,
189
227
  resources: { ports, scratch },
190
228
  history: {