@plainconceptsplatform/agent-harness 3.4.1 → 3.6.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 +455 -454
- 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/wizard.js +134 -134
- 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/gh.md +67 -67
- package/cli/fragments/ops-ship/gl.md +84 -84
- package/cli/index.js +116 -116
- 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/clean/index.js +120 -120
- package/cli/steps/copy/agents.js +120 -120
- package/cli/steps/copy/fullstack-engineer.js +89 -89
- package/cli/steps/copy/opencode-json.js +324 -324
- package/cli/steps/copy/skills.js +316 -316
- package/cli/steps/models/format.js +88 -88
- package/cli/steps/models/index.js +64 -64
- 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 +81 -81
- 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 +10 -10
- 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-archive.md +7 -7
- package/harness/.opencode/commands/plan-explore.md +11 -11
- 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/package.json +5 -5
- package/harness/.opencode/plugins/pc-commit-coauthor.js +41 -0
- package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
- package/harness/.opencode/plugins/pc-subagent-tiers.js +367 -367
- package/harness/.opencode/plugins/pc-system-reminders.js +386 -386
- package/harness/.opencode/scripts/check-diff.mjs +252 -244
- package/harness/.opencode/scripts/code-rules.mjs +288 -288
- package/harness/ARCHITECTURE.md +13 -13
- package/harness/DESIGN.md +13 -13
- package/harness/opencode.jsonc +36 -36
- package/harness/openspec/config.yaml +20 -20
- package/harness/skills-lock.json +17 -17
- package/package.json +14 -12
- package/skills/agent-harness-cli/SKILL.md +86 -86
- package/skills/pc-guardrails-generic/SKILL.md +53 -53
- package/skills/pc-guardrails-project/SKILL.md +8 -8
- package/skills/pc-make-architecture/structure-template.md +64 -64
- package/skills/pc-make-engineer/SKILL.md +59 -59
- package/skills/pc-make-engineer/template.md +36 -36
- 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-ops-ship/SKILL.md +22 -22
- package/skills/pc-plan-apply/SKILL.md +88 -86
- package/skills/pc-plan-apply/simple-mode.md +21 -21
- package/skills/pc-plan-explore/SKILL.md +21 -21
- package/skills/pc-plan-goal/SKILL.md +91 -91
- package/skills/pc-plan-goal/failure-policy.md +30 -30
- package/skills/pc-plan-goal/output-mode.md +10 -10
- package/skills/pc-plan-propose/SKILL.md +125 -125
- package/skills/pc-plan-propose/task-annotation.md +39 -39
- package/skills/pc-plan-quick/SKILL.md +46 -46
- package/skills/pc-repo-audit/SKILL.md +44 -44
- package/skills/pc-repo-help/SKILL.md +87 -87
- package/skills/pc-repo-verify/SKILL.md +150 -150
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Read and triage PR review feedback. Reports what needs fixing, does not implement fixes.
|
|
3
|
-
agent: planner
|
|
4
|
-
subagent: false
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
Repo platform is set in `.opencode/harness.json` → `platform.repo`. The platform-specific content below is injected by the CLI during onboarding.
|
|
8
|
-
|
|
9
|
-
<!-- PC-PLATFORM-REVIEW-START -->
|
|
10
|
-
<!-- PC-PLATFORM-REVIEW-END -->
|
|
1
|
+
---
|
|
2
|
+
description: Read and triage PR review feedback. Reports what needs fixing, does not implement fixes.
|
|
3
|
+
agent: planner
|
|
4
|
+
subagent: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Repo platform is set in `.opencode/harness.json` → `platform.repo`. The platform-specific content below is injected by the CLI during onboarding.
|
|
8
|
+
|
|
9
|
+
<!-- PC-PLATFORM-REVIEW-START -->
|
|
10
|
+
<!-- PC-PLATFORM-REVIEW-END -->
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Create a pull request for the current feature branch with screenshots if UI changed.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
Load the `pc-ops-ship` skill and execute it.
|
|
6
|
-
|
|
7
|
-
Input:
|
|
8
|
-
|
|
9
|
-
$ARGUMENTS
|
|
1
|
+
---
|
|
2
|
+
description: Create a pull request for the current feature branch with screenshots if UI changed.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Load the `pc-ops-ship` skill and execute it.
|
|
6
|
+
|
|
7
|
+
Input:
|
|
8
|
+
|
|
9
|
+
$ARGUMENTS
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Archive the oldest merged unarchived OpenSpec change and update documentation.
|
|
3
|
-
agent: planner
|
|
4
|
-
subagent: false
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
Load the `pc-plan-archive` skill and execute it in **interactive mode**. No input required.
|
|
1
|
+
---
|
|
2
|
+
description: Archive the oldest merged unarchived OpenSpec change and update documentation.
|
|
3
|
+
agent: planner
|
|
4
|
+
subagent: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Load the `pc-plan-archive` skill and execute it in **interactive mode**. No input required.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Think through an idea, investigate a problem, or clarify requirements before creating a change.
|
|
3
|
-
agent: planner
|
|
4
|
-
subagent: false
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
Load the `pc-plan-explore` skill and execute it in **interactive mode**.
|
|
8
|
-
|
|
9
|
-
Input:
|
|
10
|
-
|
|
11
|
-
$ARGUMENTS
|
|
1
|
+
---
|
|
2
|
+
description: Think through an idea, investigate a problem, or clarify requirements before creating a change.
|
|
3
|
+
agent: planner
|
|
4
|
+
subagent: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Load the `pc-plan-explore` skill and execute it in **interactive mode**.
|
|
8
|
+
|
|
9
|
+
Input:
|
|
10
|
+
|
|
11
|
+
$ARGUMENTS
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Parse a work item or idea and propose a change plan with enriched task assignments.
|
|
3
|
-
agent: planner
|
|
4
|
-
subagent: false
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
Load the `pc-plan-propose` skill and execute it in **interactive mode**.
|
|
8
|
-
|
|
9
|
-
Input:
|
|
10
|
-
|
|
11
|
-
$ARGUMENTS
|
|
1
|
+
---
|
|
2
|
+
description: Parse a work item or idea and propose a change plan with enriched task assignments.
|
|
3
|
+
agent: planner
|
|
4
|
+
subagent: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Load the `pc-plan-propose` skill and execute it in **interactive mode**.
|
|
8
|
+
|
|
9
|
+
Input:
|
|
10
|
+
|
|
11
|
+
$ARGUMENTS
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: "Quick plan: analyze the codebase and create a task checklist using the Todo pane. No files, no OpenSpec."
|
|
3
|
-
agent: planner
|
|
4
|
-
subagent: false
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
Load the `pc-plan-quick` skill.
|
|
1
|
+
---
|
|
2
|
+
description: "Quick plan: analyze the codebase and create a task checklist using the Todo pane. No files, no OpenSpec."
|
|
3
|
+
agent: planner
|
|
4
|
+
subagent: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Load the `pc-plan-quick` skill.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Write a detailed, repo-aware user story from a feature idea or need.
|
|
3
|
-
agent: planner
|
|
4
|
-
subagent: false
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
Load the `pc-plan-story` skill.
|
|
8
|
-
|
|
9
|
-
Input:
|
|
10
|
-
|
|
11
|
-
$ARGUMENTS
|
|
1
|
+
---
|
|
2
|
+
description: Write a detailed, repo-aware user story from a feature idea or need.
|
|
3
|
+
agent: planner
|
|
4
|
+
subagent: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Load the `pc-plan-story` skill.
|
|
8
|
+
|
|
9
|
+
Input:
|
|
10
|
+
|
|
11
|
+
$ARGUMENTS
|
|
@@ -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
|
-
"dependencies": {
|
|
3
|
-
"@opencode/plugin": "2.0.22"
|
|
4
|
-
}
|
|
5
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@opencode/plugin": "2.0.22"
|
|
4
|
+
}
|
|
5
|
+
}
|
|
@@ -0,0 +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,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
|
+
})
|