@nledford/omp-coord 0.3.1 → 0.4.0

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.3.1",
10
+ "version": "0.4.0",
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,30 @@ 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.0] - 2026-10-28
10
+
11
+ ### Added
12
+
13
+ - `examples/omp-coord.json` has two `refuse` rules that protect git's rerere cache, which every worktree of a repository shares: deleting `rr-cache` (`rm`, `find -delete`, `git clean` and the like) and `git rerere clear`, `forget` and `gc` are refused, with a reason that points to `git -c rerere.enabled=false …` for throwaway builds. Reading the cache, `git rerere status`, `diff`, `remaining` and a command that names a throwaway repository under `/tmp` (an `rr-cache` path there, or `git -C`/`GIT_DIR=` for `git rerere`) are allowed; the limits are in the docs. Copy the rules into your `.omp-coord.json` above any `queue` rules to use them; no code or default changed. See [queues](docs/queues.md#example-protecting-gits-rerere-cache).
14
+
15
+ ### Changed
16
+
17
+ - **Breaking:** while any `refuse` rule is configured, a bash command longer than 32,768 characters is refused with a message, instead of being judged by the patterns: a pattern that stalls on a very long line froze the session's `tool_call` check (omp gives it 30 s and fails closed), and one that runs out of backtracking budget answered "no match" and let the command through. A command that used to run and is now refused: write the long script to a file with the write tool and run it from a short command. Configs without a `refuse` rule (queue rules only) are not affected. See [queues](docs/queues.md#agents-commands).
18
+
19
+ ## [0.3.2] - 2026-10-28
20
+
21
+ ### Changed
22
+
23
+ - **Breaking:** the extension no longer starts agents' bash commands with `export OMP_COORD_AGENT=<agent id>;`, and no longer sets `OMP_COORD_AGENT` at all. The rewritten command showed up in the conversation, and agents copied the prefix into later calls. Instead `omp-coord run` finds the agent from `PI_SESSION_ID`, the id omp gives each bash call for its agent's conversation, which the extension records on the board (the main session as `Main`, each subagent when its run starts, dropped when it ends). A script that read `OMP_COORD_AGENT` inside an agent's command must read `PI_SESSION_ID` or ask the board instead; `OMP_COORD_AGENT=<name> omp-coord run …` still names the agent for a job started where omp gives no `PI_SESSION_ID`, or one the board doesn't know. Only commands a `queue` rule matches are rewritten now, as before 0.2.3; omp's `bash.patterns` rules and other extensions see every other command as the agent wrote it.
24
+
25
+ ### Deprecated
26
+
27
+ - `tagAgentCommands` does nothing and is still accepted, so existing `.omp-coord.json` files keep loading; remove it. Queued jobs record their agent whether it was on or not.
28
+
29
+ ### Fixed
30
+
31
+ - A job queued by an agent now records the agent whether or not `tagAgentCommands` was on. A board made by an older omp-coord gains the two new columns when this version opens it; while a session of the scope (or an older `omp-coord run`) still uses the old version, its saves drop the recorded ids: the main session's comes back within 2 s, but a subagent's only at its next run, so while any older omp-coord is in use a subagent's jobs may show no agent (display only). omp gives bash calls `PI_SESSION_ID` from 18.12.4 on (the plugin still runs on 18.12.0 to 18.12.3, where jobs record no agent unless a script sets `OMP_COORD_AGENT`).
32
+
9
33
  ## [0.3.1] - 2026-10-27
10
34
 
11
35
  ### Fixed
@@ -82,7 +106,9 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
82
106
  - An SQLite event log per scope, with retention.
83
107
  - Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
84
108
 
85
- [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.3.1...HEAD
109
+ [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.4.0...HEAD
110
+ [0.4.0]: https://github.com/nledford/omp-coord/compare/v0.3.2...v0.4.0
111
+ [0.3.2]: https://github.com/nledford/omp-coord/compare/v0.3.1...v0.3.2
86
112
  [0.3.1]: https://github.com/nledford/omp-coord/compare/v0.3.0...v0.3.1
87
113
  [0.3.0]: https://github.com/nledford/omp-coord/compare/v0.2.3...v0.3.0
88
114
  [0.2.3]: https://github.com/nledford/omp-coord/compare/v0.2.2...v0.2.3
package/docs/commands.md CHANGED
@@ -61,7 +61,7 @@ Every time is milliseconds since the epoch (`Date.now()`); a session is its lett
61
61
  | `line[]` | Sessions waiting for a slot: `pool`, `session`, `position` (from 1), `since`, `heldUntil` (`null` unless a freed slot is held for it). |
62
62
  | `agents[]` | One per agent holding a slot, by session letter then `since`: `id` (`null` for a spawn with no key and no first run yet), `type`, `parent` (`Main` or an agent id), `session`, `pool` (`null`: outside the caps), `state` (`starting` or `running`), `since`. |
63
63
  | `claims[]` | `kind` (`branch`, `worktree`, `pr`), `target`, `session`, `agent`, `note`, `since`. |
64
- | `queues[]` | `name`, `order`, `slots` (now), `configuredSlots`, `running[]` and `waiting[]` (in the order they will start), each job: `id`, `session`, `owner` (the owning session's id, or `null` outside omp), `lane`, `priority`, `key` (or `null`), `command`, `agent` (omp's id of the agent whose command queued it, `"Main"` for the main agent; `null` when `tagAgentCommands` is off, for a job started outside an agent's bash call, and while any session of the scope runs an omp-coord older than 0.2.3, whose saves drop it), `state`, `queuedAt`, `startedAt` (`null` while waiting), `position` (from 1 while waiting, `null` while running). |
64
+ | `queues[]` | `name`, `order`, `slots` (now), `configuredSlots`, `running[]` and `waiting[]` (in the order they will start), each job: `id`, `session`, `owner` (the owning session's id, or `null` outside omp), `lane`, `priority`, `key` (or `null`), `command`, `agent` (omp's id of the agent whose command queued it, `"Main"` for the main agent; `null` for a job started outside an agent's bash call, on omp older than 18.12.4 (which sets no `PI_SESSION_ID`), and while any session of the scope runs an omp-coord older than this one, whose saves drop the ids the agent is found by), `state`, `queuedAt`, `startedAt` (`null` while waiting), `position` (from 1 while waiting, `null` while running). |
65
65
  | `notes[]` | The latest three decisions, oldest first: `id`, `at`, `session` (the letter when it was written, kept after that session ends; `null` outside omp), `author`, `text`. |
66
66
 
67
67
  ```json
@@ -102,5 +102,6 @@ Exit codes: `0` success, the command's own for `run`, `75` a job not run, `124`
102
102
  | `OMP_COORD_SESSION` | the extension | The session that owns jobs started from its agents' commands. |
103
103
  | `OMP_COORD_PORTS` | the extension | The session's port block, e.g. `18700-18799`, when `resources.ports` is set. |
104
104
  | `OMP_COORD_SCRATCH` | the extension | The session's scratch folder, when `resources.scratch` is set. |
105
- | `OMP_COORD_AGENT` | the extension, with `tagAgentCommands` | The agent whose command this is (`Main` for the main agent); `omp-coord run` records it on the job. |
105
+ | `PI_SESSION_ID` | omp | The id of the conversation an agent's bash call belongs to. `omp-coord run` looks up which agent that is (the extension records it on the board: `Main` for the main session, the subagent's id for a subagent) and records it on the job. |
106
+ | `OMP_COORD_AGENT` | you | The agent to record on a job started where omp gave no `PI_SESSION_ID` (a script), or one the board doesn't know. The extension no longer sets it. |
106
107
  | `OMP_COORD_JOB`, `OMP_COORD_QUEUE` | `omp-coord run` | The running job's id and queue, for nested runs. |
@@ -14,7 +14,7 @@
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. |
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)). |
18
18
  | `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
19
  | `queues.<name>.order` | `"fifo"` | `"fifo"`, `"fair-share"` or `"priority"` (see [queues](queues.md#order)). |
20
20
  | `queues.<name>.lanes` | `["normal"]` | Lane names, first served first; the last is the default. |
@@ -22,7 +22,7 @@
22
22
  | `queues.<name>.onDuplicate` | `"refuse"` | `"refuse"` or `"supersede"` a waiting job with the same key. |
23
23
  | `queues.<name>.timeoutMinutes` | none | Stop a job's command after this long: SIGTERM to its process group, then SIGKILL to whatever of it is left 10 s later; it exits 124. `--timeout` overrides it per job. |
24
24
  | `queues.<name>.loadSlots` | `[]` | `[{ "above": 150, "slots": 1 }]`: fewer slots under load; `0` holds the queue. |
25
- | `tagAgentCommands` | `false` | Start each agent bash command with `export OMP_COORD_AGENT=<agent id>;`, so each queued job records the agent that queued it (queue lines read `session A/<agent>`). Leave it off when you use omp's `bash.patterns` allow or deny rules (a rewritten command no longer matches them) or another extension that rewrites bash commands (the last rewrite wins). |
25
+ | `tagAgentCommands` | none | Deprecated and ignored: every queued job records the agent that queued it now (on omp 18.12.4 or later), without rewriting commands. Accepted so existing files keep loading; remove it. |
26
26
  | `resources.ports` | none | `{ "from", "to", "perSession", "perAgent" (10) }`: each session gets a block of `perSession` ports, each agent a block of `perAgent` from its session's. |
27
27
  | `resources.scratch` | none | Root of the scratch folders: `<scratch>/<letter>` per session, `<scratch>/<letter>/<agent id>` per agent. Relative to the config file. |
28
28
  | `history.retentionDays` | `30` | Events and decision-log notes older than this are deleted. |
package/docs/queues.md CHANGED
@@ -49,6 +49,25 @@ A command that puts its work in the background (`cargo test &`) holds the slot o
49
49
 
50
50
  When an agent's bash command matches a `queue` rule under `commands`, the extension rewrites it to run `omp-coord run <queue> --owner <session> -- sh -c '<command>'` with omp's own binary as Bun (`BUN_BE_BUN=1`), so it needs no separate Bun install. The waiting happens inside the bash call, which counts against the bash tool's timeout: agents should run long jobs as background (`async`) bash calls. The rewritten command runs in `sh`, so shell variables the agent set earlier without exporting them are not visible to it.
51
51
 
52
- With `tagAgentCommands` on, the extension also starts every agent bash command with `export OMP_COORD_AGENT=<agent id>;`, and `omp-coord run` records that agent on the job: queue lines read `session A/WaveImport` instead of only `session A`. It is off by default because the rewritten command no longer matches omp's `bash.patterns` rules (they must match the whole command) and replaces any other extension's rewrite of the same call.
52
+ `omp-coord run` records the agent whose command it is on each job, so queue lines read `session A/WaveImport` instead of only `session A`. omp (18.12.4 or later) gives every bash call the id of its agent's conversation (`PI_SESSION_ID`), and the extension records on the board which agent that is, so the command text is never touched: queue rules, omp's `bash.patterns` rules and other extensions' rewrites see exactly what the agent wrote. A script can name the agent itself with `OMP_COORD_AGENT`.
53
53
 
54
- A command matching a `refuse` rule is refused with the rule's reason.
54
+ A command matching a `refuse` rule is refused with the rule's reason. While any `refuse` rule is configured, a command longer than 32,768 characters is refused too, with a message that says so (create the file with the write tool and run it from a short command): the check runs inside omp's `tool_call` handler, which omp gives 30 seconds and fails closed, and a regex that runs out of its backtracking budget answers "no match", so a very long line could otherwise stall the session or slip past a rule. Configs without a `refuse` rule are not affected.
55
+
56
+ ### Example: protecting git's rerere cache
57
+
58
+ `examples/omp-coord.json` carries two `refuse` rules (copy them into your `.omp-coord.json`; nothing is refused unless a rule is in the file) for a cache that every worktree of a repository shares: `<git-common-dir>/rr-cache`, where `git rerere` keeps recorded conflict resolutions. A command that deletes entries from it takes away resolutions other sessions' agents recorded, and their next rebase hits the same conflicts again. Rules are first-match-wins, so list them before any `queue` rule: a command that matches both (`rm -rf .git/rr-cache && bun test`) is queued, not refused, when the `queue` rule comes first.
59
+
60
+ - **Refused**: `rm`, `rmdir`, `unlink`, `trash`, `shred`, `find … -delete` and `git clean` (also `git --work-tree=<path> clean`) in a command line (up to the next `;`, `&` or newline, a backslash-newline continuing the line, so also `… | xargs rm`) that names a path containing `rr-cache` (`.git/rr-cache`, `"$(git rev-parse --git-common-dir)/rr-cache"`); the same delete as the very next command after `cd` into such a path (`cd .git/rr-cache && rm -rf *`, also `cd -- …`, `pushd`, a delete on the next line, also `cd "$(git rev-parse --git-common-dir)/rr-cache"` and inside `( … )` or `sh -c "…"`); and `git rerere clear`, `forget` and `gc`, with up to 64 `git` global options before `rerere` (values may be quoted, escaped or `$(…)`, up to 4,096 characters each), after `sudo`, `env`, `xargs`, `timeout [-s SIG] [-k D] N` (`sudo`, `env`, `xargs` and `time` with short flags that take no value, so `sudo -u me git …`, `xargs -I{} git …` and `env -u X git …` get through), `nice` (and `nice -n 10`), `!`, `if`, `while`, `until`, `elif`, `do`, `then`, `else`, `{`, a pipe or `;`, and with `git` given by path (`/usr/bin/git`). The reason names the alternative: run the throwaway command with `git -c rerere.enabled=false …`, so its resolutions never reach the shared cache.
61
+ - **Not refused**: reading the cache (`ls`, `cat`, `du`), `git rerere status`, `diff` and `remaining`, and anything aimed at a throwaway repository under `/tmp`, `/private/tmp`, `/var/folders` or `$TMPDIR` (also `${TMPDIR:-/tmp}`): an `rr-cache` path that starts there (and has no `..` after it), or, for `git rerere`, a `GIT_DIR=`, `--git-dir` or `git -C` that points there in the same command (between `;`, `&`, `|` or a newline) before `rerere`.
62
+
63
+ These rules match command text, like every rule here, so they stop mistakes, not a determined dodge. Limits:
64
+
65
+ - A path that does not spell `rr-cache` (`rm -rf .git/rr-*`, a variable holding the path, a script that deletes it) gets through, and so does `mv`.
66
+ - A `cd` in an earlier call, or a delete that is not the next command after the `cd`, is not seen; neither is `cd … && find . -exec rm …` (a `find … -delete` is). A redirection or `|| exit` after the `cd` is allowed for.
67
+ - Text that merely mentions the words is refused too (`git commit -m "…; git rerere gc is refused"`, a heredoc line, `git log -S"git rerere gc"`), as is a verb and `rr-cache` in the same command line even when unrelated: `grep rm .git/rr-cache` and `ls .git/rr-cache | grep rm` are refused. So is `git rerere clear` written as the start of a quoted string (`sh -c 'git rerere clear'` is meant to be caught, and so is `git commit -m 'git rerere clear'`).
68
+ - A throwaway repository is recognized only inside the one command: `cd /tmp/x && git rerere clear` and an earlier `export GIT_DIR=…` are refused (write `git -C /tmp/x rerere clear`), and so is one outside the temporary folders, because text cannot tell it from the project's own.
69
+ - Other ways to run the same thing get through. After a `cd`: `if cd …; then rm …`, `&& { rm …; }`, `&& (rm …)`, `&& nice rm …`, and a loop such as `for d in .git/rr-cache/*; do rm -rf "$d"; done`. For `git rerere`: `eval git rerere clear`, `parallel`, `stdbuf`, `find … -exec git rerere forget {} +`, `git rerere 'clear'`, `xargs` with options that take a value (`xargs -n 1`, `xargs -I{}`), `timeout --signal=KILL`, and a command inside `python -c` or `node -e`. A guard that reads command text cannot close these; it stops the usual spellings.
70
+ - A delete and the `rr-cache` path more than 2,000 characters apart in one command line are not connected; that bound, and the other limits on how far a match reaches, keep matching time small, and the 32,768-character cap above bounds it for every pattern: the tests time adversarial lines of 5 KB to 30 KB, each under half a second on a busy machine, and check that a real offense after each is still refused. A regex that runs out of its engine's backtracking budget answers "no match", which would let a command through; reviewers found such stalling lines of a few hundred bytes (since fixed), and none is known up to the cap (the tests run shapes of up to about 32 KB).
71
+ - A `GIT_DIR=` before an `sh -c "git rerere clear"` is not seen inside the quoted string, and a variable whose name merely ends in `GIT_DIR=` counts as one.
72
+ - Only the first throwaway option counts: `git -C /tmp/x -C /work/repo rerere clear` is exempt, though a later `-C` points at the project. `${TMPDIR:-<anything>}` is exempt whatever the default.
73
+ - A quoted path with a space in it (`"/tmp/my dir/.git/rr-cache"`) is judged by the part after the space.
@@ -22,6 +22,16 @@
22
22
  "test": { "slots": 3, "order": "fair-share", "agingMinutes": 10, "timeoutMinutes": 30 }
23
23
  },
24
24
  "commands": [
25
+ {
26
+ "pattern": "(?:(?<![\\w.-])(?:rm|rmdir|unlink|trash|shred)(?![\\w.-])|\\s-delete\\b|(?=((?<![\\w.-])git\\b[^;&|\\n]{0,200}?\\sclean\\b))\\1)[^;&\\n]{0,2000}(?:\\\\\\n[^;&\\n]{0,2000}){0,4}(?:^|[\\s=(])(?!['\"]?(?:(?:/private)?/(?:tmp|var/folders)/|\\$\\{?TMPDIR(?::-[^}\\s]*)?\\}?['\"]?/)(?![^\\s;&|=(]*\\.\\./))['\"]?[^\\s;&|=(]*rr-cache|(?:^|[\\s=(])(?!['\"]?(?:(?:/private)?/(?:tmp|var/folders)/|\\$\\{?TMPDIR(?::-[^}\\s]*)?\\}?['\"]?/)(?![^\\s;&|=(]*\\.\\./))['\"]?[^\\s;&|=(]*rr-cache[^;&\\n]{0,2000}(?:\\\\\\n[^;&\\n]{0,2000}){0,4}(?:(?<![\\w.-])(?:rm|rmdir|unlink|trash|shred)(?![\\w.-])|\\s-delete\\b|(?<![\\w.-])git\\b[^;&|\\n]{0,200}\\sclean\\b)|(?<![\\w.-])git\\b[^;&|\\n]{0,200}\\s--work-tree[=\\s][^;&|\\n]{0,200}rr-cache[^;&|\\n]{0,200}\\sclean\\b|(?:^|[;&|\\n({\\`]|(?<=\\s)['\"])[^\\S\\n]*(?:cd|pushd)[^\\S\\n]+(?:-[-\\w]*[^\\S\\n]+)?(?!['\"]?(?:(?:/private)?/(?:tmp|var/folders)/|\\$\\{?TMPDIR(?::-[^}\\s]*)?\\}?['\"]?/)(?![^\\s;&|=(]*\\.\\./))(?=[^;&|\\n]{0,2000}(?:\\\\\\n[^;&|\\n]{0,2000}){0,4}rr-cache)(?:\"[^\"\\n]*\"|'[^'\\n]*'|\\$\\([^)\\n]*\\)|[^\\s;&|\"'$<>]|\\$(?!\\())+(?:(?:[^\\S\\n]+\\d+|[^\\S\\n]*)&?[<>]&?[^\\s;&|<>]*){0,4}(?:[^\\S\\n]*\\|\\|[^\\S\\n]*(?:exit|return)(?:[^\\S\\n]+\\d+)?)?[^\\S\\n]*(?:\\\\\\n[^\\S\\n]*)?(?:&&|;|\\n)[\\s\\\\]*(?:(?:sudo|command)\\s+)*(?:/\\S*/)?(?:(?:(?<![\\w.-])(?:rm|rmdir|unlink|trash|shred)(?![\\w.-])|\\s-delete\\b|(?<![\\w.-])git\\b[^;&|\\n]{0,200}\\sclean\\b)|find\\b[^;&|\\n]{0,2000}(?:\\\\\\n[^;&|\\n]{0,2000}){0,4}\\s-delete\\b)",
27
+ "action": "refuse",
28
+ "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 …`"
29
+ },
30
+ {
31
+ "pattern": "(?:^|[;&|\\n({\\`]|(?<=\\s)['\"])(?![^;&|\\n]{0,500}?(?:GIT_DIR=|--git-dir[=\\s]|\\s-C\\s+)['\"]?(?:(?:/private)?/(?:tmp|var/folders)/|\\$\\{?TMPDIR(?::-[^}\\s]*)?\\}?['\"]?/)(?![^\\s;&|=(]*\\.\\./)(?=[^;&|\\n]{0,500}?\\srerere\\b))[^\\S\\n]*(?:(?:\\w+=(?:\"[^\"\\n]*\"|'[^'\\n]*'|\\$\\([^)\\n]*\\)|\\`[^\\`\\n]*\\`|[^\\s\"'$\\`]|\\$(?!\\()){0,512}|sudo(?:\\s+-\\w+)*|command|exec|nice(?:\\s+-\\w+(?:\\s+\\d+)?)?|!|if|while|until|elif|env(?:\\s+-\\w+)*|time(?:\\s+-\\w+)*|nohup|do|then|else|xargs(?:\\s+-\\w+)*|timeout(?:\\s+(?:-[sk]\\s+\\S+|--?[\\w-]+)){0,4}\\s+\\S+)\\s+){0,8}(?:(?:/[\\w.-]+)+/)?git\\b(?:(?:\\s+(?:\\\\\\n\\s*)*|(?:\\\\\\n\\s*)+)(?:-c(?:\\s+(?:\\\\\\n\\s*)*|(?:\\\\\\n\\s*)+)(?!-)(?:[^\\s\"'$\\`\\\\]|\\\\[^\\n]|\\$(?!\\()|\\$\\([^)\\n]*\\)|\\`[^\\`\\n]*\\`|\"[^\"]*\"|'[^']*'){1,4096}|-C(?:\\s+(?:\\\\\\n\\s*)*|(?:\\\\\\n\\s*)+)(?!-)(?:[^\\s\"'$\\`\\\\]|\\\\[^\\n]|\\$(?!\\()|\\$\\([^)\\n]*\\)|\\`[^\\`\\n]*\\`|\"[^\"]*\"|'[^']*'){1,4096}|--(?:git-dir|work-tree|namespace|exec-path)(?:\\s+(?:\\\\\\n\\s*)*|(?:\\\\\\n\\s*)+)(?!-)(?:[^\\s\"'$\\`\\\\]|\\\\[^\\n]|\\$(?!\\()|\\$\\([^)\\n]*\\)|\\`[^\\`\\n]*\\`|\"[^\"]*\"|'[^']*'){1,4096}|--[\\w-]+(?:=(?:[^\\s\"'$\\`\\\\]|\\\\[^\\n]|\\$(?!\\()|\\$\\([^)\\n]*\\)|\\`[^\\`\\n]*\\`|\"[^\"]*\"|'[^']*'){0,4096})?|-\\w+)){0,64}(?:\\s+(?:\\\\\\n\\s*)*|(?:\\\\\\n\\s*)+)rerere(?:(?:\\s+(?:\\\\\\n\\s*)*|(?:\\\\\\n\\s*)+)--?[\\w-]+){0,8}(?:\\s+(?:\\\\\\n\\s*)*|(?:\\\\\\n\\s*)+)(?:clear|forget|gc)\\b",
32
+ "action": "refuse",
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
+ },
25
35
  { "pattern": "(^|[;&|(\\s])(npm|pnpm|yarn|bun)\\s+(run\\s+)?test\\b", "action": "queue", "queue": "test" },
26
36
  { "pattern": "(^|[;&|(\\s])(npm|pnpm|yarn|bun)\\s+run\\s+build\\b", "action": "queue", "queue": "build" },
27
37
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nledford/omp-coord",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "files": [
5
5
  "src",
6
6
  "docs",
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * omp-coord command line: runs commands through the shared build and test queues, from `just` recipes, scripts,
4
- * terminals, or agents' bash calls (which the extension rewrites to go through here), and reports on the scope.
4
+ * terminals, or agents' bash calls (which the extension rewrites to go through here when a rule queues them), and
5
+ * reports on the scope.
5
6
  *
6
7
  * omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
7
8
  * omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
@@ -24,6 +25,7 @@ import { EXIT_TIMED_OUT, runJob } from "../../app/job-runner.ts";
24
25
  import { type Config, ConfigError, findConfigFile, loadConfig } from "../../config.ts";
25
26
  import { statusView } from "../../domain/overview.ts";
26
27
  import { MAX_PRIORITY } from "../../domain/queues.ts";
28
+ import { agentOfOmpSession } from "../../domain/sessions.ts";
27
29
  import { osLoad } from "../os/load.ts";
28
30
  import { groupRunning } from "../os/process-group.ts";
29
31
  import { BoardStore, stateDir } from "../sqlite/store.ts";
@@ -243,14 +245,16 @@ async function run(args: string[]): Promise<number> {
243
245
  mkdirSync(join(dir, "s"), { recursive: true, mode: 0o700 });
244
246
  const id = randomBytes(4).toString("hex");
245
247
  const address = join(dir, "s", `job-${id}.sock`);
248
+ // omp gives each bash call its agent's session id; the extension recorded which agent that is. A script outside
249
+ // omp (or one run before the extension bound the id) may name the agent itself.
250
+ const agent = agentOfOmpSession(store.read(), process.env.PI_SESSION_ID ?? "") || process.env.OMP_COORD_AGENT;
246
251
 
247
252
  return runJob(
248
253
  {
249
254
  id,
250
255
  queue,
251
256
  owner: values.owner || process.env.OMP_COORD_SESSION || null,
252
- // set per command by the extension's bash hook
253
- ...(process.env.OMP_COORD_AGENT ? { agent: process.env.OMP_COORD_AGENT } : {}),
257
+ ...(agent ? { agent } : {}),
254
258
  ...(values.lane === undefined ? {} : { lane: values.lane }),
255
259
  priority,
256
260
  ...(values.key === undefined ? {} : { key: values.key }),
@@ -19,6 +19,8 @@ export interface ExtensionContext {
19
19
  mode: "tui" | "rpc" | "json" | "print";
20
20
  hasUI: boolean;
21
21
  agent: AgentIdentity;
22
+ /** omp's id for this agent's own conversation: the `PI_SESSION_ID` its bash calls carry (read-only). */
23
+ sessionManager?: { getSessionId?(): string };
22
24
  ui: {
23
25
  notify(message: string, type?: "info" | "warning" | "error"): void;
24
26
  setStatus(key: string, text: string | undefined): void;
@@ -52,6 +54,8 @@ export type ToolCallResult = Block | { input: Record<string, unknown> };
52
54
  export interface Events {
53
55
  session_start: (event: unknown, ctx: ExtensionContext) => unknown;
54
56
  session_shutdown: (event: unknown, ctx: ExtensionContext) => unknown;
57
+ session_switch: (event: unknown, ctx: ExtensionContext) => unknown;
58
+ session_branch: (event: unknown, ctx: ExtensionContext) => unknown;
55
59
  agent_start: (event: unknown, ctx: ExtensionContext) => unknown;
56
60
  agent_end: (event: unknown, ctx: ExtensionContext) => unknown;
57
61
  before_subagent_spawn: (event: SpawnEvent, ctx: ExtensionContext) => Block | Promise<Block>;
@@ -17,7 +17,7 @@ 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, withAgent, withoutAgentExports } from "../../domain/commands.ts";
20
+ import { decideCommand } from "../../domain/commands.ts";
21
21
  import type { Claim } from "../../domain/model.ts";
22
22
  import { osLoad } from "../os/load.ts";
23
23
  import { BoardStore, stateDir } from "../sqlite/store.ts";
@@ -55,6 +55,8 @@ interface Runtime {
55
55
  listener: Listener;
56
56
  sessionId: string;
57
57
  label: string;
58
+ /** omp's id for the main conversation, as agents' bash calls carry it (`PI_SESSION_ID`). */
59
+ ompSessionId: string | undefined;
58
60
  /** The main session's binding and context: messages for the session go through them. */
59
61
  pi: ExtensionAPI;
60
62
  ctx: ExtensionContext;
@@ -98,14 +100,24 @@ export default function ompCoord(pi: ExtensionAPI): void {
98
100
  shared.bindings.set(ctx.agent.id, pi);
99
101
  return;
100
102
  }
101
- if (shared.runtime) {
102
- shared.runtime.pi = pi;
103
- shared.runtime.ctx = ctx;
103
+ const rt = shared.runtime;
104
+ if (rt) {
105
+ rt.pi = pi;
106
+ rt.ctx = ctx;
107
+ syncMainSession(rt, ctx);
104
108
  return;
105
109
  }
106
110
  await start(pi, ctx);
107
111
  });
108
112
 
113
+ // omp keeps one process across /new, /resume and /fork, giving the main conversation a new id without a new
114
+ // `session_start`: its bash calls carry that id from now on.
115
+ for (const event of ["session_switch", "session_branch"] as const)
116
+ pi.on(event, (_event, ctx) => {
117
+ const rt = shared.runtime;
118
+ if (rt && ctx.agent.kind === "main") syncMainSession(rt, ctx);
119
+ });
120
+
109
121
  pi.on("session_shutdown", (_event, ctx) => {
110
122
  if (ctx.agent.kind !== "main") {
111
123
  if (shared.bindings.get(ctx.agent.id) === pi) shared.bindings.delete(ctx.agent.id);
@@ -149,6 +161,7 @@ export default function ompCoord(pi: ExtensionAPI): void {
149
161
  const rt = shared.runtime;
150
162
  if (!rt || !isTrackedSubagent(ctx)) return;
151
163
  guarded(rt, "record an agent run", () => {
164
+ const ompSessionId = ompSessionIdOf(ctx);
152
165
  const parentId = ctx.agent.parentId ?? "Main";
153
166
  const count = rt.coordinator.agentStarted({
154
167
  sessionId: rt.sessionId,
@@ -157,6 +170,7 @@ export default function ompCoord(pi: ExtensionAPI): void {
157
170
  parentIsSubagent: parentId !== "Main",
158
171
  agentType: ctx.agent.name.toLowerCase(),
159
172
  evalWorker: rt.evalAgents.has(ctx.agent.id),
173
+ ...(ompSessionId === undefined ? {} : { ompSessionId }),
160
174
  });
161
175
  refreshQueue(rt);
162
176
  if (count && count.used > count.cap) {
@@ -183,6 +197,8 @@ export default function ompCoord(pi: ExtensionAPI): void {
183
197
  if (!rt || event.toolName !== "bash") return undefined;
184
198
  const command = event.input.command;
185
199
  if (typeof command !== "string") return undefined;
200
+ // the id must be on the board before the command can reach `omp-coord run`, whatever event announced a switch
201
+ if (ctx.agent.kind === "main") syncMainSession(rt, ctx);
186
202
 
187
203
  try {
188
204
  const cwd = typeof event.input.cwd === "string" ? resolve(ctx.cwd, event.input.cwd) : ctx.cwd;
@@ -205,20 +221,15 @@ export default function ompCoord(pi: ExtensionAPI): void {
205
221
  notify(rt, `omp-coord could not check claims: ${describe(error)}`, "error");
206
222
  }
207
223
 
208
- // a copied `export OMP_COORD_AGENT=…;` would otherwise end up inside a queued job's own command
209
- const own = rt.config.tagAgentCommands ? withoutAgentExports(command) : command;
210
- const decision = decideCommand(rt.config.commands, own, {
224
+ const decision = decideCommand(rt.config.commands, command, {
211
225
  ...RUNNER,
212
226
  owner: rt.sessionId,
213
227
  config: rt.config.source,
214
228
  });
215
229
  if (decision.kind === "refuse") return { block: true, reason: `omp-coord: ${decision.reason}` };
216
- const run = decision.kind === "queue" ? decision.command : own;
217
- // Every agent of a session shares the omp process's environment, so the agent's id can reach `omp-coord run` (and
218
- // the queue lines) only through the command itself; opt-in (`tagAgentCommands`), since the rewrite defeats omp's
219
- // bash.patterns rules and other extensions' rewrites. Off, the hook changes only queued commands, as before.
220
- if (rt.config.tagAgentCommands) return { input: { ...event.input, command: withAgent(ctx.agent.id, run) } };
221
- if (decision.kind === "queue") return { input: { ...event.input, command: run } };
230
+ // Only a queued command is rewritten. Which agent runs it needs no rewrite: omp gives every bash call its agent's
231
+ // session id (`PI_SESSION_ID`), and the board maps that id to the agent (`session_start`, `agent_start`).
232
+ if (decision.kind === "queue") return { input: { ...event.input, command: decision.command } };
222
233
  return undefined;
223
234
  });
224
235
 
@@ -476,9 +487,10 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
476
487
  ? "While this is the only session, spawns past the cap wait in omp's queue and start as slots free; with others, they are refused and this session holds a place in line."
477
488
  : `Spawns past the cap are refused: ${typeof handle === "string" ? handle : "omp gave the extension no settings scope"}.`;
478
489
  }
490
+ const ompSessionId = ompSessionIdOf(ctx);
479
491
  let joined: Pick<Runtime, "store" | "coordinator" | "listener" | "sessionId" | "label">;
480
492
  try {
481
- joined = openSession(config, ctx.cwd);
493
+ joined = openSession(config, ctx.cwd, ompSessionId);
482
494
  } catch (error) {
483
495
  shared.inactive = `omp-coord is off: ${describe(error)}`;
484
496
  ctx.ui.notify(shared.inactive, "error");
@@ -488,6 +500,7 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
488
500
  const rt: Runtime = {
489
501
  config,
490
502
  ...joined,
503
+ ompSessionId,
491
504
  pi,
492
505
  ctx,
493
506
  timer: undefined,
@@ -543,6 +556,7 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
543
556
  function openSession(
544
557
  config: Config,
545
558
  cwd: string,
559
+ ompSessionId: string | undefined,
546
560
  ): Pick<Runtime, "store" | "coordinator" | "listener" | "sessionId" | "label"> {
547
561
  const dir = stateDir();
548
562
  mkdirSync(join(dir, "s"), { recursive: true, mode: 0o700 });
@@ -552,7 +566,7 @@ function openSession(
552
566
  const coordinator = new Coordinator(config, store, unixLiveness, Date, osLoad);
553
567
  const sessionId = randomBytes(6).toString("hex");
554
568
  listener = startBeacon(join(dir, "s", `${sessionId}.sock`));
555
- const label = coordinator.join(identity(sessionId, cwd, listener));
569
+ const label = coordinator.join(identity(sessionId, cwd, listener, ompSessionId));
556
570
  return { store, coordinator, listener, sessionId, label };
557
571
  } catch (error) {
558
572
  listener?.stop();
@@ -561,8 +575,29 @@ function openSession(
561
575
  }
562
576
  }
563
577
 
564
- function identity(sessionId: string, cwd: string, listener: Listener) {
565
- return { id: sessionId, pid: process.pid, cwd, address: listener.address };
578
+ function identity(sessionId: string, cwd: string, listener: Listener, ompSessionId: string | undefined) {
579
+ return {
580
+ id: sessionId,
581
+ pid: process.pid,
582
+ cwd,
583
+ address: listener.address,
584
+ ...(ompSessionId === undefined ? {} : { ompSessionId }),
585
+ };
586
+ }
587
+
588
+ /** Records the main conversation's omp session id on the board when it is not the one already there. */
589
+ function syncMainSession(rt: Runtime, ctx: ExtensionContext): void {
590
+ const id = ompSessionIdOf(ctx);
591
+ if (id === undefined || id === rt.ompSessionId) return;
592
+ guarded(rt, "record the session id", () => {
593
+ rt.coordinator.bindOmpSession(rt.sessionId, id);
594
+ rt.ompSessionId = id;
595
+ });
596
+ }
597
+
598
+ /** omp's id for the conversation this context belongs to: what its bash calls carry as `PI_SESSION_ID`. */
599
+ function ompSessionIdOf(ctx: ExtensionContext): string | undefined {
600
+ return ctx.sessionManager?.getSessionId?.() || undefined;
566
601
  }
567
602
 
568
603
  /** `<scratch>/<session letter>[/<agent id>]`, created on first use; undefined when the scope has no scratch root. */
@@ -600,7 +635,9 @@ async function tick(rt: Runtime): Promise<void> {
600
635
  if (shared.runtime !== rt) return;
601
636
  rt.ticks++;
602
637
  const now = Date.now();
603
- const { label, inbox, holds, board } = rt.coordinator.tick(identity(rt.sessionId, rt.ctx.cwd, rt.listener));
638
+ const { label, inbox, holds, board } = rt.coordinator.tick(
639
+ identity(rt.sessionId, rt.ctx.cwd, rt.listener, rt.ompSessionId),
640
+ );
604
641
  rt.label = label;
605
642
  rt.queue?.apply(spawnAllowance(board, rt.config.agents, rt.sessionId, now));
606
643
 
@@ -37,7 +37,8 @@ CREATE TABLE sessions (
37
37
  started_at INTEGER NOT NULL,
38
38
  config_hash TEXT NOT NULL,
39
39
  ports_from INTEGER,
40
- ports_to INTEGER
40
+ ports_to INTEGER,
41
+ omp_session TEXT
41
42
  ) STRICT;
42
43
  CREATE TABLE slots (
43
44
  id INTEGER PRIMARY KEY,
@@ -48,7 +49,8 @@ CREATE TABLE slots (
48
49
  pool TEXT,
49
50
  state TEXT NOT NULL CHECK (state IN ('starting', 'running')),
50
51
  since INTEGER NOT NULL,
51
- unqueued TEXT CHECK (unqueued IN ('revived', 'eval'))
52
+ unqueued TEXT CHECK (unqueued IN ('revived', 'eval')),
53
+ omp_session TEXT
52
54
  ) STRICT;
53
55
  CREATE TABLE tickets (
54
56
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -258,6 +260,8 @@ export class BoardStore implements BoardRepository {
258
260
  if (!has("jobs", "agent")) this.#db.exec("ALTER TABLE jobs ADD COLUMN agent TEXT");
259
261
  if (!has("slots", "unqueued"))
260
262
  this.#db.exec("ALTER TABLE slots ADD COLUMN unqueued TEXT CHECK (unqueued IN ('revived', 'eval'))");
263
+ if (!has("sessions", "omp_session")) this.#db.exec("ALTER TABLE sessions ADD COLUMN omp_session TEXT");
264
+ if (!has("slots", "omp_session")) this.#db.exec("ALTER TABLE slots ADD COLUMN omp_session TEXT");
261
265
  }
262
266
 
263
267
  #maybePruneEvents(now: number): void {
@@ -283,6 +287,7 @@ export class BoardStore implements BoardRepository {
283
287
  config_hash: string;
284
288
  ports_from: number | null;
285
289
  ports_to: number | null;
290
+ omp_session: string | null;
286
291
  },
287
292
  []
288
293
  >("SELECT * FROM sessions")
@@ -296,6 +301,7 @@ export class BoardStore implements BoardRepository {
296
301
  startedAt: r.started_at,
297
302
  configHash: r.config_hash,
298
303
  ports: r.ports_from === null || r.ports_to === null ? undefined : { from: r.ports_from, to: r.ports_to },
304
+ ...(r.omp_session ? { ompSessionId: r.omp_session } : {}),
299
305
  })),
300
306
  slots: db
301
307
  .query<
@@ -308,6 +314,7 @@ export class BoardStore implements BoardRepository {
308
314
  state: "starting" | "running";
309
315
  since: number;
310
316
  unqueued: "revived" | "eval" | null;
317
+ omp_session: string | null;
311
318
  },
312
319
  []
313
320
  >("SELECT * FROM slots ORDER BY id")
@@ -321,6 +328,7 @@ export class BoardStore implements BoardRepository {
321
328
  state: r.state,
322
329
  since: r.since,
323
330
  ...(r.unqueued ? { unqueued: r.unqueued } : {}),
331
+ ...(r.omp_session ? { ompSessionId: r.omp_session } : {}),
324
332
  })),
325
333
  waitlist: db
326
334
  .query<{ session_id: string; pool: string; since: number; hold_until: number | null; notified: number }, []>(
@@ -452,7 +460,7 @@ export class BoardStore implements BoardRepository {
452
460
  }
453
461
 
454
462
  const session = db.query(
455
- "INSERT INTO sessions (id, label, pid, cwd, socket, started_at, config_hash, ports_from, ports_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
463
+ "INSERT INTO sessions (id, label, pid, cwd, socket, started_at, config_hash, ports_from, ports_to, omp_session) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
456
464
  );
457
465
  for (const s of board.sessions)
458
466
  session.run(
@@ -465,12 +473,23 @@ export class BoardStore implements BoardRepository {
465
473
  s.configHash,
466
474
  s.ports?.from ?? null,
467
475
  s.ports?.to ?? null,
476
+ s.ompSessionId ?? null,
468
477
  );
469
478
  const slot = db.query(
470
- "INSERT INTO slots (session_id, agent_id, parent_id, agent_type, pool, state, since, unqueued) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
479
+ "INSERT INTO slots (session_id, agent_id, parent_id, agent_type, pool, state, since, unqueued, omp_session) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
471
480
  );
472
481
  for (const s of board.slots)
473
- slot.run(s.sessionId, s.agentId ?? null, s.parentId, s.agentType, s.pool, s.state, s.since, s.unqueued ?? null);
482
+ slot.run(
483
+ s.sessionId,
484
+ s.agentId ?? null,
485
+ s.parentId,
486
+ s.agentType,
487
+ s.pool,
488
+ s.state,
489
+ s.since,
490
+ s.unqueued ?? null,
491
+ s.ompSessionId ?? null,
492
+ );
474
493
  const ticket = db.query(
475
494
  "INSERT INTO tickets (session_id, pool, since, hold_until, notified) VALUES (?, ?, ?, ?, ?)",
476
495
  );
@@ -31,7 +31,7 @@ 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
33
  import { dropJobs, type EnqueueResult, schedule, setJobPriority } from "../domain/queues.ts";
34
- import { registerSession, removeSession } from "../domain/sessions.ts";
34
+ import { bindOmpSession, registerSession, removeSession } from "../domain/sessions.ts";
35
35
  import type { BoardRepository, Clock, Liveness, LoadProbe } from "./ports.ts";
36
36
 
37
37
  export interface SessionIdentity {
@@ -40,6 +40,8 @@ export interface SessionIdentity {
40
40
  cwd: string;
41
41
  /** Liveness address this session answers on. */
42
42
  address: string;
43
+ /** omp's id for the main conversation, kept on the board so the runner can tell whose bash call it is. */
44
+ ompSessionId?: string;
43
45
  }
44
46
 
45
47
  export interface TickResult {
@@ -86,7 +88,15 @@ export class Coordinator {
86
88
  #register(b: Board, me: SessionIdentity, now: number): string {
87
89
  return registerSession(
88
90
  b,
89
- { id: me.id, pid: me.pid, cwd: me.cwd, socket: me.address, configHash: this.config.hash, now },
91
+ {
92
+ id: me.id,
93
+ pid: me.pid,
94
+ cwd: me.cwd,
95
+ socket: me.address,
96
+ configHash: this.config.hash,
97
+ ...(me.ompSessionId === undefined ? {} : { ompSessionId: me.ompSessionId }),
98
+ now,
99
+ },
90
100
  this.config.resources.ports,
91
101
  );
92
102
  }
@@ -152,6 +162,11 @@ export class Coordinator {
152
162
  return this.repo.transact(b => requestSpawn(b, this.config.agents, { ...req, now: this.clock.now() }));
153
163
  }
154
164
 
165
+ /** omp moved this session onto another main conversation (new, resumed or forked): its bash calls carry the new id. */
166
+ bindOmpSession(sessionId: string, ompSessionId: string): void {
167
+ this.repo.transact(b => bindOmpSession(b, sessionId, ompSessionId));
168
+ }
169
+
155
170
  agentStarted(a: {
156
171
  sessionId: string;
157
172
  agentId: string;
@@ -159,6 +174,7 @@ export class Coordinator {
159
174
  parentIsSubagent: boolean;
160
175
  agentType: string;
161
176
  evalWorker?: boolean;
177
+ ompSessionId?: string;
162
178
  }) {
163
179
  return this.repo.transact(b => agentStarted(b, this.config.agents, { ...a, now: this.clock.now() }));
164
180
  }
package/src/config.ts CHANGED
@@ -26,12 +26,6 @@ export interface Config {
26
26
  commands: CommandRule[];
27
27
  /** Build and test queues, machine-wide within the scope. */
28
28
  queues: Queues;
29
- /**
30
- * Start each agent bash command with `export OMP_COORD_AGENT=<agent id>;` so queued jobs record whose they are.
31
- * Off by default: the rewritten command no longer matches omp's `bash.patterns` rules and replaces other
32
- * extensions' rewrites of the same call.
33
- */
34
- tagAgentCommands: boolean;
35
29
  /** Port ranges and scratch folders handed to sessions and their agents. */
36
30
  resources: { ports: PortPolicy | undefined; scratch: string | undefined };
37
31
  /** Retention of the event log and the decision log. */
@@ -192,7 +186,6 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
192
186
  },
193
187
  commands,
194
188
  queues,
195
- tagAgentCommands: flag(root.tagAgentCommands ?? false, "tagAgentCommands", fail),
196
189
  resources: { ports, scratch },
197
190
  history: {
198
191
  maxAgeMs: count(history.retentionDays ?? 30, "history.retentionDays", fail) * 24 * 3_600_000,
@@ -154,6 +154,8 @@ export function agentStarted(
154
154
  * spawn queue, so its new slot is marked "eval", not "revived".
155
155
  */
156
156
  evalWorker?: boolean;
157
+ /** omp's id for the agent's own session, so its bash calls can be traced to it. */
158
+ ompSessionId?: string;
157
159
  now: number;
158
160
  },
159
161
  ): { pool: string; used: number; cap: number } | null {
@@ -184,6 +186,7 @@ export function agentStarted(
184
186
  if (slot) {
185
187
  slot.agentId = a.agentId;
186
188
  slot.state = "running";
189
+ if (a.ompSessionId !== undefined) slot.ompSessionId = a.ompSessionId;
187
190
  } else {
188
191
  slot = {
189
192
  sessionId: a.sessionId,
@@ -194,6 +197,7 @@ export function agentStarted(
194
197
  state: "running",
195
198
  since: a.now,
196
199
  unqueued: a.evalWorker ? "eval" : "revived",
200
+ ...(a.ompSessionId === undefined ? {} : { ompSessionId: a.ompSessionId }),
197
201
  };
198
202
  board.slots.push(slot);
199
203
  }
@@ -26,10 +26,22 @@ export interface Runner {
26
26
  config: string;
27
27
  }
28
28
 
29
+ /**
30
+ * Longest command a `refuse` rule is judged on. omp's `tool_call` check is synchronous here and fails closed after 30 s,
31
+ * and a regex that spends its whole backtracking budget answers "no match", which would let the command through. A
32
+ * longer command is refused instead, so the cost of every pattern is bounded by this length.
33
+ */
34
+ export const MAX_CHECKED_COMMAND = 32_768;
35
+
29
36
  export function decideCommand(rules: CommandRule[], command: string, runner: Runner): CommandDecision {
30
37
  // Already queued: a command that runs `omp-coord run`, or one this extension rewrote.
31
38
  if (/(^|[;&|(\s'"/])omp-coord'?\s+'?run\b/.test(command) || runner.argv.every(a => command.includes(a)))
32
39
  return { kind: "allow" };
40
+ if (command.length > MAX_CHECKED_COMMAND && rules.some(r => r.action === "refuse"))
41
+ return {
42
+ kind: "refuse",
43
+ 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
+ };
33
45
  const rule = rules.find(r => r.pattern.test(command));
34
46
  if (!rule) return { kind: "allow" };
35
47
  if (rule.action === "refuse") return { kind: "refuse", reason: rule.reason };
@@ -54,27 +66,3 @@ export function decideCommand(rules: CommandRule[], command: string, runner: Run
54
66
  function quote(word: string): string {
55
67
  return `'${word.replaceAll("'", `'\\''`)}'`;
56
68
  }
57
-
58
- /**
59
- * The command with the id of the agent running it exported first, so a queued job records whose it is
60
- * (`omp-coord run` reads `OMP_COORD_AGENT`). An export, not a `VAR=… cmd` prefix, so it reaches every command of a
61
- * compound line; each agent's next command sets it again.
62
- */
63
- export function withAgent(agentId: string, command: string): string {
64
- return `export OMP_COORD_AGENT=${quote(agentId)}; ${withoutAgentExports(command)}`;
65
- }
66
-
67
- /** The command without the `export OMP_COORD_AGENT=…;` prefixes it starts with, if any. */
68
- export function withoutAgentExports(command: string): string {
69
- return command.replace(LEADING_AGENT_EXPORTS, "");
70
- }
71
-
72
- /**
73
- * The `export OMP_COORD_AGENT=…;` prefixes already at the start of a command. The rewritten command is what the
74
- * conversation shows, so an agent that copies its earlier calls sends them back; without stripping, each call would
75
- * add one more. A value is single-quoted as `quote` writes it (`'\''` for a quote inside) or a bare word of
76
- * shell-safe characters only, so a hand-typed value holding `\;`, `|`, `>` or a substitution is never mistaken for
77
- * a prefix and nothing of the command itself is dropped. Only spaces and tabs may surround the assignment: in bash a
78
- * newline ends a command, so an `export` alone on its line is a command of its own, not part of a prefix.
79
- */
80
- const LEADING_AGENT_EXPORTS = /^(?:\s*export[ \t]+OMP_COORD_AGENT=(?:'(?:[^']|'\\'')*'|[\w.@%+,:/=-]+)[ \t]*;)+\s*/;
@@ -41,6 +41,11 @@ export interface SessionRecord {
41
41
  configHash: string;
42
42
  /** Port range handed to this session, when the scope hands out ports. */
43
43
  ports: PortRange | undefined;
44
+ /**
45
+ * omp's id for the main session's conversation (`PI_SESSION_ID` in its agents' bash calls). omp can change it
46
+ * within one process (`/new`, `/resume`, `/fork`), so the extension binds it again whenever it sees another.
47
+ */
48
+ ompSessionId?: string;
44
49
  }
45
50
 
46
51
  export interface PortRange {
@@ -102,6 +107,8 @@ export interface AgentSlot {
102
107
  * those outside the queue, as spawns of `invocationKind` "eval").
103
108
  */
104
109
  unqueued?: "revived" | "eval";
110
+ /** omp's id for the agent's own session (`PI_SESSION_ID` in its bash calls), once its run started. */
111
+ ompSessionId?: string;
105
112
  }
106
113
 
107
114
  /** A session's place in line for a pool, after a refused spawn. */
@@ -11,11 +11,23 @@ const LABELS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
11
11
  */
12
12
  export function registerSession(
13
13
  board: Board,
14
- s: { id: string; pid: number; cwd: string; socket: string; configHash: string; now: number },
14
+ s: {
15
+ id: string;
16
+ pid: number;
17
+ cwd: string;
18
+ socket: string;
19
+ configHash: string;
20
+ ompSessionId?: string;
21
+ now: number;
22
+ },
15
23
  ports?: Pick<PortPolicy, "from" | "to" | "perSession">,
16
24
  ): string {
17
25
  const existing = board.sessions.find(x => x.id === s.id);
18
- if (existing) return existing.label;
26
+ if (existing) {
27
+ // an older omp-coord's save writes every session without it
28
+ if (s.ompSessionId !== undefined) existing.ompSessionId = s.ompSessionId;
29
+ return existing.label;
30
+ }
19
31
  const taken = new Set(board.sessions.map(x => x.label));
20
32
  const label = [...LABELS].find(l => !taken.has(l));
21
33
  if (!label) throw new Error(`${LABELS.length} sessions are already registered`);
@@ -36,6 +48,7 @@ export function registerSession(
36
48
  startedAt: s.now,
37
49
  configHash: s.configHash,
38
50
  ports: block,
51
+ ...(s.ompSessionId === undefined ? {} : { ompSessionId: s.ompSessionId }),
39
52
  });
40
53
  emit(board, s.now, "session_joined", s.id, {
41
54
  pid: s.pid,
@@ -45,6 +58,22 @@ export function registerSession(
45
58
  return label;
46
59
  }
47
60
 
61
+ /** Records omp's id for the main session's conversation, replacing the one it had. */
62
+ export function bindOmpSession(board: Board, sessionId: string, ompSessionId: string): void {
63
+ const session = board.sessions.find(s => s.id === sessionId);
64
+ if (session) session.ompSessionId = ompSessionId;
65
+ }
66
+
67
+ /**
68
+ * The agent whose bash call carries this omp session id (`PI_SESSION_ID`): `Main` for a session's main conversation,
69
+ * a running subagent's id for its own. Undefined for an id the board doesn't know, such as an agent whose run ended.
70
+ */
71
+ export function agentOfOmpSession(board: Board, ompSessionId: string): string | undefined {
72
+ const slot = board.slots.find(s => s.ompSessionId === ompSessionId && s.agentId !== undefined);
73
+ if (slot) return slot.agentId;
74
+ return board.sessions.some(s => s.ompSessionId === ompSessionId) ? "Main" : undefined;
75
+ }
76
+
48
77
  /**
49
78
  * Removes a session that ended or died: its slots free, it leaves the line, messages to it are dropped, and its
50
79
  * claims, ports and fair-share history go.