@plainconceptsplatform/agent-harness 2.9.0 → 3.0.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.
@@ -1,219 +1,184 @@
1
- // pc-subagent-monitor: tracks spawned subagents in .opencode/harness-run.json for
2
- // live TUI display and crash recovery, and holds a wave to agents.maxConcurrent.
1
+ // pc-subagent-monitor: holds a spawn wave to agents.maxConcurrent and keeps a
2
+ // live record of child sessions in .opencode/harness-run.json.
3
3
  //
4
- // It throws in exactly one case: to deny a spawn over the cap. Every other
5
- // failure, including its own bugs, is swallowed — a monitor that breaks the run
6
- // it is watching is worse than no monitor.
7
-
4
+ // OpenCode 2 denies through the permission system, not by throwing from a tool
5
+ // hook, so the cap is a `subagent` permission rule: an `evaluate` hook counts
6
+ // the children of the asking session and denies the launch over the limit.
7
+ //
8
+ // Child sessions are observed from the event stream. V2 does not document the
9
+ // event type strings (the payload schema is an opaque `string`), so the handler
10
+ // is deliberately tolerant and, with PC_HARNESS_DEBUG set, prints every event
11
+ // type once so the names can be pinned from a real run. Counting via the event
12
+ // stream is the iteration-one mechanism; `ctx.session` in the plugin context is
13
+ // not documented to expose `list`, so it is not used here.
14
+
15
+ import { Plugin } from "@opencode/plugin"
8
16
  import fs from "node:fs/promises"
9
17
  import path from "node:path"
10
18
 
11
- // A spawn that has been allowed but whose session has not appeared yet. A wave
12
- // is one assistant turn, and opencode calls tool.execute.before for every
13
- // task() in that turn before the first child session exists, so counting live
14
- // sessions alone would let an entire wave through whatever the cap says.
15
- //
16
- // The slot is released when the session lands, and expires otherwise:
17
- // tool.execute.after never fires for a tool that threw, so a failed spawn must
18
- // not hold a slot for the rest of the run.
19
19
  const PENDING_TTL_MS = 15_000
20
20
 
21
- export const PcSubagentMonitor = async ({ directory, client }) => {
22
- const root = directory || process.cwd()
23
- const statePath = path.join(root, ".opencode", "harness-run.json")
24
- const state = { updatedAt: null, agents: {} }
25
- const pending = []
26
-
27
- try {
28
- const prev = JSON.parse(await fs.readFile(statePath, "utf-8"))
29
- if (prev?.agents && typeof prev.agents === "object") {
30
- for (const [id, entry] of Object.entries(prev.agents)) {
31
- if (entry?.status === "running") entry.stale = true
32
- state.agents[id] = entry
21
+ export default Plugin.define({
22
+ id: "pc.subagent-monitor",
23
+ async setup(ctx) {
24
+ const root = ctx.location.directory || process.cwd()
25
+ const statePath = path.join(root, ".opencode", "harness-run.json")
26
+ const state = { updatedAt: null, agents: {} }
27
+ const pending = []
28
+ const seenTypes = new Set()
29
+ let capCache = null
30
+
31
+ try {
32
+ const prev = JSON.parse(await fs.readFile(statePath, "utf-8"))
33
+ if (prev?.agents && typeof prev.agents === "object") {
34
+ for (const [id, entry] of Object.entries(prev.agents)) {
35
+ if (entry?.status === "running") entry.stale = true
36
+ state.agents[id] = entry
37
+ }
33
38
  }
39
+ } catch {
40
+ // fresh start
34
41
  }
35
- } catch {
36
- // fresh start
37
- }
38
-
39
- let _modelsCache = null
40
- async function loadModels() {
41
- if (_modelsCache) return _modelsCache
42
- const result = { build: null, fast: null, plan: null }
43
- for (const file of ["harness.user.json", "harness.json"]) {
44
- try {
45
- const raw = await fs.readFile(path.join(root, ".opencode", file), "utf-8")
46
- const { models = {} } = JSON.parse(raw)
47
- for (const tier of ["build", "fast", "plan"]) {
48
- if (!result[tier] && models[tier]) result[tier] = models[tier]
42
+
43
+ let persistScheduled = false
44
+ function persist() {
45
+ if (persistScheduled) return
46
+ persistScheduled = true
47
+ queueMicrotask(async () => {
48
+ persistScheduled = false
49
+ state.updatedAt = new Date().toISOString()
50
+ try {
51
+ await fs.mkdir(path.dirname(statePath), { recursive: true })
52
+ const tmpPath = `${statePath}.tmp`
53
+ await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8")
54
+ await fs.rename(tmpPath, statePath)
55
+ } catch {
56
+ // best-effort
49
57
  }
50
- } catch {
51
- continue
52
- }
58
+ })
53
59
  }
54
- _modelsCache = result
55
- return result
56
- }
57
-
58
- async function modelForAgent(agent) {
59
- if (!agent) return null
60
- const dotIdx = agent.lastIndexOf(".")
61
- const tier = dotIdx !== -1 ? agent.slice(dotIdx + 1) : null
62
- if (tier && ["build", "fast", "plan"].includes(tier)) {
63
- const models = await loadModels()
64
- return models[tier] ?? null
60
+
61
+ function pruneStale() {
62
+ for (const [id, entry] of Object.entries(state.agents)) {
63
+ if (entry?.stale) delete state.agents[id]
64
+ }
65
65
  }
66
- return null
67
- }
68
-
69
- function parseTasks(title) {
70
- if (!title) return []
71
- const m = /^\s*([\d]+(?:\.[\d]+)*(?:\s*,\s*[\d]+(?:\.[\d]+)*)*)/.exec(title)
72
- return m ? m[1].split(",").map(s => s.trim()) : []
73
- }
74
-
75
- // Debounced persist: coalesce rapid writes when multiple subagents spawn
76
- // in the same microtask batch (e.g. a wave of 5 task() calls).
77
- let _persistScheduled = false
78
- async function persist() {
79
- if (_persistScheduled) return
80
- _persistScheduled = true
81
- queueMicrotask(async () => {
82
- _persistScheduled = false
83
- state.updatedAt = new Date().toISOString()
66
+
67
+ async function maxConcurrent() {
68
+ if (capCache) return capCache
69
+ let configured = 3
84
70
  try {
85
- await fs.mkdir(path.dirname(statePath), { recursive: true })
86
- const tmpPath = `${statePath}.tmp`
87
- await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8")
88
- await fs.rename(tmpPath, statePath)
71
+ const raw = await fs.readFile(path.join(root, ".opencode", "harness.json"), "utf-8")
72
+ const value = JSON.parse(raw)?.agents?.maxConcurrent
73
+ if (Number.isFinite(value)) configured = value
89
74
  } catch {
90
- // best-effort
75
+ // no config yet; the CLI's own default is 3
91
76
  }
92
- })
93
- }
94
-
95
- function sessionInfo(props) {
96
- const info = props?.info ?? props ?? {}
97
- return {
98
- id: info.id ?? info.sessionID ?? props?.sessionID,
99
- parentID: info.parentID ?? info.parentId,
100
- agent: info.agent,
101
- title: info.title,
77
+ capCache = Math.min(5, Math.max(1, Math.trunc(configured)))
78
+ return capCache
102
79
  }
103
- }
104
80
 
105
- function pruneStale() {
106
- for (const [id, entry] of Object.entries(state.agents)) {
107
- if (entry?.stale) delete state.agents[id]
81
+ function runningFor(parentID) {
82
+ return Object.values(state.agents)
83
+ .filter(entry => entry?.status === "running" && !entry.stale && entry.parentID === parentID)
84
+ .length
108
85
  }
109
- }
110
86
 
111
- let _capCache = null
112
- async function maxConcurrent() {
113
- if (_capCache) return _capCache
114
- let configured = 3
115
- try {
116
- const raw = await fs.readFile(path.join(root, ".opencode", "harness.json"), "utf-8")
117
- const value = JSON.parse(raw)?.agents?.maxConcurrent
118
- if (Number.isFinite(value)) configured = value
119
- } catch {
120
- // no config yet; the CLI's own default is 3
121
- }
122
- _capCache = Math.min(5, Math.max(1, Math.trunc(configured)))
123
- return _capCache
124
- }
125
-
126
- function runningFor(parentID) {
127
- return Object.values(state.agents)
128
- .filter(entry => entry?.status === "running" && !entry.stale && entry.parentID === parentID)
129
- .length
130
- }
131
-
132
- function livePending(parentID) {
133
- const now = Date.now()
134
- for (let i = pending.length - 1; i >= 0; i--) {
135
- if (now - pending[i].at > PENDING_TTL_MS) pending.splice(i, 1)
87
+ function livePending(parentID) {
88
+ const now = Date.now()
89
+ for (let i = pending.length - 1; i >= 0; i--) {
90
+ if (now - pending[i].at > PENDING_TTL_MS) pending.splice(i, 1)
91
+ }
92
+ return pending.filter(entry => entry.parentID === parentID).length
136
93
  }
137
- return pending.filter(entry => entry.parentID === parentID).length
138
- }
139
94
 
140
- function releasePending(parentID) {
141
- const index = pending.findIndex(entry => entry.parentID === parentID)
142
- if (index !== -1) pending.splice(index, 1)
143
- }
144
-
145
- return {
146
- // The cap was prose in pc-plan-apply ("you enforce the cap"), which asked
147
- // the lead to count its own parallel calls. Six disjoint groups and a cap
148
- // of three is exactly the moment a model stops counting.
149
- "tool.execute.before": async (input) => {
150
- if (input?.tool !== "task") return
95
+ function releasePending(parentID) {
96
+ const index = pending.findIndex(entry => entry.parentID === parentID)
97
+ if (index !== -1) pending.splice(index, 1)
98
+ }
151
99
 
100
+ // The cap was prose in pc-plan-apply ("you enforce the cap"), which asked the
101
+ // lead to count its own parallel calls. Six disjoint groups and a cap of
102
+ // three is exactly the moment a model stops counting.
103
+ await ctx.permission.hook("evaluate", async event => {
104
+ if (event.action !== "subagent") return
152
105
  let cap
153
106
  let live
154
107
  try {
155
108
  pruneStale()
156
109
  cap = await maxConcurrent()
157
- live = runningFor(input.sessionID) + livePending(input.sessionID)
110
+ live = runningFor(event.sessionID) + livePending(event.sessionID)
158
111
  if (live < cap) {
159
- pending.push({ parentID: input.sessionID, at: Date.now() })
112
+ pending.push({ parentID: event.sessionID, at: Date.now() })
160
113
  return
161
114
  }
162
115
  } catch (error) {
163
- // Compute inside the try, deny outside it, so this catch can never
164
- // swallow the one exception the hook is meant to raise.
165
116
  console.error(`[harness] concurrency check failed open: ${error?.message}`)
166
117
  return
167
118
  }
119
+ deny(event, live, cap)
120
+ })
168
121
 
169
- throw new Error(
122
+ function deny(event, live, cap) {
123
+ event.effect = "deny"
124
+ event.message =
170
125
  `[harness] ${live} subagents are already in flight and agents.maxConcurrent is ${cap}.\n` +
171
- "Collect a running worker before spawning another. A denied spawn is not a failed task: re-issue it in the next wave.",
172
- )
173
- },
174
- event: async ({ event }) => {
126
+ "Collect a running worker before spawning another. A denied spawn is not a failed task: re-issue it in the next wave."
127
+ }
128
+
129
+ function extractSession(event) {
130
+ const data = event?.data ?? event?.properties ?? event ?? {}
131
+ const info = data?.info ?? data?.session ?? data ?? {}
132
+ const id = info.id ?? info.sessionID ?? data.sessionID
133
+ const parentID = info.parentID ?? info.parentId ?? data.parentID
134
+ const status = info.status ?? data.status
135
+ return { id, parentID, agent: info.agent ?? null, title: info.title ?? null, status }
136
+ }
137
+
138
+ const controller = new AbortController()
139
+ void (async () => {
175
140
  try {
176
- if (!event?.type?.startsWith("session.")) return
177
- const info = sessionInfo(event.properties)
178
- if (!info.id) return
179
-
180
- if (event.type === "session.created" && info.parentID) {
181
- pruneStale()
182
- // The spawn this session came from no longer needs its pending slot;
183
- // the session itself is now what the cap counts.
184
- releasePending(info.parentID)
185
-
186
- state.agents[info.id] = {
187
- agent: info.agent ?? null,
188
- parentID: info.parentID,
189
- model: await modelForAgent(info.agent),
190
- tasks: parseTasks(info.title),
191
- title: info.title ?? null,
192
- status: "running",
193
- startedAt: new Date().toISOString(),
194
- endedAt: null,
141
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
142
+ try {
143
+ const type = String(event?.type ?? "")
144
+ if (process.env.PC_HARNESS_DEBUG && type && !seenTypes.has(type)) {
145
+ seenTypes.add(type)
146
+ console.error(`[pc-subagent-monitor] event: ${type}`)
147
+ }
148
+ const { id, parentID, agent, title, status } = extractSession(event)
149
+ if (!id || !parentID) continue
150
+
151
+ if (status === "running" || /created|started/.test(type)) {
152
+ pruneStale()
153
+ releasePending(parentID)
154
+ state.agents[id] = {
155
+ agent,
156
+ parentID,
157
+ title,
158
+ status: "running",
159
+ startedAt: new Date().toISOString(),
160
+ endedAt: null,
161
+ }
162
+ await persist()
163
+ continue
164
+ }
165
+
166
+ const entry = state.agents[id]
167
+ if (!entry) continue
168
+ if (status === "idle" || /idle|completed|succeeded|failed/.test(type)) {
169
+ entry.status = "done"
170
+ entry.endedAt = new Date().toISOString()
171
+ await persist()
172
+ }
173
+ } catch {
174
+ // monitoring must never disrupt the run
195
175
  }
196
- await persist()
197
- return
198
- }
199
-
200
- const entry = state.agents[info.id]
201
- if (!entry) return
202
-
203
- if (event.type === "session.idle" && entry.status === "running") {
204
- entry.status = "done"
205
- entry.endedAt = new Date().toISOString()
206
- await persist()
207
- client?.tui?.showToast?.({
208
- body: {
209
- message: `subagent done: ${entry.title ?? info.id}`,
210
- variant: "success",
211
- },
212
- })
213
176
  }
214
177
  } catch {
215
- // monitoring must never disrupt the run
178
+ // stream aborted on unload
216
179
  }
217
- },
218
- }
219
- }
180
+ })()
181
+
182
+ return () => controller.abort()
183
+ },
184
+ })