@nledford/omp-coord 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  "name": "omp-coord",
8
8
  "source": "./",
9
9
  "description": "Shared subagent caps, build and test queues, claims, pause, a decision log and messages across omp sessions working in one project on one machine.",
10
- "version": "0.2.0",
10
+ "version": "0.2.2",
11
11
  "author": { "name": "Nathaniel Ledford" },
12
12
  "homepage": "https://github.com/nledford/omp-coord",
13
13
  "repository": "https://github.com/nledford/omp-coord",
package/CHANGELOG.md CHANGED
@@ -6,6 +6,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.2] - 2026-10-27
10
+
11
+ ### Added
12
+
13
+ - `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).
14
+ - 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`.
15
+
16
+ ## [0.2.1] - 2026-10-26
17
+
18
+ ### Added
19
+
20
+ - Releases are published to npmjs.com as `@nledford/omp-coord`, with provenance: `omp plugin install @nledford/omp-coord` needs no token or `.npmrc`.
21
+
22
+ ### Fixed
23
+
24
+ - On Linux, where `/proc` cannot be read (some hardened containers), a timed-out job now falls back to signalling its process group instead of failing.
25
+
9
26
  ## [0.2.0] - 2026-10-26
10
27
 
11
28
  ### Added
@@ -36,6 +53,8 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
36
53
  - An SQLite event log per scope, with retention.
37
54
  - Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
38
55
 
39
- [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.2.0...HEAD
56
+ [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.2.2...HEAD
57
+ [0.2.2]: https://github.com/nledford/omp-coord/compare/v0.2.1...v0.2.2
58
+ [0.2.1]: https://github.com/nledford/omp-coord/compare/v0.2.0...v0.2.1
40
59
  [0.2.0]: https://github.com/nledford/omp-coord/compare/v0.1.0...v0.2.0
41
60
  [0.1.0]: https://github.com/nledford/omp-coord/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # omp-coord
2
2
 
3
+ [![CI](https://github.com/nledford/omp-coord/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/nledford/omp-coord/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@nledford/omp-coord)](https://www.npmjs.com/package/@nledford/omp-coord)
5
+ [![omp](https://img.shields.io/badge/omp-%E2%89%A5%2018.12-blue)](https://github.com/can1357/oh-my-pi)
6
+ [![License: MIT](https://img.shields.io/github/license/nledford/omp-coord)](LICENSE)
7
+
3
8
  An [omp](https://github.com/can1357/oh-my-pi) plugin, plus a small command-line runner, that lets several omp sessions on one machine, working in the same project, share one set of agent caps and build/test queues, and talk to each other.
4
9
 
5
10
  - **Shared agent caps.** Subagents count against pools (for example 8 `work` and 2 `short` slots) shared by every session in the project. A spawn over the cap is refused through omp's spawn check, so the cap does not depend on agents remembering to take and free slots.
@@ -16,7 +21,7 @@ Each session gets a letter and shows `coord B · work 5/8 · short 1/2` in omp's
16
21
  ## Quick start
17
22
 
18
23
  ```sh
19
- omp plugin install "git+ssh://git@github.com/nledford/omp-coord.git"
24
+ omp plugin install @nledford/omp-coord
20
25
  ```
21
26
 
22
27
  Then put a `.omp-coord.json` in the project's top folder:
@@ -36,12 +41,12 @@ Every omp session started in that folder or below now shares 6 agent slots, and
36
41
 
37
42
  | | |
38
43
  |---|---|
39
- | [Install](docs/install.md) | Plugin install over SSH or the marketplace, updating, the `omp-coord` command. |
44
+ | [Install](docs/install.md) | From npm, GitHub, the marketplace or GitHub Packages; updating; the `omp-coord` command. |
40
45
  | [Configuration](docs/configuration.md) | Every `.omp-coord.json` field. |
41
46
  | [Agent slots](docs/agent-slots.md) | How caps, the line, holds and nested agents work. |
42
47
  | [Queues](docs/queues.md) | `omp-coord run`, order, lanes, time limits, agents' commands. |
43
48
  | [Working across sessions](docs/coordination.md) | Messages, claims, pause, the decision log, ports, crash recovery. |
44
- | [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. |
45
50
  | [Event log](docs/event-log.md) | The SQLite tables and events, with example queries. |
46
51
  | [Versioning](docs/versioning.md) | What the version promises; cutting a release. |
47
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`, `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
@@ -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/install.md CHANGED
@@ -2,43 +2,50 @@
2
2
 
3
3
  Requires omp 18.12 or later. No dependencies at runtime: the extension and the runner use the Bun built into omp.
4
4
 
5
- ## From GitHub Packages
5
+ ## From npm
6
+
7
+ ```sh
8
+ omp plugin install @nledford/omp-coord
9
+ omp plugin install @nledford/omp-coord@0.2.0 # a given release
10
+ ```
11
+
12
+ Releases are published to npmjs.com from the release workflow, with [provenance](https://docs.npmjs.com/generating-provenance-statements) that ties each package to the commit and workflow that built it (from 0.2.1 on). A machine set up for GitHub Packages (below) maps the `@nledford` scope to GitHub, so this command installs from there instead.
6
13
 
7
- Each release is published as the npm package `@nledford/omp-coord` to GitHub Packages. It is private like the repository, and GitHub Packages asks for a token even for public packages, so each machine needs one-time setup: a token that can read packages, and the `@nledford` scope pointed at GitHub's registry in `~/.npmrc`.
14
+ ## From GitHub
8
15
 
9
16
  ```sh
10
- gh auth refresh --scopes read:packages # once: let the GitHub CLI's token read packages
11
- cat >> ~/.npmrc <<EOF
12
- @nledford:registry=https://npm.pkg.github.com
13
- //npm.pkg.github.com/:_authToken=$(gh auth token)
14
- EOF
17
+ omp plugin install github:nledford/omp-coord
15
18
  ```
16
19
 
17
- Then install a release, or the newest one:
20
+ Or through omp's marketplace, which this repository also serves:
18
21
 
19
22
  ```sh
20
- omp plugin install @nledford/omp-coord@0.2.0
21
- omp plugin install @nledford/omp-coord
23
+ omp plugin marketplace add nledford/omp-coord
24
+ omp plugin install omp-coord@omp-coord
22
25
  ```
23
26
 
24
- ## From GitHub
27
+ ## From GitHub Packages
25
28
 
26
- Over SSH. The repository is private for now, and the `github:` shorthand and HTTPS URLs fail without credentials; `github:nledford/omp-coord` works once it is public:
29
+ Each release is also published to GitHub Packages. GitHub Packages asks for a token to install any npm package, public ones included, so each machine needs one-time setup: a token that can read packages, and the `@nledford` scope pointed at GitHub's registry in `~/.npmrc`.
27
30
 
28
31
  ```sh
29
- omp plugin install "git+ssh://git@github.com/nledford/omp-coord.git"
32
+ gh auth refresh --scopes read:packages # once: let the GitHub CLI's token read packages
33
+ cat >> ~/.npmrc <<EOF
34
+ @nledford:registry=https://npm.pkg.github.com
35
+ //npm.pkg.github.com/:_authToken=$(gh auth token)
36
+ EOF
30
37
  ```
31
38
 
32
- Or through omp's marketplace, which this repository also serves:
39
+ Then install a release, or the newest one:
33
40
 
34
41
  ```sh
35
- omp plugin marketplace add git@github.com:nledford/omp-coord.git
36
- omp plugin install omp-coord@omp-coord
42
+ omp plugin install @nledford/omp-coord@0.2.0
43
+ omp plugin install @nledford/omp-coord
37
44
  ```
38
45
 
39
46
  ## Updating
40
47
 
41
- `omp plugin upgrade @nledford/omp-coord` moves a package or git URL install to the newest release or commit; `omp plugin upgrade omp-coord@omp-coord` moves a marketplace install once a release has raised the catalog's version. Bun remembers the commit a git URL resolved to, so installing a git URL again can bring back an older commit; to pin a release over git, add its tag: `…/omp-coord.git#v0.2.0`. [CHANGELOG.md](../CHANGELOG.md) lists what each release changed.
48
+ `omp plugin upgrade @nledford/omp-coord` moves a package or git install to the newest release or commit; `omp plugin upgrade omp-coord@omp-coord` moves a marketplace install once a release has raised the catalog's version. Bun remembers the commit a git install resolved to, so installing from git again can bring back an older commit; to pin a release, add its tag: `github:nledford/omp-coord#v0.2.0`. [CHANGELOG.md](../CHANGELOG.md) lists what each release changed.
42
49
 
43
50
  ## From a checkout
44
51
 
@@ -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,9 +1,6 @@
1
1
  {
2
2
  "name": "@nledford/omp-coord",
3
- "version": "0.2.0",
4
- "publishConfig": {
5
- "registry": "https://npm.pkg.github.com"
6
- },
3
+ "version": "0.2.2",
7
4
  "files": [
8
5
  "src",
9
6
  "docs",
@@ -16,7 +13,7 @@
16
13
  "author": "Nathaniel Ledford",
17
14
  "repository": {
18
15
  "type": "git",
19
- "url": "https://github.com/nledford/omp-coord.git"
16
+ "url": "git+https://github.com/nledford/omp-coord.git"
20
17
  },
21
18
  "keywords": [
22
19
  "omp",
@@ -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.
@@ -14,7 +14,7 @@
14
14
  * 2 a usage or configuration error.
15
15
  */
16
16
  import { randomBytes } from "node:crypto";
17
- import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
17
+ import { mkdirSync, readdirSync, statSync } from "node:fs";
18
18
  import { constants, userInfo } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { parseArgs } from "node:util";
@@ -22,10 +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";
28
+ import { groupRunning } from "../os/process-group.ts";
27
29
  import { BoardStore, stateDir } from "../sqlite/store.ts";
28
- 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";
29
38
  import { startBeacon, unixLiveness } from "../unix/beacon.ts";
30
39
 
31
40
  const EXIT_USAGE = 2;
@@ -35,18 +44,49 @@ const DAY = 86_400_000;
35
44
 
36
45
  const USAGE = `usage:
37
46
  omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command…>
38
- 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…>
39
48
  omp-coord pause [reason…] | resume | doctor [--fix]`;
40
49
 
41
50
  class UsageError extends Error {}
42
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
+
43
83
  async function main(argv: string[]): Promise<number> {
44
84
  const [sub, ...rest] = argv;
45
85
  switch (sub) {
46
86
  case "run":
47
87
  return run(rest);
48
88
  case "status":
49
- return withScope(({ config, coordinator }) => print(formatStatus(coordinator.board(), config, "", Date.now())));
89
+ return status(rest);
50
90
  case "stats":
51
91
  return withScope(({ coordinator }) =>
52
92
  print(formatStats(coordinator.stats(wholeNumber(rest[0], 7, "days") * DAY))),
@@ -309,32 +349,6 @@ function signalNumber(signal: NodeJS.Signals): number {
309
349
  return constants.signals[signal] ?? 1;
310
350
  }
311
351
 
312
- /**
313
- * Whether process group `pgid` still has a member that has not exited. A signal to the group succeeds while it holds
314
- * only zombies, which is what an exited command's children become in a container whose first process never reaps
315
- * orphans; on Linux, /proc tells them apart. Elsewhere (macOS has no /proc; launchd reaps orphans) the signal decides.
316
- */
317
- function groupRunning(pgid: number): boolean {
318
- try {
319
- process.kill(-pgid, 0);
320
- } catch {
321
- return false;
322
- }
323
- if (process.platform !== "linux") return true;
324
- for (const entry of readdirSync("/proc")) {
325
- if (!/^\d+$/.test(entry)) continue;
326
- try {
327
- // /proc/<pid>/stat: "pid (comm) state ppid pgrp …"; comm may contain spaces and parentheses.
328
- const stat = readFileSync(`/proc/${entry}/stat`, "utf8");
329
- const [state, , pgrp] = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
330
- if (Number(pgrp) === pgid && state !== "Z") return true;
331
- } catch {
332
- // The process ended while being read.
333
- }
334
- }
335
- return false;
336
- }
337
-
338
352
  if (import.meta.main) {
339
353
  try {
340
354
  process.exitCode = await main(Bun.argv.slice(2));
@@ -20,7 +20,15 @@ import { decideCommand } 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
 
@@ -334,7 +342,7 @@ export default function ompCoord(pi: ExtensionAPI): void {
334
342
 
335
343
  pi.registerCommand("coord", {
336
344
  description:
337
- "Coordination with other omp sessions: `/coord` status; `send <letter|B/agent|all> <text>`; `pause [reason]`, `resume`; " +
345
+ "Coordination with other omp sessions: `/coord` status; `agents`; `send <letter|B/agent|all> <text>`; `pause [reason]`, `resume`; " +
338
346
  "`claim <branch|worktree|pr> <target> [note]`, `release <kind> <target>`; `note <text>`, `notes [n]`; `stats [days]`; `interrupted [hours]`",
339
347
  async handler(args, ctx) {
340
348
  const rt = shared.runtime;
@@ -349,6 +357,8 @@ export default function ompCoord(pi: ExtensionAPI): void {
349
357
  switch (sub) {
350
358
  case "":
351
359
  return ctx.ui.notify(statusText(rt), "info");
360
+ case "agents":
361
+ return ctx.ui.notify(formatAgents(rt.coordinator.board(), Date.now()), "info");
352
362
  case "send": {
353
363
  const m = /^(\S+)\s+([\s\S]+)$/.exec(tail);
354
364
  return say(
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Whether a process group still has a member that has not exited, for stopping a timed-out job's whole group.
3
+ *
4
+ * A signal to the group succeeds while it holds only zombies, which is what an exited command's children become in a
5
+ * container whose first process never reaps orphans. On Linux, /proc tells them apart. Elsewhere (macOS has no /proc;
6
+ * launchd reaps orphans), or when /proc cannot be read, the signal decides.
7
+ */
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+
10
+ /** The state and process group of a process, from the text of `/proc/<pid>/stat`. */
11
+ export function parseProcStat(stat: string): { state: string; pgrp: number } | undefined {
12
+ // "pid (comm) state ppid pgrp …": comm may contain spaces and parentheses, so fields start after the last ")".
13
+ const close = stat.lastIndexOf(")");
14
+ if (close === -1) return undefined;
15
+ const [state, , pgrp] = stat.slice(close + 2).split(" ");
16
+ const group = Number(pgrp);
17
+ return state && Number.isInteger(group) ? { state, pgrp: group } : undefined;
18
+ }
19
+
20
+ export function groupRunning(pgid: number): boolean {
21
+ try {
22
+ process.kill(-pgid, 0);
23
+ } catch {
24
+ return false;
25
+ }
26
+ if (process.platform !== "linux") return true;
27
+ let pids: string[];
28
+ try {
29
+ pids = readdirSync("/proc").filter(entry => /^\d+$/.test(entry));
30
+ } catch {
31
+ return true;
32
+ }
33
+ for (const pid of pids) {
34
+ let stat: string;
35
+ try {
36
+ stat = readFileSync(`/proc/${pid}/stat`, "utf8");
37
+ } catch {
38
+ continue; // ended while being read, or hidden
39
+ }
40
+ const p = parseProcStat(stat);
41
+ if (p?.pgrp === pgid && p.state !== "Z") return true;
42
+ }
43
+ return false;
44
+ }
@@ -6,6 +6,7 @@ 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
8
  import { type AgentPolicy, type Board, 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):",
@@ -0,0 +1,212 @@
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
+ state: "running" | "waiting";
55
+ /** When it joined the line (a superseding job keeps the place of the one it replaced). */
56
+ queuedAt: number;
57
+ /** When it started; null while waiting. */
58
+ startedAt: number | null;
59
+ /** Place in line, from 1, in the order jobs will start; null while running. */
60
+ position: number | null;
61
+ }
62
+
63
+ export interface StatusView {
64
+ /** When the view was built. */
65
+ generatedAt: number;
66
+ scope: { name: string; source: string };
67
+ /** `by` is the pausing session's letter; null from a terminal, or once that session ended (`byId` tells which). */
68
+ paused: { by: string | null; byId: string | null; reason: string; since: number } | null;
69
+ /** The load average the limits act on; null without a fresh reading. */
70
+ load: number | null;
71
+ sessions: {
72
+ letter: string;
73
+ id: string;
74
+ folder: string;
75
+ pid: number;
76
+ since: number;
77
+ /** Slots held per configured pool, and agents outside the caps. */
78
+ agents: { pools: Record<string, number>; outsideCaps: number };
79
+ ports: { from: number; to: number } | null;
80
+ /** Whether it read a different config file than this scope's. */
81
+ differentConfig: boolean;
82
+ }[];
83
+ pools: { name: string; used: number; cap: number; configuredCap: number }[];
84
+ /** Sessions waiting for a slot, per pool, first in line first. */
85
+ line: { pool: string; session: string | null; position: number; since: number; heldUntil: number | null }[];
86
+ agents: AgentView[];
87
+ claims: {
88
+ kind: string;
89
+ target: string;
90
+ session: string | null;
91
+ agent: string;
92
+ note: string;
93
+ since: number;
94
+ }[];
95
+ queues: {
96
+ name: string;
97
+ order: string;
98
+ /** Slots now: lowered under load, 0 while paused. */
99
+ slots: number;
100
+ configuredSlots: number;
101
+ running: JobView[];
102
+ waiting: JobView[];
103
+ }[];
104
+ /** The latest notes, oldest first. `session` is the letter when the note was written, kept after that session ends. */
105
+ notes: { id: number; at: number; session: string | null; author: string; text: string }[];
106
+ }
107
+
108
+ /** Every agent holding a slot, by session letter then start. */
109
+ export function agentList(board: Board): AgentView[] {
110
+ return board.slots
111
+ .map(s => ({
112
+ id: s.agentId ?? null,
113
+ type: s.agentType,
114
+ parent: s.parentId,
115
+ session: labelOf(board, s.sessionId) ?? null,
116
+ pool: s.pool,
117
+ state: s.state,
118
+ since: s.since,
119
+ }))
120
+ .sort(
121
+ (a, b) =>
122
+ (a.session ?? "").localeCompare(b.session ?? "") || a.since - b.since || (a.id ?? "").localeCompare(b.id ?? ""),
123
+ );
124
+ }
125
+
126
+ export function statusView(board: Board, config: ScopeConfig, now: number): StatusView {
127
+ const policy = config.agents;
128
+ const letter = (sessionId: string | null) => (sessionId === null ? null : (labelOf(board, sessionId) ?? null));
129
+ const pools = Object.keys(policy.pools);
130
+
131
+ const job = (j: Board["jobs"][number], position: number | null): JobView => ({
132
+ id: j.id,
133
+ session: letter(j.owner),
134
+ owner: j.owner,
135
+ lane: j.lane,
136
+ priority: j.priority,
137
+ key: j.key ?? null,
138
+ command: j.command,
139
+ state: position === null ? "running" : "waiting",
140
+ queuedAt: j.since,
141
+ startedAt: j.startedAt ?? null,
142
+ position,
143
+ });
144
+
145
+ const paused = board.pause;
146
+ return {
147
+ generatedAt: now,
148
+ scope: { name: config.scope, source: config.source },
149
+ paused: paused ? { by: letter(paused.by), byId: paused.by, reason: paused.reason, since: paused.since } : null,
150
+ load: currentLoad(board, now) ?? null,
151
+ sessions: [...board.sessions]
152
+ .sort((a, b) => a.label.localeCompare(b.label))
153
+ .map(s => {
154
+ const held = board.slots.filter(x => x.sessionId === s.id);
155
+ return {
156
+ letter: s.label,
157
+ id: s.id,
158
+ folder: s.cwd,
159
+ pid: s.pid,
160
+ since: s.startedAt,
161
+ agents: {
162
+ pools: Object.fromEntries(pools.map(p => [p, held.filter(x => x.pool === p).length])),
163
+ outsideCaps: held.filter(x => x.pool === null).length,
164
+ },
165
+ ports: s.ports ? { from: s.ports.from, to: s.ports.to } : null,
166
+ differentConfig: s.configHash !== config.hash,
167
+ };
168
+ }),
169
+ pools: pools.map(name => ({
170
+ name,
171
+ used: used(board, name),
172
+ cap: capOf(board, policy, name, now),
173
+ configuredCap: policy.pools[name]?.cap ?? 0,
174
+ })),
175
+ line: pools.flatMap(pool =>
176
+ board.waitlist
177
+ .filter(t => t.pool === pool)
178
+ .sort((a, b) => a.since - b.since)
179
+ .map((t, i) => ({
180
+ pool,
181
+ session: letter(t.sessionId),
182
+ position: i + 1,
183
+ since: t.since,
184
+ heldUntil: t.holdUntil ?? null,
185
+ })),
186
+ ),
187
+ agents: agentList(board),
188
+ claims: board.claims.map(c => ({
189
+ kind: c.kind,
190
+ target: c.target,
191
+ session: letter(c.sessionId),
192
+ agent: c.agentId,
193
+ note: c.note,
194
+ since: c.since,
195
+ })),
196
+ queues: Object.entries(config.queues).map(([name, q]) => ({
197
+ name,
198
+ order: q.order,
199
+ slots: slotsOf(board, q, now),
200
+ configuredSlots: q.slots,
201
+ running: board.jobs.filter(j => j.queue === name && j.state === "running").map(j => job(j, null)),
202
+ waiting: orderWaiting(q, name, board, now).map((j, i) => job(j, i + 1)),
203
+ })),
204
+ notes: board.notes.slice(-LATEST_NOTES).map(n => ({
205
+ id: n.id,
206
+ at: n.at,
207
+ session: n.label,
208
+ author: n.author,
209
+ text: n.text,
210
+ })),
211
+ };
212
+ }