pantheon-opencode 1.2.0 → 1.2.2
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/AGENTS.md +9 -0
- package/README.md +112 -15
- package/bin/pantheon-init.mjs +93 -0
- package/commands/pantheon-vision.md +67 -0
- package/opencode.json +14 -10
- package/package.json +20 -4
- package/scripts/_pantheon_paths.py +0 -2
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +307 -15
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +24 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +55 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +43 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/health-check.mjs +101 -1
- package/scripts/install/model-picker.mjs +118 -0
- package/scripts/install/opencode.mjs +155 -43
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install/venv.mjs +25 -2
- package/scripts/install-mcp.mjs +170 -37
- package/scripts/mcp_persistence_server.py +2 -1
- package/scripts/prune.mjs +359 -0
- package/scripts/secret-scan.mjs +88 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/validate-routing.mjs +15 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/hermes.md +2 -2
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +1 -1
- package/src/mcp/_pantheon_paths.py +0 -2
- package/src/mcp/install-mcp.mjs +6 -913
- package/src/mcp/mcp_persistence_server.py +2 -1
- package/src/mcp/pantheon_vision_server.py +495 -0
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -0
- package/src/pantheon/auto-wake.ts +91 -0
- package/src/pantheon/background-job-board.ts +568 -0
- package/src/pantheon/file-persistence.ts +95 -0
- package/src/pantheon/presets.d.mts +108 -0
- package/src/pantheon/presets.mjs +554 -0
- package/src/pantheon/vision.ts +1437 -0
- package/src/plugin.ts +69 -25
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +812 -223
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +881 -121
- package/src/plugins/tui/package.json +3 -3
- package/src/plugins/tui/src/index.tsx +881 -121
- package/src/routing.yml +148 -0
- package/src/skills/context-compression/SKILL.md +1 -1
- package/src/skills/loop-engineering/SKILL.md +1 -1
- package/src/skills/orchestration-workflow/SKILL.md +11 -11
- package/scripts/scrub_secrets.py +0 -281
- package/src/mcp/requirements-mcp-core.txt +0 -5
package/src/plugin.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
1
2
|
import type { PluginConfig } from 'opencode'
|
|
2
3
|
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
3
4
|
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
5
|
+
import { applyActivePresetToConfig } from './pantheon/presets.mjs'
|
|
6
|
+
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
4
7
|
|
|
5
8
|
// ─── Background Job Board Singleton ────────────────────────────────────
|
|
6
9
|
|
|
@@ -8,17 +11,11 @@ const board = new BackgroundJobBoard({
|
|
|
8
11
|
maxConcurrentPerAgent: 3,
|
|
9
12
|
signalDir: '.pantheon/deepwork/board-signals',
|
|
10
13
|
})
|
|
11
|
-
|
|
12
|
-
// Wire up file-based persistence and recover orphaned jobs from crashes
|
|
13
14
|
const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
|
|
14
15
|
board.setPersistence(persistence)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
console.error('[Pantheon Plugin] Failed to recover running jobs:', err),
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
// Log board state changes to console for observability
|
|
16
|
+
board
|
|
17
|
+
.recoverRunningJobs()
|
|
18
|
+
.catch((err) => console.error('[Pantheon Plugin] Failed to recover running jobs:', err))
|
|
22
19
|
board.onTerminal((taskID: string) => {
|
|
23
20
|
const job = board.get(taskID)
|
|
24
21
|
if (job) {
|
|
@@ -29,29 +26,76 @@ board.onTerminal((taskID: string) => {
|
|
|
29
26
|
})
|
|
30
27
|
|
|
31
28
|
/**
|
|
32
|
-
*
|
|
29
|
+
* Pantheon plugin for OpenCode. Pasted images are intercepted via the
|
|
30
|
+
* `chat.message` hook (proven to fire in opencode 1.18.11). When a provider
|
|
31
|
+
* key is available — env PANTHEON_OPENCODE_API_KEY / OPENCODE_API_KEY, or the
|
|
32
|
+
* opencode auth store for `opencode auth login` users — the image is
|
|
33
|
+
* described NATIVELY by the multimodal model via the opencode Zen
|
|
34
|
+
* OpenAI-compatible endpoint, and replaced with the text description — no MCP
|
|
35
|
+
* tool required. Without a key the legacy pattern applies: the image is
|
|
36
|
+
* replaced with a text instruction telling the model to call a vision MCP tool
|
|
37
|
+
* (default `pantheon_vision_vision_describe`). Either way the image never reaches
|
|
38
|
+
* the main provider, so text-only models cannot fail with an `image_url` error.
|
|
33
39
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
40
|
+
* The canonical `pantheon-vision` MCP owns the standalone describe/OCR/analyze
|
|
41
|
+
* tools. The installed OpenCode plugin API exposes no stable pre-provider
|
|
42
|
+
* message hook (1.18.11 only exposes the experimental history transform), so
|
|
43
|
+
* that transform is retained as the runtime-compatible fallback. If a stable
|
|
44
|
+
* provider-bound hook is added, register the same sanitizer there instead of
|
|
45
|
+
* assuming this experimental hook is guaranteed to run.
|
|
46
|
+
*
|
|
47
|
+
* IMPORTANT (OpenCode 1.18.11 legacy loader): this module must export EXACTLY
|
|
48
|
+
* ONE function-valued export — the default plugin. The legacy loader does
|
|
49
|
+
* `Object.values(mod)` and invokes every function export as a plugin factory;
|
|
50
|
+
* any named function export (e.g. a re-exported helper like
|
|
51
|
+
* generateInjectionPrompt) is called with a PluginInput object and can throw.
|
|
52
|
+
* Helpers live in src/pantheon/vision.ts and are imported from there directly.
|
|
36
53
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
54
|
+
const plugin: Plugin = async (input: PluginInput) => {
|
|
55
|
+
const vision = createVisionHandler(input)
|
|
40
56
|
|
|
41
|
-
|
|
42
|
-
name: 'pantheon',
|
|
43
|
-
version: '5.0.0',
|
|
44
|
-
description: 'Pantheon multi-agent orchestration platform',
|
|
45
|
-
|
|
46
|
-
hooks: {
|
|
57
|
+
return {
|
|
47
58
|
config: async (config: PluginConfig) => {
|
|
48
59
|
config.agentsPath = config.agentsPath ?? []
|
|
49
60
|
config.agentsPath.push(new URL('./agents', import.meta.url).pathname)
|
|
50
|
-
|
|
51
61
|
config.skillsPaths = config.skillsPaths ?? []
|
|
52
62
|
config.skillsPaths.push(new URL('./skills', import.meta.url).pathname)
|
|
53
63
|
|
|
54
|
-
|
|
64
|
+
// Apply the active model preset (`init --preset` / `set-tier` write
|
|
65
|
+
// .pantheon/active-preset.json): resolve it with the SAME candidate
|
|
66
|
+
// order the vision handler uses (project > XDG > HOME) and mutate the
|
|
67
|
+
// agent models / reasoning effort / fallback models + provider configs.
|
|
68
|
+
// Fail-safe: without an active preset the config is untouched; a missing
|
|
69
|
+
// provider key is logged and skipped — the hook must never break startup
|
|
70
|
+
// (set-tier already fail-fast validates keys at write time). Vision
|
|
71
|
+
// rotation stays in vision.ts, which resolves its model independently.
|
|
72
|
+
try {
|
|
73
|
+
const resolved = applyActivePresetToConfig(config, {
|
|
74
|
+
candidates: activePresetCandidates(),
|
|
75
|
+
})
|
|
76
|
+
if (resolved) {
|
|
77
|
+
console.log(
|
|
78
|
+
`[Pantheon Plugin] Applied model preset: ${resolved.name} (${resolved.source})`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Fully STATIC warning — the thrown error object is tainted because
|
|
83
|
+
// presets.mjs constructs it with the provider's apiKeyEnv name (e.g.
|
|
84
|
+
// PANTHEON_OPENCODE_API_KEY), and CodeQL's clear-text logging dataflow
|
|
85
|
+
// flags ANY interpolation derived from it — including err.name and
|
|
86
|
+
// err.message. The env var name is not itself sensitive to log, but
|
|
87
|
+
// the scanner cannot distinguish it, so nothing derived from the error
|
|
88
|
+
// may appear here. Diagnostics (provider id + env var) are already
|
|
89
|
+
// emitted by applyActivePresetToConfig's own logger before it throws.
|
|
90
|
+
// The catch binding is intentionally omitted so no tainted value can
|
|
91
|
+
// ever reach this log line (CodeQL alert #11).
|
|
92
|
+
console.warn('[plugin] preset application failed (see logs for details)')
|
|
93
|
+
}
|
|
55
94
|
},
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
'chat.message': vision.chatMessage,
|
|
96
|
+
'experimental.chat.messages.transform': vision.messagesTransform,
|
|
97
|
+
event: vision.event,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default plugin
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook-runner.ts — version-proof runner for the Pantheon security-hook layer.
|
|
3
|
+
*
|
|
4
|
+
* The previous plugin used the Bun Shell `$` injected by the opencode plugin
|
|
5
|
+
* API (`$.quiet().nothrow().timeout(...)`). `.timeout()` does not exist on the
|
|
6
|
+
* BunShell interface, so every hook crashed with a TypeError and the layer was
|
|
7
|
+
* removed (24-25/07). This runner uses ONLY `node:child_process` — stable
|
|
8
|
+
* across every Node >= 18 runtime and every opencode plugin loader version.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code stdin protocol: the hook scripts receive
|
|
11
|
+
* `{tool_name, tool_input, agent_id, session_id}` as JSON on stdin. Scripts
|
|
12
|
+
* that read env vars instead (log-session-start.sh, on-subagent-delegation-*)
|
|
13
|
+
* get their fields mapped into the child env (SESSION_ID / AGENT_NAME /
|
|
14
|
+
* TASK_DESC) so both protocols work end to end.
|
|
15
|
+
*
|
|
16
|
+
* IMPORTANT (verified empirically): the `input:` option on the async
|
|
17
|
+
* `execFile()` API is silently ignored by Node — it only exists on
|
|
18
|
+
* spawnSync/execFileSync. This runner therefore writes stdin explicitly to a
|
|
19
|
+
* spawned child, which delivers the payload reliably.
|
|
20
|
+
*
|
|
21
|
+
* Hooks NEVER throw: every failure (missing script, timeout, non-zero exit)
|
|
22
|
+
* is returned as a structured HookResult so the opencode session can never be
|
|
23
|
+
* taken down by the security layer.
|
|
24
|
+
*/
|
|
25
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import type { Readable } from 'node:stream'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
|
|
30
|
+
export const HOOK_TIMEOUT_MS = 15_000
|
|
31
|
+
export const HOOK_MAX_BUFFER = 10 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
/** Claude Code hook protocol payload. */
|
|
34
|
+
export type HookPayload = {
|
|
35
|
+
tool_name?: string
|
|
36
|
+
tool_input?: unknown
|
|
37
|
+
agent_id?: string
|
|
38
|
+
session_id?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type HookResult = {
|
|
42
|
+
code: number
|
|
43
|
+
stdout: string
|
|
44
|
+
stderr: string
|
|
45
|
+
signal: NodeJS.Signals | null
|
|
46
|
+
timedOut: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type RunHookOptions = {
|
|
50
|
+
env?: Record<string, string | undefined>
|
|
51
|
+
cwd?: string
|
|
52
|
+
timeout?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the hooks directory relative to this module — robust to any npm
|
|
57
|
+
* global-install prefix because it anchors on import.meta.url, not __dirname
|
|
58
|
+
* or process.cwd().
|
|
59
|
+
*/
|
|
60
|
+
export function resolveHooksDir(): string {
|
|
61
|
+
return fileURLToPath(new URL('../../scripts/hooks/', import.meta.url))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map the Claude Code protocol payload into the child environment. Scripts
|
|
66
|
+
* that read env vars (log-session-start.sh, delegation audit scripts) rely on
|
|
67
|
+
* these; scripts that read stdin receive the full JSON via `input` in runHook.
|
|
68
|
+
* PATH is always preserved so `python3`/`bash` resolution inside the scripts
|
|
69
|
+
* is never affected by a stripped-down environment.
|
|
70
|
+
*/
|
|
71
|
+
export function hookEnv(
|
|
72
|
+
payload: HookPayload,
|
|
73
|
+
extra?: Record<string, string | undefined>,
|
|
74
|
+
): NodeJS.ProcessEnv {
|
|
75
|
+
return {
|
|
76
|
+
...process.env,
|
|
77
|
+
PATH: process.env.PATH ?? '',
|
|
78
|
+
SESSION_ID: payload.session_id ?? '',
|
|
79
|
+
AGENT_NAME: payload.agent_id ?? '',
|
|
80
|
+
TASK_DESC: payload.tool_name ?? '',
|
|
81
|
+
...(extra ?? {}),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run one hook script with the Claude Code protocol payload on stdin.
|
|
87
|
+
*
|
|
88
|
+
* @param script file name inside scripts/hooks/ (e.g. "validate-tool-safety.sh")
|
|
89
|
+
* @param payload protocol payload translated from the opencode hook event
|
|
90
|
+
* @param opts optional env overrides / cwd / timeout
|
|
91
|
+
* @returns HookResult — NEVER throws; resolve with a non-zero code instead
|
|
92
|
+
*/
|
|
93
|
+
export function runHook(
|
|
94
|
+
script: string,
|
|
95
|
+
payload: HookPayload,
|
|
96
|
+
opts: RunHookOptions = {},
|
|
97
|
+
): Promise<HookResult> {
|
|
98
|
+
const scriptPath = path.join(opts.cwd ?? resolveHooksDir(), script)
|
|
99
|
+
const timeout = opts.timeout ?? HOOK_TIMEOUT_MS
|
|
100
|
+
|
|
101
|
+
return new Promise<HookResult>((resolve) => {
|
|
102
|
+
const stdoutBuf: { value: string } = { value: '' }
|
|
103
|
+
const stderrBuf: { value: string } = { value: '' }
|
|
104
|
+
let settled = false
|
|
105
|
+
|
|
106
|
+
const settle = (result: HookResult) => {
|
|
107
|
+
if (!settled) {
|
|
108
|
+
settled = true
|
|
109
|
+
resolve(result)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const spawnOpts = {
|
|
114
|
+
timeout,
|
|
115
|
+
killSignal: 'SIGKILL' as const,
|
|
116
|
+
env: hookEnv(payload, opts.env),
|
|
117
|
+
// stdio:'pipe' is equivalent to ['pipe','pipe','pipe'] — it guarantees
|
|
118
|
+
// the child's stdin is OUR pipe, NEVER the opencode TUI's terminal
|
|
119
|
+
// (which would leave `cat`-style hooks blocked reading forever and hit
|
|
120
|
+
// the opencode core hook timeout).
|
|
121
|
+
stdio: 'pipe' as const,
|
|
122
|
+
windowsHide: true,
|
|
123
|
+
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// stdio:'pipe' picks the ChildProcessWithoutNullStreams overload — do not
|
|
127
|
+
// widen these option literals or child.stdin/stdout become nullable.
|
|
128
|
+
let child: ChildProcessWithoutNullStreams
|
|
129
|
+
try {
|
|
130
|
+
child = spawn(scriptPath, [], spawnOpts)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// Synchronous spawn failure (invalid args etc.) — never propagate.
|
|
133
|
+
settle({
|
|
134
|
+
code: 1,
|
|
135
|
+
stdout: '',
|
|
136
|
+
stderr: `spawn failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
137
|
+
signal: null,
|
|
138
|
+
timedOut: false,
|
|
139
|
+
})
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The child may exit before we write stdin — swallow EPIPE instead of
|
|
144
|
+
// crashing the plugin process with an unhandled stream error.
|
|
145
|
+
child.stdin.on('error', () => {})
|
|
146
|
+
|
|
147
|
+
const collect = (stream: Readable, buf: { value: string }) => {
|
|
148
|
+
stream.setEncoding('utf-8')
|
|
149
|
+
stream.on('data', (chunk: string) => {
|
|
150
|
+
if (buf.value.length < HOOK_MAX_BUFFER) {
|
|
151
|
+
buf.value += chunk.slice(0, HOOK_MAX_BUFFER - buf.value.length)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
collect(child.stdout, stdoutBuf)
|
|
156
|
+
collect(child.stderr, stderrBuf)
|
|
157
|
+
|
|
158
|
+
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
159
|
+
// ENOENT / EACCES — script missing or not executable. Report, never throw.
|
|
160
|
+
settle({
|
|
161
|
+
code: 1,
|
|
162
|
+
stdout: stdoutBuf.value,
|
|
163
|
+
stderr: stderrBuf.value || `hook script failed to spawn: ${err.message}`,
|
|
164
|
+
signal: null,
|
|
165
|
+
timedOut: false,
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
child.on('close', (code, signal) => {
|
|
170
|
+
settle({
|
|
171
|
+
code: code ?? 1,
|
|
172
|
+
stdout: stdoutBuf.value,
|
|
173
|
+
stderr: stderrBuf.value,
|
|
174
|
+
signal,
|
|
175
|
+
timedOut: code === null && signal === 'SIGKILL',
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Deliver the Claude Code protocol JSON on stdin. stdin.end() runs
|
|
180
|
+
// UNCONDITIONALLY — even for an empty payload {} or a script that never
|
|
181
|
+
// reads stdin — so the child can never block waiting for input and hit
|
|
182
|
+
// the runtime hook timeout (reported as "Hook X timed out after 30000ms").
|
|
183
|
+
child.stdin.write(JSON.stringify(payload))
|
|
184
|
+
child.stdin.end()
|
|
185
|
+
})
|
|
186
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pantheon-hooks.ts — Pantheon runtime security-hook plugin for OpenCode.
|
|
3
|
+
*
|
|
4
|
+
* Re-created per Council Synthesis 2026-08-05 (P0): the previous hook plugin
|
|
5
|
+
* was removed on 24-25/07 because it used the Bun Shell `$` from the plugin
|
|
6
|
+
* API, whose interface has NO `.timeout()` method — every hook crashed with
|
|
7
|
+
* `TypeError: $.quiet().nothrow().timeout is not a function`. The 10 functional
|
|
8
|
+
* scripts in scripts/hooks/ (gitleaks covers only staged files, NOT tool
|
|
9
|
+
* calls) were left orphaned. This plugin wires them back into the opencode
|
|
10
|
+
* runtime via `node:child_process` (version-proof — see hook-runner.ts).
|
|
11
|
+
*
|
|
12
|
+
* Hook mapping (Claude Code protocol payload {tool_name, tool_input,
|
|
13
|
+
* agent_id, session_id} delivered on the script stdin):
|
|
14
|
+
* - tool.execute.before → validate-talos-scope.sh, scan-secrets.sh,
|
|
15
|
+
* validate-tool-safety.sh + on-subagent-delegation-start.sh (only when the
|
|
16
|
+
* tool is a delegation tool such as `task`, to keep the audit log honest)
|
|
17
|
+
* - tool.execute.after → format-multi-language.sh, log-session-start.sh +
|
|
18
|
+
* on-subagent-delegation-stop.sh (delegation tool only)
|
|
19
|
+
* - event session.created → log-session-start.sh, validate-post-conditions.sh
|
|
20
|
+
*
|
|
21
|
+
* Notes:
|
|
22
|
+
* - `agent_id` is NOT present in the opencode tool.execute hook payloads
|
|
23
|
+
* ({tool, sessionID, callID}), so it is passed as '' — validate-talos-scope
|
|
24
|
+
* then takes its safe skip path; the agent-agnostic scan-secrets and
|
|
25
|
+
* validate-tool-safety protections remain fully active.
|
|
26
|
+
* - tool.execute hooks cannot hard-deny the tool call through this API
|
|
27
|
+
* (the output only mutates args; real deny is the permission system), so
|
|
28
|
+
* violations surface as stderr + non-zero exit codes logged below.
|
|
29
|
+
* - Logging policy: non-zero exit → console.error (the security signal);
|
|
30
|
+
* zero exit → SILENT by default. Audit scripts (log-session-start,
|
|
31
|
+
* on-subagent-delegation-*) still write their log FILES (sessions.log,
|
|
32
|
+
* delegations.log) from inside the .sh scripts — only the console echo is
|
|
33
|
+
* suppressed so the chat/TUI stays clean. Set PANTHEON_HOOKS_LOG=1 (or
|
|
34
|
+
* "debug") to re-enable the zero-exit console echo for debugging.
|
|
35
|
+
* The env var is read once at plugin load time (opencode startup).
|
|
36
|
+
* - Hooks NEVER throw: every body is try/catch'd and only logs.
|
|
37
|
+
*
|
|
38
|
+
* IMPORTANT (OpenCode 1.18.11 legacy loader): this module must export EXACTLY
|
|
39
|
+
* ONE function-valued export — the default plugin (see src/plugin.ts L47-52).
|
|
40
|
+
* Helpers live in ./hook-runner.ts and are imported from there.
|
|
41
|
+
*/
|
|
42
|
+
import type { Plugin } from '@opencode-ai/plugin'
|
|
43
|
+
import { runHook, type HookPayload } from './hook-runner.ts'
|
|
44
|
+
|
|
45
|
+
/** Tools that represent a subagent delegation (opencode `task` tool etc.). */
|
|
46
|
+
const DELEGATION_TOOL_RE = /^(task|.*delegate.*|.*subagent.*)$/i
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* When set to a truthy value ("1" or "debug"), zero-exit audit-hook output is
|
|
50
|
+
* echoed to the console (opt-in debugging). Default (unset): fully silent on
|
|
51
|
+
* success — the .sh scripts still persist their log FILES on disk; only the
|
|
52
|
+
* console echo is suppressed. Read at plugin load time.
|
|
53
|
+
*/
|
|
54
|
+
const AUDIT_LOG_ENABLED = (process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
|
|
55
|
+
|
|
56
|
+
/** Scripts whose zero-exit stderr is relayed when PANTHEON_HOOKS_LOG is set. */
|
|
57
|
+
const AUDIT_HOOKS = new Set([
|
|
58
|
+
'log-session-start.sh',
|
|
59
|
+
'on-subagent-delegation-start.sh',
|
|
60
|
+
'on-subagent-delegation-stop.sh',
|
|
61
|
+
'validate-post-conditions.sh',
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
function trim(s: string, max = 2000): string {
|
|
65
|
+
const t = s.trim()
|
|
66
|
+
return t.length > max ? `${t.slice(0, max)}…` : t
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Translate the opencode hook event into the Claude Code protocol payload. */
|
|
70
|
+
function payloadFor(
|
|
71
|
+
input: { tool: string; sessionID: string },
|
|
72
|
+
toolInput: unknown,
|
|
73
|
+
): HookPayload {
|
|
74
|
+
return {
|
|
75
|
+
tool_name: input.tool,
|
|
76
|
+
tool_input: toolInput ?? {},
|
|
77
|
+
// tool.execute hooks carry no agent — '' lets talos-scope skip safely.
|
|
78
|
+
agent_id: (input as { agent?: { id?: string } }).agent?.id ?? '',
|
|
79
|
+
session_id: input.sessionID,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run one hook with the logging policy; NEVER throw out of the hook. */
|
|
84
|
+
async function safeRun(script: string, payload: HookPayload): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
const result = await runHook(script, payload)
|
|
87
|
+
const tag = `[pantheon-hooks:${script}]`
|
|
88
|
+
if (result.code !== 0) {
|
|
89
|
+
console.error(
|
|
90
|
+
`${tag} exit ${result.code}${result.timedOut ? ' (timed out)' : ''}: ${trim(result.stderr || result.stdout || 'no output')}`,
|
|
91
|
+
)
|
|
92
|
+
} else if (AUDIT_LOG_ENABLED && AUDIT_HOOKS.has(script) && result.stderr.trim()) {
|
|
93
|
+
// Opt-in only (PANTHEON_HOOKS_LOG=1): audit scripts echo their FILE
|
|
94
|
+
// writes here for debugging. Default is silence — the .sh scripts
|
|
95
|
+
// already persist sessions.log / delegations.log on disk.
|
|
96
|
+
console.log(`${tag} ${trim(result.stderr)}`)
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// runHook never throws, but the hook body must survive anything.
|
|
100
|
+
console.error(
|
|
101
|
+
`[pantheon-hooks:${script}] unexpected failure: ${err instanceof Error ? err.message : String(err)}`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const plugin: Plugin = async () => {
|
|
107
|
+
return {
|
|
108
|
+
'tool.execute.before': async (input, output) => {
|
|
109
|
+
try {
|
|
110
|
+
const payload = payloadFor(input, output.args ?? {})
|
|
111
|
+
const hooks = [
|
|
112
|
+
'validate-talos-scope.sh',
|
|
113
|
+
'scan-secrets.sh',
|
|
114
|
+
'validate-tool-safety.sh',
|
|
115
|
+
]
|
|
116
|
+
if (DELEGATION_TOOL_RE.test(input.tool)) {
|
|
117
|
+
hooks.push('on-subagent-delegation-start.sh')
|
|
118
|
+
}
|
|
119
|
+
await Promise.all(hooks.map((script) => safeRun(script, payload)))
|
|
120
|
+
} catch {
|
|
121
|
+
// Never let a hook take down the opencode session.
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
'tool.execute.after': async (input) => {
|
|
125
|
+
try {
|
|
126
|
+
const payload = payloadFor(input, input.args ?? {})
|
|
127
|
+
const hooks = ['format-multi-language.sh']
|
|
128
|
+
if (DELEGATION_TOOL_RE.test(input.tool)) {
|
|
129
|
+
hooks.push('on-subagent-delegation-stop.sh')
|
|
130
|
+
}
|
|
131
|
+
await Promise.all(hooks.map((script) => safeRun(script, payload)))
|
|
132
|
+
} catch {
|
|
133
|
+
// Never let a hook take down the opencode session.
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
event: async ({ event: ev }) => {
|
|
137
|
+
try {
|
|
138
|
+
if (ev.type === 'session.created') {
|
|
139
|
+
const payload: HookPayload = { session_id: ev.properties.info.id }
|
|
140
|
+
await Promise.all(
|
|
141
|
+
['log-session-start.sh', 'validate-post-conditions.sh'].map((script) =>
|
|
142
|
+
safeRun(script, payload),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Never let a hook take down the opencode session.
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default plugin
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;cA8qCM,QAAQ;EAAoB"}
|