pantheon-opencode 1.3.3 → 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 +115 -12
- package/bin/pantheon-init.mjs +160 -120
- package/opencode.json +0 -4
- package/package.json +7 -4
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +108 -26
- 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 +6 -2
- package/src/pantheon/background-job-board.ts +24 -38
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/delegation-compaction.ts +143 -16
- package/src/pantheon/delegation-finalize.ts +8 -1
- package/src/pantheon/delegation-notify.ts +19 -157
- package/src/pantheon/delegation.ts +330 -28
- package/src/pantheon/dispatch-guard.ts +9 -4
- package/src/pantheon/goal-loop.ts +7 -4
- package/src/pantheon/hashline/tool.ts +2 -1
- 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 +79 -27
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +177 -50
- package/src/plugins/pantheon-hooks.ts +35 -22
- 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 +3 -1
package/src/plugin.ts
CHANGED
|
@@ -1,27 +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'
|
|
4
5
|
import { createCostCommand } from './pantheon/cost-command.ts'
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
collectRootSessionIDs,
|
|
8
|
+
createDelegationTools,
|
|
9
|
+
type DelegationClient,
|
|
10
|
+
} from './pantheon/delegation.ts'
|
|
6
11
|
import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
|
|
7
12
|
import { createEnforcementGuard, readOnlyRegistry } from './pantheon/delegation-enforce.ts'
|
|
8
|
-
import {
|
|
13
|
+
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
9
14
|
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
10
15
|
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
11
16
|
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
12
17
|
import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
|
|
13
18
|
import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
|
|
14
|
-
import {
|
|
19
|
+
import { createPantheonLogger } from './pantheon/logger.ts'
|
|
20
|
+
import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
|
|
21
|
+
import { applyActivePresetToConfig, loadRoutingAgentModels } from './pantheon/presets.mjs'
|
|
15
22
|
import {
|
|
16
23
|
TODO_ENFORCER_DEFAULTS,
|
|
17
24
|
TodoEnforcer,
|
|
18
25
|
type TodoEnforcerClient,
|
|
19
26
|
todoEnforcerEnabledFromEnv,
|
|
20
27
|
} from './pantheon/todo-enforcer.ts'
|
|
28
|
+
import { TodoPreserver } from './pantheon/todo-preserve.ts'
|
|
21
29
|
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
22
30
|
|
|
23
31
|
// ─── Background Job Board Singleton ────────────────────────────────────
|
|
24
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
|
+
|
|
25
50
|
const board = new BackgroundJobBoard({
|
|
26
51
|
maxConcurrentPerAgent: 3,
|
|
27
52
|
signalDir: '.pantheon/deepwork/board-signals',
|
|
@@ -30,31 +55,22 @@ const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
|
|
|
30
55
|
board.setPersistence(persistence)
|
|
31
56
|
board
|
|
32
57
|
.recoverRunningJobs()
|
|
33
|
-
.catch((err) =>
|
|
34
|
-
// ─── Phase 3:
|
|
35
|
-
|
|
36
|
-
// The notifier is the completion channel: the spike refuted client push
|
|
37
|
-
// (noReply delivers nothing to the parent), so terminal-job notifications are
|
|
38
|
-
// QUEUED here and injected into the parent's next chat.message by the flush in
|
|
39
|
-
// the 'chat.message' hook below (graceful-degradation path). Gated by the same
|
|
40
|
-
// env as pantheon-hooks toasts: PANTHEON_TOASTS=off disables queueing.
|
|
41
|
-
const notifier = new DelegationNotifier()
|
|
42
|
-
const notificationsEnabled = (process.env.PANTHEON_TOASTS ?? '').trim().toLowerCase() !== 'off'
|
|
58
|
+
.catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
|
|
59
|
+
// ─── Phase 3: Board terminal audit log ─────────────────────────────────
|
|
43
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.
|
|
44
68
|
board.onTerminal((taskID: string) => {
|
|
45
69
|
const job = board.get(taskID)
|
|
46
70
|
if (!job) return
|
|
47
|
-
|
|
71
|
+
log.info(
|
|
48
72
|
`[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
|
|
49
73
|
)
|
|
50
|
-
// Queue the completion notification for the job's parent session. The
|
|
51
|
-
// onTerminal listener is the SINGLE notification point — the timeout path
|
|
52
|
-
// (timeout finalize → updateStatus) and the event path (session.idle →
|
|
53
|
-
// finalizeDelegation → updateStatus) both transition the board and fire
|
|
54
|
-
// here, so every terminal job (incl. timedOut) gets exactly one notification.
|
|
55
|
-
if (notificationsEnabled) {
|
|
56
|
-
notifier.notifyParent(job)
|
|
57
|
-
}
|
|
58
74
|
})
|
|
59
75
|
|
|
60
76
|
// Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
|
|
@@ -65,9 +81,7 @@ setInterval(
|
|
|
65
81
|
() => {
|
|
66
82
|
void board
|
|
67
83
|
.pruneExpired(86_400_000)
|
|
68
|
-
.catch((err: unknown) =>
|
|
69
|
-
console.error('[Pantheon Plugin] Background board prune failed:', err),
|
|
70
|
-
)
|
|
84
|
+
.catch((err: unknown) => log.error('[Pantheon Plugin] Background board prune failed:', err))
|
|
71
85
|
},
|
|
72
86
|
30 * 60 * 1000,
|
|
73
87
|
).unref()
|
|
@@ -86,11 +100,49 @@ const enforcementGuard = createEnforcementGuard({
|
|
|
86
100
|
const COMPACTION_MAX_ITEMS = 10
|
|
87
101
|
|
|
88
102
|
// Root-session registry for the delegation depth guard: sessions created
|
|
89
|
-
// without a parentID are roots. Populated from
|
|
90
|
-
// event hook
|
|
91
|
-
//
|
|
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.
|
|
92
113
|
const rootSessions = new Set<string>()
|
|
93
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
|
+
|
|
94
146
|
/** Extract the SDK error message ({name, data: {message}}) or a fallback. */
|
|
95
147
|
function sdkErrorMessage(error: { data?: { message?: string } } | null | undefined): string {
|
|
96
148
|
const message = error?.data?.message
|
|
@@ -198,6 +250,16 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
|
|
|
198
250
|
* Helpers live in src/pantheon/vision.ts and are imported from there directly.
|
|
199
251
|
*/
|
|
200
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
|
+
}
|
|
201
263
|
const vision = createVisionHandler(input)
|
|
202
264
|
// Phase 2/3: background delegation toolset + the bound finalize lifecycle
|
|
203
265
|
// hook. Completion is observed through the event hook below
|
|
@@ -205,7 +267,11 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
205
267
|
const delegation = createDelegationTools({
|
|
206
268
|
board,
|
|
207
269
|
client: adaptDelegationClient(input.client),
|
|
208
|
-
options: {
|
|
270
|
+
options: {
|
|
271
|
+
rootSessions,
|
|
272
|
+
readOnlyAgents: new Set(['apollo', 'gaia']),
|
|
273
|
+
agentModels: routingAgentModels,
|
|
274
|
+
},
|
|
209
275
|
})
|
|
210
276
|
|
|
211
277
|
// Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
|
|
@@ -220,6 +286,15 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
220
286
|
options: { ...TODO_ENFORCER_DEFAULTS, enabled: todoEnforcerEnabledFromEnv() },
|
|
221
287
|
})
|
|
222
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
|
+
|
|
223
298
|
// Wave 3 (PR #46): full-auto goal loop — opt-in (`full_auto.enabled:
|
|
224
299
|
// false` default, mirrored by GOAL_LOOP_DEFAULTS). File-based GoalStore
|
|
225
300
|
// (`.pantheon/goals/<sessionID>.json` — the plugin layer cannot reach MCP
|
|
@@ -251,6 +326,15 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
251
326
|
|
|
252
327
|
return {
|
|
253
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)
|
|
254
338
|
config.agentsPath = config.agentsPath ?? []
|
|
255
339
|
config.agentsPath.push(new URL('./agents', import.meta.url).pathname)
|
|
256
340
|
config.skillsPaths = config.skillsPaths ?? []
|
|
@@ -269,9 +353,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
269
353
|
candidates: activePresetCandidates(),
|
|
270
354
|
})
|
|
271
355
|
if (resolved) {
|
|
272
|
-
|
|
273
|
-
`[Pantheon Plugin] Applied model preset: ${resolved.name} (${resolved.source})`,
|
|
274
|
-
)
|
|
356
|
+
log.info(`[Pantheon Plugin] Applied model preset: ${resolved.name} (${resolved.source})`)
|
|
275
357
|
}
|
|
276
358
|
} catch {
|
|
277
359
|
// Fully STATIC warning — the thrown error object is tainted because
|
|
@@ -284,7 +366,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
284
366
|
// emitted by applyActivePresetToConfig's own logger before it throws.
|
|
285
367
|
// The catch binding is intentionally omitted so no tainted value can
|
|
286
368
|
// ever reach this log line (CodeQL alert #11).
|
|
287
|
-
|
|
369
|
+
log.warn('[plugin] preset application failed (see logs for details)')
|
|
288
370
|
}
|
|
289
371
|
},
|
|
290
372
|
// Phase 2: background delegation tools (structural — matches the `tool`
|
|
@@ -303,10 +385,10 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
303
385
|
},
|
|
304
386
|
'chat.message': async (hookInput, output) => {
|
|
305
387
|
await vision.chatMessage(hookInput, output)
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
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.
|
|
310
392
|
// Wave 1: a user message is activity — the todo enforcer's
|
|
311
393
|
// user-activity gate skips injection for userActivityQuietMs afterwards.
|
|
312
394
|
todoEnforcer.noteUserActivity(hookInput.sessionID)
|
|
@@ -318,9 +400,27 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
318
400
|
const info = ev.properties.info
|
|
319
401
|
if (info && info.parentID === undefined) rootSessions.add(info.id)
|
|
320
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
|
+
}
|
|
321
420
|
// Phase 3: observe completion on child sessions → finalizeDelegation.
|
|
322
|
-
// The board transition fires onTerminal →
|
|
323
|
-
//
|
|
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.
|
|
324
424
|
try {
|
|
325
425
|
const delegated = await handleDelegationEvent(ev, {
|
|
326
426
|
board,
|
|
@@ -337,28 +437,55 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
337
437
|
}
|
|
338
438
|
} catch (err) {
|
|
339
439
|
// The event hook must never break the session.
|
|
340
|
-
|
|
440
|
+
log.error('[Pantheon Plugin] Delegation event handling failed:', err)
|
|
341
441
|
}
|
|
342
442
|
await vision.event({ event: ev })
|
|
343
443
|
},
|
|
344
444
|
// Phase 4: deny mutating tools in read-only delegated sessions. Additive
|
|
345
445
|
// key — does not touch Phase 3's tools/event/onTerminal/chat.message.
|
|
346
|
-
'tool.execute.before':
|
|
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
|
+
},
|
|
347
455
|
// Wave 2 (PR #46): augment `read` output with hashline tags. Additive —
|
|
348
456
|
// pantheon-hooks.ts (a separate plugin instance) owns its own
|
|
349
457
|
// tool.execute.after, so there is no key collision. Non-read tools pass
|
|
350
458
|
// through untouched.
|
|
351
459
|
'tool.execute.after': readEnhancer,
|
|
352
|
-
// Phase 4: keep
|
|
353
|
-
// compaction
|
|
354
|
-
//
|
|
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.
|
|
355
466
|
'experimental.session.compacting': async (_input, output) => {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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)
|
|
362
489
|
}
|
|
363
490
|
},
|
|
364
491
|
}
|
|
@@ -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) => {
|
|
@@ -1445,11 +1461,8 @@ const plugin: Plugin = async ({ client, directory }) => {
|
|
|
1445
1461
|
// Early return BEFORE the buffer drain keeps the reminder queued for
|
|
1446
1462
|
// the parent's next real (msg_-ID'd) message.
|
|
1447
1463
|
if (!input.messageID) return
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
pendingChatReminders.length = 0
|
|
1451
|
-
if (fresh.length === 0) return
|
|
1452
|
-
const body = fresh.map((r) => r.text).join('\n')
|
|
1464
|
+
const body = drainFreshChatReminders()
|
|
1465
|
+
if (body === undefined) return
|
|
1453
1466
|
// Minimal TextPart — the core backfills messageID/sessionID for
|
|
1454
1467
|
// hook-injected parts (see opencode chat.message trigger pipeline).
|
|
1455
1468
|
// The guard above guarantees input.messageID is a non-empty string,
|
|
@@ -1464,7 +1477,7 @@ const plugin: Plugin = async ({ client, directory }) => {
|
|
|
1464
1477
|
await reportFailure(
|
|
1465
1478
|
ctx,
|
|
1466
1479
|
`[pantheon-hooks:chat-reminder] ${body}`,
|
|
1467
|
-
{ script: 'chat-reminder'
|
|
1480
|
+
{ script: 'chat-reminder' },
|
|
1468
1481
|
'info',
|
|
1469
1482
|
)
|
|
1470
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"}
|