pantheon-opencode 1.3.2 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -1
- package/bin/pantheon-init.mjs +160 -120
- package/opencode.json +0 -1
- package/package.json +10 -5
- package/scripts/cost.mjs +69 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +132 -32
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +80 -0
- package/src/pantheon/background-job-board.ts +115 -40
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +237 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +277 -0
- package/src/pantheon/delegation-notify.ts +100 -0
- package/src/pantheon/delegation.ts +690 -0
- package/src/pantheon/dispatch-guard.ts +107 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +300 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +260 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +421 -0
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +406 -13
- package/src/plugins/pantheon-hooks.ts +52 -30
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +35 -2
package/src/plugin.ts
CHANGED
|
@@ -1,12 +1,52 @@
|
|
|
1
1
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
2
2
|
import type { PluginConfig } from 'opencode'
|
|
3
3
|
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
4
|
+
import { reassertAfterCompaction } from './pantheon/compaction-assert.ts'
|
|
5
|
+
import { createCostCommand } from './pantheon/cost-command.ts'
|
|
6
|
+
import {
|
|
7
|
+
collectRootSessionIDs,
|
|
8
|
+
createDelegationTools,
|
|
9
|
+
type DelegationClient,
|
|
10
|
+
} from './pantheon/delegation.ts'
|
|
11
|
+
import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
|
|
12
|
+
import { createEnforcementGuard, readOnlyRegistry } from './pantheon/delegation-enforce.ts'
|
|
13
|
+
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
4
14
|
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
5
|
-
import {
|
|
15
|
+
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
16
|
+
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
17
|
+
import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
|
|
18
|
+
import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
|
|
19
|
+
import { createPantheonLogger } from './pantheon/logger.ts'
|
|
20
|
+
import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
|
|
21
|
+
import { applyActivePresetToConfig, loadRoutingAgentModels } from './pantheon/presets.mjs'
|
|
22
|
+
import {
|
|
23
|
+
TODO_ENFORCER_DEFAULTS,
|
|
24
|
+
TodoEnforcer,
|
|
25
|
+
type TodoEnforcerClient,
|
|
26
|
+
todoEnforcerEnabledFromEnv,
|
|
27
|
+
} from './pantheon/todo-enforcer.ts'
|
|
28
|
+
import { TodoPreserver } from './pantheon/todo-preserve.ts'
|
|
6
29
|
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
7
30
|
|
|
8
31
|
// ─── Background Job Board Singleton ────────────────────────────────────
|
|
9
32
|
|
|
33
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
|
|
34
|
+
// plugin writes to the process stdout/stderr, which the opencode TUI renders
|
|
35
|
+
// directly into the terminal — the "lixo". Every line goes to
|
|
36
|
+
// .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
|
|
37
|
+
const log = createPantheonLogger({ module: 'pantheon-plugin' })
|
|
38
|
+
|
|
39
|
+
// Fase 6 (release-134): the delegation toolset's options.agentModels (branch
|
|
40
|
+
// (b) of resolveChildModel) is wired from routing.yml's default (first)
|
|
41
|
+
// preset — a STATIC per-agent model mapping independent of the active preset,
|
|
42
|
+
// so delegated children get a sane model even without an active preset (the
|
|
43
|
+
// previous production wiring left branch (b) dead: delegation depended 100%
|
|
44
|
+
// on the active preset). Built ONCE at module load, never per dispatch.
|
|
45
|
+
// Fail-open: a missing/corrupt routing.yml yields {} (warned by the helper)
|
|
46
|
+
// and delegation falls back to the active preset / opencode default — the
|
|
47
|
+
// plugin never throws at startup.
|
|
48
|
+
const routingAgentModels = loadRoutingAgentModels({ logger: log })
|
|
49
|
+
|
|
10
50
|
const board = new BackgroundJobBoard({
|
|
11
51
|
maxConcurrentPerAgent: 3,
|
|
12
52
|
signalDir: '.pantheon/deepwork/board-signals',
|
|
@@ -15,16 +55,174 @@ const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
|
|
|
15
55
|
board.setPersistence(persistence)
|
|
16
56
|
board
|
|
17
57
|
.recoverRunningJobs()
|
|
18
|
-
.catch((err) =>
|
|
58
|
+
.catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
|
|
59
|
+
// ─── Phase 3: Board terminal audit log ─────────────────────────────────
|
|
60
|
+
|
|
61
|
+
// The onTerminal listener is the completion AUDIT point: it writes a
|
|
62
|
+
// file-only log line (console echo opt-in via PANTHEON_HOOKS_LOG). There is
|
|
63
|
+
// deliberately NO chat delivery — the user policy is zero delegation
|
|
64
|
+
// notifications in the transcript. Completion visibility lives in the board
|
|
65
|
+
// `[unread]` marker (pantheon_delegation_list), pantheon_delegation_read,
|
|
66
|
+
// TUI toasts (pantheon-hooks, PANTHEON_TOASTS gate) and compaction
|
|
67
|
+
// carry-forward.
|
|
19
68
|
board.onTerminal((taskID: string) => {
|
|
20
69
|
const job = board.get(taskID)
|
|
21
|
-
if (job)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
70
|
+
if (!job) return
|
|
71
|
+
log.info(
|
|
72
|
+
`[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
|
|
77
|
+
// pruneExpired is async and internally swallows persistence errors, so the
|
|
78
|
+
// void-catch here is purely defensive. .unref() ensures the timer never
|
|
79
|
+
// keeps the process alive on its own.
|
|
80
|
+
setInterval(
|
|
81
|
+
() => {
|
|
82
|
+
void board
|
|
83
|
+
.pruneExpired(86_400_000)
|
|
84
|
+
.catch((err: unknown) => log.error('[Pantheon Plugin] Background board prune failed:', err))
|
|
85
|
+
},
|
|
86
|
+
30 * 60 * 1000,
|
|
87
|
+
).unref()
|
|
88
|
+
|
|
89
|
+
// ─── Phase 4: Read-Only Enforcement + Compaction Context ───────────────
|
|
90
|
+
|
|
91
|
+
// The read-only registry is populated by createDelegationTools (delegation.ts)
|
|
92
|
+
// when a delegate is read-only (explicit flag or agent ∈ readOnlyAgents).
|
|
93
|
+
// The guard denies edit/write/bash/task inside those sessions by throwing
|
|
94
|
+
// from tool.execute.before; unknown sessions are allowed (safe default).
|
|
95
|
+
const enforcementGuard = createEnforcementGuard({
|
|
96
|
+
getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
|
|
26
97
|
})
|
|
27
98
|
|
|
99
|
+
// Mirrors routing.yml background_delegation.max_compaction_items.
|
|
100
|
+
const COMPACTION_MAX_ITEMS = 10
|
|
101
|
+
|
|
102
|
+
// Root-session registry for the delegation depth guard: sessions created
|
|
103
|
+
// without a parentID are roots. Populated from TWO sources:
|
|
104
|
+
// 1. the `session.created` event hook (live roots — fires for sessions
|
|
105
|
+
// created while the plugin runs);
|
|
106
|
+
// 2. a fail-open startup seed from client.session.list() in the config
|
|
107
|
+
// hook (compaction-134) — opencode does NOT replay session.created
|
|
108
|
+
// events for sessions that exist before plugin load, so without the
|
|
109
|
+
// seed a resumed root would never enter this set after a restart.
|
|
110
|
+
// delegation.ts additionally falls back to knownChildren ("any session WE
|
|
111
|
+
// created is a sub-session; unknown sessions are roots") so the depth guard
|
|
112
|
+
// works even when this set is incomplete.
|
|
113
|
+
const rootSessions = new Set<string>()
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Seed resumed root sessions without making OpenCode startup depend on the
|
|
117
|
+
* session API responding. Some OpenCode environments do not have the API
|
|
118
|
+
* ready while plugin config hooks run; awaiting session.list() there leaves
|
|
119
|
+
* the TUI stuck before it can open. The delegation layer already treats an
|
|
120
|
+
* unknown session as a root, so this enrichment is deliberately best-effort.
|
|
121
|
+
*/
|
|
122
|
+
function seedRootSessionsInBackground(input: PluginInput): void {
|
|
123
|
+
// Fail-open: environments without the session API (client.session absent or
|
|
124
|
+
// not yet wired while config hooks run) must NOT break startup. `session`
|
|
125
|
+
// is captured and the method invoked on it so `this` stays bound; the
|
|
126
|
+
// delegation layer already treats unknown sessions as roots, so skipping
|
|
127
|
+
// the seed is always safe.
|
|
128
|
+
const session = input.client.session
|
|
129
|
+
if (typeof session?.list !== 'function') return
|
|
130
|
+
void session
|
|
131
|
+
.list()
|
|
132
|
+
.then((result) => {
|
|
133
|
+
if (!result.error && Array.isArray(result.data)) {
|
|
134
|
+
const seeded = collectRootSessionIDs(result.data)
|
|
135
|
+
if (seeded.size > 0) {
|
|
136
|
+
for (const id of seeded) rootSessions.add(id)
|
|
137
|
+
log.info(`[Pantheon Plugin] Seeded ${seeded.size} root session(s) from session.list`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
.catch((err) => {
|
|
142
|
+
log.warn('[Pantheon Plugin] Root session seed failed (fail-open):', err)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Extract the SDK error message ({name, data: {message}}) or a fallback. */
|
|
147
|
+
function sdkErrorMessage(error: { data?: { message?: string } } | null | undefined): string {
|
|
148
|
+
const message = error?.data?.message
|
|
149
|
+
return typeof message === 'string' && message.trim() !== '' ? message : 'request failed'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Adapt the opencode SDK client to the structural DelegationClient the
|
|
154
|
+
* delegation toolset expects: the SDK wraps every call in a
|
|
155
|
+
* `{data, error, request, response}` result while delegation.ts uses direct
|
|
156
|
+
* returns. Errors surface as throws so the toolset's own error handling
|
|
157
|
+
* (tool errors returned as TEXT) keeps working.
|
|
158
|
+
*/
|
|
159
|
+
function adaptDelegationClient(client: PluginInput['client']): DelegationClient {
|
|
160
|
+
return {
|
|
161
|
+
session: {
|
|
162
|
+
create: async (input) => {
|
|
163
|
+
const body: {
|
|
164
|
+
parentID?: string
|
|
165
|
+
title?: string
|
|
166
|
+
model?: { id: string; providerID: string }
|
|
167
|
+
} = { parentID: input.body.parentID }
|
|
168
|
+
if (input.body.title !== undefined) body.title = input.body.title
|
|
169
|
+
// Forward the routed child model (E2E fix): without it the child
|
|
170
|
+
// falls back to the key-gated default model and dies at startup.
|
|
171
|
+
if (input.body.model !== undefined) body.model = input.body.model
|
|
172
|
+
const result = await client.session.create({ body })
|
|
173
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
174
|
+
return { id: result.data.id }
|
|
175
|
+
},
|
|
176
|
+
promptAsync: async (input) => {
|
|
177
|
+
const result = await client.session.promptAsync({
|
|
178
|
+
path: input.path,
|
|
179
|
+
body: { agent: input.body.agent, parts: input.body.parts },
|
|
180
|
+
})
|
|
181
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
182
|
+
return result.data
|
|
183
|
+
},
|
|
184
|
+
messages: async (input) => {
|
|
185
|
+
const result = await client.session.messages({ path: input.path })
|
|
186
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
187
|
+
return result.data
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Adapt the opencode SDK client to the structural TodoEnforcerClient the
|
|
195
|
+
* todo enforcer expects: unwraps the SDK `{data, error, request, response}`
|
|
196
|
+
* result and surfaces errors as throws (the enforcer swallows + logs them,
|
|
197
|
+
* so the event hook can never break the session).
|
|
198
|
+
*/
|
|
199
|
+
function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerClient {
|
|
200
|
+
return {
|
|
201
|
+
session: {
|
|
202
|
+
todo: async (input) => {
|
|
203
|
+
const result = await client.session.todo({ path: input.path })
|
|
204
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
205
|
+
return result.data
|
|
206
|
+
},
|
|
207
|
+
messages: async (input) => {
|
|
208
|
+
const result = await client.session.messages({ path: input.path })
|
|
209
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
210
|
+
return result.data
|
|
211
|
+
},
|
|
212
|
+
children: async (input) => {
|
|
213
|
+
const result = await client.session.children({ path: input.path })
|
|
214
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
215
|
+
return result.data
|
|
216
|
+
},
|
|
217
|
+
promptAsync: async (input) => {
|
|
218
|
+
const result = await client.session.promptAsync({ path: input.path, body: input.body })
|
|
219
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
220
|
+
return result.data
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
28
226
|
/**
|
|
29
227
|
* Pantheon plugin for OpenCode. Pasted images are intercepted via the
|
|
30
228
|
* `chat.message` hook (proven to fire in opencode 1.18.11). When a provider
|
|
@@ -52,10 +250,91 @@ board.onTerminal((taskID: string) => {
|
|
|
52
250
|
* Helpers live in src/pantheon/vision.ts and are imported from there directly.
|
|
53
251
|
*/
|
|
54
252
|
const plugin: Plugin = async (input: PluginInput) => {
|
|
253
|
+
// release-134: runtime guard against DOUBLE registration (duplicate toasts +
|
|
254
|
+
// duplicate task_ids in one process — 2026-08-12 live logs). opencode
|
|
255
|
+
// merges the global config (npm-package plugin paths) with the project
|
|
256
|
+
// config (repo source paths), so this module loads from TWO filesystem
|
|
257
|
+
// paths in the same process and the factory would otherwise run twice. The
|
|
258
|
+
// second invocation becomes a no-op: an empty hooks object (every Hooks
|
|
259
|
+
// field is optional) → hooks/tools register exactly once.
|
|
260
|
+
if (pantheonPluginOnce('pantheon:plugin')) {
|
|
261
|
+
return {}
|
|
262
|
+
}
|
|
55
263
|
const vision = createVisionHandler(input)
|
|
264
|
+
// Phase 2/3: background delegation toolset + the bound finalize lifecycle
|
|
265
|
+
// hook. Completion is observed through the event hook below
|
|
266
|
+
// (session.idle / session.error on a child) → finalizeDelegation.
|
|
267
|
+
const delegation = createDelegationTools({
|
|
268
|
+
board,
|
|
269
|
+
client: adaptDelegationClient(input.client),
|
|
270
|
+
options: {
|
|
271
|
+
rootSessions,
|
|
272
|
+
readOnlyAgents: new Set(['apollo', 'gaia']),
|
|
273
|
+
agentModels: routingAgentModels,
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
|
|
278
|
+
// Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
|
|
279
|
+
// access — COMPACTION_MAX_ITEMS pattern). The event hook routes non-board
|
|
280
|
+
// session.idle events here; board-child idles go to finalizeDelegation.
|
|
281
|
+
// Runtime kill-switch: PANTHEON_TODO_ENFORCER=off (routing.yml is a doc
|
|
282
|
+
// mirror only — the env var is the real switch, PANTHEON_TOASTS pattern).
|
|
283
|
+
const todoEnforcer = new TodoEnforcer({
|
|
284
|
+
client: adaptTodoEnforcerClient(input.client),
|
|
285
|
+
board,
|
|
286
|
+
options: { ...TODO_ENFORCER_DEFAULTS, enabled: todoEnforcerEnabledFromEnv() },
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// release-134 Phase 3: post-compaction todo restore. Captures the session's
|
|
290
|
+
// todo list in 'experimental.session.compacting', activates the snapshot on
|
|
291
|
+
// 'session.compacted', and rewrites the first post-compaction `todowrite`
|
|
292
|
+
// with the exact list (todo-preserve.ts). Additive + fail-open: every step
|
|
293
|
+
// degrades to a logged warn, never throws in a hook.
|
|
294
|
+
const todoPreserver = new TodoPreserver({
|
|
295
|
+
client: adaptTodoEnforcerClient(input.client),
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// Wave 3 (PR #46): full-auto goal loop — opt-in (`full_auto.enabled:
|
|
299
|
+
// false` default, mirrored by GOAL_LOOP_DEFAULTS). File-based GoalStore
|
|
300
|
+
// (`.pantheon/goals/<sessionID>.json` — the plugin layer cannot reach MCP
|
|
301
|
+
// KV). Idle routing: an active goal owns the idle (goal loop); otherwise
|
|
302
|
+
// the todo enforcer gets it (see idle-continuation.ts).
|
|
303
|
+
const goalStore = new GoalStore({ dir: '.pantheon/goals' })
|
|
304
|
+
const goalLoop = new GoalLoop({
|
|
305
|
+
store: goalStore,
|
|
306
|
+
client: adaptTodoEnforcerClient(input.client),
|
|
307
|
+
board,
|
|
308
|
+
options: GOAL_LOOP_DEFAULTS,
|
|
309
|
+
})
|
|
310
|
+
const goalTools = goalLoop.tools()
|
|
311
|
+
const idleDispatcher = createIdleDispatcher({ goalLoop, todoEnforcer })
|
|
312
|
+
|
|
313
|
+
// Wave 2 (PR #46): hashline — tag-anchored edits. The read enhancer
|
|
314
|
+
// (tool.execute.after) augments `read` output with per-line sha256 tags
|
|
315
|
+
// (`12#XJ|content`); hashline_edit anchors edits to those refs. Additive —
|
|
316
|
+
// the plugin has no tool.execute.after yet, and pantheon-hooks.ts (a
|
|
317
|
+
// separate plugin instance) keeps its own. Mirrors routing.yml `hashline`.
|
|
318
|
+
const hashlineEdit = createHashlineEditTool()
|
|
319
|
+
const readEnhancer = createReadEnhancer()
|
|
320
|
+
|
|
321
|
+
// Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
|
|
322
|
+
// opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
|
|
323
|
+
// Fully wired (unlike dispatch-guard, which is manual-orchestration-only
|
|
324
|
+
// because opencode 1.18.x cannot intercept task completion via hooks).
|
|
325
|
+
const costCommand = createCostCommand()
|
|
56
326
|
|
|
57
327
|
return {
|
|
58
328
|
config: async (config: PluginConfig) => {
|
|
329
|
+
// compaction-134 seed: opencode does NOT replay `session.created`
|
|
330
|
+
// events for sessions that exist before plugin load, so after a
|
|
331
|
+
// restart the resumed ROOT session never enters rootSessions and the
|
|
332
|
+
// depth guard would reject its pantheon_delegate calls. Seed the
|
|
333
|
+
// registry from session.list() in the background — every session
|
|
334
|
+
// WITHOUT a parentID is a root. Fail-open: a failed/throttled list
|
|
335
|
+
// leaves the set untouched (delegation.ts treats unknown sessions as
|
|
336
|
+
// roots), and config setup must never wait for the session API.
|
|
337
|
+
seedRootSessionsInBackground(input)
|
|
59
338
|
config.agentsPath = config.agentsPath ?? []
|
|
60
339
|
config.agentsPath.push(new URL('./agents', import.meta.url).pathname)
|
|
61
340
|
config.skillsPaths = config.skillsPaths ?? []
|
|
@@ -74,9 +353,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
74
353
|
candidates: activePresetCandidates(),
|
|
75
354
|
})
|
|
76
355
|
if (resolved) {
|
|
77
|
-
|
|
78
|
-
`[Pantheon Plugin] Applied model preset: ${resolved.name} (${resolved.source})`,
|
|
79
|
-
)
|
|
356
|
+
log.info(`[Pantheon Plugin] Applied model preset: ${resolved.name} (${resolved.source})`)
|
|
80
357
|
}
|
|
81
358
|
} catch {
|
|
82
359
|
// Fully STATIC warning — the thrown error object is tainted because
|
|
@@ -89,12 +366,128 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
89
366
|
// emitted by applyActivePresetToConfig's own logger before it throws.
|
|
90
367
|
// The catch binding is intentionally omitted so no tainted value can
|
|
91
368
|
// ever reach this log line (CodeQL alert #11).
|
|
92
|
-
|
|
369
|
+
log.warn('[plugin] preset application failed (see logs for details)')
|
|
93
370
|
}
|
|
94
371
|
},
|
|
95
|
-
|
|
372
|
+
// Phase 2: background delegation tools (structural — matches the `tool`
|
|
373
|
+
// hook field shape; read_only sessions feed the Phase 4 registry).
|
|
374
|
+
tool: {
|
|
375
|
+
pantheon_delegate: delegation.pantheon_delegate,
|
|
376
|
+
pantheon_delegation_read: delegation.pantheon_delegation_read,
|
|
377
|
+
pantheon_delegation_list: delegation.pantheon_delegation_list,
|
|
378
|
+
hashline_edit: hashlineEdit,
|
|
379
|
+
// Wave 3 (PR #46): full-auto goal tools (single active goal/session).
|
|
380
|
+
pantheon_goal_create: goalTools.pantheon_goal_create,
|
|
381
|
+
pantheon_goal_update: goalTools.pantheon_goal_update,
|
|
382
|
+
pantheon_goal_get: goalTools.pantheon_goal_get,
|
|
383
|
+
// Wave 4 (PR #46): /cost — delegation cost report from opencode.db.
|
|
384
|
+
pantheon_cost: costCommand.pantheon_cost,
|
|
385
|
+
},
|
|
386
|
+
'chat.message': async (hookInput, output) => {
|
|
387
|
+
await vision.chatMessage(hookInput, output)
|
|
388
|
+
// No delegation notification delivery here — the user policy is ZERO
|
|
389
|
+
// notification text injected into the chat transcript (previously the
|
|
390
|
+
// notifier.flushQueue prepend). pantheon-hooks' chat-reminders.ts keeps
|
|
391
|
+
// its own <system-reminder> channel, untouched.
|
|
392
|
+
// Wave 1: a user message is activity — the todo enforcer's
|
|
393
|
+
// user-activity gate skips injection for userActivityQuietMs afterwards.
|
|
394
|
+
todoEnforcer.noteUserActivity(hookInput.sessionID)
|
|
395
|
+
},
|
|
96
396
|
'experimental.chat.messages.transform': vision.messagesTransform,
|
|
97
|
-
event:
|
|
397
|
+
event: async ({ event: ev }) => {
|
|
398
|
+
// Phase 3: sessions created without a parent are roots for the depth guard.
|
|
399
|
+
if (ev.type === 'session.created') {
|
|
400
|
+
const info = ev.properties.info
|
|
401
|
+
if (info && info.parentID === undefined) rootSessions.add(info.id)
|
|
402
|
+
}
|
|
403
|
+
// release-134 Phase 3: activate the todo snapshot captured at
|
|
404
|
+
// compacting time — the first todowrite within the restore window is
|
|
405
|
+
// rewritten with the exact list (see todo-preserve.ts). Fail-open.
|
|
406
|
+
if (ev.type === 'session.compacted') {
|
|
407
|
+
await todoPreserver.onCompacted(ev.properties.sessionID)
|
|
408
|
+
// release-134 Phase 4: re-assert post-compaction state — enqueue a
|
|
409
|
+
// fresh-state reminder (running/unread board jobs + active goals) for
|
|
410
|
+
// the session's next chat.message delivery. Fail-open (never throws).
|
|
411
|
+
await reassertAfterCompaction({
|
|
412
|
+
sessionID: ev.properties.sessionID,
|
|
413
|
+
board,
|
|
414
|
+
goals: {
|
|
415
|
+
enabled: GOAL_LOOP_DEFAULTS.enabled,
|
|
416
|
+
list: (s: string) => goalStore.list(s),
|
|
417
|
+
},
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
// Phase 3: observe completion on child sessions → finalizeDelegation.
|
|
421
|
+
// The board transition fires onTerminal → the file-only audit log (no
|
|
422
|
+
// chat delivery — see the onTerminal listener above). Unknown sessions
|
|
423
|
+
// are a no-op.
|
|
424
|
+
try {
|
|
425
|
+
const delegated = await handleDelegationEvent(ev, {
|
|
426
|
+
board,
|
|
427
|
+
finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
|
|
428
|
+
})
|
|
429
|
+
// Wave 1/3: non-board idle → idle dispatcher. Board children are
|
|
430
|
+
// handled by handleDelegationEvent above; everything else (roots,
|
|
431
|
+
// non-board sessions) is routed by priority: an active goal owns the
|
|
432
|
+
// idle (goal loop), otherwise the todo enforcer re-injects. Neither
|
|
433
|
+
// onIdle ever throws (internal failures are logged + swallowed), so
|
|
434
|
+
// the session is safe.
|
|
435
|
+
if (!delegated && ev.type === 'session.idle') {
|
|
436
|
+
await idleDispatcher.onIdle(ev.properties.sessionID)
|
|
437
|
+
}
|
|
438
|
+
} catch (err) {
|
|
439
|
+
// The event hook must never break the session.
|
|
440
|
+
log.error('[Pantheon Plugin] Delegation event handling failed:', err)
|
|
441
|
+
}
|
|
442
|
+
await vision.event({ event: ev })
|
|
443
|
+
},
|
|
444
|
+
// Phase 4: deny mutating tools in read-only delegated sessions. Additive
|
|
445
|
+
// key — does not touch Phase 3's tools/event/onTerminal/chat.message.
|
|
446
|
+
'tool.execute.before': async (input, output) => {
|
|
447
|
+
// Chain: the read-only enforcement guard runs first (denies mutating
|
|
448
|
+
// tools in read-only sessions), then release-134 Phase 3 rewrites the
|
|
449
|
+
// first post-compaction `todowrite` with the captured todo snapshot.
|
|
450
|
+
// Both are no-ops for non-matching sessions/tools; the preserver is
|
|
451
|
+
// fail-open (only its intentional restore denial throws).
|
|
452
|
+
await enforcementGuard(input)
|
|
453
|
+
await todoPreserver.beforeTodoWrite(input, output)
|
|
454
|
+
},
|
|
455
|
+
// Wave 2 (PR #46): augment `read` output with hashline tags. Additive —
|
|
456
|
+
// pantheon-hooks.ts (a separate plugin instance) owns its own
|
|
457
|
+
// tool.execute.after, so there is no key collision. Non-read tools pass
|
|
458
|
+
// through untouched.
|
|
459
|
+
'tool.execute.after': readEnhancer,
|
|
460
|
+
// Phase 4 + release-134 Phase 2: keep the session's working state across
|
|
461
|
+
// compaction — preservation directive, active goals (<mission_context>),
|
|
462
|
+
// pending todos (<todo_context>), and in-flight background delegations
|
|
463
|
+
// (running + unread terminal ≤ max_compaction_items). Guarded: only
|
|
464
|
+
// pushes when there is something to preserve; a build failure must never
|
|
465
|
+
// break the experimental compaction hook.
|
|
466
|
+
'experimental.session.compacting': async (_input, output) => {
|
|
467
|
+
try {
|
|
468
|
+
const blocks = await buildCompactionContext(board, {
|
|
469
|
+
sessionID: _input.sessionID,
|
|
470
|
+
maxItems: COMPACTION_MAX_ITEMS,
|
|
471
|
+
goals: {
|
|
472
|
+
enabled: GOAL_LOOP_DEFAULTS.enabled,
|
|
473
|
+
list: (sessionID: string) => goalStore.list(sessionID),
|
|
474
|
+
},
|
|
475
|
+
todos: {
|
|
476
|
+
enabled: todoEnforcerEnabledFromEnv(),
|
|
477
|
+
list: (sessionID: string) => todoEnforcer.listPendingTodos(sessionID),
|
|
478
|
+
},
|
|
479
|
+
})
|
|
480
|
+
if (blocks.length > 0) {
|
|
481
|
+
output.context.push(...blocks)
|
|
482
|
+
}
|
|
483
|
+
// release-134 Phase 3 (additive): snapshot the session's full todo
|
|
484
|
+
// list for post-compaction restore. Best-effort — a GET failure is
|
|
485
|
+
// logged and the compaction proceeds normally.
|
|
486
|
+
await todoPreserver.capture(_input.sessionID)
|
|
487
|
+
} catch (err: unknown) {
|
|
488
|
+
log.warn('[Pantheon Plugin] Compaction context build failed:', err)
|
|
489
|
+
}
|
|
490
|
+
},
|
|
98
491
|
}
|
|
99
492
|
}
|
|
100
493
|
|
|
@@ -186,7 +186,14 @@ import { appendFile, mkdir } from 'node:fs/promises'
|
|
|
186
186
|
import { join } from 'node:path'
|
|
187
187
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
188
188
|
import type { Part } from '@opencode-ai/sdk'
|
|
189
|
+
import {
|
|
190
|
+
CHAT_REMINDER_TTL_MS,
|
|
191
|
+
drainFreshChatReminders,
|
|
192
|
+
enqueueChatReminder,
|
|
193
|
+
pendingChatReminders,
|
|
194
|
+
} from '../pantheon/chat-reminders.ts'
|
|
189
195
|
import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
|
|
196
|
+
import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
|
|
190
197
|
|
|
191
198
|
/** Tools that represent a subagent delegation (opencode `task` tool etc.). */
|
|
192
199
|
const DELEGATION_TOOL_RE = /^(task|.*delegate.*|.*subagent.*)$/i
|
|
@@ -424,6 +431,15 @@ const TASK_ID_TEXT_RE = /\btask\s+id="(ses_[^"]+)"/i
|
|
|
424
431
|
* opencode 1.18.13 drops tui.toast.show events (untagged), so every signal
|
|
425
432
|
* that would fire a toast is ALSO queued here and injected into the next user
|
|
426
433
|
* message by the 'chat.message' hook as a single <system-reminder> text part.
|
|
434
|
+
*
|
|
435
|
+
* The buffer itself lives in the SHARED chat-reminders.ts module: this plugin
|
|
436
|
+
* file must export EXACTLY ONE function-valued export (legacy loader —
|
|
437
|
+
* see the IMPORTANT note at the top), so the enqueue API cannot be exported
|
|
438
|
+
* from here. Importing the shared module instead gives src/plugin.ts (a
|
|
439
|
+
* separate plugin file, loaded via plain dynamic import) the SAME buffer
|
|
440
|
+
* instance — release-134 Phase 4 (compaction-assert.ts) enqueues
|
|
441
|
+
* post-compaction state re-assertions here and this hook delivers them.
|
|
442
|
+
*
|
|
427
443
|
* Bounded: at most CHAT_REMINDER_MAX entries, each expiring
|
|
428
444
|
* CHAT_REMINDER_TTL_MS after its tool event. Consumed (and cleared) by the
|
|
429
445
|
* chat.message hook. Full-buffer enqueues are SKIPPED, never unbounded — the
|
|
@@ -432,20 +448,6 @@ const TASK_ID_TEXT_RE = /\btask\s+id="(ses_[^"]+)"/i
|
|
|
432
448
|
* session.idle flushes the buffer into ONE fresh aggregated entry (see
|
|
433
449
|
* flushIdleReminders) so completions survive until the next user message.
|
|
434
450
|
*/
|
|
435
|
-
const CHAT_REMINDER_MAX = 10
|
|
436
|
-
const CHAT_REMINDER_TTL_MS = 60_000
|
|
437
|
-
const pendingChatReminders: { text: string; at: number }[] = []
|
|
438
|
-
|
|
439
|
-
/** Queue one chat-reminder line, pruning expired entries first. Never throws. */
|
|
440
|
-
function enqueueChatReminder(text: string): void {
|
|
441
|
-
const now = Date.now()
|
|
442
|
-
for (let i = pendingChatReminders.length - 1; i >= 0; i--) {
|
|
443
|
-
const r = pendingChatReminders[i]
|
|
444
|
-
if (r !== undefined && now - r.at > CHAT_REMINDER_TTL_MS) pendingChatReminders.splice(i, 1)
|
|
445
|
-
}
|
|
446
|
-
if (pendingChatReminders.length >= CHAT_REMINDER_MAX) return
|
|
447
|
-
pendingChatReminders.push({ text, at: now })
|
|
448
|
-
}
|
|
449
451
|
|
|
450
452
|
/** Variants accepted by the opencode TUI showToast (TuiShowToastData.body.variant). */
|
|
451
453
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error'
|
|
@@ -1124,10 +1126,13 @@ function flushIdleReminders(ctx: HookContext): void {
|
|
|
1124
1126
|
|
|
1125
1127
|
// ONE aggregated entry, fresh timestamp — chat.message delivers it next.
|
|
1126
1128
|
enqueueChatReminder(lines.join('\n'))
|
|
1129
|
+
// 1.3.4 dedup: log a SUMMARY here (count only), never the joined body — the
|
|
1130
|
+
// SAME lines used to be echoed twice (idle-flush with " | ", chat-reminder
|
|
1131
|
+
// delivery with "\n"). The content is logged exactly once, at delivery.
|
|
1127
1132
|
void reportFailure(
|
|
1128
1133
|
ctx,
|
|
1129
|
-
`[pantheon-hooks:idle-flush] ${lines.
|
|
1130
|
-
{ script: 'idle-flush', count: fresh.length },
|
|
1134
|
+
`[pantheon-hooks:idle-flush] ${fresh.length} reminder(s) flushed into the chat buffer (${lines.length} line(s) after aggregation)`,
|
|
1135
|
+
{ script: 'idle-flush', count: fresh.length, lineCount: lines.length },
|
|
1131
1136
|
'info',
|
|
1132
1137
|
)
|
|
1133
1138
|
}
|
|
@@ -1203,6 +1208,17 @@ async function safeRun(
|
|
|
1203
1208
|
}
|
|
1204
1209
|
|
|
1205
1210
|
const plugin: Plugin = async ({ client, directory }) => {
|
|
1211
|
+
// release-134: runtime guard against DOUBLE registration (duplicate toasts +
|
|
1212
|
+
// duplicate task_ids in one process — 2026-08-12 live logs). opencode
|
|
1213
|
+
// merges the global config (npm-package plugin paths) with the project
|
|
1214
|
+
// config (repo source paths), so this module loads from TWO filesystem
|
|
1215
|
+
// paths in the same process and the factory would otherwise run twice —
|
|
1216
|
+
// every hook (tool.execute.before/after, chat.message, event) firing
|
|
1217
|
+
// TWICE. The second invocation becomes a no-op (empty hooks object) so
|
|
1218
|
+
// hooks register exactly once.
|
|
1219
|
+
if (pantheonPluginOnce('pantheon:hooks')) {
|
|
1220
|
+
return {}
|
|
1221
|
+
}
|
|
1206
1222
|
const ctx: HookContext = { client, directory }
|
|
1207
1223
|
return {
|
|
1208
1224
|
'tool.execute.before': async (input, output) => {
|
|
@@ -1427,35 +1443,41 @@ const plugin: Plugin = async ({ client, directory }) => {
|
|
|
1427
1443
|
// tui.toast.show events on opencode 1.18.13 (untagged — see header), so
|
|
1428
1444
|
// queued agent-lifecycle signals (delegation start/done, hook failures,
|
|
1429
1445
|
// olympians/council summaries) are injected here as ONE <system-reminder> text
|
|
1430
|
-
// part into the next user message, then cleared.
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1446
|
+
// part into the next user message, then cleared.
|
|
1447
|
+
// P0 (2026-08-11, live E2E — release blocker): this hook ALSO fires for the
|
|
1448
|
+
// child session's client.session.promptAsync (the old "subagent prompts use
|
|
1449
|
+
// a different path" claim is FALSE on 1.18.13), where input.messageID is
|
|
1450
|
+
// EMPTY/undefined. Injecting there with `?? ''` produced a part opencode's
|
|
1451
|
+
// schema rejects (SchemaError: Expected a string starting with "msg", got
|
|
1452
|
+
// "") → prompt_async failed → every delegation died in ~20ms. The guard
|
|
1453
|
+
// below skips injection ENTIRELY on that path WITHOUT draining the buffer,
|
|
1454
|
+
// so the reminder still lands on the parent's next real message.
|
|
1437
1455
|
'chat.message': async (input, output) => {
|
|
1438
1456
|
try {
|
|
1439
1457
|
if (ENABLED_TOAST_CATEGORIES.size === 0) return
|
|
1440
1458
|
if (pendingChatReminders.length === 0) return
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1459
|
+
// Subagent promptAsync fires have no messageID — injecting any part
|
|
1460
|
+
// with an empty messageID crashes the child session (schema reject).
|
|
1461
|
+
// Early return BEFORE the buffer drain keeps the reminder queued for
|
|
1462
|
+
// the parent's next real (msg_-ID'd) message.
|
|
1463
|
+
if (!input.messageID) return
|
|
1464
|
+
const body = drainFreshChatReminders()
|
|
1465
|
+
if (body === undefined) return
|
|
1446
1466
|
// Minimal TextPart — the core backfills messageID/sessionID for
|
|
1447
1467
|
// hook-injected parts (see opencode chat.message trigger pipeline).
|
|
1468
|
+
// The guard above guarantees input.messageID is a non-empty string,
|
|
1469
|
+
// so no `?? ''` fallback is needed (an explicit "" is schema-rejected).
|
|
1448
1470
|
output.parts.push({
|
|
1449
1471
|
id: `prt_${crypto.randomUUID()}`,
|
|
1450
1472
|
sessionID: input.sessionID,
|
|
1451
|
-
messageID: input.messageID
|
|
1473
|
+
messageID: input.messageID,
|
|
1452
1474
|
type: 'text',
|
|
1453
1475
|
text: `<system-reminder>\n${body}\n</system-reminder>`,
|
|
1454
1476
|
} satisfies Part)
|
|
1455
1477
|
await reportFailure(
|
|
1456
1478
|
ctx,
|
|
1457
1479
|
`[pantheon-hooks:chat-reminder] ${body}`,
|
|
1458
|
-
{ script: 'chat-reminder'
|
|
1480
|
+
{ script: 'chat-reminder' },
|
|
1459
1481
|
'info',
|
|
1460
1482
|
)
|
|
1461
1483
|
} catch {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/server.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* No-op server stub for the opencode plugin loader.
|
|
4
|
+
*
|
|
5
|
+
* The opencode plugin host requires every plugin (TUI included) to expose a
|
|
6
|
+
* `server()` entry via `exports["./server"]` (or package.json `main`). When
|
|
7
|
+
* that entry is missing the host fails to attach the plugin's server half and
|
|
8
|
+
* the TUI runtime never wires the reactive channel — the initial JSX mounts
|
|
9
|
+
* but effects/refresh/events stay dead (the "mudo panel" symptom).
|
|
10
|
+
*
|
|
11
|
+
* This stub satisfies the loader contract; pantheon-tui is TUI-only and has
|
|
12
|
+
* no background server work of its own.
|
|
13
|
+
*/
|
|
14
|
+
declare function server(): {};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { server as default };
|
|
17
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","names":[],"sources":["../src/server.ts"],"mappings":";;;;;;;;;;;;;iBAYwB"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/server.ts
|
|
2
|
+
/**
|
|
3
|
+
* No-op server stub for the opencode plugin loader.
|
|
4
|
+
*
|
|
5
|
+
* The opencode plugin host requires every plugin (TUI included) to expose a
|
|
6
|
+
* `server()` entry via `exports["./server"]` (or package.json `main`). When
|
|
7
|
+
* that entry is missing the host fails to attach the plugin's server half and
|
|
8
|
+
* the TUI runtime never wires the reactive channel — the initial JSX mounts
|
|
9
|
+
* but effects/refresh/events stay dead (the "mudo panel" symptom).
|
|
10
|
+
*
|
|
11
|
+
* This stub satisfies the loader contract; pantheon-tui is TUI-only and has
|
|
12
|
+
* no background server work of its own.
|
|
13
|
+
*/
|
|
14
|
+
function server() {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { server as default };
|
|
19
|
+
|
|
20
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","names":[],"sources":["../src/server.ts"],"sourcesContent":["/**\n * No-op server stub for the opencode plugin loader.\n *\n * The opencode plugin host requires every plugin (TUI included) to expose a\n * `server()` entry via `exports[\"./server\"]` (or package.json `main`). When\n * that entry is missing the host fails to attach the plugin's server half and\n * the TUI runtime never wires the reactive channel — the initial JSX mounts\n * but effects/refresh/events stay dead (the \"mudo panel\" symptom).\n *\n * This stub satisfies the loader contract; pantheon-tui is TUI-only and has\n * no background server work of its own.\n */\nexport default function server() {\n return {}\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,SAAwB,SAAS;CAC/B,OAAO,CAAC;AACV"}
|