@nightlab-agent/opencode-subagent-cap 0.1.0 → 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 +15 -0
- package/README.md +1 -1
- package/gate.js +23 -10
- package/index.js +10 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
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
|
+
|
|
3
18
|
## 0.1.0 (2026-11-01)
|
|
4
19
|
|
|
5
20
|
First release. Caps parallel `task` subagents in opencode 1.19 (tested on 1.19.5) (`OPENCODE_SUBAGENT_MAX`, default 2);
|
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ test only).
|
|
|
65
65
|
- It does not support the opencode 2.x line (`@opencode/cli`), where the subagent tool and the plugin
|
|
66
66
|
hooks are different.
|
|
67
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
|
|
68
|
+
path has not been run in opencode (covered by a unit test only).
|
|
69
69
|
- It does not cap across separate opencode processes: each process has its own count.
|
|
70
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
71
|
|
package/gate.js
CHANGED
|
@@ -7,9 +7,10 @@ const KEEP_ENDED = 500 // ended calls remembered for the race below; oldest drop
|
|
|
7
7
|
export function createGate(max) {
|
|
8
8
|
let running = 0
|
|
9
9
|
const queue = []
|
|
10
|
-
const held = new Map() // callID ->
|
|
10
|
+
const held = new Map() // callID -> sessions holding a slot under it (a reused id can hold two)
|
|
11
11
|
const ended = new Map() // callID -> status, for calls that ended before they asked for a slot
|
|
12
|
-
const released = new Set() //
|
|
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
|
|
13
14
|
|
|
14
15
|
function remember(set, id) {
|
|
15
16
|
set instanceof Map ? set.set(id, true) : set.add(id)
|
|
@@ -28,31 +29,43 @@ export function createGate(max) {
|
|
|
28
29
|
continue
|
|
29
30
|
}
|
|
30
31
|
running++
|
|
31
|
-
held.set(w.callID, (held.get(w.callID) ??
|
|
32
|
+
held.set(w.callID, [...(held.get(w.callID) ?? []), w.sessionID])
|
|
32
33
|
w.resolve()
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
return {
|
|
37
|
-
acquire(callID) {
|
|
38
|
+
acquire(callID, sessionID) {
|
|
38
39
|
return new Promise((resolve, reject) => {
|
|
39
|
-
queue.push({ callID, resolve, reject })
|
|
40
|
+
queue.push({ callID, sessionID, resolve, reject })
|
|
40
41
|
admitNext()
|
|
41
42
|
})
|
|
42
43
|
},
|
|
43
44
|
release(callID, status) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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)
|
|
47
49
|
running--
|
|
48
|
-
remember(released, callID)
|
|
49
|
-
} else if (status
|
|
50
|
+
remember(status ? endedFirst : released, callID)
|
|
51
|
+
} else if (status) {
|
|
50
52
|
const i = queue.findIndex((w) => w.callID === callID)
|
|
51
53
|
if (i >= 0) refuse(queue.splice(i, 1)[0])
|
|
52
54
|
else remember(ended, callID)
|
|
53
55
|
}
|
|
54
56
|
admitNext()
|
|
55
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
|
+
},
|
|
56
69
|
stats: () => ({ max, running, waiting: queue.length, ended: ended.size }),
|
|
57
70
|
}
|
|
58
71
|
}
|
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const DONE = new Set(["completed", "error"])
|
|
|
8
8
|
export const SubagentCap = async ({ client }) => {
|
|
9
9
|
const gate = createGate(readMax())
|
|
10
10
|
const parentOf = new Map()
|
|
11
|
+
const idleCount = new Map() // sessionID -> turns ended, to catch an Esc during the parent lookup
|
|
11
12
|
|
|
12
13
|
// A failed lookup counts as nested (not queued, not cached): queuing a call that really is
|
|
13
14
|
// nested would deadlock, while letting one top-level call through only misses the cap once.
|
|
@@ -28,13 +29,21 @@ export const SubagentCap = async ({ client }) => {
|
|
|
28
29
|
"tool.execute.before": async (input, output) => {
|
|
29
30
|
if (input.tool !== "task") return
|
|
30
31
|
if (output?.args?.background) return
|
|
32
|
+
const turn = idleCount.get(input.sessionID)
|
|
31
33
|
if (await isNested(input.sessionID)) return
|
|
32
|
-
|
|
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)
|
|
33
36
|
},
|
|
34
37
|
"tool.execute.after": async (input) => {
|
|
35
38
|
if (input.tool === "task") gate.release(input.callID)
|
|
36
39
|
},
|
|
37
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
|
+
}
|
|
38
47
|
if (event?.type !== "message.part.updated") return
|
|
39
48
|
const part = event.properties?.part
|
|
40
49
|
if (part?.type !== "tool" || part.tool !== "task") return
|
package/package.json
CHANGED