@plainconceptsplatform/agent-harness 3.9.0 → 3.11.0
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/LICENSE +21 -21
- package/README.md +555 -551
- package/cli/commands/join.js +259 -259
- package/cli/commands/migrate.js +310 -310
- package/cli/commands/shared.js +27 -27
- package/cli/commands/single.js +79 -79
- package/cli/commands/update.js +121 -121
- package/cli/commands/wizard.js +134 -134
- package/cli/fragments/archive/az.md +97 -97
- package/cli/fragments/archive/gh.md +96 -96
- package/cli/fragments/archive/gl.md +96 -96
- package/cli/fragments/archive/none.md +75 -75
- package/cli/fragments/guardrails/codegraph.md +5 -5
- package/cli/fragments/guardrails/humanizer.md +4 -4
- package/cli/fragments/guardrails/memory.md +4 -4
- package/cli/fragments/guardrails/rtk.md +3 -3
- package/cli/fragments/guardrails/simple-english.md +4 -4
- package/cli/fragments/ops-backlog/az.md +28 -28
- package/cli/fragments/ops-backlog/gh.md +28 -28
- package/cli/fragments/ops-backlog/jira.md +28 -28
- package/cli/fragments/ops-review/az.md +62 -62
- package/cli/fragments/ops-review/gh.md +51 -51
- package/cli/fragments/ops-review/gl.md +55 -55
- package/cli/fragments/ops-ship/az.md +81 -81
- package/cli/fragments/ops-ship/gh.md +67 -67
- package/cli/fragments/ops-ship/gl.md +84 -84
- package/cli/index.js +116 -116
- package/cli/presets/agents-content.json +34 -34
- package/cli/presets/browser.json +10 -10
- package/cli/presets/clean.json +21 -21
- package/cli/presets/models.json +68 -68
- package/cli/presets/optimization.json +32 -32
- package/cli/presets/platforms.json +76 -76
- package/cli/presets/source.json +23 -23
- package/cli/steps/browser/index.js +52 -48
- package/cli/steps/clean/index.js +120 -120
- package/cli/steps/copy/agents.js +176 -176
- package/cli/steps/copy/commands.js +79 -79
- package/cli/steps/copy/fullstack-engineer.js +81 -81
- package/cli/steps/copy/index.js +88 -88
- package/cli/steps/copy/opencode-json.js +359 -324
- package/cli/steps/copy/skills.js +353 -316
- package/cli/steps/metadata/index.js +115 -115
- package/cli/steps/models/format.js +88 -88
- package/cli/steps/models/index.js +64 -64
- package/cli/steps/models/write.js +38 -38
- package/cli/steps/openspec/index.js +136 -136
- package/cli/steps/optimization/codegraph.js +141 -141
- package/cli/steps/optimization/detect.js +66 -66
- package/cli/steps/optimization/humanizer.js +17 -17
- package/cli/steps/optimization/index.js +158 -158
- package/cli/steps/optimization/memory.js +91 -91
- package/cli/steps/optimization/patch-guardrails.js +111 -111
- package/cli/steps/optimization/simple-english.js +17 -17
- package/cli/steps/optimization/skills-lock.js +30 -30
- package/cli/steps/platform/index.js +109 -109
- package/cli/steps/source/index.js +123 -123
- package/cli/utils/agent-color.js +111 -111
- package/cli/utils/copy.js +134 -134
- package/cli/utils/exec-spinner.js +47 -47
- package/cli/utils/exec.js +134 -134
- package/cli/utils/legacy-check.js +30 -30
- package/cli/utils/models-cache.js +58 -58
- package/cli/utils/models-pricing.js +42 -42
- package/cli/utils/paths.js +82 -82
- package/cli/utils/process.js +3 -3
- package/cli/utils/terminal.js +6 -6
- package/cli/utils/update-manifest.js +75 -75
- package/harness/.opencode/_gitignore +12 -12
- package/harness/.opencode/commands/init.md +5 -5
- package/harness/.opencode/commands/make-architecture.md +5 -5
- package/harness/.opencode/commands/make-design.md +5 -5
- package/harness/.opencode/commands/make-engineer.md +5 -5
- package/harness/.opencode/commands/make-guardrails.md +5 -5
- package/harness/.opencode/commands/make-user-model.md +5 -5
- package/harness/.opencode/commands/ops-backlog.md +10 -10
- package/harness/.opencode/commands/ops-review.md +10 -10
- package/harness/.opencode/commands/ops-ship.md +9 -9
- package/harness/.opencode/commands/plan-apply.md +9 -9
- package/harness/.opencode/commands/plan-archive.md +7 -7
- package/harness/.opencode/commands/plan-explore.md +11 -11
- package/harness/.opencode/commands/plan-goal.md +5 -5
- package/harness/.opencode/commands/plan-mockup.md +11 -0
- package/harness/.opencode/commands/plan-propose.md +11 -11
- package/harness/.opencode/commands/plan-quick.md +7 -7
- package/harness/.opencode/commands/plan-story.md +11 -11
- package/harness/.opencode/commands/repo-audit.md +7 -7
- package/harness/.opencode/commands/repo-help.md +5 -5
- package/harness/.opencode/commands/repo-initialize.md +5 -5
- package/harness/.opencode/commands/repo-onboard.md +5 -5
- package/harness/.opencode/commands/repo-verify.md +5 -5
- package/harness/.opencode/plugins/pc-commit-coauthor.js +41 -41
- package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
- package/harness/.opencode/plugins/pc-system-reminders.js +424 -424
- package/harness/.opencode/scripts/check-diff.mjs +305 -305
- package/harness/.opencode/scripts/code-rules.mjs +288 -288
- package/harness/.opencode/scripts/platform-contract.mjs +366 -366
- package/harness/AGENTS.md +55 -55
- package/harness/ARCHITECTURE.md +13 -13
- package/harness/DESIGN.md +13 -13
- package/harness/opencode.jsonc +37 -36
- package/harness/openspec/config.yaml +20 -20
- package/harness/skills-lock.json +23 -17
- package/package.json +14 -12
- package/skills/README.md +18 -18
- package/skills/agent-harness-cli/SKILL.md +86 -86
- package/skills/browser-automation/SKILL.md +100 -72
- package/skills/pc-guardrails-generic/SKILL.md +53 -53
- package/skills/pc-guardrails-project/SKILL.md +8 -8
- package/skills/pc-make-architecture/SKILL.md +33 -33
- package/skills/pc-make-architecture/structure-template.md +64 -64
- package/skills/pc-make-design/SKILL.md +45 -45
- package/skills/pc-make-engineer/SKILL.md +59 -59
- package/skills/pc-make-engineer/signal-mapping.md +53 -53
- package/skills/pc-make-engineer/template.md +36 -36
- package/skills/pc-make-guardrails/SKILL.md +44 -44
- package/skills/pc-make-guardrails/category-reference.md +112 -112
- package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
- package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
- package/skills/pc-make-user-model/SKILL.md +56 -56
- package/skills/pc-ops-ship/SKILL.md +22 -22
- package/skills/pc-plan-apply/SKILL.md +92 -90
- package/skills/pc-plan-apply/simple-mode.md +21 -21
- package/skills/pc-plan-archive/SKILL.md +66 -66
- package/skills/pc-plan-explore/SKILL.md +22 -21
- package/skills/pc-plan-goal/SKILL.md +91 -91
- package/skills/pc-plan-goal/branching.md +39 -39
- package/skills/pc-plan-goal/failure-policy.md +30 -30
- package/skills/pc-plan-goal/output-mode.md +10 -10
- package/skills/pc-plan-goal/output.md +71 -71
- package/skills/pc-plan-mockup/SKILL.md +85 -0
- package/skills/pc-plan-mockup/mockup-contract.md +65 -0
- package/skills/pc-plan-propose/SKILL.md +127 -127
- package/skills/pc-plan-propose/task-annotation.md +39 -39
- package/skills/pc-plan-quick/SKILL.md +46 -46
- package/skills/pc-plan-story/SKILL.md +96 -93
- package/skills/pc-repo-audit/SKILL.md +44 -44
- package/skills/pc-repo-help/SKILL.md +90 -87
- package/skills/pc-repo-initialize/SKILL.md +119 -119
- package/skills/pc-repo-onboard/SKILL.md +32 -32
- package/skills/pc-repo-verify/SKILL.md +150 -150
- package/skills/pc-userstory-az/SKILL.md +71 -71
- package/skills/pc-userstory-browser/SKILL.md +73 -64
- package/skills/pc-userstory-gh/SKILL.md +63 -63
- package/skills/pc-userstory-jira/SKILL.md +74 -74
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Audit every configured source root against the fullstack engineer's guardrails and abilities. Read-only.
|
|
3
|
-
agent: planner
|
|
4
|
-
subagent: false
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
Load the `pc-repo-audit` skill.
|
|
1
|
+
---
|
|
2
|
+
description: Audit every configured source root against the fullstack engineer's guardrails and abilities. Read-only.
|
|
3
|
+
agent: planner
|
|
4
|
+
subagent: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Load the `pc-repo-audit` skill.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Show all available commands and when to use each one.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
Load the `pc-repo-help` skill and display its reference to the user exactly as written. Do not summarize.
|
|
1
|
+
---
|
|
2
|
+
description: Show all available commands and when to use each one.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Load the `pc-repo-help` skill and display its reference to the user exactly as written. Do not summarize.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Initialize the project. Presents a single form with all setup questions, then executes selected steps.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
Load the `pc-repo-initialize` skill.
|
|
1
|
+
---
|
|
2
|
+
description: Initialize the project. Presents a single form with all setup questions, then executes selected steps.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Load the `pc-repo-initialize` skill.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Walk the user through the project and its agentic infrastructure. Explains what exists, how agents work, and how to use the system.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
Load the `pc-repo-onboard` skill.
|
|
1
|
+
---
|
|
2
|
+
description: Walk the user through the project and its agentic infrastructure. Explains what exists, how agents work, and how to use the system.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Load the `pc-repo-onboard` skill.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Verify current-branch changes, plus immutable dependency installs, builds, and tests for every discovered project.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
Load the `pc-repo-verify` skill.
|
|
1
|
+
---
|
|
2
|
+
description: Verify current-branch changes, plus immutable dependency installs, builds, and tests for every discovered project.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Load the `pc-repo-verify` skill.
|
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
// pc-commit-coauthor: every commit an OpenCode agent makes names OpenCode as a
|
|
2
|
-
// co-author, so agent work stays identifiable in history and in the metrics
|
|
3
|
-
// built from it.
|
|
4
|
-
//
|
|
5
|
-
// The rule is enforced, not requested. `tool.execute.before` rewrites the shell
|
|
6
|
-
// tool's command before it runs (and before the permission check sees it):
|
|
7
|
-
// each `git commit` gains a `--trailer`, which git folds into the message
|
|
8
|
-
// however it was written (`-m`, `-F`, an editor, `--amend`). It fails open: a
|
|
9
|
-
// bug here leaves the command as the agent wrote it.
|
|
10
|
-
|
|
11
|
-
import { Plugin } from "@opencode/plugin"
|
|
12
|
-
|
|
13
|
-
export const TRAILER = "Co-Authored-By: opencode <noreply@opencode.ai>"
|
|
14
|
-
|
|
15
|
-
const SHELL_TOOL = "shell"
|
|
16
|
-
|
|
17
|
-
// `git commit` at the start of the command or of any segment, after git's own
|
|
18
|
-
// `-C <path>` / `-c <key=value>` options. `commit-tree` and `commit-graph` are
|
|
19
|
-
// other commands and do not match.
|
|
20
|
-
const GIT_COMMIT = /(^|[;&|\n({`])(\s*git(?:\s+-[Cc]\s+(?:"[^"]*"|'[^']*'|[^\s"']+)+)*\s+commit)(?![\w-])/g
|
|
21
|
-
|
|
22
|
-
export function withTrailer(command) {
|
|
23
|
-
if (typeof command !== "string" || command.includes("noreply@opencode.ai")) return command
|
|
24
|
-
return command.replace(GIT_COMMIT, (_, before, commit) => `${before}${commit} --trailer "${TRAILER}"`)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export default Plugin.define({
|
|
28
|
-
id: "pc.commit-coauthor",
|
|
29
|
-
async setup(ctx) {
|
|
30
|
-
await ctx.tool.hook("execute.before", event => {
|
|
31
|
-
try {
|
|
32
|
-
if (event.tool !== SHELL_TOOL) return
|
|
33
|
-
const command = event.input?.command
|
|
34
|
-
const next = withTrailer(command)
|
|
35
|
-
if (next !== command) event.input = { ...event.input, command: next }
|
|
36
|
-
} catch (error) {
|
|
37
|
-
console.error(`[harness] commit co-author hook failed open: ${error?.message}`)
|
|
38
|
-
}
|
|
39
|
-
})
|
|
40
|
-
},
|
|
41
|
-
})
|
|
1
|
+
// pc-commit-coauthor: every commit an OpenCode agent makes names OpenCode as a
|
|
2
|
+
// co-author, so agent work stays identifiable in history and in the metrics
|
|
3
|
+
// built from it.
|
|
4
|
+
//
|
|
5
|
+
// The rule is enforced, not requested. `tool.execute.before` rewrites the shell
|
|
6
|
+
// tool's command before it runs (and before the permission check sees it):
|
|
7
|
+
// each `git commit` gains a `--trailer`, which git folds into the message
|
|
8
|
+
// however it was written (`-m`, `-F`, an editor, `--amend`). It fails open: a
|
|
9
|
+
// bug here leaves the command as the agent wrote it.
|
|
10
|
+
|
|
11
|
+
import { Plugin } from "@opencode/plugin"
|
|
12
|
+
|
|
13
|
+
export const TRAILER = "Co-Authored-By: opencode <noreply@opencode.ai>"
|
|
14
|
+
|
|
15
|
+
const SHELL_TOOL = "shell"
|
|
16
|
+
|
|
17
|
+
// `git commit` at the start of the command or of any segment, after git's own
|
|
18
|
+
// `-C <path>` / `-c <key=value>` options. `commit-tree` and `commit-graph` are
|
|
19
|
+
// other commands and do not match.
|
|
20
|
+
const GIT_COMMIT = /(^|[;&|\n({`])(\s*git(?:\s+-[Cc]\s+(?:"[^"]*"|'[^']*'|[^\s"']+)+)*\s+commit)(?![\w-])/g
|
|
21
|
+
|
|
22
|
+
export function withTrailer(command) {
|
|
23
|
+
if (typeof command !== "string" || command.includes("noreply@opencode.ai")) return command
|
|
24
|
+
return command.replace(GIT_COMMIT, (_, before, commit) => `${before}${commit} --trailer "${TRAILER}"`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default Plugin.define({
|
|
28
|
+
id: "pc.commit-coauthor",
|
|
29
|
+
async setup(ctx) {
|
|
30
|
+
await ctx.tool.hook("execute.before", event => {
|
|
31
|
+
try {
|
|
32
|
+
if (event.tool !== SHELL_TOOL) return
|
|
33
|
+
const command = event.input?.command
|
|
34
|
+
const next = withTrailer(command)
|
|
35
|
+
if (next !== command) event.input = { ...event.input, command: next }
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(`[harness] commit co-author hook failed open: ${error?.message}`)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
})
|
|
@@ -1,184 +1,184 @@
|
|
|
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
|
-
//
|
|
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"
|
|
16
|
-
import fs from "node:fs/promises"
|
|
17
|
-
import path from "node:path"
|
|
18
|
-
|
|
19
|
-
const PENDING_TTL_MS = 15_000
|
|
20
|
-
|
|
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
|
-
}
|
|
38
|
-
}
|
|
39
|
-
} catch {
|
|
40
|
-
// fresh start
|
|
41
|
-
}
|
|
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
|
|
57
|
-
}
|
|
58
|
-
})
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function pruneStale() {
|
|
62
|
-
for (const [id, entry] of Object.entries(state.agents)) {
|
|
63
|
-
if (entry?.stale) delete state.agents[id]
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function maxConcurrent() {
|
|
68
|
-
if (capCache) return capCache
|
|
69
|
-
let configured = 3
|
|
70
|
-
try {
|
|
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
|
|
74
|
-
} catch {
|
|
75
|
-
// no config yet; the CLI's own default is 3
|
|
76
|
-
}
|
|
77
|
-
capCache = Math.min(5, Math.max(1, Math.trunc(configured)))
|
|
78
|
-
return capCache
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function runningFor(parentID) {
|
|
82
|
-
return Object.values(state.agents)
|
|
83
|
-
.filter(entry => entry?.status === "running" && !entry.stale && entry.parentID === parentID)
|
|
84
|
-
.length
|
|
85
|
-
}
|
|
86
|
-
|
|
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
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function releasePending(parentID) {
|
|
96
|
-
const index = pending.findIndex(entry => entry.parentID === parentID)
|
|
97
|
-
if (index !== -1) pending.splice(index, 1)
|
|
98
|
-
}
|
|
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
|
|
105
|
-
let cap
|
|
106
|
-
let live
|
|
107
|
-
try {
|
|
108
|
-
pruneStale()
|
|
109
|
-
cap = await maxConcurrent()
|
|
110
|
-
live = runningFor(event.sessionID) + livePending(event.sessionID)
|
|
111
|
-
if (live < cap) {
|
|
112
|
-
pending.push({ parentID: event.sessionID, at: Date.now() })
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
} catch (error) {
|
|
116
|
-
console.error(`[harness] concurrency check failed open: ${error?.message}`)
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
deny(event, live, cap)
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
function deny(event, live, cap) {
|
|
123
|
-
event.effect = "deny"
|
|
124
|
-
event.message =
|
|
125
|
-
`[harness] ${live} subagents are already in flight and agents.maxConcurrent is ${cap}.\n` +
|
|
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 () => {
|
|
140
|
-
try {
|
|
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
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
} catch {
|
|
178
|
-
// stream aborted on unload
|
|
179
|
-
}
|
|
180
|
-
})()
|
|
181
|
-
|
|
182
|
-
return () => controller.abort()
|
|
183
|
-
},
|
|
184
|
-
})
|
|
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
|
+
//
|
|
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"
|
|
16
|
+
import fs from "node:fs/promises"
|
|
17
|
+
import path from "node:path"
|
|
18
|
+
|
|
19
|
+
const PENDING_TTL_MS = 15_000
|
|
20
|
+
|
|
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
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// fresh start
|
|
41
|
+
}
|
|
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
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pruneStale() {
|
|
62
|
+
for (const [id, entry] of Object.entries(state.agents)) {
|
|
63
|
+
if (entry?.stale) delete state.agents[id]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function maxConcurrent() {
|
|
68
|
+
if (capCache) return capCache
|
|
69
|
+
let configured = 3
|
|
70
|
+
try {
|
|
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
|
|
74
|
+
} catch {
|
|
75
|
+
// no config yet; the CLI's own default is 3
|
|
76
|
+
}
|
|
77
|
+
capCache = Math.min(5, Math.max(1, Math.trunc(configured)))
|
|
78
|
+
return capCache
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function runningFor(parentID) {
|
|
82
|
+
return Object.values(state.agents)
|
|
83
|
+
.filter(entry => entry?.status === "running" && !entry.stale && entry.parentID === parentID)
|
|
84
|
+
.length
|
|
85
|
+
}
|
|
86
|
+
|
|
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
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function releasePending(parentID) {
|
|
96
|
+
const index = pending.findIndex(entry => entry.parentID === parentID)
|
|
97
|
+
if (index !== -1) pending.splice(index, 1)
|
|
98
|
+
}
|
|
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
|
|
105
|
+
let cap
|
|
106
|
+
let live
|
|
107
|
+
try {
|
|
108
|
+
pruneStale()
|
|
109
|
+
cap = await maxConcurrent()
|
|
110
|
+
live = runningFor(event.sessionID) + livePending(event.sessionID)
|
|
111
|
+
if (live < cap) {
|
|
112
|
+
pending.push({ parentID: event.sessionID, at: Date.now() })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(`[harness] concurrency check failed open: ${error?.message}`)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
deny(event, live, cap)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
function deny(event, live, cap) {
|
|
123
|
+
event.effect = "deny"
|
|
124
|
+
event.message =
|
|
125
|
+
`[harness] ${live} subagents are already in flight and agents.maxConcurrent is ${cap}.\n` +
|
|
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 () => {
|
|
140
|
+
try {
|
|
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
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// stream aborted on unload
|
|
179
|
+
}
|
|
180
|
+
})()
|
|
181
|
+
|
|
182
|
+
return () => controller.abort()
|
|
183
|
+
},
|
|
184
|
+
})
|