@nledford/omp-coord 0.2.3 → 0.3.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.2.3",
10
+ "version": "0.3.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,19 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-27
10
+
11
+ ### Changed
12
+
13
+ - **Breaking:** while a session is the only one in its scope, spawns past the cap now wait their turn instead of being refused (`agents.queueSpawns`, on by default): the extension keeps omp's per-session spawn queue (`task.maxConcurrency`) at what the caps allow the session, so you can start as many agents as you need and those past the cap start by themselves as slots free. With other sessions in the scope, or while paused, spawns over the cap are refused and the session joins the line, as before. A `task.maxConcurrency` you set stays the ceiling; the override is never saved and is cleared when the session ends. Set `agents.queueSpawns: false` to keep refusals always. See [agent slots](docs/agent-slots.md#spawns-wait-their-turn).
14
+ - `coord_status` and `/coord` say whether this session's spawns wait in omp's queue right now, and if not, why (another session, paused, or every slot taken by agents outside the queue).
15
+
16
+ ### Fixed
17
+
18
+ - With `tagAgentCommands` on, a command that already starts with `export OMP_COORD_AGENT=…;` (an agent repeating an earlier, rewritten call it saw in the conversation) gets exactly one prefix for the agent running it, instead of one more each time.
19
+ - A release whose tag push GitHub delivers twice, or a release run again, no longer fails: release runs for one tag wait for each other, npm's current "already exists" (409) answer counts as already published once npm lists that version, and a GitHub release that exists already is left as it is.
20
+ - With `tagAgentCommands` on, a copied prefix no longer ends up inside a queued job's own command.
21
+
9
22
  ## [0.2.3] - 2026-10-27
10
23
 
11
24
  ### Added
@@ -63,7 +76,8 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
63
76
  - An SQLite event log per scope, with retention.
64
77
  - Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
65
78
 
66
- [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.2.3...HEAD
79
+ [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.3.0...HEAD
80
+ [0.3.0]: https://github.com/nledford/omp-coord/compare/v0.2.3...v0.3.0
67
81
  [0.2.3]: https://github.com/nledford/omp-coord/compare/v0.2.2...v0.2.3
68
82
  [0.2.2]: https://github.com/nledford/omp-coord/compare/v0.2.1...v0.2.2
69
83
  [0.2.1]: https://github.com/nledford/omp-coord/compare/v0.2.0...v0.2.1
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
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.
9
9
 
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.
10
+ - **Shared agent caps.** Subagents count against pools (for example 8 `work` and 2 `short` slots) shared by every session in the project. While a session is alone, spawns past the cap wait in omp's own queue and start by themselves as slots free; with other sessions, a spawn over the cap is refused through omp's spawn check and the session joins a fair line. The cap does not depend on agents remembering to take and free slots.
11
11
  - **A fair line.** A refused session joins the line; the next free slot is held for it, and it is told so.
12
12
  - **Subagents start only reviewers.** "No nested worker agents" is enforced rather than asked for.
13
13
  - **Build and test queues.** `omp-coord run <queue> -- <command>` waits for one of the queue's slots, then runs the command, within an optional time limit. Agents' matching bash commands are rewritten to go through it.
@@ -13,9 +13,22 @@ Subagents count against pools of slots (for example 8 `work` and 2 `short`) shar
13
13
  | An approved spawn never starts | Its slot frees after `agents.startTimeoutSeconds`. |
14
14
  | A session exits or its process dies | All its slots and its place in line go, within about 10 seconds. |
15
15
 
16
+ ## Spawns wait their turn
17
+
18
+ With `agents.queueSpawns` (on by default), while a session is the only one in its scope, the extension keeps omp's own per-session spawn queue, the `task.maxConcurrency` setting, at the number of the session's spawns the caps let run now: its top-level agents already running or starting, the default pool's free slots, and one held for it there. Start as many agents as you need: omp starts the ones that fit and keeps the rest waiting in its queue, each starting as one of the session's agents ends. A value you set yourself for `task.maxConcurrency` stays the ceiling (it is read when the session starts). The value is a runtime override, never saved, and it is cleared when the session ends.
19
+
20
+ When a second session joins the scope, or the scope is paused, the override is cleared and spawns over the cap are refused as described under The line below. omp re-reads its queue size only when the session asks for a spawn or one of its runs ends, and a spawn waiting in omp's queue never reaches the spawn check, so it would never join the line: across sessions, only refusals keep the line fair.
21
+
22
+ Limits:
23
+
24
+ - omp's queue holds every agent the main session starts, so a reviewer started by the main session waits its turn too, and an agent of another pool (for example `short`) starts only when a default-pool slot is free as well. Subagents read the same setting, so a worker's own reviewers queue under the same number; with `agents.nested.maxRunningPerParent` at 1 that never delays them. An eval workpool sizes itself from the same setting while its own workers take slots, so while a workpool's workers run the override is cleared and the spawn check decides.
25
+ - Agents started outside omp's queue (revived with `write agent://…`, eval `agent()` and workpool workers) take slots the queue doesn't count. When one of them ends, waiting spawns start at the session's next spawn or run end, not at once. If they fill every slot, the override is cleared and spawns over the cap are refused.
26
+ - A spawn approved but never started holds its slot until `agents.startTimeoutSeconds` runs out. If that leaves no free slot, the spawns waiting in omp's queue reach the spawn check one after another and are refused, until the slot is given back.
27
+ - omp re-reads the setting only when the session asks for a spawn or one of its runs ends: when a second session joins, spawns already waiting in omp's queue reach the spawn check at that next moment.
28
+
16
29
  ## The line
17
30
 
18
- A spawn over the cap is refused, and the session joins the line for that pool. When a slot frees, it is held for the session refused first (default 5 minutes, `agents.holdMinutes`) and that session is told so; other sessions' spawns are refused meanwhile. A session that does not use its held slot in time loses it to the next in line.
31
+ A spawn over the cap that reaches the spawn check is refused, and the session joins the line for that pool. When a slot frees, it is held for the session refused first (default 5 minutes, `agents.holdMinutes`) and that session is told so; other sessions' spawns are refused meanwhile. A session that does not use its held slot in time loses it to the next in line.
19
32
 
20
33
  The refusal, as the refused session sees it (here both `work` slots are in use by session B):
21
34
 
@@ -25,7 +38,7 @@ When one of B's agents ends, the slot is held for A, which is woken with a notic
25
38
 
26
39
  ![The omp-coord notice "A work slot is free and held for this session until 09:47 AM", followed by the restarted agent finishing](images/slot-held.png)
27
40
 
28
- omp gives the spawn check 30 seconds and then lets the spawn through, so the extension never waits inside it: it refuses at once and holds the next free slot instead. If the shared board cannot be read, spawns are let through with an error notice: the cap is an agreement between sessions, not a security boundary.
41
+ omp gives the spawn check 30 seconds and then lets the spawn through, so the extension never waits inside it: spawns wait in omp's queue before the check while the session is alone (above), and one that reaches it over the cap is refused at once, with the next free slot held. If the shared board cannot be read, spawns are let through with an error notice: the cap is an agreement between sessions, not a security boundary.
29
42
 
30
43
  While the machine's 1-minute load average is above a pool's `loadCaps` threshold, the lower cap applies. Running agents are never stopped; new ones wait until the total is under the lower cap.
31
44
 
@@ -12,6 +12,7 @@
12
12
  | `agents.nested.maxRunningPerParent` | `1` | How many of those one subagent may run at once. |
13
13
  | `agents.holdMinutes` | `5` | How long a freed slot stays held for the session first in line. |
14
14
  | `agents.startTimeoutSeconds` | `120` | How long an approved spawn may take to start before its slot frees. |
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. |
15
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. |
16
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
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, `.`, `_`, `-`. |
@@ -31,14 +31,22 @@ There are no prereleases (`-rc.1` and the like): the marketplace catalog follows
31
31
 
32
32
  ## Cutting a release
33
33
 
34
- From `main`, with a clean work tree, not behind `origin/main`:
34
+ Start on `main`, with a clean work tree, not behind `origin/main`:
35
35
 
36
36
  ```sh
37
37
  just release-plan # commits and changelog since the last release; the next version for each kind of change
38
- just release fix # or feature, breaking, or an exact version such as 1.0.0
39
- just release-publish # asks, then pushes main and the tag; CI checks it and creates the GitHub release
38
+ just release fix # or feature, breaking, or an exact version such as 1.0.0: a release-vX.Y.Z branch and commit
39
+ just release-pr # (on that branch) pushes it and opens its pull request; merge it once CI passes
40
+ git switch main && git pull # main now holds the release
41
+ just release-publish # asks, then tags the release on main and pushes only the tag; CI creates the GitHub release
40
42
  ```
41
43
 
42
- `just release` refuses an empty Unreleased section in the changelog, sets the version in `package.json` and the marketplace catalog, dates the changelog, runs the checks, then commits `Release vX.Y.Z` and tags it, all locally. If anything fails before the commit, the files are put back. The version changes only in release commits, because omp upgrades marketplace installs only when the catalog's version goes up.
44
+ `main` and the release tags are protected by repository rulesets: `main` changes only by pull request with CI passing, never by force or deletion, and a `vX.Y.Z` tag, once pushed, can't be moved or deleted. So a release reaches `main` by its own pull request, and only the tag is pushed directly. Any merge method works.
43
45
 
44
- The release workflow checks that the tag is on `main` and matches the version files and the changelog, runs the checks on macOS, and creates the GitHub release with the changelog entry as its notes.
46
+ `just release` refuses an empty Unreleased section in the changelog, then creates the `release-vX.Y.Z` branch, sets the version in `package.json` and the marketplace catalog, dates the changelog, runs the checks and commits `Release vX.Y.Z`, all locally. If anything fails, the files are put back and you stay on (or return to) `main` with no branch left. The version changes only in release commits, because omp upgrades marketplace installs only when the catalog's version goes up.
47
+
48
+ `just release-publish` tags the commit on `main` that added the dated changelog section of the new version (the merge, squash or rebased commit), even if other pull requests merged after it, and refuses if `origin` already has that tag. A push that failed can be run again.
49
+
50
+ Once the release PR is merged, marketplace installs (which follow `main`) can see the new version before the tag and the GitHub release exist, so run `just release-publish` right after merging.
51
+
52
+ The release workflow checks that the tag is on `main` and matches the version files and the changelog, runs the checks on macOS, publishes the packages and creates the GitHub release with the changelog entry as its notes. If it fails for a passing reason (a registry or runner hiccup), re-run it: it skips what is already published. If the tagged code itself is broken, the version is used up (tags can't move): fix it and release the next patch version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nledford/omp-coord",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "files": [
5
5
  "src",
6
6
  "docs",
@@ -66,6 +66,8 @@ export interface ZodString {
66
66
 
67
67
  export interface ExtensionAPI {
68
68
  on<K extends keyof Events>(event: K, handler: Events[K]): void;
69
+ /** The host session; `settings` is the scope omp's typed setting handles take. */
70
+ pi?: { settings?: unknown };
69
71
  setLabel(label: string): void;
70
72
  registerTool<P>(tool: {
71
73
  name: string;
@@ -16,7 +16,8 @@ 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, withAgent } from "../../domain/commands.ts";
19
+ import { spawnAllowance } from "../../domain/agents.ts";
20
+ import { decideCommand, withAgent, withoutAgentExports } from "../../domain/commands.ts";
20
21
  import type { Claim } from "../../domain/model.ts";
21
22
  import { osLoad } from "../os/load.ts";
22
23
  import { BoardStore, stateDir } from "../sqlite/store.ts";
@@ -31,6 +32,7 @@ import {
31
32
  } from "../text/report.ts";
32
33
  import { startBeacon, unixLiveness } from "../unix/beacon.ts";
33
34
  import type { ExtensionAPI, ExtensionContext, Timer, ToolResult } from "./api.ts";
35
+ import { loadSettingHandle, type SpawnQueue, spawnQueue } from "./spawn-queue.ts";
34
36
 
35
37
  /**
36
38
  * How agents' queued commands start the runner: the omp binary itself runs the CLI adapter as Bun
@@ -60,6 +62,12 @@ interface Runtime {
60
62
  ticks: number;
61
63
  footer: string | undefined;
62
64
  pruning: boolean;
65
+ /** Drives omp's spawn queue (`agents.queueSpawns`); undefined when off or unavailable. */
66
+ queue: SpawnQueue | undefined;
67
+ /** Whether spawns past the cap wait in omp's queue, or why not; shown in coord_status and /coord. */
68
+ queueNote: string;
69
+ /** Agents first started by an eval spawn (workpool workers): their later runs are marked "eval" too. */
70
+ evalAgents: Set<string>;
63
71
  }
64
72
 
65
73
  interface Shared {
@@ -68,13 +76,17 @@ interface Shared {
68
76
  inactive: string;
69
77
  /** Each subagent's binding, by agent id: a message for that subagent is sent through it. */
70
78
  bindings: Map<string, ExtensionAPI>;
79
+ /** Main-session bindings that shut down, so a `start` for one that awaited across its own shutdown gives up. */
80
+ shutDown: WeakSet<ExtensionAPI>;
71
81
  }
72
82
 
73
83
  /** Shared by every copy of this module in the process (omp re-imports extensions for isolated subagents). */
74
84
  const shared: Shared = (() => {
75
85
  const store = globalThis as { [key: symbol]: Shared | undefined };
76
86
  const key = Symbol.for("omp-coord.runtime");
77
- store[key] ??= { runtime: undefined, inactive: NO_CONFIG, bindings: new Map() };
87
+ store[key] ??= { runtime: undefined, inactive: NO_CONFIG, bindings: new Map(), shutDown: new WeakSet() };
88
+ // a copy made by an older version of this module in the same process has no set yet
89
+ store[key].shutDown ??= new WeakSet();
78
90
  return store[key];
79
91
  })();
80
92
 
@@ -99,23 +111,24 @@ export default function ompCoord(pi: ExtensionAPI): void {
99
111
  if (shared.bindings.get(ctx.agent.id) === pi) shared.bindings.delete(ctx.agent.id);
100
112
  return;
101
113
  }
114
+ shared.shutDown.add(pi);
102
115
  if (shared.runtime?.pi === pi) stop(shared.runtime);
103
116
  });
104
117
 
105
118
  pi.on("before_subagent_spawn", (event, ctx) => {
106
119
  const rt = shared.runtime;
107
120
  if (!rt) return undefined;
121
+ let decision: ReturnType<Coordinator["requestSpawn"]>;
108
122
  try {
109
- const decision = rt.coordinator.requestSpawn({
123
+ decision = rt.coordinator.requestSpawn({
110
124
  sessionId: rt.sessionId,
111
125
  parentAgentId: ctx.agent.id,
112
126
  parentIsSubagent: ctx.agent.kind === "sub",
113
127
  agentType: event.agent.toLowerCase(),
114
128
  ...(event.spawnKey === undefined ? {} : { spawnKey: event.spawnKey }),
129
+ ...(event.invocationKind === undefined ? {} : { invocationKind: event.invocationKind }),
115
130
  });
116
- if (decision.allowed) return undefined;
117
- notify(rt, `omp-coord refused ${event.agent} from ${ctx.agent.id}: ${decision.reason}`, "warning");
118
- return { block: true, reason: `omp-coord: ${decision.reason}` };
131
+ if (event.invocationKind === "eval" && event.spawnKey !== undefined) rt.evalAgents.add(event.spawnKey);
119
132
  } catch (error) {
120
133
  // The cap is a shared agreement, not a security boundary: a broken board must not stop all work.
121
134
  notify(
@@ -125,6 +138,11 @@ export default function ompCoord(pi: ExtensionAPI): void {
125
138
  );
126
139
  return undefined;
127
140
  }
141
+ // outside the decision: a failure here must never turn a refusal into a start
142
+ refreshQueue(rt);
143
+ if (decision.allowed) return undefined;
144
+ notify(rt, `omp-coord refused ${event.agent} from ${ctx.agent.id}: ${decision.reason}`, "warning");
145
+ return { block: true, reason: `omp-coord: ${decision.reason}` };
128
146
  });
129
147
 
130
148
  pi.on("agent_start", (_event, ctx) => {
@@ -138,7 +156,9 @@ export default function ompCoord(pi: ExtensionAPI): void {
138
156
  parentId,
139
157
  parentIsSubagent: parentId !== "Main",
140
158
  agentType: ctx.agent.name.toLowerCase(),
159
+ evalWorker: rt.evalAgents.has(ctx.agent.id),
141
160
  });
161
+ refreshQueue(rt);
142
162
  if (count && count.used > count.cap) {
143
163
  notify(
144
164
  rt,
@@ -152,9 +172,10 @@ export default function ompCoord(pi: ExtensionAPI): void {
152
172
  pi.on("agent_end", (_event, ctx) => {
153
173
  const rt = shared.runtime;
154
174
  if (!rt || !isTrackedSubagent(ctx)) return;
155
- guarded(rt, "free an agent slot", () =>
156
- rt.coordinator.agentEnded({ sessionId: rt.sessionId, agentId: ctx.agent.id }),
157
- );
175
+ guarded(rt, "free an agent slot", () => {
176
+ rt.coordinator.agentEnded({ sessionId: rt.sessionId, agentId: ctx.agent.id });
177
+ refreshQueue(rt);
178
+ });
158
179
  });
159
180
 
160
181
  pi.on("tool_call", (event, ctx) => {
@@ -184,13 +205,15 @@ export default function ompCoord(pi: ExtensionAPI): void {
184
205
  notify(rt, `omp-coord could not check claims: ${describe(error)}`, "error");
185
206
  }
186
207
 
187
- const decision = decideCommand(rt.config.commands, command, {
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, {
188
211
  ...RUNNER,
189
212
  owner: rt.sessionId,
190
213
  config: rt.config.source,
191
214
  });
192
215
  if (decision.kind === "refuse") return { block: true, reason: `omp-coord: ${decision.reason}` };
193
- const run = decision.kind === "queue" ? decision.command : command;
216
+ const run = decision.kind === "queue" ? decision.command : own;
194
217
  // Every agent of a session shares the omp process's environment, so the agent's id can reach `omp-coord run` (and
195
218
  // the queue lines) only through the command itself; opt-in (`tagAgentCommands`), since the rewrite defeats omp's
196
219
  // bash.patterns rules and other extensions' rewrites. Off, the hook changes only queued commands, as before.
@@ -427,6 +450,8 @@ export default function ompCoord(pi: ExtensionAPI): void {
427
450
  }
428
451
 
429
452
  async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
453
+ // a binding started again after an earlier shutdown starts fresh
454
+ shared.shutDown.delete(pi);
430
455
  const configPath = findConfigFile(ctx.cwd);
431
456
  if (!configPath) return;
432
457
  let config: Config;
@@ -438,6 +463,19 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
438
463
  return;
439
464
  }
440
465
 
466
+ // whether spawns past the cap wait in omp's queue, and why not, for the session-start notice
467
+ let queue: SpawnQueue | undefined;
468
+ let queueNote = "Spawns past the cap are refused (agents.queueSpawns is off).";
469
+ if (config.agents.queueSpawns) {
470
+ const handle = await loadSettingHandle();
471
+ // this session ended (switched or reloaded) while the handle loaded: don't join the board for it
472
+ if (shared.shutDown.has(pi)) return;
473
+ const scope = pi.pi?.settings;
474
+ queue = typeof handle === "string" ? undefined : spawnQueue(handle, scope);
475
+ queueNote = queue
476
+ ? "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
+ : `Spawns past the cap are refused: ${typeof handle === "string" ? handle : "omp gave the extension no settings scope"}.`;
478
+ }
441
479
  let joined: Pick<Runtime, "store" | "coordinator" | "listener" | "sessionId" | "label">;
442
480
  try {
443
481
  joined = openSession(config, ctx.cwd);
@@ -447,7 +485,19 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
447
485
  return;
448
486
  }
449
487
 
450
- const rt: Runtime = { config, ...joined, pi, ctx, timer: undefined, ticks: 0, footer: undefined, pruning: false };
488
+ const rt: Runtime = {
489
+ config,
490
+ ...joined,
491
+ pi,
492
+ ctx,
493
+ timer: undefined,
494
+ ticks: 0,
495
+ footer: undefined,
496
+ pruning: false,
497
+ queue,
498
+ queueNote,
499
+ evalAgents: new Set(),
500
+ };
451
501
  shared.runtime = rt;
452
502
  // Commands this session's agents run (e.g. a `just` recipe calling `omp-coord run`) see these.
453
503
  process.env.OMP_COORD_SESSION = rt.sessionId;
@@ -456,6 +506,7 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
456
506
  const scratch = scratchFor(rt);
457
507
  if (scratch) process.env.OMP_COORD_SCRATCH = scratch;
458
508
  rt.timer = ctx.setInterval(() => tick(rt), TICK_MS);
509
+ refreshQueue(rt);
459
510
  await prune(rt);
460
511
 
461
512
  const board = rt.coordinator.board();
@@ -468,7 +519,8 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
468
519
  (others.length
469
520
  ? `Other sessions: ${others.map(s => `${s.label} (${s.cwd})`).join(", ")}. Agent caps are shared with them.`
470
521
  : "No other session is active.") +
471
- pausedNote,
522
+ pausedNote +
523
+ ` ${queueNote}`,
472
524
  "info",
473
525
  );
474
526
  if (differing.length) {
@@ -522,8 +574,18 @@ function scratchFor(rt: Runtime, agentId?: string): string | undefined {
522
574
  return dir;
523
575
  }
524
576
 
577
+ /** Sets omp's spawn queue to what the caps allow this session now (a no-op when the queue isn't driven). */
578
+ function refreshQueue(rt: Runtime): void {
579
+ if (!rt.queue) return;
580
+ const queue = rt.queue;
581
+ guarded(rt, "size omp's spawn queue", () =>
582
+ queue.apply(spawnAllowance(rt.coordinator.board(), rt.config.agents, rt.sessionId, Date.now())),
583
+ );
584
+ }
585
+
525
586
  function stop(rt: Runtime): void {
526
587
  shared.runtime = undefined;
588
+ rt.queue?.release();
527
589
  for (const key of ["OMP_COORD_SESSION", "OMP_COORD_PORTS", "OMP_COORD_SCRATCH"]) delete process.env[key];
528
590
  rt.ctx.clearTimer(rt.timer);
529
591
  try {
@@ -540,6 +602,7 @@ async function tick(rt: Runtime): Promise<void> {
540
602
  const now = Date.now();
541
603
  const { label, inbox, holds, board } = rt.coordinator.tick(identity(rt.sessionId, rt.ctx.cwd, rt.listener));
542
604
  rt.label = label;
605
+ rt.queue?.apply(spawnAllowance(board, rt.config.agents, rt.sessionId, now));
543
606
 
544
607
  for (const m of inbox) {
545
608
  const body =
@@ -588,7 +651,14 @@ function sendFrom(rt: Runtime, to: string, body: string): ToolResult {
588
651
  }
589
652
 
590
653
  function statusText(rt: Runtime): string {
591
- return formatStatus(rt.coordinator.board(), rt.config, rt.sessionId, Date.now());
654
+ const board = rt.coordinator.board();
655
+ // whether this session's spawns wait in omp's queue right now, and why not
656
+ const queueLine = !rt.queue
657
+ ? rt.queueNote
658
+ : spawnAllowance(board, rt.config.agents, rt.sessionId, Date.now()) !== undefined
659
+ ? "Spawns past the cap wait in omp's queue and start as slots free (this is the only session)."
660
+ : `Spawns over the cap are refused and put this session in line (${board.pause ? "the scope is paused" : board.sessions.length > 1 ? "other sessions are in the scope" : board.slots.some(x => x.sessionId === rt.sessionId && x.unqueued === "eval") ? "a workpool's workers are running" : "no slot is free for a new spawn"}).`;
661
+ return `${formatStatus(board, rt.config, rt.sessionId, Date.now())}\n${queueLine}`;
592
662
  }
593
663
 
594
664
  function deliver(binding: ExtensionAPI, content: string): void {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The one module of omp's own that the extension imports at run time (see spawn-queue.ts). omp supplies it to
3
+ * extensions by rewriting the import as it loads them; it is not a package dependency, so its type is declared here.
4
+ */
5
+ declare module "@oh-my-pi/pi-coding-agent/config/registry" {
6
+ import type { SettingHandle } from "./spawn-queue.ts";
7
+ export function lookup(id: string): SettingHandle | undefined;
8
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Keeps omp's own per-session spawn queue (the `task.maxConcurrency` setting) at the number of this session's spawns
3
+ * the caps let run now (`spawnAllowance`), so spawns past it wait in omp's queue and start as places free, instead of
4
+ * reaching the spawn check and being refused.
5
+ *
6
+ * Driven only while the session is alone in its scope and not paused (see `spawnAllowance`); otherwise the override is
7
+ * cleared and spawns over the cap are refused at the spawn check. omp re-reads the setting only when the session asks
8
+ * for a spawn or one of its runs ends, so a changed value takes effect then.
9
+ *
10
+ * The setting is changed by a runtime override (never persisted) and cleared when the session stops. A value the user
11
+ * set is a ceiling the override never exceeds; it is read when the session starts, so a change to it takes effect in
12
+ * the next session (while the override is in place it hides the setting's other layers). If omp's settings registry can't be loaded (an omp without it), nothing
13
+ * changes and spawns are refused as before.
14
+ */
15
+
16
+ /** The slice of omp's typed setting handle used here (`@oh-my-pi/pi-coding-agent/config/registry`). */
17
+ export interface SettingHandle {
18
+ get(scope: unknown): unknown;
19
+ override(scope: unknown, value: number | undefined): void;
20
+ clearOverride(scope: unknown): void;
21
+ isConfigured(scope: unknown): boolean;
22
+ }
23
+
24
+ export interface SpawnQueue {
25
+ /**
26
+ * Sets the queue's size to `allowance`, capped by the user's own value, writing only when it changes; `undefined`
27
+ * gives the queue back to the user's own value, leaving spawns to the spawn check.
28
+ */
29
+ apply(allowance: number | undefined): void;
30
+ /** Removes the override, giving the session back the user's own value. */
31
+ release(): void;
32
+ }
33
+
34
+ export const SETTING_ID = "task.maxConcurrency";
35
+
36
+ /** A queue driver over `handle` in `scope`; `undefined` when either is missing. */
37
+ export function spawnQueue(handle: SettingHandle | undefined, scope: unknown): SpawnQueue | undefined {
38
+ if (!handle || scope === undefined) return undefined;
39
+ // the user's own value, read before the first override: a positive number is a ceiling; 0 (unlimited) or unset is none
40
+ const own = handle.isConfigured(scope) ? Number(handle.get(scope)) : 0;
41
+ const ceiling = Number.isFinite(own) && own > 0 ? Math.trunc(own) : Number.POSITIVE_INFINITY;
42
+ let last: number | undefined;
43
+ return {
44
+ apply(allowance) {
45
+ if (allowance === undefined) {
46
+ this.release();
47
+ return;
48
+ }
49
+ // never 0: omp reads 0 as unlimited
50
+ const value = Math.max(1, Math.min(Math.trunc(allowance), ceiling));
51
+ if (value === last) return;
52
+ handle.override(scope, value);
53
+ last = value;
54
+ },
55
+ release() {
56
+ if (last === undefined) return;
57
+ handle.clearOverride(scope);
58
+ last = undefined;
59
+ },
60
+ };
61
+ }
62
+
63
+ /** omp's handle for the setting, or why there is none (this omp has no settings registry to load it from). */
64
+ export async function loadSettingHandle(): Promise<SettingHandle | string> {
65
+ try {
66
+ // Dynamic on purpose: the host's registry exists only inside an omp that has it. A static import would fail the
67
+ // whole extension's load in an older omp, in the CLI and in tests, where this module is loaded without omp. The
68
+ // specifier stays a literal: omp maps host packages onto its bundled copies by rewriting the specifiers in an
69
+ // extension's source as it loads it, and a specifier held in a variable is missed (seen live: "Cannot find package").
70
+ const registry: { lookup?: (id: string) => SettingHandle | undefined } = await import(
71
+ "@oh-my-pi/pi-coding-agent/config/registry"
72
+ );
73
+ return registry.lookup?.(SETTING_ID) ?? `omp's settings registry has no ${SETTING_ID}`;
74
+ } catch (error) {
75
+ return `omp's settings registry can't be loaded (${error instanceof Error ? error.message : String(error)})`;
76
+ }
77
+ }
@@ -47,7 +47,8 @@ CREATE TABLE slots (
47
47
  agent_type TEXT NOT NULL,
48
48
  pool TEXT,
49
49
  state TEXT NOT NULL CHECK (state IN ('starting', 'running')),
50
- since INTEGER NOT NULL
50
+ since INTEGER NOT NULL,
51
+ unqueued TEXT CHECK (unqueued IN ('revived', 'eval'))
51
52
  ) STRICT;
52
53
  CREATE TABLE tickets (
53
54
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -255,6 +256,8 @@ export class BoardStore implements BoardRepository {
255
256
  .all()
256
257
  .some(c => c.name === column);
257
258
  if (!has("jobs", "agent")) this.#db.exec("ALTER TABLE jobs ADD COLUMN agent TEXT");
259
+ if (!has("slots", "unqueued"))
260
+ this.#db.exec("ALTER TABLE slots ADD COLUMN unqueued TEXT CHECK (unqueued IN ('revived', 'eval'))");
258
261
  }
259
262
 
260
263
  #maybePruneEvents(now: number): void {
@@ -304,6 +307,7 @@ export class BoardStore implements BoardRepository {
304
307
  pool: string | null;
305
308
  state: "starting" | "running";
306
309
  since: number;
310
+ unqueued: "revived" | "eval" | null;
307
311
  },
308
312
  []
309
313
  >("SELECT * FROM slots ORDER BY id")
@@ -316,6 +320,7 @@ export class BoardStore implements BoardRepository {
316
320
  pool: r.pool,
317
321
  state: r.state,
318
322
  since: r.since,
323
+ ...(r.unqueued ? { unqueued: r.unqueued } : {}),
319
324
  })),
320
325
  waitlist: db
321
326
  .query<{ session_id: string; pool: string; since: number; hold_until: number | null; notified: number }, []>(
@@ -462,10 +467,10 @@ export class BoardStore implements BoardRepository {
462
467
  s.ports?.to ?? null,
463
468
  );
464
469
  const slot = db.query(
465
- "INSERT INTO slots (session_id, agent_id, parent_id, agent_type, pool, state, since) VALUES (?, ?, ?, ?, ?, ?, ?)",
470
+ "INSERT INTO slots (session_id, agent_id, parent_id, agent_type, pool, state, since, unqueued) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
466
471
  );
467
472
  for (const s of board.slots)
468
- slot.run(s.sessionId, s.agentId ?? null, s.parentId, s.agentType, s.pool, s.state, s.since);
473
+ slot.run(s.sessionId, s.agentId ?? null, s.parentId, s.agentType, s.pool, s.state, s.since, s.unqueued ?? null);
469
474
  const ticket = db.query(
470
475
  "INSERT INTO tickets (session_id, pool, since, hold_until, notified) VALUES (?, ?, ?, ?, ?)",
471
476
  );
@@ -158,6 +158,7 @@ export class Coordinator {
158
158
  parentId: string;
159
159
  parentIsSubagent: boolean;
160
160
  agentType: string;
161
+ evalWorker?: boolean;
161
162
  }) {
162
163
  return this.repo.transact(b => agentStarted(b, this.config.agents, { ...a, now: this.clock.now() }));
163
164
  }
package/src/config.ts CHANGED
@@ -187,6 +187,7 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
187
187
  },
188
188
  holdMs: count(agents.holdMinutes ?? 5, "agents.holdMinutes", fail) * 60_000,
189
189
  startTimeoutMs: count(agents.startTimeoutSeconds ?? 120, "agents.startTimeoutSeconds", fail) * 1000,
190
+ queueSpawns: flag(agents.queueSpawns ?? true, "agents.queueSpawns", fail),
190
191
  messagesPerMinute: count(messages.perMinute ?? 10, "messages.perMinute", fail),
191
192
  },
192
193
  commands,
@@ -35,6 +35,8 @@ export interface SpawnRequest {
35
35
  agentType: string;
36
36
  /** omp's spawn key: the new agent's id for `task`, eval `agent()` and workpools; only a label for some callers. */
37
37
  spawnKey?: string;
38
+ /** How omp starts it; "eval" (eval `agent()` and workpool workers) takes no place in omp's spawn queue. */
39
+ invocationKind?: string;
38
40
  now: number;
39
41
  }
40
42
 
@@ -123,6 +125,7 @@ function addStartingSlot(board: Board, req: SpawnRequest, pool: string | null):
123
125
  pool,
124
126
  state: "starting",
125
127
  since: req.now,
128
+ ...(req.invocationKind === "eval" ? { unqueued: "eval" as const } : {}),
126
129
  });
127
130
  emit(board, req.now, "spawn_approved", req.sessionId, {
128
131
  agentType: req.agentType,
@@ -146,6 +149,11 @@ export function agentStarted(
146
149
  parentId: string;
147
150
  parentIsSubagent: boolean;
148
151
  agentType: string;
152
+ /**
153
+ * A workpool worker's later run (its first run was an eval spawn): omp runs it outside the spawn check and the
154
+ * spawn queue, so its new slot is marked "eval", not "revived".
155
+ */
156
+ evalWorker?: boolean;
149
157
  now: number;
150
158
  },
151
159
  ): { pool: string; used: number; cap: number } | null {
@@ -185,6 +193,7 @@ export function agentStarted(
185
193
  pool: a.parentIsSubagent ? null : poolFor(policy, a.agentType),
186
194
  state: "running",
187
195
  since: a.now,
196
+ unqueued: a.evalWorker ? "eval" : "revived",
188
197
  };
189
198
  board.slots.push(slot);
190
199
  }
@@ -264,3 +273,33 @@ function listOr(items: string[]): string {
264
273
  if (items.length === 1) return items[0] as string;
265
274
  return `${items.slice(0, -1).join(", ")} or ${items.at(-1)}`;
266
275
  }
276
+
277
+ /**
278
+ * How many of this session's own (main-agent) spawns may run at once right now, for omp's per-session spawn queue
279
+ * (`task.maxConcurrency`), or `undefined` when the queue must not be driven and spawns are left to the spawn check.
280
+ *
281
+ * Driven only while this session is alone in the scope, not paused, and runs no eval workpool workers. omp re-reads its queue size only when this
282
+ * session asks for a spawn or one of its runs ends, and a spawn parked in that queue never reaches the spawn check, so
283
+ * it never joins the line: with other sessions, a slot one of them frees could not be held for this session fairly.
284
+ * There the refusal and the line decide, as they always did.
285
+ *
286
+ * Alone, the allowance is this session's top-level agents that hold a place in omp's queue (running or starting, not
287
+ * `unqueued` ones: revived agents and workpool workers), plus the default pool's free slots and one held for it there. Other pools'
288
+ * free slots are left out: omp's queue doesn't know a waiting spawn's pool, so counting them would let a default-pool
289
+ * spawn through to a refusal. `undefined` too when that comes to 0 (every slot taken by agents outside omp's queue):
290
+ * omp reads 0 as unlimited, and a floor of 1 would let each waiting spawn through to a refusal in turn.
291
+ */
292
+ export function spawnAllowance(board: Board, policy: AgentPolicy, sessionId: string, now: number): number | undefined {
293
+ if (board.pause || board.sessions.some(s => s.id !== sessionId)) return undefined;
294
+ // an eval workpool sizes itself from the same setting while its own workers take slots: one number can't size
295
+ // both queues, so while workers run the spawn check decides
296
+ if (board.slots.some(s => s.sessionId === sessionId && s.unqueued === "eval")) return undefined;
297
+ const pool = policy.defaultPool;
298
+ const queued = board.slots.filter(s => s.sessionId === sessionId && s.parentId === "Main" && !s.unqueued).length;
299
+ const held = board.waitlist.filter(
300
+ t => t.sessionId === sessionId && t.pool === pool && t.holdUntil !== undefined,
301
+ ).length;
302
+ const free = Math.max(0, capOf(board, policy, pool, now) - used(board, pool) - activeHolds(board, pool));
303
+ const allowance = queued + held + free;
304
+ return allowance > 0 ? allowance : undefined;
305
+ }
@@ -61,5 +61,20 @@ function quote(word: string): string {
61
61
  * compound line; each agent's next command sets it again.
62
62
  */
63
63
  export function withAgent(agentId: string, command: string): string {
64
- return `export OMP_COORD_AGENT=${quote(agentId)}; ${command}`;
64
+ return `export OMP_COORD_AGENT=${quote(agentId)}; ${withoutAgentExports(command)}`;
65
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*/;
@@ -22,6 +22,8 @@ export interface AgentPolicy {
22
22
  holdMs: number;
23
23
  /** How long an approved spawn may take to start its first run before its slot is given back. */
24
24
  startTimeoutMs: number;
25
+ /** Keep omp's per-session spawn queue (`task.maxConcurrency`) at what the caps allow, so spawns wait instead of being refused. */
26
+ queueSpawns: boolean;
25
27
  /** Messages one session may send per minute. */
26
28
  messagesPerMinute: number;
27
29
  }
@@ -94,6 +96,12 @@ export interface AgentSlot {
94
96
  /** `starting`: approved, first run not seen yet. `running`: in a run. */
95
97
  state: "starting" | "running";
96
98
  since: number;
99
+ /**
100
+ * Holds no place in omp's spawn queue, so `spawnAllowance` doesn't count it: started without a spawn this extension
101
+ * approved (revived with `write agent://…`, or running when this session joined), or a workpool worker (omp starts
102
+ * those outside the queue, as spawns of `invocationKind` "eval").
103
+ */
104
+ unqueued?: "revived" | "eval";
97
105
  }
98
106
 
99
107
  /** A session's place in line for a pool, after a refused spawn. */