@nledford/omp-coord 0.2.1 → 0.2.3

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.2.1",
10
+ "version": "0.2.3",
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,23 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.3] - 2026-10-27
10
+
11
+ ### Added
12
+
13
+ - `tagAgentCommands` (off by default): each queued job records the agent whose command queued it, and the queue lines in `/coord` and `omp-coord status` read `session A/WaveImport`; `status --json` has `agent` on each job and the `job_queued` event carries it. With it on, the extension starts every agent bash command with `export OMP_COORD_AGENT=<agent id>;`, which `omp-coord run` reads; leave it off if you use omp's `bash.patterns` rules or another extension that rewrites bash commands. A board made by an older omp-coord gains the new column when this version opens it; while any session of the scope still runs an older omp-coord, its saves drop the recorded agents (display only).
14
+
15
+ ### Changed
16
+
17
+ - A queue line shows the job's key (for example `rebase-2204 ci`) instead of its command when the job has one; the command still shows when it doesn't, and `status --json` keeps both.
18
+
19
+ ## [0.2.2] - 2026-10-27
20
+
21
+ ### Added
22
+
23
+ - `omp-coord status --json` prints the board as one JSON object for scripts (scope, sessions, pools, line, agents, claims, queues with their running and waiting jobs, latest notes, load; times in epoch milliseconds). With a board that cannot be read it prints nothing to stdout and exits non-zero. The fields are documented in [docs/commands.md](docs/commands.md).
24
+ - A list of the running agents with their type, parent, pool, state and age: `/coord agents`, `omp-coord status --agents`, and `agents` in `status --json`.
25
+
9
26
  ## [0.2.1] - 2026-10-26
10
27
 
11
28
  ### Added
@@ -46,7 +63,9 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
46
63
  - An SQLite event log per scope, with retention.
47
64
  - Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
48
65
 
49
- [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.2.1...HEAD
66
+ [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.2.3...HEAD
67
+ [0.2.3]: https://github.com/nledford/omp-coord/compare/v0.2.2...v0.2.3
68
+ [0.2.2]: https://github.com/nledford/omp-coord/compare/v0.2.1...v0.2.2
50
69
  [0.2.1]: https://github.com/nledford/omp-coord/compare/v0.2.0...v0.2.1
51
70
  [0.2.0]: https://github.com/nledford/omp-coord/compare/v0.1.0...v0.2.0
52
71
  [0.1.0]: https://github.com/nledford/omp-coord/releases/tag/v0.1.0
package/README.md CHANGED
@@ -46,7 +46,7 @@ Every omp session started in that folder or below now shares 6 agent slots, and
46
46
  | [Agent slots](docs/agent-slots.md) | How caps, the line, holds and nested agents work. |
47
47
  | [Queues](docs/queues.md) | `omp-coord run`, order, lanes, time limits, agents' commands. |
48
48
  | [Working across sessions](docs/coordination.md) | Messages, claims, pause, the decision log, ports, crash recovery. |
49
- | [Commands](docs/commands.md) | `/coord`, agent tools, CLI, environment variables. |
49
+ | [Commands](docs/commands.md) | `/coord`, agent tools, CLI (`status --json` for scripts), environment variables. |
50
50
  | [Event log](docs/event-log.md) | The SQLite tables and events, with example queries. |
51
51
  | [Versioning](docs/versioning.md) | What the version promises; cutting a release. |
52
52
 
package/docs/commands.md CHANGED
@@ -5,6 +5,7 @@
5
5
  | Command | Does |
6
6
  |---|---|
7
7
  | `/coord` | Sessions, slots, the line, claims, queues, the latest decisions; marks a paused scope and sessions reading a different config file. |
8
+ | `/coord agents` | The agents holding slots, one line each: session, id, type, parent (`Main` or an agent id), pool or `outside the caps`, state and age. Use it to see who runs, and what type each is, before messaging them. |
8
9
  | `/coord send <letter\|B/agent\|all> <text>` | Message other sessions or one of their subagents. |
9
10
  | `/coord pause [reason]`, `/coord resume` | Stop and restart new agents and queued jobs in every session. |
10
11
  | `/coord claim <branch\|worktree\|pr> <target> [note]`, `/coord release <kind> <target>` | Claim or release. |
@@ -26,12 +27,70 @@
26
27
 
27
28
  ```sh
28
29
  omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
29
- omp-coord status | stats [days] | interrupted [hours] | notes [count] | note <text…>
30
+ omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
30
31
  omp-coord pause [reason…] | resume | doctor [--fix]
31
32
  ```
32
33
 
33
34
  `run` is described under [queues](queues.md). The others act on the scope whose `.omp-coord.json` is found from the current folder.
34
35
 
36
+ ### `status`
37
+
38
+ `omp-coord status` prints the same overview as `/coord`. `--agents` prints only the running agents, as `/coord agents` does:
39
+
40
+ ```text
41
+ Agents (3):
42
+ A Fixer task parent Main pool work running 5 min
43
+ A Fixer.Check reviewer parent Fixer outside the caps running 4 min
44
+ B Other task parent Main pool work running 2 min
45
+ ```
46
+
47
+ Each line is the session letter, the agent's id (`(no id yet)` for an approved spawn omp gave no key for), its type, its parent, its pool, its state (`running`, or `starting` until its first run is seen) and how long ago it took its slot.
48
+
49
+ `--json` prints the board as one JSON object on stdout, for scripts. It is built whole before anything is written: if the board cannot be read, nothing goes to stdout, the error goes to stderr, and the exit code is not `0` (`2` for a usage or configuration error, `1` otherwise). `--json` and `--agents` do not combine.
50
+
51
+ Every time is milliseconds since the epoch (`Date.now()`); a session is its letter, or `null` when there is none (a job started outside omp, or by a session that has ended). Fields are never omitted: a missing value is `null`, a list is `[]`. Removing or renaming a field is a breaking change ([versioning](versioning.md)); new fields may appear.
52
+
53
+ | Field | Meaning |
54
+ |---|---|
55
+ | `generatedAt` | When the object was built. |
56
+ | `scope` | `{ name, source }`: the scope and the config file it was read from. |
57
+ | `paused` | `null`, or `{ by, byId, reason, since }`: `by` is the pausing session's letter, `byId` its id; both are `null` for a pause from a terminal, and only `by` once that session has ended. |
58
+ | `load` | The load average the limits act on, or `null` without a fresh reading. |
59
+ | `sessions[]` | `letter`, `id`, `folder`, `pid`, `since`, `agents` (`{ pools: { <pool>: slots held }, outsideCaps }`), `ports` (`{ from, to }` or `null`), `differentConfig` (read another config file than this scope's). By letter. |
60
+ | `pools[]` | `name`, `used`, `cap` (now: lowered under load, `0` while paused), `configuredCap`. |
61
+ | `line[]` | Sessions waiting for a slot: `pool`, `session`, `position` (from 1), `since`, `heldUntil` (`null` unless a freed slot is held for it). |
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
+ | `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). |
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
+
67
+ ```json
68
+ {
69
+ "generatedAt": 1793000000000,
70
+ "scope": { "name": "demo", "source": "/work/demo/.omp-coord.json" },
71
+ "paused": null,
72
+ "load": 3.5,
73
+ "sessions": [
74
+ { "letter": "A", "id": "7f3c…", "folder": "/work/demo", "pid": 4210, "since": 1792999000000,
75
+ "agents": { "pools": { "work": 1 }, "outsideCaps": 1 }, "ports": null, "differentConfig": false }
76
+ ],
77
+ "pools": [{ "name": "work", "used": 1, "cap": 2, "configuredCap": 2 }],
78
+ "line": [],
79
+ "agents": [
80
+ { "id": "Fixer", "type": "task", "parent": "Main", "session": "A", "pool": "work", "state": "running", "since": 1792999300000 },
81
+ { "id": "Fixer.Check", "type": "reviewer", "parent": "Fixer", "session": "A", "pool": null, "state": "running", "since": 1792999400000 }
82
+ ],
83
+ "claims": [],
84
+ "queues": [
85
+ { "name": "heavy", "order": "fifo", "slots": 1, "configuredSlots": 1,
86
+ "running": [{ "id": "a1b2c3d4", "session": "A", "owner": "7f3c…", "lane": "normal", "priority": 0, "key": "demo ci",
87
+ "command": "cargo test", "state": "running", "queuedAt": 1792999500000, "startedAt": 1792999500000, "position": null }],
88
+ "waiting": [] }
89
+ ],
90
+ "notes": []
91
+ }
92
+ ```
93
+
35
94
  Exit codes: `0` success, the command's own for `run`, `75` a job not run, `124` a job stopped by its time limit, `2` a usage or configuration error.
36
95
 
37
96
  ## Environment
@@ -43,4 +102,5 @@ Exit codes: `0` success, the command's own for `run`, `75` a job not run, `124`
43
102
  | `OMP_COORD_SESSION` | the extension | The session that owns jobs started from its agents' commands. |
44
103
  | `OMP_COORD_PORTS` | the extension | The session's port block, e.g. `18700-18799`, when `resources.ports` is set. |
45
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. |
46
106
  | `OMP_COORD_JOB`, `OMP_COORD_QUEUE` | `omp-coord run` | The running job's id and queue, for nested runs. |
@@ -21,6 +21,7 @@
21
21
  | `queues.<name>.onDuplicate` | `"refuse"` | `"refuse"` or `"supersede"` a waiting job with the same key. |
22
22
  | `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. |
23
23
  | `queues.<name>.loadSlots` | `[]` | `[{ "above": 150, "slots": 1 }]`: fewer slots under load; `0` holds the queue. |
24
+ | `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). |
24
25
  | `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. |
25
26
  | `resources.scratch` | none | Root of the scratch folders: `<scratch>/<letter>` per session, `<scratch>/<letter>/<agent id>` per agent. Relative to the config file. |
26
27
  | `history.retentionDays` | `30` | Events and decision-log notes older than this are deleted. |
@@ -38,4 +38,4 @@ With `resources.ports`, each session gets its own block of ports, in `OMP_COORD_
38
38
 
39
39
  `/coord interrupted` (or `omp-coord interrupted`, which first drops sessions whose process is gone) lists, per session that left, the agents that were mid-run and the folder the session ran in. omp keeps their transcripts; revive each from a session in that folder with `write agent://<id>`.
40
40
 
41
- `/coord stats` sums waits, refusals, peaks and job outcomes per day; `omp-coord doctor` checks the scope's health, and `doctor --fix` drops sessions and jobs whose process is gone.
41
+ `/coord agents` (or `omp-coord status --agents`) lists who runs: each agent's session, id, type, parent, pool and age, so a message to `B/<agent id>` goes to the agent you mean. `/coord stats` sums waits, refusals, peaks and job outcomes per day; `omp-coord doctor` checks the scope's health, and `doctor --fix` drops sessions and jobs whose process is gone.
package/docs/event-log.md CHANGED
@@ -14,7 +14,7 @@ These tables are rebuilt as sessions come and go; their layout is not part of th
14
14
  | `slots` | Agent slots held: `session_id`, `agent_id`, `parent_id`, `agent_type`, `pool` (null outside the caps), `state` (`starting` or `running`), `since`. |
15
15
  | `tickets` | The line: `session_id`, `pool`, `since`, `hold_until` (set while a slot is held for it), `notified`. |
16
16
  | `messages` | Messages not yet delivered. |
17
- | `jobs` | Queued and running build/test jobs: `id`, `queue`, `owner` (session id, or null outside omp), `lane`, `priority`, `key`, `command`, `pid`, `state` (`waiting`, `running`, `superseded`), `since`, `started_at`. |
17
+ | `jobs` | Queued and running build/test jobs: `id`, `queue`, `owner` (session id, or null outside omp), `lane`, `priority`, `key`, `command`, `agent` (null when not recorded), `pid`, `state` (`waiting`, `running`, `superseded`), `since`, `started_at`. |
18
18
  | `claims` | `kind` (`branch`, `worktree`, `pr`), `target`, `session_id`, `agent_id`, `note`, `since`. |
19
19
  | `pause` | One row while the scope is paused: `by_session` (null from a terminal), `reason`, `since`. |
20
20
  | `agent_ports` | Port blocks agents took: `session_id`, `agent_id`, `from_port`, `to_port`. |
@@ -25,7 +25,7 @@ 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`), `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`.
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
29
  - Control: `paused` (with `reason`), `resumed` (with `pausedMs`), `claimed`, `released`, `claim_refused` (with `holder`), `note_added` (note number and length).
30
30
 
31
31
  `notes` is the decision log: `id`, `at`, `label`, `author`, `text`.
package/docs/queues.md CHANGED
@@ -49,4 +49,6 @@ 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.
53
+
52
54
  A command matching a `refuse` rule is refused with the rule's reason.
@@ -8,6 +8,7 @@ The version covers:
8
8
 
9
9
  - the `.omp-coord.json` fields, their defaults and what they accept ([configuration](configuration.md));
10
10
  - the `omp-coord` subcommands, flags and exit codes (75, 124, 2) ([commands](commands.md));
11
+ - the fields of `omp-coord status --json` ([commands](commands.md#status));
11
12
  - the agent tools (`coord_*`) and their parameters, and the `/coord` commands;
12
13
  - the environment variables it sets or reads (`OMP_COORD_*`);
13
14
  - the event types and fields in the event log, and the decision log ([event log](event-log.md));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nledford/omp-coord",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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), and reports on the scope.
5
5
  *
6
6
  * omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
7
- * omp-coord status | stats [days] | interrupted [hours] | notes [count] | note <text…>
7
+ * omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
8
8
  * omp-coord pause [reason…] | resume | doctor [--fix]
9
9
  *
10
10
  * `run` waits for a slot, runs the command with the terminal's stdin/stdout/stderr, and exits with its exit code.
@@ -22,11 +22,19 @@ import { Coordinator } from "../../app/coordinator.ts";
22
22
  import { doctor } from "../../app/doctor.ts";
23
23
  import { EXIT_TIMED_OUT, runJob } from "../../app/job-runner.ts";
24
24
  import { type Config, ConfigError, findConfigFile, loadConfig } from "../../config.ts";
25
+ import { statusView } from "../../domain/overview.ts";
25
26
  import { MAX_PRIORITY } from "../../domain/queues.ts";
26
27
  import { osLoad } from "../os/load.ts";
27
28
  import { groupRunning } from "../os/process-group.ts";
28
29
  import { BoardStore, stateDir } from "../sqlite/store.ts";
29
- import { formatFindings, formatInterrupted, formatNotes, formatStats, formatStatus } from "../text/report.ts";
30
+ import {
31
+ formatAgents,
32
+ formatFindings,
33
+ formatInterrupted,
34
+ formatNotes,
35
+ formatStats,
36
+ formatStatus,
37
+ } from "../text/report.ts";
30
38
  import { startBeacon, unixLiveness } from "../unix/beacon.ts";
31
39
 
32
40
  const EXIT_USAGE = 2;
@@ -36,18 +44,49 @@ const DAY = 86_400_000;
36
44
 
37
45
  const USAGE = `usage:
38
46
  omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command…>
39
- omp-coord status | stats [days] | interrupted [hours] | notes [count] | note <text…>
47
+ omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
40
48
  omp-coord pause [reason…] | resume | doctor [--fix]`;
41
49
 
42
50
  class UsageError extends Error {}
43
51
 
52
+ /**
53
+ * `status` prints the board as text; `--agents` lists only the running agents; `--json` prints one JSON object. The
54
+ * output is built whole before anything is written, so a board that cannot be read leaves stdout empty.
55
+ */
56
+ function status(args: string[]): Promise<number> {
57
+ let flags: ReturnType<typeof parseStatusFlags>;
58
+ try {
59
+ flags = parseStatusFlags(args);
60
+ } catch (error) {
61
+ throw new UsageError(`omp-coord status: ${error instanceof Error ? error.message : String(error)}`);
62
+ }
63
+ const { agents, json } = flags.values;
64
+ if (flags.positionals.length > 0 || (agents && json))
65
+ throw new UsageError("omp-coord status: give no arguments, or one of --agents and --json");
66
+ return withScope(({ config, coordinator }) => {
67
+ const board = coordinator.board();
68
+ const now = Date.now();
69
+ if (json) return print(JSON.stringify(statusView(board, config, now), null, 2));
70
+ return print(agents ? formatAgents(board, now) : formatStatus(board, config, "", now));
71
+ });
72
+ }
73
+
74
+ function parseStatusFlags(args: string[]) {
75
+ return parseArgs({
76
+ args,
77
+ allowPositionals: true,
78
+ strict: true,
79
+ options: { agents: { type: "boolean" }, json: { type: "boolean" } },
80
+ });
81
+ }
82
+
44
83
  async function main(argv: string[]): Promise<number> {
45
84
  const [sub, ...rest] = argv;
46
85
  switch (sub) {
47
86
  case "run":
48
87
  return run(rest);
49
88
  case "status":
50
- return withScope(({ config, coordinator }) => print(formatStatus(coordinator.board(), config, "", Date.now())));
89
+ return status(rest);
51
90
  case "stats":
52
91
  return withScope(({ coordinator }) =>
53
92
  print(formatStats(coordinator.stats(wholeNumber(rest[0], 7, "days") * DAY))),
@@ -210,6 +249,8 @@ async function run(args: string[]): Promise<number> {
210
249
  id,
211
250
  queue,
212
251
  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 } : {}),
213
254
  ...(values.lane === undefined ? {} : { lane: values.lane }),
214
255
  priority,
215
256
  ...(values.key === undefined ? {} : { key: values.key }),
@@ -16,11 +16,19 @@ 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
- import { decideCommand } from "../../domain/commands.ts";
19
+ import { decideCommand, withAgent } from "../../domain/commands.ts";
20
20
  import type { Claim } from "../../domain/model.ts";
21
21
  import { osLoad } from "../os/load.ts";
22
22
  import { BoardStore, stateDir } from "../sqlite/store.ts";
23
- import { clock, formatFooter, formatInterrupted, formatNotes, formatStats, formatStatus } from "../text/report.ts";
23
+ import {
24
+ clock,
25
+ formatAgents,
26
+ formatFooter,
27
+ formatInterrupted,
28
+ formatNotes,
29
+ formatStats,
30
+ formatStatus,
31
+ } from "../text/report.ts";
24
32
  import { startBeacon, unixLiveness } from "../unix/beacon.ts";
25
33
  import type { ExtensionAPI, ExtensionContext, Timer, ToolResult } from "./api.ts";
26
34
 
@@ -182,7 +190,12 @@ export default function ompCoord(pi: ExtensionAPI): void {
182
190
  config: rt.config.source,
183
191
  });
184
192
  if (decision.kind === "refuse") return { block: true, reason: `omp-coord: ${decision.reason}` };
185
- if (decision.kind === "queue") return { input: { ...event.input, command: decision.command } };
193
+ const run = decision.kind === "queue" ? decision.command : command;
194
+ // Every agent of a session shares the omp process's environment, so the agent's id can reach `omp-coord run` (and
195
+ // the queue lines) only through the command itself; opt-in (`tagAgentCommands`), since the rewrite defeats omp's
196
+ // bash.patterns rules and other extensions' rewrites. Off, the hook changes only queued commands, as before.
197
+ if (rt.config.tagAgentCommands) return { input: { ...event.input, command: withAgent(ctx.agent.id, run) } };
198
+ if (decision.kind === "queue") return { input: { ...event.input, command: run } };
186
199
  return undefined;
187
200
  });
188
201
 
@@ -334,7 +347,7 @@ export default function ompCoord(pi: ExtensionAPI): void {
334
347
 
335
348
  pi.registerCommand("coord", {
336
349
  description:
337
- "Coordination with other omp sessions: `/coord` status; `send <letter|B/agent|all> <text>`; `pause [reason]`, `resume`; " +
350
+ "Coordination with other omp sessions: `/coord` status; `agents`; `send <letter|B/agent|all> <text>`; `pause [reason]`, `resume`; " +
338
351
  "`claim <branch|worktree|pr> <target> [note]`, `release <kind> <target>`; `note <text>`, `notes [n]`; `stats [days]`; `interrupted [hours]`",
339
352
  async handler(args, ctx) {
340
353
  const rt = shared.runtime;
@@ -349,6 +362,8 @@ export default function ompCoord(pi: ExtensionAPI): void {
349
362
  switch (sub) {
350
363
  case "":
351
364
  return ctx.ui.notify(statusText(rt), "info");
365
+ case "agents":
366
+ return ctx.ui.notify(formatAgents(rt.coordinator.board(), Date.now()), "info");
352
367
  case "send": {
353
368
  const m = /^(\S+)\s+([\s\S]+)$/.exec(tail);
354
369
  return say(
@@ -77,6 +77,7 @@ CREATE TABLE jobs (
77
77
  priority INTEGER NOT NULL,
78
78
  key TEXT,
79
79
  command TEXT NOT NULL,
80
+ agent TEXT,
80
81
  socket TEXT NOT NULL,
81
82
  pid INTEGER NOT NULL,
82
83
  state TEXT NOT NULL CHECK (state IN ('waiting', 'running', 'superseded')),
@@ -229,7 +230,10 @@ export class BoardStore implements BoardRepository {
229
230
 
230
231
  #migrate(): void {
231
232
  const version = this.#db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0;
232
- if (version === SCHEMA_VERSION) return;
233
+ if (version === SCHEMA_VERSION) {
234
+ this.#addColumns();
235
+ return;
236
+ }
233
237
  if (version !== 0) {
234
238
  throw new Error(
235
239
  `the board database has schema ${version}, but this omp-coord reads schema ${SCHEMA_VERSION}; update omp-coord`,
@@ -239,6 +243,20 @@ export class BoardStore implements BoardRepository {
239
243
  this.#db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
240
244
  }
241
245
 
246
+ /**
247
+ * Nullable columns added after schema 1 was released, without a version bump: a board made by an older omp-coord
248
+ * gets them here, and an older omp-coord still running (an omp session not yet restarted) keeps reading and
249
+ * writing the board, ignoring them (its saves write null there, which readers show as unknown).
250
+ */
251
+ #addColumns(): void {
252
+ const has = (table: string, column: string) =>
253
+ this.#db
254
+ .query<{ name: string }, []>(`PRAGMA table_info(${table})`)
255
+ .all()
256
+ .some(c => c.name === column);
257
+ if (!has("jobs", "agent")) this.#db.exec("ALTER TABLE jobs ADD COLUMN agent TEXT");
258
+ }
259
+
242
260
  #maybePruneEvents(now: number): void {
243
261
  const last = Number(
244
262
  this.#db.query<{ value: string }, []>("SELECT value FROM meta WHERE key = 'events_pruned_at'").get()?.value ?? 0,
@@ -337,6 +355,7 @@ export class BoardStore implements BoardRepository {
337
355
  priority: number;
338
356
  key: string | null;
339
357
  command: string;
358
+ agent: string | null;
340
359
  socket: string;
341
360
  pid: number;
342
361
  state: "waiting" | "running" | "superseded";
@@ -354,6 +373,7 @@ export class BoardStore implements BoardRepository {
354
373
  priority: r.priority,
355
374
  key: r.key ?? undefined,
356
375
  command: r.command,
376
+ agent: r.agent ?? undefined,
357
377
  socket: r.socket,
358
378
  pid: r.pid,
359
379
  state: r.state,
@@ -455,7 +475,7 @@ export class BoardStore implements BoardRepository {
455
475
  const sent = db.query("INSERT INTO sent (session_id, at) VALUES (?, ?)");
456
476
  for (const x of board.sent) sent.run(x.sessionId, x.at);
457
477
  const job = db.query(
458
- "INSERT INTO jobs (id, queue, owner, lane, priority, key, command, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
478
+ "INSERT INTO jobs (id, queue, owner, lane, priority, key, command, agent, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
459
479
  );
460
480
  for (const j of board.jobs) {
461
481
  job.run(
@@ -466,6 +486,7 @@ export class BoardStore implements BoardRepository {
466
486
  j.priority,
467
487
  j.key ?? null,
468
488
  j.command,
489
+ j.agent ?? null,
469
490
  j.socket,
470
491
  j.pid,
471
492
  j.state,
@@ -5,7 +5,8 @@ import { capOf, used } from "../../domain/agents.ts";
5
5
  import { pauseReason } from "../../domain/control.ts";
6
6
  import type { DayStats, InterruptedSession } from "../../domain/history.ts";
7
7
  import { currentLoad } from "../../domain/limits.ts";
8
- import { type AgentPolicy, type Board, labelOf, type Note } from "../../domain/model.ts";
8
+ import { type AgentPolicy, type Board, type Job, labelOf, type Note } from "../../domain/model.ts";
9
+ import { agentList, LATEST_NOTES } from "../../domain/overview.ts";
9
10
  import { orderWaiting, type Queues, slotsOf } from "../../domain/queues.ts";
10
11
 
11
12
  export function clock(ms: number): string {
@@ -14,6 +15,37 @@ export function clock(ms: number): string {
14
15
 
15
16
  const minutes = (ms: number) => `${Math.round(ms / 60_000)} min`;
16
17
 
18
+ /** `45 s`, `12 min`, `2 h 5 min`. */
19
+ function age(ms: number): string {
20
+ const seconds = Math.max(0, Math.round(ms / 1000));
21
+ if (seconds < 60) return `${seconds} s`;
22
+ const mins = Math.floor(seconds / 60);
23
+ return mins < 60 ? `${mins} min` : `${Math.floor(mins / 60)} h ${mins % 60} min`;
24
+ }
25
+
26
+ /**
27
+ * The agents holding slots, one line each: session letter, id, type, parent, pool, state and how long ago the slot was
28
+ * taken. A starting agent's id is the spawn key omp gave, or `(no id yet)`.
29
+ */
30
+ export function formatAgents(board: Board, now: number): string {
31
+ const agents = agentList(board);
32
+ if (agents.length === 0) return "No agents are running.";
33
+ const rows = agents.map(a => [
34
+ a.session ?? "?",
35
+ a.id ?? "(no id yet)",
36
+ a.type,
37
+ `parent ${a.parent}`,
38
+ a.pool === null ? "outside the caps" : `pool ${a.pool}`,
39
+ a.state,
40
+ age(now - a.since),
41
+ ]);
42
+ const widths = rows[0]?.map((_, c) => Math.max(...rows.map(r => (r[c] as string).length))) ?? [];
43
+ return [
44
+ `Agents (${agents.length}):`,
45
+ ...rows.map(r => ` ${r.map((cell, c) => (c === r.length - 1 ? cell : cell.padEnd(widths[c] ?? 0))).join(" ")}`),
46
+ ].join("\n");
47
+ }
48
+
17
49
  /** Everything on the board, from the view of session `me` ("" outside omp). */
18
50
  export function formatStatus(board: Board, config: Config, me: string, now: number): string {
19
51
  const policy = config.agents;
@@ -80,7 +112,7 @@ export function formatStatus(board: Board, config: Config, me: string, now: numb
80
112
  const queueLines = formatQueues(board, config.queues, now);
81
113
  if (queueLines.length) lines.push("Build and test queues:", ...queueLines);
82
114
 
83
- const recent = board.notes.slice(-3);
115
+ const recent = board.notes.slice(-LATEST_NOTES);
84
116
  if (recent.length)
85
117
  lines.push(
86
118
  "Latest decisions (all: notes):",
@@ -94,8 +126,13 @@ export function formatStatus(board: Board, config: Config, me: string, now: numb
94
126
  /** One block per queue: running jobs, then waiting jobs in the order they will start. */
95
127
  export function formatQueues(board: Board, queues: Queues, now: number): string[] {
96
128
  const lines: string[] = [];
97
- const who = (owner: string | null) =>
98
- owner === null ? "outside omp" : `session ${labelOf(board, owner) ?? "(ended)"}`;
129
+ // whose job: the session, and the agent when recorded ("session A/WaveImport")
130
+ const who = (j: Job) =>
131
+ (j.owner === null ? "outside omp" : `session ${labelOf(board, j.owner) ?? "(ended)"}`) +
132
+ (j.agent ? `/${j.agent}` : "");
133
+ // what it is: the job's key ("<checkout> <recipe> <args>") reads better than the command's full path; the command
134
+ // when there is no key
135
+ const what = (j: Job) => j.key || j.command;
99
136
  for (const [name, q] of Object.entries(queues)) {
100
137
  const running = board.jobs.filter(j => j.queue === name && j.state === "running");
101
138
  const waiting = orderWaiting(q, name, board, now);
@@ -103,14 +140,14 @@ export function formatQueues(board: Board, queues: Queues, now: number): string[
103
140
  const lowered = slots < q.slots && !board.pause ? ` (lowered from ${q.slots} under load)` : "";
104
141
  lines.push(` ${name} (${q.order}): ${running.length}/${slots} running${lowered}, ${waiting.length} waiting`);
105
142
  for (const j of running)
106
- lines.push(` running ${j.id} ${who(j.owner)} ${minutes(now - (j.startedAt ?? now))} ${j.command}`);
143
+ lines.push(` running ${j.id} ${who(j)} ${minutes(now - (j.startedAt ?? now))} ${what(j)}`);
107
144
  for (const [i, j] of waiting.entries()) {
108
145
  const extras = [
109
146
  j.lane === q.lanes.at(-1) ? "" : `lane ${j.lane}`,
110
147
  j.priority ? `priority ${j.priority}` : "",
111
148
  ].filter(Boolean);
112
149
  lines.push(
113
- ` #${i + 1} ${j.id} ${who(j.owner)} waited ${minutes(now - j.since)}${extras.length ? ` (${extras.join(", ")})` : ""} ${j.command}`,
150
+ ` #${i + 1} ${j.id} ${who(j)} waited ${minutes(now - j.since)}${extras.length ? ` (${extras.join(", ")})` : ""} ${what(j)}`,
114
151
  );
115
152
  }
116
153
  }
@@ -17,6 +17,8 @@ export interface JobSpec {
17
17
  id: string;
18
18
  queue: string;
19
19
  owner: string | null;
20
+ /** omp's id of the agent whose command this is, for display. */
21
+ agent?: string;
20
22
  lane?: string;
21
23
  priority: number;
22
24
  key?: string;
package/src/config.ts CHANGED
@@ -26,6 +26,12 @@ 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;
29
35
  /** Port ranges and scratch folders handed to sessions and their agents. */
30
36
  resources: { ports: PortPolicy | undefined; scratch: string | undefined };
31
37
  /** Retention of the event log and the decision log. */
@@ -185,6 +191,7 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
185
191
  },
186
192
  commands,
187
193
  queues,
194
+ tagAgentCommands: flag(root.tagAgentCommands ?? false, "tagAgentCommands", fail),
188
195
  resources: { ports, scratch },
189
196
  history: {
190
197
  maxAgeMs: count(history.retentionDays ?? 30, "history.retentionDays", fail) * 24 * 3_600_000,
@@ -205,6 +212,11 @@ function list(v: unknown, field: string, fail: Fail): unknown[] {
205
212
  return v;
206
213
  }
207
214
 
215
+ function flag(v: unknown, field: string, fail: Fail): boolean {
216
+ if (typeof v !== "boolean") fail(field, "must be true or false");
217
+ return v as boolean;
218
+ }
219
+
208
220
  function str(v: unknown, field: string, fail: Fail): string {
209
221
  if (typeof v !== "string" || v.trim() === "") return fail(field, "must be a non-empty string");
210
222
  return v;
@@ -54,3 +54,12 @@ export function decideCommand(rules: CommandRule[], command: string, runner: Run
54
54
  function quote(word: string): string {
55
55
  return `'${word.replaceAll("'", `'\\''`)}'`;
56
56
  }
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)}; ${command}`;
65
+ }
@@ -158,6 +158,9 @@ export interface Job {
158
158
  queue: string;
159
159
  /** The omp session whose agent started it, or null (a terminal, a script). Fair share is per owner. */
160
160
  owner: string | null;
161
+ /** omp's id of the agent whose command queued it ("Main" for the main agent), or undefined (outside omp, or an
162
+ * older omp-coord that did not record it). For display only. */
163
+ agent: string | undefined;
161
164
  lane: string;
162
165
  priority: number;
163
166
  /** Jobs with the same key are duplicates (e.g. "<checkout> ci"). */
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The board as plain data for readers: `omp-coord status --json` prints a `StatusView`, and the text reports format
3
+ * its `agents`. Pure: callers pass `now` and the policies. Every time is milliseconds since the epoch; a session is
4
+ * named by its letter, or null when there is none (a job started outside omp, or by a session that has ended).
5
+ *
6
+ * The field names are part of the contract (docs/commands.md, docs/versioning.md): rename or remove one only as a
7
+ * breaking change.
8
+ */
9
+ import { capOf, used } from "./agents.ts";
10
+ import { currentLoad } from "./limits.ts";
11
+ import { type AgentPolicy, type Board, labelOf } from "./model.ts";
12
+ import { orderWaiting, type Queues, slotsOf } from "./queues.ts";
13
+
14
+ /** What the view needs of the scope's configuration. `Config` satisfies it. */
15
+ export interface ScopeConfig {
16
+ scope: string;
17
+ /** The config file the scope was read from. */
18
+ source: string;
19
+ /** Hash of that file; sessions that read another one are flagged. */
20
+ hash: string;
21
+ agents: AgentPolicy;
22
+ queues: Queues;
23
+ }
24
+
25
+ /** How many of the latest notes the view carries. */
26
+ export const LATEST_NOTES = 3;
27
+
28
+ export interface AgentView {
29
+ /** omp's agent id; null for an approved spawn whose first run has not started and that omp gave no key for. */
30
+ id: string | null;
31
+ type: string;
32
+ /** `Main` or the id of the subagent that started it. */
33
+ parent: string;
34
+ /** Letter of the session it runs in. */
35
+ session: string | null;
36
+ /** The pool holding its slot; null outside the caps (exempt types and nested reviewers). */
37
+ pool: string | null;
38
+ /** `starting`: approved, first run not seen yet. */
39
+ state: "starting" | "running";
40
+ /** When it took its slot. */
41
+ since: number;
42
+ }
43
+
44
+ export interface JobView {
45
+ id: string;
46
+ /** Letter of the session whose agent started it; null outside omp or once that session ended. */
47
+ session: string | null;
48
+ /** The owning session's id; null outside omp. */
49
+ owner: string | null;
50
+ lane: string;
51
+ priority: number;
52
+ key: string | null;
53
+ command: string;
54
+ /** omp's id of the agent whose command queued it ("Main" for the main agent); null outside omp or when not recorded. */
55
+ agent: string | null;
56
+ state: "running" | "waiting";
57
+ /** When it joined the line (a superseding job keeps the place of the one it replaced). */
58
+ queuedAt: number;
59
+ /** When it started; null while waiting. */
60
+ startedAt: number | null;
61
+ /** Place in line, from 1, in the order jobs will start; null while running. */
62
+ position: number | null;
63
+ }
64
+
65
+ export interface StatusView {
66
+ /** When the view was built. */
67
+ generatedAt: number;
68
+ scope: { name: string; source: string };
69
+ /** `by` is the pausing session's letter; null from a terminal, or once that session ended (`byId` tells which). */
70
+ paused: { by: string | null; byId: string | null; reason: string; since: number } | null;
71
+ /** The load average the limits act on; null without a fresh reading. */
72
+ load: number | null;
73
+ sessions: {
74
+ letter: string;
75
+ id: string;
76
+ folder: string;
77
+ pid: number;
78
+ since: number;
79
+ /** Slots held per configured pool, and agents outside the caps. */
80
+ agents: { pools: Record<string, number>; outsideCaps: number };
81
+ ports: { from: number; to: number } | null;
82
+ /** Whether it read a different config file than this scope's. */
83
+ differentConfig: boolean;
84
+ }[];
85
+ pools: { name: string; used: number; cap: number; configuredCap: number }[];
86
+ /** Sessions waiting for a slot, per pool, first in line first. */
87
+ line: { pool: string; session: string | null; position: number; since: number; heldUntil: number | null }[];
88
+ agents: AgentView[];
89
+ claims: {
90
+ kind: string;
91
+ target: string;
92
+ session: string | null;
93
+ agent: string;
94
+ note: string;
95
+ since: number;
96
+ }[];
97
+ queues: {
98
+ name: string;
99
+ order: string;
100
+ /** Slots now: lowered under load, 0 while paused. */
101
+ slots: number;
102
+ configuredSlots: number;
103
+ running: JobView[];
104
+ waiting: JobView[];
105
+ }[];
106
+ /** The latest notes, oldest first. `session` is the letter when the note was written, kept after that session ends. */
107
+ notes: { id: number; at: number; session: string | null; author: string; text: string }[];
108
+ }
109
+
110
+ /** Every agent holding a slot, by session letter then start. */
111
+ export function agentList(board: Board): AgentView[] {
112
+ return board.slots
113
+ .map(s => ({
114
+ id: s.agentId ?? null,
115
+ type: s.agentType,
116
+ parent: s.parentId,
117
+ session: labelOf(board, s.sessionId) ?? null,
118
+ pool: s.pool,
119
+ state: s.state,
120
+ since: s.since,
121
+ }))
122
+ .sort(
123
+ (a, b) =>
124
+ (a.session ?? "").localeCompare(b.session ?? "") || a.since - b.since || (a.id ?? "").localeCompare(b.id ?? ""),
125
+ );
126
+ }
127
+
128
+ export function statusView(board: Board, config: ScopeConfig, now: number): StatusView {
129
+ const policy = config.agents;
130
+ const letter = (sessionId: string | null) => (sessionId === null ? null : (labelOf(board, sessionId) ?? null));
131
+ const pools = Object.keys(policy.pools);
132
+
133
+ const job = (j: Board["jobs"][number], position: number | null): JobView => ({
134
+ id: j.id,
135
+ session: letter(j.owner),
136
+ owner: j.owner,
137
+ lane: j.lane,
138
+ priority: j.priority,
139
+ key: j.key ?? null,
140
+ command: j.command,
141
+ agent: j.agent ?? null,
142
+ state: position === null ? "running" : "waiting",
143
+ queuedAt: j.since,
144
+ startedAt: j.startedAt ?? null,
145
+ position,
146
+ });
147
+
148
+ const paused = board.pause;
149
+ return {
150
+ generatedAt: now,
151
+ scope: { name: config.scope, source: config.source },
152
+ paused: paused ? { by: letter(paused.by), byId: paused.by, reason: paused.reason, since: paused.since } : null,
153
+ load: currentLoad(board, now) ?? null,
154
+ sessions: [...board.sessions]
155
+ .sort((a, b) => a.label.localeCompare(b.label))
156
+ .map(s => {
157
+ const held = board.slots.filter(x => x.sessionId === s.id);
158
+ return {
159
+ letter: s.label,
160
+ id: s.id,
161
+ folder: s.cwd,
162
+ pid: s.pid,
163
+ since: s.startedAt,
164
+ agents: {
165
+ pools: Object.fromEntries(pools.map(p => [p, held.filter(x => x.pool === p).length])),
166
+ outsideCaps: held.filter(x => x.pool === null).length,
167
+ },
168
+ ports: s.ports ? { from: s.ports.from, to: s.ports.to } : null,
169
+ differentConfig: s.configHash !== config.hash,
170
+ };
171
+ }),
172
+ pools: pools.map(name => ({
173
+ name,
174
+ used: used(board, name),
175
+ cap: capOf(board, policy, name, now),
176
+ configuredCap: policy.pools[name]?.cap ?? 0,
177
+ })),
178
+ line: pools.flatMap(pool =>
179
+ board.waitlist
180
+ .filter(t => t.pool === pool)
181
+ .sort((a, b) => a.since - b.since)
182
+ .map((t, i) => ({
183
+ pool,
184
+ session: letter(t.sessionId),
185
+ position: i + 1,
186
+ since: t.since,
187
+ heldUntil: t.holdUntil ?? null,
188
+ })),
189
+ ),
190
+ agents: agentList(board),
191
+ claims: board.claims.map(c => ({
192
+ kind: c.kind,
193
+ target: c.target,
194
+ session: letter(c.sessionId),
195
+ agent: c.agentId,
196
+ note: c.note,
197
+ since: c.since,
198
+ })),
199
+ queues: Object.entries(config.queues).map(([name, q]) => ({
200
+ name,
201
+ order: q.order,
202
+ slots: slotsOf(board, q, now),
203
+ configuredSlots: q.slots,
204
+ running: board.jobs.filter(j => j.queue === name && j.state === "running").map(j => job(j, null)),
205
+ waiting: orderWaiting(q, name, board, now).map((j, i) => job(j, i + 1)),
206
+ })),
207
+ notes: board.notes.slice(-LATEST_NOTES).map(n => ({
208
+ id: n.id,
209
+ at: n.at,
210
+ session: n.label,
211
+ author: n.author,
212
+ text: n.text,
213
+ })),
214
+ };
215
+ }
@@ -43,6 +43,7 @@ export interface JobRequest {
43
43
  id: string;
44
44
  queue: string;
45
45
  owner: string | null;
46
+ agent?: string;
46
47
  lane?: string;
47
48
  priority: number;
48
49
  key?: string;
@@ -81,6 +82,7 @@ export function enqueueJob(board: Board, queues: Queues, req: JobRequest): Enque
81
82
  id: req.id,
82
83
  queue: req.queue,
83
84
  owner: req.owner,
85
+ agent: req.agent,
84
86
  lane,
85
87
  priority: req.priority,
86
88
  key: req.key,
@@ -97,6 +99,7 @@ export function enqueueJob(board: Board, queues: Queues, req: JobRequest): Enque
97
99
  lane,
98
100
  priority: req.priority,
99
101
  ...(req.key === undefined ? {} : { key: req.key }),
102
+ ...(req.agent === undefined ? {} : { agent: req.agent }),
100
103
  });
101
104
  schedule(board, queues, req.now);
102
105
  return { ok: true };