@plainconceptsplatform/agent-harness 2.7.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.
Files changed (61) hide show
  1. package/README.md +12 -6
  2. package/cli/commands/migrate.js +1 -1
  3. package/cli/presets/quota.json +6 -2
  4. package/cli/steps/copy/opencode-json.js +260 -135
  5. package/cli/steps/copy/skills.js +12 -2
  6. package/cli/steps/optimization/quota.js +36 -20
  7. package/cli/utils/copy.js +0 -1
  8. package/harness/.opencode/_gitignore +9 -9
  9. package/harness/.opencode/package.json +1 -5
  10. package/harness/.opencode/plugins/pc-subagent-monitor.js +144 -179
  11. package/harness/.opencode/plugins/pc-subagent-tiers.js +216 -248
  12. package/harness/.opencode/plugins/pc-system-reminders.js +386 -434
  13. package/harness/opencode.jsonc +21 -32
  14. package/package.json +3 -1
  15. package/skills/README.md +18 -0
  16. package/skills/agent-harness-cli/SKILL.md +86 -0
  17. package/skills/pc-guardrails-generic/SKILL.md +52 -0
  18. package/{harness/.agents/skills → skills}/pc-make-architecture/SKILL.md +1 -0
  19. package/skills/pc-make-architecture/structure-template.md +64 -0
  20. package/{harness/.agents/skills → skills}/pc-make-engineer/SKILL.md +59 -59
  21. package/{harness/.agents/skills → skills}/pc-make-engineer/template.md +36 -42
  22. package/{harness/.agents/skills → skills}/pc-make-guardrails/SKILL.md +1 -0
  23. package/{harness/.agents/skills → skills}/pc-make-guardrails/category-reference.md +40 -1
  24. package/harness/.agents/skills/pc-guardrails-generic/SKILL.md +0 -47
  25. package/harness/.agents/skills/pc-make-architecture/structure-template.md +0 -38
  26. package/harness/.agents/skills/pc-make-evidence-scaffold/SKILL.md +0 -18
  27. package/harness/.agents/skills/pc-make-evidence-scaffold/evidence-contract.md +0 -29
  28. package/harness/.opencode/commands/make-evidence-scaffold.md +0 -5
  29. package/harness/.opencode/tui/pc-subagents.tsx +0 -98
  30. package/harness/.opencode/tui.json +0 -6
  31. /package/{harness/.agents/skills → skills}/browser-automation/SKILL.md +0 -0
  32. /package/{harness/.agents/skills → skills}/pc-guardrails-project/SKILL.md +0 -0
  33. /package/{harness/.agents/skills → skills}/pc-make-design/SKILL.md +0 -0
  34. /package/{harness/.agents/skills → skills}/pc-make-engineer/signal-mapping.md +0 -0
  35. /package/{harness/.agents/skills → skills}/pc-make-merge-risk-assess/SKILL.md +0 -0
  36. /package/{harness/.agents/skills → skills}/pc-make-merge-risk-assess/category-reference.md +0 -0
  37. /package/{harness/.agents/skills → skills}/pc-make-user-model/SKILL.md +0 -0
  38. /package/{harness/.agents/skills → skills}/pc-ops-evidence/SKILL.md +0 -0
  39. /package/{harness/.agents/skills → skills}/pc-ops-ship/SKILL.md +0 -0
  40. /package/{harness/.agents/skills → skills}/pc-plan-apply/SKILL.md +0 -0
  41. /package/{harness/.agents/skills → skills}/pc-plan-apply/simple-mode.md +0 -0
  42. /package/{harness/.agents/skills → skills}/pc-plan-archive/SKILL.md +0 -0
  43. /package/{harness/.agents/skills → skills}/pc-plan-explore/SKILL.md +0 -0
  44. /package/{harness/.agents/skills → skills}/pc-plan-goal/SKILL.md +0 -0
  45. /package/{harness/.agents/skills → skills}/pc-plan-goal/branching.md +0 -0
  46. /package/{harness/.agents/skills → skills}/pc-plan-goal/failure-policy.md +0 -0
  47. /package/{harness/.agents/skills → skills}/pc-plan-goal/output-mode.md +0 -0
  48. /package/{harness/.agents/skills → skills}/pc-plan-goal/output.md +0 -0
  49. /package/{harness/.agents/skills → skills}/pc-plan-propose/SKILL.md +0 -0
  50. /package/{harness/.agents/skills → skills}/pc-plan-propose/task-annotation.md +0 -0
  51. /package/{harness/.agents/skills → skills}/pc-plan-quick/SKILL.md +0 -0
  52. /package/{harness/.agents/skills → skills}/pc-plan-story/SKILL.md +0 -0
  53. /package/{harness/.agents/skills → skills}/pc-repo-audit/SKILL.md +0 -0
  54. /package/{harness/.agents/skills → skills}/pc-repo-help/SKILL.md +0 -0
  55. /package/{harness/.agents/skills → skills}/pc-repo-initialize/SKILL.md +0 -0
  56. /package/{harness/.agents/skills → skills}/pc-repo-onboard/SKILL.md +0 -0
  57. /package/{harness/.agents/skills → skills}/pc-repo-verify/SKILL.md +0 -0
  58. /package/{harness/.agents/skills → skills}/pc-userstory-az/SKILL.md +0 -0
  59. /package/{harness/.agents/skills → skills}/pc-userstory-browser/SKILL.md +0 -0
  60. /package/{harness/.agents/skills → skills}/pc-userstory-gh/SKILL.md +0 -0
  61. /package/{harness/.agents/skills → skills}/pc-userstory-jira/SKILL.md +0 -0
@@ -16,11 +16,6 @@ export function splitPackageSpec(spec) {
16
16
  return { name: spec.slice(0, separator), version: spec.slice(separator + 1) }
17
17
  }
18
18
 
19
- function ensurePlugin(config) {
20
- if (!Array.isArray(config.plugin)) config.plugin = []
21
- if (!config.plugin.includes(PLUGIN)) config.plugin.push(PLUGIN)
22
- }
23
-
24
19
  export async function installQuota(options = {}) {
25
20
  if (!options.skipHeader) header('Installing opencode-quota')
26
21
 
@@ -57,9 +52,14 @@ export async function installQuota(options = {}) {
57
52
  const opencodeDir = path.join(process.cwd(), '.opencode')
58
53
  const opencodePath = path.join(process.cwd(), 'opencode.jsonc')
59
54
  const pkgPath = path.join(opencodeDir, 'package.json')
60
- const tuiPath = path.join(opencodeDir, 'tui.json')
61
- const quotaDir = path.join(opencodeDir, 'opencode-quota')
62
- const quotaPath = path.join(quotaDir, 'quota-toast.json')
55
+
56
+ // opencode-quota 5.x reads its settings from `opencode-quota/quota-toast.jsonc`
57
+ // next to the OpenCode config (project root), not from `.opencode/`. The old
58
+ // `.opencode/opencode-quota/quota-toast.json` is the v4 location and is migrated
59
+ // below so the settings actually take effect.
60
+ const quotaDir = path.join(process.cwd(), 'opencode-quota')
61
+ const quotaPath = path.join(quotaDir, 'quota-toast.jsonc')
62
+ const legacyQuotaPath = path.join(opencodeDir, 'opencode-quota', 'quota-toast.json')
63
63
 
64
64
  let opencodeText = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)
65
65
  let opencode
@@ -78,18 +78,20 @@ export async function installQuota(options = {}) {
78
78
  ? await fse.readJson(pkgPath)
79
79
  : {}
80
80
 
81
- const tui = await fse.pathExists(tuiPath)
82
- ? await fse.readJson(tuiPath)
83
- : { $schema: 'https://opencode.ai/tui.json' }
84
-
85
- const plugins = Array.isArray(opencode.plugin) ? opencode.plugin : []
81
+ // V2 configures server plugins under `plugins`. A V1 `plugin` array found
82
+ // here is carried over so the quota entry is not lost when the config
83
+ // patcher has not run first.
84
+ const plugins = Array.isArray(opencode.plugins)
85
+ ? [...opencode.plugins]
86
+ : Array.isArray(opencode.plugin)
87
+ ? [...opencode.plugin]
88
+ : []
86
89
  if (!plugins.includes(PLUGIN)) {
87
90
  plugins.push(PLUGIN)
88
- opencodeText = applyEdits(opencodeText, modify(opencodeText, ['plugin'], plugins, {
91
+ opencodeText = applyEdits(opencodeText, modify(opencodeText, ['plugins'], plugins, {
89
92
  formattingOptions: { insertSpaces: true, tabSize: 2 },
90
93
  }))
91
94
  }
92
- ensurePlugin(tui)
93
95
 
94
96
  if (!pkg.dependencies) pkg.dependencies = {}
95
97
  const { name: pkgName, version: pkgVersion } = splitPackageSpec(PLUGIN)
@@ -98,18 +100,32 @@ export async function installQuota(options = {}) {
98
100
  await fse.ensureDir(opencodeDir)
99
101
  await fse.writeFile(opencodePath, opencodeText, 'utf-8')
100
102
  await fse.writeJson(pkgPath, pkg, { spaces: 2 })
101
- await fse.writeJson(tuiPath, tui, { spaces: 2 })
102
103
 
103
- const quotaConfig = await fse.pathExists(quotaPath)
104
- ? await fse.readJson(quotaPath)
105
- : {}
104
+ // Carry over any existing settings (new location first, then the legacy
105
+ // v4 file) and layer the recommended defaults on top. The edge defaults
106
+ // disable toasts and reset notifications and keep slash reports inline.
107
+ let quotaConfig = {}
108
+ if (await fse.pathExists(quotaPath)) {
109
+ quotaConfig = await fse.readJson(quotaPath)
110
+ } else if (await fse.pathExists(legacyQuotaPath)) {
111
+ quotaConfig = await fse.readJson(legacyQuotaPath)
112
+ }
106
113
 
107
114
  Object.assign(quotaConfig, quotaPreset.defaults)
108
115
 
109
116
  await fse.ensureDir(quotaDir)
110
117
  await fse.writeJson(quotaPath, quotaConfig, { spaces: 2 })
111
118
 
112
- success('opencode-quota configured (manual setup)')
119
+ if (await fse.pathExists(legacyQuotaPath)) {
120
+ await fse.remove(legacyQuotaPath)
121
+ const legacyDir = path.dirname(legacyQuotaPath)
122
+ if ((await fse.readdir(legacyDir)).length === 0) {
123
+ await fse.remove(legacyDir)
124
+ }
125
+ info('Moved opencode-quota settings to opencode-quota/quota-toast.jsonc')
126
+ }
127
+
128
+ success('opencode-quota configured (popups and notifications disabled)')
113
129
  info('Restart OpenCode and run /quota to verify')
114
130
  return { optedIn: true, installed: true }
115
131
  } catch (err) {
package/cli/utils/copy.js CHANGED
@@ -40,7 +40,6 @@ function isTemplateTest(relativePath) {
40
40
  export function isManagedContentPath(relativePath) {
41
41
  const normalized = relativePath.split(path.sep).join('/')
42
42
  if (normalized.startsWith('.opencode/plugins/')) return true
43
- if (normalized === '.opencode/tui/pc-subagents.tsx') return true
44
43
  if (normalized.startsWith('.opencode/commands/') && !MARKER_COMMANDS.has(normalized)) return true
45
44
  return normalized === 'openspec/specs/.gitkeep' || normalized === 'openspec/changes/archive/.gitkeep'
46
45
  }
@@ -1,9 +1,9 @@
1
- node_modules
2
- harness-run.json
3
- harness.user.json
4
- harness-managed.json
5
- source-roots.json
6
- tmp/
7
- *-engineer.*.md
8
- agents/build.md
9
- agents/plan.md
1
+ node_modules
2
+ harness-run.json
3
+ harness.user.json
4
+ harness-managed.json
5
+ source-roots.json
6
+ tmp/
7
+ *-engineer.*.md
8
+ agents/build.md
9
+ agents/plan.md
@@ -1,9 +1,5 @@
1
1
  {
2
2
  "dependencies": {
3
- "@opencode-ai/plugin": "1.18.19",
4
- "@mohak34/opencode-notifier": "0.2.8",
5
- "@opentui/core": "0.5.6",
6
- "@opentui/solid": "0.5.6",
7
- "solid-js": "1.9.12"
3
+ "@opencode/plugin": "2.0.22"
8
4
  }
9
5
  }
@@ -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
+ })