@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.
- package/README.md +12 -6
- package/cli/commands/migrate.js +1 -1
- package/cli/presets/quota.json +6 -2
- package/cli/steps/copy/opencode-json.js +260 -135
- package/cli/steps/copy/skills.js +12 -2
- package/cli/steps/optimization/quota.js +36 -20
- package/cli/utils/copy.js +0 -1
- package/harness/.opencode/_gitignore +9 -9
- package/harness/.opencode/package.json +1 -5
- package/harness/.opencode/plugins/pc-subagent-monitor.js +144 -179
- package/harness/.opencode/plugins/pc-subagent-tiers.js +216 -248
- package/harness/.opencode/plugins/pc-system-reminders.js +386 -434
- package/harness/opencode.jsonc +21 -32
- package/package.json +3 -1
- package/skills/README.md +18 -0
- package/skills/agent-harness-cli/SKILL.md +86 -0
- package/skills/pc-guardrails-generic/SKILL.md +52 -0
- package/{harness/.agents/skills → skills}/pc-make-architecture/SKILL.md +1 -0
- package/skills/pc-make-architecture/structure-template.md +64 -0
- package/{harness/.agents/skills → skills}/pc-make-engineer/SKILL.md +59 -59
- package/{harness/.agents/skills → skills}/pc-make-engineer/template.md +36 -42
- package/{harness/.agents/skills → skills}/pc-make-guardrails/SKILL.md +1 -0
- package/{harness/.agents/skills → skills}/pc-make-guardrails/category-reference.md +40 -1
- package/harness/.agents/skills/pc-guardrails-generic/SKILL.md +0 -47
- package/harness/.agents/skills/pc-make-architecture/structure-template.md +0 -38
- package/harness/.agents/skills/pc-make-evidence-scaffold/SKILL.md +0 -18
- package/harness/.agents/skills/pc-make-evidence-scaffold/evidence-contract.md +0 -29
- package/harness/.opencode/commands/make-evidence-scaffold.md +0 -5
- package/harness/.opencode/tui/pc-subagents.tsx +0 -98
- package/harness/.opencode/tui.json +0 -6
- /package/{harness/.agents/skills → skills}/browser-automation/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-guardrails-project/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-make-design/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-make-engineer/signal-mapping.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-make-merge-risk-assess/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-make-merge-risk-assess/category-reference.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-make-user-model/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-ops-evidence/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-ops-ship/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-apply/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-apply/simple-mode.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-archive/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-explore/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-goal/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-goal/branching.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-goal/failure-policy.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-goal/output-mode.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-goal/output.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-propose/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-propose/task-annotation.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-quick/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-plan-story/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-repo-audit/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-repo-help/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-repo-initialize/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-repo-onboard/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-repo-verify/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-userstory-az/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-userstory-browser/SKILL.md +0 -0
- /package/{harness/.agents/skills → skills}/pc-userstory-gh/SKILL.md +0 -0
- /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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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, ['
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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,219 +1,184 @@
|
|
|
1
|
-
// pc-subagent-monitor:
|
|
2
|
-
// live
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
}
|
|
51
|
-
continue
|
|
52
|
-
}
|
|
58
|
+
})
|
|
53
59
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
86
|
-
const
|
|
87
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
110
|
+
live = runningFor(event.sessionID) + livePending(event.sessionID)
|
|
158
111
|
if (live < cap) {
|
|
159
|
-
pending.push({ parentID:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
//
|
|
178
|
+
// stream aborted on unload
|
|
216
179
|
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
180
|
+
})()
|
|
181
|
+
|
|
182
|
+
return () => controller.abort()
|
|
183
|
+
},
|
|
184
|
+
})
|