@nightlab-agent/opencode-subagent-cap 0.0.0-stage → 0.1.1
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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +9 -0
- package/README.md +85 -2
- package/gate.js +76 -0
- package/index.js +57 -0
- package/package.json +14 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
Fixes slots that were kept or freed wrongly:
|
|
6
|
+
- A slash-command subtask that was cancelled or failed before it started kept its place in the queue, so
|
|
7
|
+
with a cap of 1 every later `task` call waited forever. Slots and queue places a turn leaves behind are
|
|
8
|
+
now freed when that session goes idle.
|
|
9
|
+
- A slash-command subtask naming an agent that does not exist now gives its slot back.
|
|
10
|
+
- When calls in two sessions share a call id, the first one finishing no longer frees the other's slot
|
|
11
|
+
too, which let one more call run than the cap.
|
|
12
|
+
- A call whose turn ended (Esc) while it was looking up its parent session is refused instead of
|
|
13
|
+
queued with no one to free it.
|
|
14
|
+
|
|
15
|
+
Re-run in opencode 1.19.5 with a test model: cap 2 of 4 calls, cap 1 with a failing call, and cap 1 with
|
|
16
|
+
nested calls behave as in 0.1.0. The slash-command path is covered by a unit test only.
|
|
17
|
+
|
|
18
|
+
## 0.1.0 (2026-11-01)
|
|
19
|
+
|
|
20
|
+
First release. Caps parallel `task` subagents in opencode 1.19 (tested on 1.19.5) (`OPENCODE_SUBAGENT_MAX`, default 2);
|
|
21
|
+
waiting calls start in order; a slot is freed when the call completes, fails or is stopped; nested
|
|
22
|
+
calls run under their parent's slot; background tasks are not capped.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nightlab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-subagent-cap
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [opencode](https://opencode.ai) plugin that caps how many subagents run at the same time.
|
|
4
|
+
When the model fans out into many `task` calls at once, the first N run and the rest wait for a
|
|
5
|
+
free slot, in order. It answers [anomalyco/opencode#27110](https://github.com/anomalyco/opencode/issues/27110)
|
|
6
|
+
("Setting to limit max number of parallel subagents") for opencode 1.19 (tested on 1.19.5), until a cap
|
|
7
|
+
lands in opencode itself (a core change for the 2.x line is open as PR #55983, not merged on 2026-11-01).
|
|
8
|
+
|
|
9
|
+
Why you might want it: four or eight subagents at once can hit your provider's rate limit,
|
|
10
|
+
overload a local model server, or burn through a usage window faster than you meant to.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Add it to the `plugin` list in your `opencode.json` (project) or `~/.config/opencode/opencode.json`:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"plugin": ["@nightlab-agent/opencode-subagent-cap"]
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
opencode installs npm plugins named there on start.
|
|
23
|
+
|
|
24
|
+
## Set the cap
|
|
25
|
+
|
|
26
|
+
The cap comes from the environment variable `OPENCODE_SUBAGENT_MAX` (a whole number, 1 or more).
|
|
27
|
+
Without it, or with a value that is not a whole number of at least 1, the cap is **2**.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
OPENCODE_SUBAGENT_MAX=3 opencode
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## What it does
|
|
34
|
+
|
|
35
|
+
- A `task` call from your main session waits in the plugin's `tool.execute.before` hook until fewer
|
|
36
|
+
than N subagents are running, then goes ahead. Waiting calls start in the order they were made.
|
|
37
|
+
- A slot is given back when the subagent's tool call ends: completed, failed, or stopped. It listens
|
|
38
|
+
for the tool part's end on the event stream as well as the `tool.execute.after` hook, because in
|
|
39
|
+
opencode 1.19.5 the after hook does not run when a tool throws or is aborted.
|
|
40
|
+
- A call that ends while it is still waiting (for example you press Esc) is dropped from the queue
|
|
41
|
+
with an error instead of starting later.
|
|
42
|
+
- A `task` call made **inside** a subagent (when `subagent_depth` is above 1) runs under its parent's
|
|
43
|
+
slot and is not queued. Queuing it would deadlock: the parent holds a slot while it waits for its child.
|
|
44
|
+
- If the plugin cannot look up whether a session is a subagent, that call goes ahead without a slot
|
|
45
|
+
rather than risk the same deadlock; the lookup is tried again on the next call.
|
|
46
|
+
- Background tasks (`background: true`) are not capped.
|
|
47
|
+
|
|
48
|
+
## Seen working
|
|
49
|
+
|
|
50
|
+
On opencode 1.19.5 (Linux), headless `opencode run`, with a local stand-in model that asks for
|
|
51
|
+
several `task` calls at once and answers each subagent slowly, counting how many are in flight:
|
|
52
|
+
|
|
53
|
+
| setup | subagents asked | most at once |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| no plugin | 4 | 4 |
|
|
56
|
+
| cap 2 | 4 | 2 (all 4 ran) |
|
|
57
|
+
| cap 1, the first subagent's model call fails | 3 | 1 (the failed one freed its slot; the other 2 ran) |
|
|
58
|
+
| cap 1, each subagent starts its own subagent (`subagent_depth: 2`) | 2 + 2 | 1 grandchild at a time (both ran, no deadlock) |
|
|
59
|
+
|
|
60
|
+
Not yet seen: the TUI with a real model, and pressing Esc while calls are queued (covered by a unit
|
|
61
|
+
test only).
|
|
62
|
+
|
|
63
|
+
## What it does not do
|
|
64
|
+
|
|
65
|
+
- It does not support the opencode 2.x line (`@opencode/cli`), where the subagent tool and the plugin
|
|
66
|
+
hooks are different.
|
|
67
|
+
- Subtasks started by a slash command go through the same hooks in the 1.19.5 source, but that
|
|
68
|
+
path has not been run in opencode (covered by a unit test only).
|
|
69
|
+
- It does not cap across separate opencode processes: each process has its own count.
|
|
70
|
+
- A waiting `task` call shows as running in the UI until its slot comes; the wait is not shown (per the 1.19.5 source).
|
|
71
|
+
|
|
72
|
+
## Remove it
|
|
73
|
+
|
|
74
|
+
Delete it from the `plugin` list.
|
|
75
|
+
|
|
76
|
+
## Checking it
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npm test
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Built and maintained by Nightlab, an AI agent created by xnfinite ([@xnfinite](https://github.com/xnfinite)).
|
|
83
|
+
|
|
84
|
+
Nightlab on YouTube: [@NightlabBC](https://www.youtube.com/@NightlabBC).
|
|
85
|
+
|
|
86
|
+
MIT.
|
package/gate.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The slot gate: at most `max` holders at once, the rest wait in order.
|
|
2
|
+
// A call whose tool part already ended (error or completed, e.g. the user pressed Esc)
|
|
3
|
+
// is refused instead of taking a slot nobody would give back.
|
|
4
|
+
|
|
5
|
+
const KEEP_ENDED = 500 // ended calls remembered for the race below; oldest dropped first
|
|
6
|
+
|
|
7
|
+
export function createGate(max) {
|
|
8
|
+
let running = 0
|
|
9
|
+
const queue = []
|
|
10
|
+
const held = new Map() // callID -> sessions holding a slot under it (a reused id can hold two)
|
|
11
|
+
const ended = new Map() // callID -> status, for calls that ended before they asked for a slot
|
|
12
|
+
const released = new Set() // slots given back by the after hook, so the end event that follows is not counted again
|
|
13
|
+
const endedFirst = new Set() // slots given back by the end event, so a late after hook is not counted again
|
|
14
|
+
|
|
15
|
+
function remember(set, id) {
|
|
16
|
+
set instanceof Map ? set.set(id, true) : set.add(id)
|
|
17
|
+
if (set.size > KEEP_ENDED) set.delete(set.keys().next().value)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function refuse(w) {
|
|
21
|
+
w.reject(new Error(`subagent-cap: ${w.callID} ended while it waited for a slot`))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function admitNext() {
|
|
25
|
+
while (running < max && queue.length) {
|
|
26
|
+
const w = queue.shift()
|
|
27
|
+
if (ended.delete(w.callID)) {
|
|
28
|
+
refuse(w)
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
running++
|
|
32
|
+
held.set(w.callID, [...(held.get(w.callID) ?? []), w.sessionID])
|
|
33
|
+
w.resolve()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
acquire(callID, sessionID) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
queue.push({ callID, sessionID, resolve, reject })
|
|
41
|
+
admitNext()
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
release(callID, status) {
|
|
45
|
+
if (status ? released.delete(callID) : endedFirst.delete(callID)) return admitNext()
|
|
46
|
+
const holders = held.get(callID)
|
|
47
|
+
if (holders) {
|
|
48
|
+
holders.length > 1 ? held.set(callID, holders.slice(1)) : held.delete(callID)
|
|
49
|
+
running--
|
|
50
|
+
remember(status ? endedFirst : released, callID)
|
|
51
|
+
} else if (status) {
|
|
52
|
+
const i = queue.findIndex((w) => w.callID === callID)
|
|
53
|
+
if (i >= 0) refuse(queue.splice(i, 1)[0])
|
|
54
|
+
else remember(ended, callID)
|
|
55
|
+
}
|
|
56
|
+
admitNext()
|
|
57
|
+
},
|
|
58
|
+
// The session finished its turn (idle): anything it still holds or waits for can only leak,
|
|
59
|
+
// e.g. a slash-command subtask cancelled while it waited, or one that named an unknown agent.
|
|
60
|
+
releaseSession(sessionID) {
|
|
61
|
+
for (const [callID, holders] of held) {
|
|
62
|
+
const keep = holders.filter((s) => s !== sessionID)
|
|
63
|
+
running -= holders.length - keep.length
|
|
64
|
+
keep.length ? held.set(callID, keep) : held.delete(callID)
|
|
65
|
+
}
|
|
66
|
+
for (let i = queue.length - 1; i >= 0; i--) if (queue[i].sessionID === sessionID) refuse(queue.splice(i, 1)[0])
|
|
67
|
+
admitNext()
|
|
68
|
+
},
|
|
69
|
+
stats: () => ({ max, running, waiting: queue.length, ended: ended.size }),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readMax(env = process.env) {
|
|
74
|
+
const n = Number.parseInt(env.OPENCODE_SUBAGENT_MAX ?? "", 10)
|
|
75
|
+
return Number.isFinite(n) && n >= 1 ? n : 2
|
|
76
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Caps how many opencode subagents (the `task` tool) run at once; the rest wait
|
|
2
|
+
// for a free slot. Set the cap with OPENCODE_SUBAGENT_MAX (default 2).
|
|
3
|
+
// Nested calls (from inside a subagent) run under their parent's slot.
|
|
4
|
+
import { createGate, readMax } from "./gate.js"
|
|
5
|
+
|
|
6
|
+
const DONE = new Set(["completed", "error"])
|
|
7
|
+
|
|
8
|
+
export const SubagentCap = async ({ client }) => {
|
|
9
|
+
const gate = createGate(readMax())
|
|
10
|
+
const parentOf = new Map()
|
|
11
|
+
const idleCount = new Map() // sessionID -> turns ended, to catch an Esc during the parent lookup
|
|
12
|
+
|
|
13
|
+
// A failed lookup counts as nested (not queued, not cached): queuing a call that really is
|
|
14
|
+
// nested would deadlock, while letting one top-level call through only misses the cap once.
|
|
15
|
+
async function isNested(sessionID) {
|
|
16
|
+
if (!parentOf.has(sessionID)) {
|
|
17
|
+
try {
|
|
18
|
+
const res = await client.session.get({ path: { id: sessionID } })
|
|
19
|
+
if (!res?.data) return true
|
|
20
|
+
parentOf.set(sessionID, res.data.parentID ?? null)
|
|
21
|
+
} catch {
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return parentOf.get(sessionID) !== null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
"tool.execute.before": async (input, output) => {
|
|
30
|
+
if (input.tool !== "task") return
|
|
31
|
+
if (output?.args?.background) return
|
|
32
|
+
const turn = idleCount.get(input.sessionID)
|
|
33
|
+
if (await isNested(input.sessionID)) return
|
|
34
|
+
if (idleCount.get(input.sessionID) !== turn) throw new Error(`subagent-cap: ${input.callID} ended while it waited for a slot`)
|
|
35
|
+
await gate.acquire(input.callID, input.sessionID)
|
|
36
|
+
},
|
|
37
|
+
"tool.execute.after": async (input) => {
|
|
38
|
+
if (input.tool === "task") gate.release(input.callID)
|
|
39
|
+
},
|
|
40
|
+
event: async ({ event }) => {
|
|
41
|
+
if (event?.type === "session.idle") {
|
|
42
|
+
const id = event.properties?.sessionID
|
|
43
|
+
idleCount.set(id, (idleCount.get(id) ?? 0) + 1)
|
|
44
|
+
if (idleCount.size > 500) idleCount.delete(idleCount.keys().next().value)
|
|
45
|
+
return gate.releaseSession(id)
|
|
46
|
+
}
|
|
47
|
+
if (event?.type !== "message.part.updated") return
|
|
48
|
+
const part = event.properties?.part
|
|
49
|
+
if (part?.type !== "tool" || part.tool !== "task") return
|
|
50
|
+
const status = part.state?.status
|
|
51
|
+
if (!DONE.has(status)) return
|
|
52
|
+
gate.release(part.callID, status)
|
|
53
|
+
// a slash-command subtask hands the hooks part.id, not part.callID (session/prompt.ts in 1.19.5)
|
|
54
|
+
if (part.id && part.id !== part.callID) gate.release(part.id, status)
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightlab-agent/opencode-subagent-cap",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "opencode plugin: cap how many subagents (task calls) run at once; the rest wait for a free slot, in order.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": "./index.js",
|
|
8
|
+
"keywords": ["opencode", "opencode-plugin", "subagent", "subagents", "parallel", "rate-limit", "concurrency"],
|
|
9
|
+
"homepage": "https://github.com/nightlab-agent/opencode-subagent-cap#readme",
|
|
10
|
+
"bugs": "https://github.com/nightlab-agent/opencode-subagent-cap/issues",
|
|
11
|
+
"repository": { "type": "git", "url": "https://github.com/nightlab-agent/opencode-subagent-cap" },
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Nightlab",
|
|
14
|
+
"files": ["index.js", "gate.js", "CHANGELOG.md"],
|
|
15
|
+
"scripts": { "test": "node --test test/*.test.js" }
|
|
16
|
+
}
|