@plainconceptsplatform/agent-harness 2.0.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 -0
- package/README.md +419 -0
- package/package.json +68 -0
- package/src/commands/join.js +244 -0
- package/src/commands/shared.js +27 -0
- package/src/commands/single.js +79 -0
- package/src/commands/update.js +109 -0
- package/src/commands/wizard.js +134 -0
- package/src/content/.agents/skills/browser-automation/SKILL.md +66 -0
- package/src/content/.agents/skills/pc-guardrails-generic/SKILL.md +68 -0
- package/src/content/.agents/skills/pc-guardrails-project/SKILL.md +8 -0
- package/src/content/.agents/skills/pc-make-architecture/SKILL.md +51 -0
- package/src/content/.agents/skills/pc-make-architecture/structure-template.md +38 -0
- package/src/content/.agents/skills/pc-make-design/SKILL.md +68 -0
- package/src/content/.agents/skills/pc-make-engineer/SKILL.md +219 -0
- package/src/content/.agents/skills/pc-make-engineer/signal-mapping.md +68 -0
- package/src/content/.agents/skills/pc-make-engineer/template.md +81 -0
- package/src/content/.agents/skills/pc-make-evidence-scaffold/SKILL.md +18 -0
- package/src/content/.agents/skills/pc-make-evidence-scaffold/evidence-contract.md +29 -0
- package/src/content/.agents/skills/pc-make-guardrails/SKILL.md +74 -0
- package/src/content/.agents/skills/pc-make-guardrails/category-reference.md +68 -0
- package/src/content/.agents/skills/pc-make-merge-risk-assess/SKILL.md +70 -0
- package/src/content/.agents/skills/pc-make-merge-risk-assess/category-reference.md +98 -0
- package/src/content/.agents/skills/pc-make-user-model/SKILL.md +66 -0
- package/src/content/.agents/skills/pc-ops-evidence/SKILL.md +127 -0
- package/src/content/.agents/skills/pc-ops-ship/SKILL.md +18 -0
- package/src/content/.agents/skills/pc-plan-apply/SKILL.md +83 -0
- package/src/content/.agents/skills/pc-plan-apply/simple-mode.md +21 -0
- package/src/content/.agents/skills/pc-plan-archive/SKILL.md +63 -0
- package/src/content/.agents/skills/pc-plan-explore/SKILL.md +9 -0
- package/src/content/.agents/skills/pc-plan-goal/SKILL.md +94 -0
- package/src/content/.agents/skills/pc-plan-goal/branching.md +30 -0
- package/src/content/.agents/skills/pc-plan-goal/failure-policy.md +30 -0
- package/src/content/.agents/skills/pc-plan-goal/output-mode.md +9 -0
- package/src/content/.agents/skills/pc-plan-goal/output.md +68 -0
- package/src/content/.agents/skills/pc-plan-propose/SKILL.md +125 -0
- package/src/content/.agents/skills/pc-plan-propose/task-annotation.md +39 -0
- package/src/content/.agents/skills/pc-plan-quick/SKILL.md +62 -0
- package/src/content/.agents/skills/pc-plan-story/SKILL.md +146 -0
- package/src/content/.agents/skills/pc-repo-audit/SKILL.md +44 -0
- package/src/content/.agents/skills/pc-repo-help/SKILL.md +91 -0
- package/src/content/.agents/skills/pc-repo-initialize/SKILL.md +130 -0
- package/src/content/.agents/skills/pc-repo-onboard/SKILL.md +87 -0
- package/src/content/.agents/skills/pc-repo-verify/SKILL.md +34 -0
- package/src/content/.agents/skills/pc-userstory-az/SKILL.md +157 -0
- package/src/content/.agents/skills/pc-userstory-browser/SKILL.md +132 -0
- package/src/content/.agents/skills/pc-userstory-gh/SKILL.md +120 -0
- package/src/content/.agents/skills/pc-userstory-jira/SKILL.md +131 -0
- package/src/content/.opencode/_gitignore +7 -0
- package/src/content/.opencode/commands/init.md +5 -0
- package/src/content/.opencode/commands/make-architecture.md +5 -0
- package/src/content/.opencode/commands/make-design.md +5 -0
- package/src/content/.opencode/commands/make-engineer.md +5 -0
- package/src/content/.opencode/commands/make-evidence-scaffold.md +5 -0
- package/src/content/.opencode/commands/make-guardrails.md +5 -0
- package/src/content/.opencode/commands/make-user-model.md +5 -0
- package/src/content/.opencode/commands/ops-backlog.md +10 -0
- package/src/content/.opencode/commands/ops-evidence.md +9 -0
- package/src/content/.opencode/commands/ops-review.md +8 -0
- package/src/content/.opencode/commands/ops-ship.md +9 -0
- package/src/content/.opencode/commands/plan-apply.md +9 -0
- package/src/content/.opencode/commands/plan-archive.md +5 -0
- package/src/content/.opencode/commands/plan-explore.md +9 -0
- package/src/content/.opencode/commands/plan-goal.md +5 -0
- package/src/content/.opencode/commands/plan-propose.md +9 -0
- package/src/content/.opencode/commands/plan-quick.md +5 -0
- package/src/content/.opencode/commands/plan-story.md +9 -0
- package/src/content/.opencode/commands/repo-audit.md +5 -0
- package/src/content/.opencode/commands/repo-help.md +5 -0
- package/src/content/.opencode/commands/repo-initialize.md +5 -0
- package/src/content/.opencode/commands/repo-onboard.md +5 -0
- package/src/content/.opencode/commands/repo-verify.md +5 -0
- package/src/content/.opencode/package.json +10 -0
- package/src/content/.opencode/plugins/pc-subagent-monitor.js +139 -0
- package/src/content/.opencode/plugins/pc-subagent-tiers.js +179 -0
- package/src/content/.opencode/plugins/pc-system-reminders.js +96 -0
- package/src/content/.opencode/plugins/pc-system-reminders.test.js +35 -0
- package/src/content/.opencode/tui/pc-subagents.tsx +98 -0
- package/src/content/.opencode/tui.json +6 -0
- package/src/content/AGENTS.md +71 -0
- package/src/content/ARCHITECTURE.md +16 -0
- package/src/content/DESIGN.md +16 -0
- package/src/content/opencode.jsonc +31 -0
- package/src/content/openspec/changes/archive/.gitkeep +0 -0
- package/src/content/openspec/config.yaml +20 -0
- package/src/content/openspec/specs/.gitkeep +0 -0
- package/src/content/skills-lock.json +17 -0
- package/src/fragments/archive/az.md +95 -0
- package/src/fragments/archive/gh.md +94 -0
- package/src/fragments/archive/gl.md +94 -0
- package/src/fragments/archive/none.md +73 -0
- package/src/fragments/guardrails/codegraph.md +7 -0
- package/src/fragments/guardrails/humanizer.md +4 -0
- package/src/fragments/guardrails/memory.md +4 -0
- package/src/fragments/guardrails/rtk.md +3 -0
- package/src/fragments/guardrails/simple-english.md +4 -0
- package/src/fragments/ops-backlog/az.md +29 -0
- package/src/fragments/ops-backlog/gh.md +30 -0
- package/src/fragments/ops-backlog/jira.md +29 -0
- package/src/fragments/ops-evidence/az.md +41 -0
- package/src/fragments/ops-evidence/gh.md +53 -0
- package/src/fragments/ops-evidence/jira.md +38 -0
- package/src/fragments/ops-review/az.md +63 -0
- package/src/fragments/ops-review/gh.md +53 -0
- package/src/fragments/ops-review/gl.md +57 -0
- package/src/fragments/ops-ship/az.md +81 -0
- package/src/fragments/ops-ship/gh.md +69 -0
- package/src/fragments/ops-ship/gl.md +86 -0
- package/src/index.js +107 -0
- package/src/presets/agents-content.json +53 -0
- package/src/presets/browser.json +22 -0
- package/src/presets/clean.json +21 -0
- package/src/presets/models.json +68 -0
- package/src/presets/openspec.json +1 -0
- package/src/presets/optimization.json +37 -0
- package/src/presets/platforms.json +76 -0
- package/src/presets/quota.json +16 -0
- package/src/presets/source.json +23 -0
- package/src/steps/browser/index.js +91 -0
- package/src/steps/clean/index.js +120 -0
- package/src/steps/copy/agents.js +118 -0
- package/src/steps/copy/commands.js +91 -0
- package/src/steps/copy/fullstack-engineer.js +83 -0
- package/src/steps/copy/index.js +88 -0
- package/src/steps/copy/opencode-json.js +129 -0
- package/src/steps/copy/skills.js +196 -0
- package/src/steps/metadata/index.js +108 -0
- package/src/steps/models/format.js +88 -0
- package/src/steps/models/index.js +64 -0
- package/src/steps/models/write.js +34 -0
- package/src/steps/openspec/index.js +136 -0
- package/src/steps/optimization/codegraph.js +127 -0
- package/src/steps/optimization/humanizer.js +17 -0
- package/src/steps/optimization/index.js +163 -0
- package/src/steps/optimization/memory.js +88 -0
- package/src/steps/optimization/patch-guardrails.js +108 -0
- package/src/steps/optimization/quota.js +119 -0
- package/src/steps/optimization/simple-english.js +17 -0
- package/src/steps/optimization/skills-lock.js +30 -0
- package/src/steps/platform/index.js +109 -0
- package/src/steps/source/index.js +123 -0
- package/src/utils/copy.js +108 -0
- package/src/utils/exec-spinner.js +47 -0
- package/src/utils/exec.js +134 -0
- package/src/utils/legacy-check.js +30 -0
- package/src/utils/models-cache.js +58 -0
- package/src/utils/models-pricing.js +42 -0
- package/src/utils/paths.js +64 -0
- package/src/utils/process.js +3 -0
- package/src/utils/terminal.js +6 -0
- package/src/utils/update-manifest.js +49 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// pc-subagent-monitor: tracks spawned subagents in .opencode/harness-run.json for
|
|
2
|
+
// live TUI display and crash recovery. Never throws: monitor failures must
|
|
3
|
+
// not break a session.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
|
|
8
|
+
export const PcSubagentMonitor = async ({ directory, client }) => {
|
|
9
|
+
const root = directory || process.cwd()
|
|
10
|
+
const statePath = path.join(root, ".opencode", "harness-run.json")
|
|
11
|
+
const state = { updatedAt: null, agents: {} }
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const prev = JSON.parse(await fs.readFile(statePath, "utf-8"))
|
|
15
|
+
if (prev?.agents && typeof prev.agents === "object") {
|
|
16
|
+
for (const [id, entry] of Object.entries(prev.agents)) {
|
|
17
|
+
if (entry?.status === "running") entry.stale = true
|
|
18
|
+
state.agents[id] = entry
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// fresh start
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let _modelsCache = null
|
|
26
|
+
async function loadModels() {
|
|
27
|
+
if (_modelsCache) return _modelsCache
|
|
28
|
+
const result = { build: null, fast: null, plan: null }
|
|
29
|
+
for (const file of ["harness.user.json", "harness.json"]) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await fs.readFile(path.join(root, ".opencode", file), "utf-8")
|
|
32
|
+
const { models = {} } = JSON.parse(raw)
|
|
33
|
+
for (const tier of ["build", "fast", "plan"]) {
|
|
34
|
+
if (!result[tier] && models[tier]) result[tier] = models[tier]
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
_modelsCache = result
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function modelForAgent(agent) {
|
|
45
|
+
if (!agent) return null
|
|
46
|
+
const dotIdx = agent.lastIndexOf(".")
|
|
47
|
+
const tier = dotIdx !== -1 ? agent.slice(dotIdx + 1) : null
|
|
48
|
+
if (tier && ["build", "fast", "plan"].includes(tier)) {
|
|
49
|
+
const models = await loadModels()
|
|
50
|
+
return models[tier] ?? null
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseTasks(title) {
|
|
56
|
+
if (!title) return []
|
|
57
|
+
const m = /^\s*([\d]+(?:\.[\d]+)*(?:\s*,\s*[\d]+(?:\.[\d]+)*)*)/.exec(title)
|
|
58
|
+
return m ? m[1].split(",").map(s => s.trim()) : []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Debounced persist: coalesce rapid writes when multiple subagents spawn
|
|
62
|
+
// in the same microtask batch (e.g. a wave of 5 task() calls).
|
|
63
|
+
let _persistScheduled = false
|
|
64
|
+
async function persist() {
|
|
65
|
+
if (_persistScheduled) return
|
|
66
|
+
_persistScheduled = true
|
|
67
|
+
queueMicrotask(async () => {
|
|
68
|
+
_persistScheduled = false
|
|
69
|
+
state.updatedAt = new Date().toISOString()
|
|
70
|
+
try {
|
|
71
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true })
|
|
72
|
+
const tmpPath = `${statePath}.tmp`
|
|
73
|
+
await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8")
|
|
74
|
+
await fs.rename(tmpPath, statePath)
|
|
75
|
+
} catch {
|
|
76
|
+
// best-effort
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sessionInfo(props) {
|
|
82
|
+
const info = props?.info ?? props ?? {}
|
|
83
|
+
return {
|
|
84
|
+
id: info.id ?? info.sessionID ?? props?.sessionID,
|
|
85
|
+
parentID: info.parentID ?? info.parentId,
|
|
86
|
+
agent: info.agent,
|
|
87
|
+
title: info.title,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pruneStale() {
|
|
92
|
+
for (const [id, entry] of Object.entries(state.agents)) {
|
|
93
|
+
if (entry?.stale) delete state.agents[id]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
event: async ({ event }) => {
|
|
99
|
+
try {
|
|
100
|
+
if (!event?.type?.startsWith("session.")) return
|
|
101
|
+
const info = sessionInfo(event.properties)
|
|
102
|
+
if (!info.id) return
|
|
103
|
+
|
|
104
|
+
if (event.type === "session.created" && info.parentID) {
|
|
105
|
+
pruneStale()
|
|
106
|
+
|
|
107
|
+
state.agents[info.id] = {
|
|
108
|
+
agent: info.agent ?? null,
|
|
109
|
+
model: await modelForAgent(info.agent),
|
|
110
|
+
tasks: parseTasks(info.title),
|
|
111
|
+
title: info.title ?? null,
|
|
112
|
+
status: "running",
|
|
113
|
+
startedAt: new Date().toISOString(),
|
|
114
|
+
endedAt: null,
|
|
115
|
+
}
|
|
116
|
+
await persist()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const entry = state.agents[info.id]
|
|
121
|
+
if (!entry) return
|
|
122
|
+
|
|
123
|
+
if (event.type === "session.idle" && entry.status === "running") {
|
|
124
|
+
entry.status = "done"
|
|
125
|
+
entry.endedAt = new Date().toISOString()
|
|
126
|
+
await persist()
|
|
127
|
+
client?.tui?.showToast?.({
|
|
128
|
+
body: {
|
|
129
|
+
message: `subagent done: ${entry.title ?? info.id}`,
|
|
130
|
+
variant: "success",
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// monitoring must never disrupt the run
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// pc-subagent-tiers: on startup, reads *-engineer.md templates and creates
|
|
2
|
+
// tier variant files with the resolved model. Variants are gitignored and
|
|
3
|
+
// regenerated every startup. Model resolution: user override > team config.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
|
|
8
|
+
const TIERS = ["build", "fast", "plan"]
|
|
9
|
+
|
|
10
|
+
export const PcSubagentTiers = async ({ directory }) => {
|
|
11
|
+
const root = directory || process.cwd()
|
|
12
|
+
const agentsDir = path.join(root, ".opencode", "agents")
|
|
13
|
+
|
|
14
|
+
async function readJson(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(await fs.readFile(filePath, "utf-8"))
|
|
17
|
+
} catch {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function resolveModels() {
|
|
23
|
+
const [user, team] = await Promise.all([
|
|
24
|
+
readJson(path.join(root, ".opencode", "harness.user.json")),
|
|
25
|
+
readJson(path.join(root, ".opencode", "harness.json")),
|
|
26
|
+
])
|
|
27
|
+
const models = {}
|
|
28
|
+
for (const tier of TIERS) {
|
|
29
|
+
models[tier] = user?.models?.[tier] ?? team?.models?.[tier] ?? null
|
|
30
|
+
}
|
|
31
|
+
return models
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function scanEngineers() {
|
|
35
|
+
try {
|
|
36
|
+
const entries = await fs.readdir(agentsDir)
|
|
37
|
+
return {
|
|
38
|
+
templates: entries.filter(f => /^[\w-]+-engineer\.md$/.test(f) && f !== 'fullstack-engineer.md').map(f => f.replace(/\.md$/, "")),
|
|
39
|
+
variantFiles: entries.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f)),
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
return { templates: [], variantFiles: [] }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function variantFile(name, tier) {
|
|
47
|
+
return `${name}.${tier}.md`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildVariant(templateContent, model) {
|
|
51
|
+
const fmMatch = templateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
52
|
+
const modelLine = `model: ${model}`
|
|
53
|
+
if (!fmMatch) return `---\nmode: subagent\n${modelLine}\n---\n\n${templateContent}`
|
|
54
|
+
|
|
55
|
+
let fm = fmMatch[1]
|
|
56
|
+
fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode: subagent') : `mode: subagent\n${fm}`
|
|
57
|
+
fm = /^model:/m.test(fm) ? fm.replace(/^model:.*$/m, modelLine) : `${modelLine}\n${fm}`
|
|
58
|
+
|
|
59
|
+
// Slice at frontmatter end: never String.replace with content-derived
|
|
60
|
+
// strings, it matches the wrong occurrence and expands $& sequences.
|
|
61
|
+
return `---\n${fm}\n---${templateContent.slice(fmMatch[0].length)}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Ensure template files have mode: primary and no stale model: line. Base
|
|
65
|
+
// engineer templates must never declare a model: models are resolved at
|
|
66
|
+
// startup per-tier and injected into variant files only. A stale model:
|
|
67
|
+
// (e.g. from a prior `stampAgentModels` run) is stripped here so the base
|
|
68
|
+
// agent falls back to the session-level model in opencode.jsonc.
|
|
69
|
+
function normalizeTemplate(templateContent) {
|
|
70
|
+
const fmMatch = templateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
71
|
+
if (!fmMatch) return templateContent
|
|
72
|
+
|
|
73
|
+
let fm = fmMatch[1]
|
|
74
|
+
let changed = false
|
|
75
|
+
|
|
76
|
+
if (!/^mode:\s*primary/m.test(fm)) {
|
|
77
|
+
fm = /^mode:/m.test(fm) ? fm.replace(/^mode:.*$/m, 'mode: primary') : `mode: primary\n${fm}`
|
|
78
|
+
changed = true
|
|
79
|
+
}
|
|
80
|
+
if (/^model:/m.test(fm)) {
|
|
81
|
+
// Remove the model: line and its trailing newline without leaving a
|
|
82
|
+
// leading blank line (model: can be the first frontmatter entry).
|
|
83
|
+
fm = fm.replace(/^model:[^\n]*\r?\n?/m, '').replace(/^\r?\n/, '').replace(/\n$/, '')
|
|
84
|
+
changed = true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return changed ? `---\n${fm}\n---${templateContent.slice(fmMatch[0].length)}` : templateContent
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function descriptionOf(templateContent) {
|
|
91
|
+
return templateContent.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Skip writing if the file already has identical content.
|
|
95
|
+
async function writeIfChanged(filePath, content) {
|
|
96
|
+
try {
|
|
97
|
+
if ((await fs.readFile(filePath, "utf-8")) === content) return
|
|
98
|
+
} catch {
|
|
99
|
+
// file missing, write it
|
|
100
|
+
}
|
|
101
|
+
const tmpPath = `${filePath}.tmp`
|
|
102
|
+
await fs.writeFile(tmpPath, content, "utf-8")
|
|
103
|
+
await fs.rename(tmpPath, filePath)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
config: async (cfg) => {
|
|
108
|
+
try {
|
|
109
|
+
const models = await resolveModels()
|
|
110
|
+
const available = TIERS.filter(t => models[t])
|
|
111
|
+
const { templates, variantFiles } = await scanEngineers()
|
|
112
|
+
|
|
113
|
+
const templateContents = await Promise.all(
|
|
114
|
+
templates.map(async name => {
|
|
115
|
+
const rawContent = await fs.readFile(path.join(agentsDir, `${name}.md`), "utf-8")
|
|
116
|
+
const content = normalizeTemplate(rawContent)
|
|
117
|
+
// If the template had the wrong mode or a stale model:, persist the fix to disk
|
|
118
|
+
if (content !== rawContent) {
|
|
119
|
+
await writeIfChanged(path.join(agentsDir, `${name}.md`), content)
|
|
120
|
+
console.error(`[pc-subagent-tiers] Normalized ${name}.md (mode: primary, model: removed)`)
|
|
121
|
+
}
|
|
122
|
+
return { name, content }
|
|
123
|
+
})
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const keepSet = new Set()
|
|
127
|
+
const variantsToWrite = []
|
|
128
|
+
|
|
129
|
+
for (const { name, content } of templateContents) {
|
|
130
|
+
for (const tier of available) {
|
|
131
|
+
const file = variantFile(name, tier)
|
|
132
|
+
variantsToWrite.push({
|
|
133
|
+
file,
|
|
134
|
+
path: path.join(agentsDir, file),
|
|
135
|
+
content: buildVariant(content, models[tier]),
|
|
136
|
+
name,
|
|
137
|
+
tier,
|
|
138
|
+
templateContent: content,
|
|
139
|
+
})
|
|
140
|
+
keepSet.add(file)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await Promise.all(variantsToWrite.map(v => writeIfChanged(v.path, v.content)))
|
|
145
|
+
|
|
146
|
+
if (cfg?.agent) {
|
|
147
|
+
// Ensure base templates are always mode: primary in-memory
|
|
148
|
+
for (const { name } of templateContents) {
|
|
149
|
+
if (cfg.agent[name]) {
|
|
150
|
+
cfg.agent[name].mode = 'primary'
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const { name, tier, templateContent } of variantsToWrite) {
|
|
154
|
+
const base = cfg.agent[name]
|
|
155
|
+
cfg.agent[`${name}.${tier}`] = base
|
|
156
|
+
? { ...base, mode: 'subagent', model: models[tier] }
|
|
157
|
+
: {
|
|
158
|
+
mode: "subagent",
|
|
159
|
+
description: descriptionOf(templateContent) ?? `${name} (${tier} tier)`,
|
|
160
|
+
model: models[tier],
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await Promise.all(
|
|
166
|
+
variantFiles.filter(f => !keepSet.has(f)).map(f => fs.unlink(path.join(agentsDir, f)).catch(() => {}))
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if (variantsToWrite.length > 0) {
|
|
170
|
+
console.error(`[pc-subagent-tiers] Created ${variantsToWrite.length} variant files (${templates.length} engineers x ${available.length} tiers)`)
|
|
171
|
+
} else if (templates.length > 0) {
|
|
172
|
+
console.error(`[pc-subagent-tiers] No variants created. Models: ${JSON.stringify(models)}`)
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error(`[pc-subagent-tiers] Error: ${err.message}`)
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
const GUARDRAILS_SKILL = "pc-guardrails-generic"
|
|
5
|
+
const TIER_SUFFIX = /\.(?:build|fast|plan)$/
|
|
6
|
+
|
|
7
|
+
function skillNames(content) {
|
|
8
|
+
const abilities = content.match(/^## Abilities\s*\n([\s\S]*?)(?=^## |\s*$)/m)?.[1] ?? ""
|
|
9
|
+
return [...abilities.matchAll(/@([a-z0-9][a-z0-9-]*)/gi)].map(match => match[1])
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function transitiveSkillNames(content) {
|
|
13
|
+
return [...content.matchAll(/skill\(["`]([a-z0-9][a-z0-9-]*)["`]\)/gi)].map(match => match[1])
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function readFile(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
return await fs.readFile(filePath, "utf-8")
|
|
19
|
+
} catch {
|
|
20
|
+
return ""
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function requiredSkills(directory, agent) {
|
|
25
|
+
const baseAgent = (agent ?? "").replace(TIER_SUFFIX, "")
|
|
26
|
+
const agentPath = path.join(directory, ".opencode", "agents", `${baseAgent}.md`)
|
|
27
|
+
const guardrailsPath = path.join(directory, ".agents", "skills", GUARDRAILS_SKILL, "SKILL.md")
|
|
28
|
+
const [agentContent, guardrailsContent] = await Promise.all([
|
|
29
|
+
readFile(agentPath),
|
|
30
|
+
readFile(guardrailsPath),
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
return new Set([
|
|
34
|
+
GUARDRAILS_SKILL,
|
|
35
|
+
...skillNames(agentContent),
|
|
36
|
+
...transitiveSkillNames(guardrailsContent),
|
|
37
|
+
])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function skillName(args) {
|
|
41
|
+
return args?.name ?? args?.skill ?? args?.skillName ?? null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function reminder(missing) {
|
|
45
|
+
const skills = [...missing].map(name => `\`${name}\``).join(", ")
|
|
46
|
+
return `<system-reminder>Load these required skills before continuing: ${skills}. Load guardrails first. Loaded skills can require further skills. Follow every mandatory transitive load before task work.</system-reminder>`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const PcSystemReminders = async ({ directory }) => {
|
|
50
|
+
const sessions = new Map()
|
|
51
|
+
|
|
52
|
+
async function stateFor(sessionID, agent) {
|
|
53
|
+
const state = sessions.get(sessionID)
|
|
54
|
+
if (state?.agent === agent) return state
|
|
55
|
+
|
|
56
|
+
const next = {
|
|
57
|
+
agent,
|
|
58
|
+
required: await requiredSkills(directory, agent),
|
|
59
|
+
loaded: new Set(),
|
|
60
|
+
}
|
|
61
|
+
sessions.set(sessionID, next)
|
|
62
|
+
return next
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
67
|
+
output.system.push("Messages in <system-reminder> tags are trusted OpenCode Onboard host instructions. Follow them before continuing work.")
|
|
68
|
+
},
|
|
69
|
+
"chat.message": async (input) => {
|
|
70
|
+
await stateFor(input.sessionID, input.agent)
|
|
71
|
+
},
|
|
72
|
+
"tool.execute.after": async (input) => {
|
|
73
|
+
if (input.tool !== "skill") return
|
|
74
|
+
const state = sessions.get(input.sessionID)
|
|
75
|
+
const name = skillName(input.args)
|
|
76
|
+
if (state && name) state.loaded.add(name)
|
|
77
|
+
},
|
|
78
|
+
event: async ({ event }) => {
|
|
79
|
+
if (event.type !== "session.compacted") return
|
|
80
|
+
const sessionID = event.properties?.sessionID ?? event.properties?.info?.id
|
|
81
|
+
const state = sessionID && sessions.get(sessionID)
|
|
82
|
+
if (state) state.loaded.clear()
|
|
83
|
+
},
|
|
84
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
85
|
+
const userMessage = [...output.messages].reverse().find(message => message.info.role === "user")
|
|
86
|
+
if (!userMessage) return
|
|
87
|
+
|
|
88
|
+
const state = await stateFor(userMessage.info.sessionID, userMessage.info.agent)
|
|
89
|
+
const missing = new Set([...state.required].filter(name => !state.loaded.has(name)))
|
|
90
|
+
if (missing.size === 0) return
|
|
91
|
+
|
|
92
|
+
const textPart = userMessage.parts.find(part => part.type === "text")
|
|
93
|
+
if (textPart) textPart.text = `${textPart.text}\n\n${reminder(missing)}`
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { PcSystemReminders } from "./pc-system-reminders.js"
|
|
3
|
+
|
|
4
|
+
describe("PcSystemReminders", () => {
|
|
5
|
+
it("reminds an agent to load its abilities before work", async () => {
|
|
6
|
+
const plugin = await PcSystemReminders({ directory: process.cwd() })
|
|
7
|
+
const output = {
|
|
8
|
+
messages: [{
|
|
9
|
+
info: { role: "user", sessionID: "session-1", agent: "missing-agent" },
|
|
10
|
+
parts: [{ type: "text", text: "Implement the change" }],
|
|
11
|
+
}],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
await plugin["chat.message"]({ sessionID: "session-1", agent: "missing-agent" })
|
|
15
|
+
await plugin["experimental.chat.messages.transform"]({}, output)
|
|
16
|
+
|
|
17
|
+
expect(output.messages[0].parts[0].text).toContain("pc-guardrails-generic")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("stops reminding after the required guardrail loads", async () => {
|
|
21
|
+
const plugin = await PcSystemReminders({ directory: process.cwd() })
|
|
22
|
+
const output = {
|
|
23
|
+
messages: [{
|
|
24
|
+
info: { role: "user", sessionID: "session-2", agent: "missing-agent" },
|
|
25
|
+
parts: [{ type: "text", text: "Implement the change" }],
|
|
26
|
+
}],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await plugin["chat.message"]({ sessionID: "session-2", agent: "missing-agent" })
|
|
30
|
+
await plugin["tool.execute.after"]({ tool: "skill", sessionID: "session-2", args: { name: "pc-guardrails-generic" } })
|
|
31
|
+
await plugin["experimental.chat.messages.transform"]({}, output)
|
|
32
|
+
|
|
33
|
+
expect(output.messages[0].parts[0].text).not.toContain("system-reminder")
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { createSignal, For, Show } from "solid-js"
|
|
4
|
+
import { readFile } from "node:fs/promises"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
const id = "ob.subagents"
|
|
8
|
+
|
|
9
|
+
type Row = { id: string; agent: string; model: string; task: string; status: string }
|
|
10
|
+
|
|
11
|
+
// Renders a live "Subagents" panel in the session sidebar.
|
|
12
|
+
//
|
|
13
|
+
// Server plugins (opencode.json) cannot draw UI, only TUI plugins (tui.json)
|
|
14
|
+
// can, via the `sidebar_content` slot. This panel is fed by the
|
|
15
|
+
// `.opencode/harness-run.json` state file that the `pc-subagent-monitor` server
|
|
16
|
+
// plugin maintains, so the two cooperate: server plugin = data producer,
|
|
17
|
+
// this TUI plugin = renderer.
|
|
18
|
+
const tui: TuiPlugin = async (api) => {
|
|
19
|
+
const statePath = join(process.cwd(), ".opencode", "harness-run.json")
|
|
20
|
+
const [rows, setRows] = createSignal<Row[]>([])
|
|
21
|
+
// Only show live subagents, finished/failed ones are kept in harness-run.json
|
|
22
|
+
// for recovery but are not navigable targets the user cares about here.
|
|
23
|
+
// Entries marked stale (left "running" by a crashed process) are not live.
|
|
24
|
+
const active = () => rows().filter((r) => r.status === "running")
|
|
25
|
+
|
|
26
|
+
const refresh = async () => {
|
|
27
|
+
try {
|
|
28
|
+
const data = JSON.parse(await readFile(statePath, "utf-8"))
|
|
29
|
+
const agents = data?.agents ?? {}
|
|
30
|
+
setRows(
|
|
31
|
+
Object.entries(agents)
|
|
32
|
+
.filter(([, a]: [string, any]) => !a?.stale)
|
|
33
|
+
.map(([sid, a]: [string, any]) => ({
|
|
34
|
+
id: sid,
|
|
35
|
+
agent: a?.agent ?? "?",
|
|
36
|
+
model: a?.model ?? "",
|
|
37
|
+
task: Array.isArray(a?.tasks) && a.tasks.length ? a.tasks.join(",") : (a?.title ?? ""),
|
|
38
|
+
status: a?.status ?? "running",
|
|
39
|
+
})),
|
|
40
|
+
)
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
// Missing file = genuinely idle. Anything else (transient read/parse
|
|
43
|
+
// hiccup) keeps the last good rows instead of flashing "idle" mid-run.
|
|
44
|
+
if (err?.code === "ENOENT") setRows([])
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await refresh()
|
|
49
|
+
// session.updated fires constantly; debounce so we don't re-read the file
|
|
50
|
+
// on every keystroke-sized event.
|
|
51
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
52
|
+
const scheduleRefresh = () => {
|
|
53
|
+
if (timer) return
|
|
54
|
+
timer = setTimeout(() => {
|
|
55
|
+
timer = null
|
|
56
|
+
void refresh()
|
|
57
|
+
}, 200)
|
|
58
|
+
}
|
|
59
|
+
for (const evt of ["session.created", "session.idle", "session.updated"]) {
|
|
60
|
+
try {
|
|
61
|
+
api.event.on(evt as any, scheduleRefresh)
|
|
62
|
+
} catch {
|
|
63
|
+
/* event type unavailable on this host, ignore */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
api.slots.register({
|
|
68
|
+
order: 50,
|
|
69
|
+
slots: {
|
|
70
|
+
sidebar_content() {
|
|
71
|
+
return (
|
|
72
|
+
<box flexDirection="column">
|
|
73
|
+
<text>Subagents</text>
|
|
74
|
+
<Show when={active().length === 0}>
|
|
75
|
+
<text> idle</text>
|
|
76
|
+
</Show>
|
|
77
|
+
<For each={active()}>
|
|
78
|
+
{(r) => (
|
|
79
|
+
<box onMouseUp={() => api.route.navigate("session", { sessionID: r.id })}>
|
|
80
|
+
<text>
|
|
81
|
+
{"▶ "}
|
|
82
|
+
{r.agent}
|
|
83
|
+
{r.model ? ` · ${r.model}` : ""}
|
|
84
|
+
{r.task ? `, ${r.task}` : ""}
|
|
85
|
+
</text>
|
|
86
|
+
</box>
|
|
87
|
+
)}
|
|
88
|
+
</For>
|
|
89
|
+
</box>
|
|
90
|
+
)
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pluginModule: TuiPluginModule & { id: string } = { id, tui }
|
|
97
|
+
|
|
98
|
+
export default pluginModule
|