pantheon-opencode 1.4.0 → 1.4.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/README.md +201 -95
- package/bin/pantheon-init.mjs +7 -0
- package/commands/pantheon-model.md +34 -0
- package/opencode.json +0 -2
- package/package.json +2 -2
- package/scripts/generate-preset-docs.mjs +239 -0
- package/scripts/install/model-picker.mjs +367 -2
- package/scripts/install/opencode.mjs +150 -137
- package/src/pantheon/chat-reminders.ts +4 -74
- package/src/pantheon/compaction-assert.ts +28 -28
- package/src/pantheon/delegation-finalize.ts +44 -11
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +45 -121
- package/src/pantheon/model-command.ts +858 -0
- package/src/pantheon/presets.d.mts +4 -3
- package/src/pantheon/presets.mjs +47 -54
- package/src/pantheon/zeus-delegate-with-retry.ts +9 -4
- package/src/plugin.ts +17 -29
- package/src/plugins/pantheon-hooks.ts +15 -158
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/dist/tui.tsx +13 -2
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +13 -2
- package/src/plugins/tui/tsdown.config.ts +11 -0
- package/src/routing.yml +71 -123
|
@@ -86,13 +86,14 @@ export declare function loadPresetDefs(routingPath?: string): Record<string, Pre
|
|
|
86
86
|
|
|
87
87
|
export interface RoutingAgentModelsOptions {
|
|
88
88
|
routingPath?: string
|
|
89
|
+
candidates?: string[]
|
|
90
|
+
env?: Record<string, string | undefined>
|
|
89
91
|
logger?: { warn?: (msg: string) => void }
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
/**
|
|
93
|
-
* Lowercase agent → "provider/model" mapping from
|
|
94
|
-
*
|
|
95
|
-
* options.agentModels. Fail-open: missing/corrupt routing.yml → {}.
|
|
95
|
+
* Lowercase agent → "provider/model" mapping from the explicitly active
|
|
96
|
+
* routing profile. No active profile (or a profile without model) → {}.
|
|
96
97
|
*/
|
|
97
98
|
export declare function loadRoutingAgentModels(
|
|
98
99
|
options?: RoutingAgentModelsOptions,
|
package/src/pantheon/presets.mjs
CHANGED
|
@@ -33,12 +33,13 @@ export const EFFORT_RANK = { low: 0, medium: 1, high: 2 }
|
|
|
33
33
|
/**
|
|
34
34
|
* Capability table: reasoning-effort ceiling + image-input (vision) support.
|
|
35
35
|
*
|
|
36
|
-
* `vision` flags mark models that accept image input. Verified
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* `vision` flags mark models that accept image input. Verified 2026-08-27:
|
|
37
|
+
* deepseek-v4-pro high, deepseek-v4-flash medium text-only; mimo-v2.5 /
|
|
38
|
+
* mimo-v2.5-free multimodal low; qwen3.6-plus-free / qwen3.8-max text-only;
|
|
39
|
+
* glm-5.3-flash high text-only; kimi-k2.7-code high, kimi-k3 medium text-only;
|
|
40
|
+
* nemotron-3-super-free high text-only; big-pickle medium text-only;
|
|
41
|
+
* minimax-m3 multimodal high; gpt-5.6 family native image support.
|
|
42
|
+
* Removed: minimax-m2.5, kimi-k2.5, glm-5.1/5.2, ox-alpha (deprecated 2026).
|
|
42
43
|
*/
|
|
43
44
|
export const CAPABILITY_TABLE = [
|
|
44
45
|
{ prefix: 'deepseek/deepseek-v4-pro', maxEffort: 'high', stripEffort: false, vision: false },
|
|
@@ -60,31 +61,18 @@ export const CAPABILITY_TABLE = [
|
|
|
60
61
|
// entry above matches 'mimo/v2.5' (full-string) but NOT 'mimo-v2.5'
|
|
61
62
|
// (segment 'mimo-v2.5' has no slash, so 'mimo/' prefix cannot match).
|
|
62
63
|
{ prefix: 'mimo-v2.5', maxEffort: 'low', stripEffort: false, vision: true },
|
|
63
|
-
// mimo-v2.5-pro is text-only per models.dev api.json (2026-08-
|
|
64
|
-
// Explicit entry needed: the 'mimo-v2.5' prefix substring-matches
|
|
65
|
-
// 'mimo-v2.5-pro' (longest-prefix wins), which would wrongly report
|
|
66
|
-
// vision: true for a text-only model.
|
|
64
|
+
// mimo-v2.5-pro is text-only per models.dev api.json (2026-08-27).
|
|
67
65
|
{ prefix: 'mimo-v2.5-pro', maxEffort: 'low', stripEffort: false, vision: false },
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
{ prefix: '
|
|
73
|
-
{ prefix: '
|
|
74
|
-
|
|
75
|
-
// qwen3.7-max is text-only per models.dev api.json (2026-08-02); the
|
|
76
|
-
// multimodal variant is qwen3.7-plus.
|
|
66
|
+
// Go subscription specific — bare segment prefixes so opencode-go/<model> IDs match
|
|
67
|
+
{ prefix: 'glm-5.3-flash', maxEffort: 'high', stripEffort: false, vision: false },
|
|
68
|
+
{ prefix: 'kimi-k2.7-code', maxEffort: 'high', stripEffort: false, vision: false },
|
|
69
|
+
{ prefix: 'kimi-k3', maxEffort: 'medium', stripEffort: false, vision: false },
|
|
70
|
+
{ prefix: 'qwen3.8-max', maxEffort: 'high', stripEffort: false, vision: false },
|
|
71
|
+
{ prefix: 'qwen3.6-plus-free', maxEffort: 'medium', stripEffort: false, vision: false },
|
|
72
|
+
// Legacy qwen3.7 retained for capability checks (not used in new presets)
|
|
77
73
|
{ prefix: 'qwen3.7-max', maxEffort: 'high', stripEffort: false, vision: false },
|
|
78
|
-
// qwen3.7-plus IS multimodal (models.dev input: text+image) but image
|
|
79
|
-
// turns through the OpenCode Go gateway return HTTP 500 (opencode#33942
|
|
80
|
-
// + #29956). Flag stays true per models.dev; presets must NOT use it as a
|
|
81
|
-
// vision fallback — minimax-m3 is the confirmed opencode-go fallback.
|
|
82
74
|
{ prefix: 'qwen3.7-plus', maxEffort: 'medium', stripEffort: false, vision: true },
|
|
83
|
-
// minimax-m2.7 / minimax-m2.5 are text-only per models.dev api.json
|
|
84
|
-
// (2026-08-02). minimax-m3 is the vision-capable MiniMax on opencode-go
|
|
85
|
-
// (confirmed multimodal via the Go gateway, opencode#29956).
|
|
86
75
|
{ prefix: 'minimax-m2.7', maxEffort: 'medium', stripEffort: false, vision: false },
|
|
87
|
-
{ prefix: 'minimax-m2.5', maxEffort: 'medium', stripEffort: false, vision: false },
|
|
88
76
|
{ prefix: 'minimax-m3', maxEffort: 'high', stripEffort: false, vision: true },
|
|
89
77
|
// BARE prefix — needed for opencode-go/deepseek-v4-flash (segment match);
|
|
90
78
|
// provider-scoped deepseek/deepseek-v4-flash entry still wins by length
|
|
@@ -94,9 +82,10 @@ export const CAPABILITY_TABLE = [
|
|
|
94
82
|
// provider-scoped deepseek/deepseek-v4-pro entry still wins by length.
|
|
95
83
|
{ prefix: 'deepseek-v4-pro', maxEffort: 'high', stripEffort: false, vision: false },
|
|
96
84
|
// opencode (Zen free tier) — bare segment prefixes for opencode/<model>.
|
|
97
|
-
// big-pickle / nemotron-3-
|
|
98
|
-
// text-only via models.dev (2026-08-
|
|
85
|
+
// big-pickle / nemotron-3-super-free / north-mini-code-free verified
|
|
86
|
+
// text-only via models.dev (2026-08-27).
|
|
99
87
|
{ prefix: 'big-pickle', maxEffort: 'medium', stripEffort: false, vision: false },
|
|
88
|
+
{ prefix: 'nemotron-3-super-free', maxEffort: 'high', stripEffort: false, vision: false },
|
|
100
89
|
{ prefix: 'nemotron-3-ultra-free', maxEffort: 'high', stripEffort: false, vision: false },
|
|
101
90
|
{ prefix: 'north-mini-code-free', maxEffort: 'high', stripEffort: false, vision: false },
|
|
102
91
|
]
|
|
@@ -345,46 +334,42 @@ export function loadRoutingPermissionTask({ routingPath, logger = console } = {}
|
|
|
345
334
|
}
|
|
346
335
|
|
|
347
336
|
/**
|
|
348
|
-
* Load the
|
|
337
|
+
* Load the active routing profile's agent → model mapping for the delegation
|
|
349
338
|
* toolset (Fase 6 — wiring agentModels).
|
|
350
339
|
*
|
|
351
340
|
* routing.yml keeps per-agent models ONLY inside presets
|
|
352
341
|
* (`presets.<name>.agents.<agent>.model`) — the top-level `agents:` section
|
|
353
|
-
* carries no model field.
|
|
354
|
-
*
|
|
355
|
-
* delegated child always gets a sane per-agent model even when no preset is
|
|
356
|
-
* active — delegation no longer depends 100% on the active preset (branch (b)
|
|
357
|
-
* of resolveChildModel, which outranks the active-preset branch (c)).
|
|
342
|
+
* carries no model field. No profile means an empty mapping; there is no
|
|
343
|
+
* implicit first/default preset or fallback model.
|
|
358
344
|
*
|
|
359
|
-
* Fail-open: a missing or unparseable routing.yml yields {}
|
|
360
|
-
* the previous behavior — active preset / opencode default) and warns via the
|
|
345
|
+
* Fail-open: a missing or unparseable routing.yml yields {} and warns via the
|
|
361
346
|
* optional logger; it NEVER throws at startup.
|
|
362
347
|
*
|
|
363
348
|
* @param {object} [opts]
|
|
364
349
|
* @param {string} [opts.routingPath]
|
|
350
|
+
* @param {string[]} [opts.candidates]
|
|
351
|
+
* @param {Record<string, string|undefined>} [opts.env]
|
|
365
352
|
* @param {{warn?: Function}} [opts.logger]
|
|
366
353
|
* @returns {Record<string, string>} lowercase agent → "provider/model"
|
|
367
354
|
*/
|
|
368
|
-
export function loadRoutingAgentModels({ routingPath, logger = console } = {}) {
|
|
369
|
-
let defs
|
|
355
|
+
export function loadRoutingAgentModels({ routingPath, candidates, env, logger = console } = {}) {
|
|
370
356
|
try {
|
|
371
|
-
|
|
357
|
+
const active = resolveActivePreset({ routingPath, candidates, env, logger })
|
|
358
|
+
if (active === null) return {}
|
|
359
|
+
const models = {}
|
|
360
|
+
for (const [agent, spec] of Object.entries(active.agents ?? {})) {
|
|
361
|
+
if (spec && typeof spec === 'object' && typeof spec.model === 'string' && spec.model !== '') {
|
|
362
|
+
models[agent.toLowerCase()] = spec.model
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return models
|
|
372
366
|
} catch (err) {
|
|
373
367
|
const reason = err instanceof Error ? err.message : String(err)
|
|
374
368
|
logger.warn?.(
|
|
375
|
-
`presets: routing.yml
|
|
369
|
+
`presets: routing.yml active profile models unavailable (${reason}) — child model omitted`,
|
|
376
370
|
)
|
|
377
371
|
return {}
|
|
378
372
|
}
|
|
379
|
-
const names = Object.keys(defs)
|
|
380
|
-
if (names.length === 0) return {}
|
|
381
|
-
const models = {}
|
|
382
|
-
for (const [agent, spec] of Object.entries(defs[names[0]]?.agents ?? {})) {
|
|
383
|
-
if (spec && typeof spec === 'object' && typeof spec.model === 'string' && spec.model !== '') {
|
|
384
|
-
models[agent.toLowerCase()] = spec.model
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return models
|
|
388
373
|
}
|
|
389
374
|
|
|
390
375
|
/**
|
|
@@ -517,8 +502,13 @@ function providerDefKeyConfigured(provider, env) {
|
|
|
517
502
|
if (!provider || typeof provider !== 'object') return true
|
|
518
503
|
const envVar = provider.apiKeyEnv
|
|
519
504
|
if (typeof envVar !== 'string' || envVar === '') return true
|
|
520
|
-
const
|
|
521
|
-
|
|
505
|
+
const candidates =
|
|
506
|
+
envVar === 'PANTHEON_OPENCODE_API_KEY' ? [envVar, 'OPENCODE_GO_API_KEY'] : [envVar]
|
|
507
|
+
for (const candidate of candidates) {
|
|
508
|
+
const key = env[candidate]
|
|
509
|
+
if (typeof key === 'string' && key.trim() !== '') return true
|
|
510
|
+
}
|
|
511
|
+
return false
|
|
522
512
|
}
|
|
523
513
|
|
|
524
514
|
/**
|
|
@@ -536,7 +526,6 @@ function providerDefKeyConfigured(provider, env) {
|
|
|
536
526
|
* @returns {string|undefined} missing env var name, or undefined when usable
|
|
537
527
|
*/
|
|
538
528
|
export function missingProviderKeyEnv(providerID, { env = process.env, routingPath } = {}) {
|
|
539
|
-
if (providerID === 'opencode-go') return undefined
|
|
540
529
|
const defs = loadPresetDefs(routingPath)
|
|
541
530
|
for (const def of Object.values(defs)) {
|
|
542
531
|
const provider = def?.providers?.[providerID]
|
|
@@ -587,7 +576,11 @@ export function applyPreset(config, resolved, { env = process.env } = {}) {
|
|
|
587
576
|
config.provider[id] ??= {}
|
|
588
577
|
config.provider[id].options ??= {}
|
|
589
578
|
config.provider[id].options.baseURL = provider.baseURL
|
|
590
|
-
|
|
579
|
+
const apiKey =
|
|
580
|
+
provider.apiKeyEnv === 'PANTHEON_OPENCODE_API_KEY'
|
|
581
|
+
? (env[provider.apiKeyEnv] ?? env.OPENCODE_GO_API_KEY)
|
|
582
|
+
: env[provider.apiKeyEnv]
|
|
583
|
+
config.provider[id].options.apiKey = apiKey
|
|
591
584
|
}
|
|
592
585
|
|
|
593
586
|
for (const [agent, spec] of Object.entries(resolved.agents ?? {})) {
|
|
@@ -272,9 +272,9 @@ export async function zeusDelegateWithRetry(
|
|
|
272
272
|
report: string
|
|
273
273
|
dispatchResult: DispatchResultLike
|
|
274
274
|
}> {
|
|
275
|
-
// 1. Create child session (parentID = caller).
|
|
276
|
-
//
|
|
277
|
-
//
|
|
275
|
+
// 1. Create child session (parentID = caller). When a model is resolved,
|
|
276
|
+
// the same model is forwarded to session.create and promptAsync; otherwise
|
|
277
|
+
// both requests omit it so the host keeps native model inheritance.
|
|
278
278
|
let childID: string
|
|
279
279
|
try {
|
|
280
280
|
const created = await client.session.create({
|
|
@@ -310,10 +310,15 @@ export async function zeusDelegateWithRetry(
|
|
|
310
310
|
// board.waitForTerminal, not via noReply — the spike refuted noReply.
|
|
311
311
|
// A promptAsync failure is logged but does not abort the wait — the board
|
|
312
312
|
// timeout will finalize the job.
|
|
313
|
+
const promptBody = {
|
|
314
|
+
agent: opts.agent,
|
|
315
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
316
|
+
parts: [{ type: 'text' as const, text: opts.prompt }],
|
|
317
|
+
}
|
|
313
318
|
void client.session
|
|
314
319
|
.promptAsync({
|
|
315
320
|
path: childPath.path,
|
|
316
|
-
body:
|
|
321
|
+
body: promptBody,
|
|
317
322
|
})
|
|
318
323
|
.catch((err: unknown) => {
|
|
319
324
|
const reason = err instanceof Error ? err.message : String(err)
|
package/src/plugin.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
|
4
4
|
import type { PluginConfig } from 'opencode'
|
|
5
5
|
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
6
6
|
import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
|
|
7
|
-
import { reassertAfterCompaction } from './pantheon/compaction-assert.ts'
|
|
8
7
|
import {
|
|
9
8
|
type ContextSandboxConfig,
|
|
10
9
|
createContextSandbox,
|
|
@@ -29,12 +28,14 @@ import {
|
|
|
29
28
|
zeusReadGuard,
|
|
30
29
|
} from './pantheon/delegation-enforce.ts'
|
|
31
30
|
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
31
|
+
import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
|
|
32
32
|
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
33
33
|
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
34
34
|
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
35
35
|
import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
|
|
36
36
|
import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
|
|
37
37
|
import { createPantheonLogger } from './pantheon/logger.ts'
|
|
38
|
+
import { createModelCommand } from './pantheon/model-command.ts'
|
|
38
39
|
import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
|
|
39
40
|
import {
|
|
40
41
|
applyActivePresetToConfig,
|
|
@@ -62,17 +63,6 @@ import { activePresetCandidates, createVisionHandler } from './pantheon/vision.t
|
|
|
62
63
|
// .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
|
|
63
64
|
const log = createPantheonLogger({ module: 'pantheon-plugin' })
|
|
64
65
|
|
|
65
|
-
// Fase 6 (release-134): the delegation toolset's options.agentModels (branch
|
|
66
|
-
// (b) of resolveChildModel) is wired from routing.yml's default (first)
|
|
67
|
-
// preset — a STATIC per-agent model mapping independent of the active preset,
|
|
68
|
-
// so delegated children get a sane model even without an active preset (the
|
|
69
|
-
// previous production wiring left branch (b) dead: delegation depended 100%
|
|
70
|
-
// on the active preset). Built ONCE at module load, never per dispatch.
|
|
71
|
-
// Fail-open: a missing/corrupt routing.yml yields {} (warned by the helper)
|
|
72
|
-
// and delegation falls back to the active preset / opencode default — the
|
|
73
|
-
// plugin never throws at startup.
|
|
74
|
-
const routingAgentModels = loadRoutingAgentModels({ logger: log })
|
|
75
|
-
|
|
76
66
|
// R4: per-agent step caps from routing.yml `agents.<name>.max_steps`
|
|
77
67
|
// (fail-open → {} = no agent capped). R1 retry_policy/cooldown are NOT
|
|
78
68
|
// wired here — the plugin has no retry path (opencode cannot intercept task
|
|
@@ -90,6 +80,7 @@ const board = new BackgroundJobBoard({
|
|
|
90
80
|
})
|
|
91
81
|
const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
|
|
92
82
|
board.setPersistence(persistence)
|
|
83
|
+
let terminalToastClient: ToastClient = {}
|
|
93
84
|
board
|
|
94
85
|
.recoverRunningJobs()
|
|
95
86
|
.catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
|
|
@@ -108,6 +99,8 @@ board.onTerminal((taskID: string) => {
|
|
|
108
99
|
log.info(
|
|
109
100
|
`[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
|
|
110
101
|
)
|
|
102
|
+
// Deliberately direct-to-TUI: onTerminal must not inject chat/transcript text.
|
|
103
|
+
void showDelegationTerminalToast(terminalToastClient, job)
|
|
111
104
|
})
|
|
112
105
|
|
|
113
106
|
// Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
|
|
@@ -223,8 +216,7 @@ function adaptDelegationClient(client: PluginInput['client']): DelegationClient
|
|
|
223
216
|
model?: { id: string; providerID: string }
|
|
224
217
|
} = { parentID: input.body.parentID }
|
|
225
218
|
if (input.body.title !== undefined) body.title = input.body.title
|
|
226
|
-
// Forward
|
|
227
|
-
// falls back to the key-gated default model and dies at startup.
|
|
219
|
+
// Forward a model only when the active routing profile supplied one.
|
|
228
220
|
if (input.body.model !== undefined) body.model = input.body.model
|
|
229
221
|
const result = await client.session.create({ body })
|
|
230
222
|
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
@@ -343,6 +335,7 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
|
|
|
343
335
|
* Helpers live in src/pantheon/vision.ts and are imported from there directly.
|
|
344
336
|
*/
|
|
345
337
|
const plugin: Plugin = async (input: PluginInput) => {
|
|
338
|
+
terminalToastClient = input.client as unknown as ToastClient
|
|
346
339
|
// release-134: runtime guard against DOUBLE registration (duplicate toasts +
|
|
347
340
|
// duplicate task_ids in one process — 2026-08-12 live logs). opencode
|
|
348
341
|
// merges the global config (npm-package plugin paths) with the project
|
|
@@ -377,7 +370,12 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
377
370
|
},
|
|
378
371
|
enforceRuntimeMatrix: true,
|
|
379
372
|
readOnlyAgents: new Set(['apollo', 'gaia']),
|
|
380
|
-
|
|
373
|
+
// Only an explicitly active routing profile may provide child models.
|
|
374
|
+
// An empty map deliberately lets OpenCode inherit the parent model.
|
|
375
|
+
agentModels: loadRoutingAgentModels({
|
|
376
|
+
candidates: activePresetCandidates(),
|
|
377
|
+
logger: log,
|
|
378
|
+
}),
|
|
381
379
|
delegationBudgets,
|
|
382
380
|
stepCapTracker,
|
|
383
381
|
...(routingPermissionTask != null ? { permissionTask: routingPermissionTask } : {}),
|
|
@@ -474,6 +472,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
474
472
|
// Fully wired (unlike dispatch-guard, which is manual-orchestration-only
|
|
475
473
|
// because opencode 1.18.x cannot intercept task completion via hooks).
|
|
476
474
|
const costCommand = createCostCommand()
|
|
475
|
+
const modelCommand = createModelCommand()
|
|
477
476
|
|
|
478
477
|
return {
|
|
479
478
|
config: async (config: PluginConfig) => {
|
|
@@ -542,13 +541,13 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
542
541
|
pantheon_goal_get: goalTools.pantheon_goal_get,
|
|
543
542
|
// Wave 4 (PR #46): /cost — delegation cost report from opencode.db.
|
|
544
543
|
pantheon_cost: costCommand.pantheon_cost,
|
|
544
|
+
// Deterministic /pantheon-model management of top-level model fields.
|
|
545
|
+
pantheon_model: modelCommand.pantheon_model,
|
|
545
546
|
},
|
|
546
547
|
'chat.message': async (hookInput, output) => {
|
|
547
548
|
await vision.chatMessage(hookInput, output)
|
|
548
549
|
// No delegation notification delivery here — the user policy is ZERO
|
|
549
|
-
// notification text injected into the chat transcript
|
|
550
|
-
// notifier.flushQueue prepend). pantheon-hooks' chat-reminders.ts keeps
|
|
551
|
-
// its own <system-reminder> channel, untouched.
|
|
550
|
+
// notification text injected into the chat transcript.
|
|
552
551
|
// Wave 1: a user message is activity — the todo enforcer's
|
|
553
552
|
// user-activity gate skips injection for userActivityQuietMs afterwards.
|
|
554
553
|
todoEnforcer.noteUserActivity(hookInput.sessionID)
|
|
@@ -573,17 +572,6 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
573
572
|
// rewritten with the exact list (see todo-preserve.ts). Fail-open.
|
|
574
573
|
if (ev.type === 'session.compacted') {
|
|
575
574
|
await todoPreserver.onCompacted(ev.properties.sessionID)
|
|
576
|
-
// release-134 Phase 4: re-assert post-compaction state — enqueue a
|
|
577
|
-
// fresh-state reminder (running/unread board jobs + active goals) for
|
|
578
|
-
// the session's next chat.message delivery. Fail-open (never throws).
|
|
579
|
-
await reassertAfterCompaction({
|
|
580
|
-
sessionID: ev.properties.sessionID,
|
|
581
|
-
board,
|
|
582
|
-
goals: {
|
|
583
|
-
enabled: GOAL_LOOP_DEFAULTS.enabled,
|
|
584
|
-
list: (s: string) => goalStore.list(s),
|
|
585
|
-
},
|
|
586
|
-
})
|
|
587
575
|
}
|
|
588
576
|
// Phase 3: observe completion on child sessions → finalizeDelegation.
|
|
589
577
|
// The board transition fires onTerminal → the file-only audit log (no
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
* PANTHEON_TOASTS=council → {errors, council}
|
|
80
80
|
* PANTHEON_TOASTS=all → {errors, delegations, council}
|
|
81
81
|
* Unknown values fall back to the default. The gate controls the TUI
|
|
82
|
-
* display
|
|
82
|
+
* display only — every fired toast is also
|
|
83
83
|
* recorded to the structured log + hooks.log (script 'toast', level
|
|
84
84
|
* 'info') so the toast trail stays auditable at any setting.
|
|
85
85
|
* - Severity priority (2026-08-06): errors/blockers — hook failures
|
|
@@ -96,15 +96,9 @@
|
|
|
96
96
|
* One group buffer at a time (groups rarely overlap); a task call more
|
|
97
97
|
* than OLYMPIANS_DETECT_WINDOW_MS after the previous one starts a fresh
|
|
98
98
|
* group.
|
|
99
|
-
* - session.idle
|
|
100
|
-
* `session.idle
|
|
101
|
-
*
|
|
102
|
-
* "✅ <agent> concluiu" lines collapse into "✅ N agentes concluídos (...)")
|
|
103
|
-
* and re-armed with a fresh timestamp — so the completion summary
|
|
104
|
-
* survives until the next user message (delivered via chat.message) instead
|
|
105
|
-
* of expiring after CHAT_REMINDER_TTL_MS while the user reads the reply.
|
|
106
|
-
* Partial groups (some members completed, some never reported) and
|
|
107
|
-
* interrupted council counters are also flushed/reset here.
|
|
99
|
+
* - session.idle handling (2026-08-06): the `event` hook additionally handles
|
|
100
|
+
* `session.idle` to reset partial aggregation state and interrupted council
|
|
101
|
+
* counters. Completion visibility remains in the board and TUI only.
|
|
108
102
|
* - Council notifications (new category 2026-08-06): a `task` whose prompt/
|
|
109
103
|
* description matches "council"/"conselho"/"synthesi" is treated as council
|
|
110
104
|
* activity — "🏛️ Council: especialistas consultados" on start and
|
|
@@ -122,11 +116,8 @@
|
|
|
122
116
|
* with wrk"). The upstream fix (server-side workspace tagging) only exists
|
|
123
117
|
* in NEWER opencode versions. THEREFORE on 1.18.13 the toast path below is
|
|
124
118
|
* a documented TUI no-op and agent-lifecycle feedback is surfaced via the
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* <system-reminder> text part into the next user message. Toasts stay in
|
|
128
|
-
* place for future opencode versions that render them — upgrade opencode
|
|
129
|
-
* for real TUI toasts.
|
|
119
|
+
* no transcript fallback is used. Toasts stay in place for future opencode
|
|
120
|
+
* versions that render them — upgrade opencode for real TUI toasts.
|
|
130
121
|
* - tool.execute.after payload fix (P1-1 follow-up, 2026-08-06): the after
|
|
131
122
|
* hook receives `(input, output)` where `output` = {title, output,
|
|
132
123
|
* metadata} — the REAL tool result. It is now merged into the payload
|
|
@@ -185,13 +176,6 @@
|
|
|
185
176
|
import { appendFile, mkdir } from 'node:fs/promises'
|
|
186
177
|
import { join } from 'node:path'
|
|
187
178
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
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'
|
|
195
179
|
import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
|
|
196
180
|
import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
|
|
197
181
|
|
|
@@ -231,7 +215,7 @@ type ToastCategory = 'errors' | 'delegations' | 'council'
|
|
|
231
215
|
* of enabled categories; every notification declares its category and is
|
|
232
216
|
* skipped unless enabled. "off" = empty set, "all" = every category. Unknown
|
|
233
217
|
* values fall back to the default. The gate controls the TUI display +
|
|
234
|
-
*
|
|
218
|
+
* display only — the structured log + hooks.log channels
|
|
235
219
|
* always write (every fired toast is recorded via reportFailure with script
|
|
236
220
|
* 'toast').
|
|
237
221
|
*/
|
|
@@ -427,26 +411,8 @@ const BACKGROUND_DISPATCH_TEXT_RE =
|
|
|
427
411
|
const TASK_ID_TEXT_RE = /\btask\s+id="(ses_[^"]+)"/i
|
|
428
412
|
|
|
429
413
|
/**
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
* that would fire a toast is ALSO queued here and injected into the next user
|
|
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
|
-
*
|
|
443
|
-
* Bounded: at most CHAT_REMINDER_MAX entries, each expiring
|
|
444
|
-
* CHAT_REMINDER_TTL_MS after its tool event. Consumed (and cleared) by the
|
|
445
|
-
* chat.message hook. Full-buffer enqueues are SKIPPED, never unbounded — the
|
|
446
|
-
* olympians/3-in-6s aggregates still cover delegation groups, matching the
|
|
447
|
-
* "throttled toasts are skipped, never backlogged" anti-spam philosophy.
|
|
448
|
-
* session.idle flushes the buffer into ONE fresh aggregated entry (see
|
|
449
|
-
* flushIdleReminders) so completions survive until the next user message.
|
|
414
|
+
* Delegation lifecycle state is intentionally not mirrored into the transcript.
|
|
415
|
+
* The board/list/read tools and TUI toasts are the supported visibility channels.
|
|
450
416
|
*/
|
|
451
417
|
|
|
452
418
|
/** Variants accepted by the opencode TUI showToast (TuiShowToastData.body.variant). */
|
|
@@ -462,11 +428,6 @@ type ToastOptions = {
|
|
|
462
428
|
dedupeKey?: string
|
|
463
429
|
/** Optional TUI toast headline (opencode showToast body.title). */
|
|
464
430
|
title?: string
|
|
465
|
-
/**
|
|
466
|
-
* Optional text for the 1.18.13 chat.message <system-reminder> fallback.
|
|
467
|
-
* Defaults to `message` when omitted — the reminder mirrors the toast.
|
|
468
|
-
*/
|
|
469
|
-
reminder?: string
|
|
470
431
|
}
|
|
471
432
|
|
|
472
433
|
/** Logging context threaded through safeRun — provided by the plugin input. */
|
|
@@ -757,10 +718,6 @@ async function notifyToast(ctx: HookContext, opts: ToastOptions): Promise<void>
|
|
|
757
718
|
toastShown.add(opts.dedupeKey)
|
|
758
719
|
}
|
|
759
720
|
if (!toastCategoryEnabled(opts.category)) return
|
|
760
|
-
// 1.18.13 fallback: queue the same signal for the chat.message hook (the
|
|
761
|
-
// TUI drops tui.toast.show on 1.18.13 — see header). Enqueued even if
|
|
762
|
-
// showToast fails, so headless/no-TUI servers still surface feedback.
|
|
763
|
-
enqueueChatReminder(opts.reminder ?? opts.message)
|
|
764
721
|
await ctx.client.tui.showToast({
|
|
765
722
|
body: {
|
|
766
723
|
...(opts.title !== undefined ? { title: opts.title } : {}),
|
|
@@ -907,7 +864,7 @@ async function agentFailedToast(
|
|
|
907
864
|
): Promise<void> {
|
|
908
865
|
const raw = result?.output ?? result?.title ?? ''
|
|
909
866
|
// Fix 1: the failure reason echoes tool RESULT content — mask any token so
|
|
910
|
-
// the toast/
|
|
867
|
+
// the toast/hooks.log trail never exposes a raw secret.
|
|
911
868
|
const reason = maskSecret(
|
|
912
869
|
trim(typeof raw === 'string' && raw.trim() !== '' ? raw : 'tool failed', 120),
|
|
913
870
|
)
|
|
@@ -1057,40 +1014,7 @@ function onCouncilDone(ctx: HookContext): void {
|
|
|
1057
1014
|
}
|
|
1058
1015
|
}
|
|
1059
1016
|
|
|
1060
|
-
/**
|
|
1061
|
-
* session.idle flush (see header): aggregate any pending chat reminders into
|
|
1062
|
-
* ONE fresh <system-reminder> entry (individual ✅ completions collapse into
|
|
1063
|
-
* "✅ N agentes concluídos (...)"), report partial groups whose members never
|
|
1064
|
-
* all completed, and reset interrupted council counters. The chat.message
|
|
1065
|
-
* hook remains the delivery path — the flushed entry has a fresh timestamp so
|
|
1066
|
-
* it survives until the next user message instead of expiring while the user
|
|
1067
|
-
* reads the reply.
|
|
1068
|
-
*/
|
|
1069
1017
|
function flushIdleReminders(ctx: HookContext): void {
|
|
1070
|
-
if (ENABLED_TOAST_CATEGORIES.size === 0) {
|
|
1071
|
-
pendingChatReminders.length = 0
|
|
1072
|
-
activeOlympians = null
|
|
1073
|
-
return
|
|
1074
|
-
}
|
|
1075
|
-
const now = Date.now()
|
|
1076
|
-
const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
|
|
1077
|
-
pendingChatReminders.length = 0
|
|
1078
|
-
|
|
1079
|
-
// Partial group: some members completed but the group never finished (their
|
|
1080
|
-
// completion was suppressed waiting for the aggregate). Report the subset.
|
|
1081
|
-
const partialGroup = activeOlympians
|
|
1082
|
-
if (partialGroup !== null) {
|
|
1083
|
-
const done = [...partialGroup.agents].filter((a) => !partialGroup.pendingCompletions.has(a))
|
|
1084
|
-
if (partialGroup.detected && partialGroup.doneCount > 0 && done.length > 0) {
|
|
1085
|
-
fresh.push({
|
|
1086
|
-
text:
|
|
1087
|
-
done.length === 1
|
|
1088
|
-
? `✅ ${done[0]} concluiu`
|
|
1089
|
-
: `✅ ${done.length} agentes concluídos (${done.sort().join(', ')})`,
|
|
1090
|
-
at: now,
|
|
1091
|
-
})
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
1018
|
activeOlympians = null
|
|
1095
1019
|
|
|
1096
1020
|
// Interrupted council counters must not block a later council's verdict —
|
|
@@ -1105,34 +1029,12 @@ function flushIdleReminders(ctx: HookContext): void {
|
|
|
1105
1029
|
councilStartNotified = false
|
|
1106
1030
|
}
|
|
1107
1031
|
|
|
1108
|
-
// Collapse individual ✅ completions into one aggregate line.
|
|
1109
|
-
const doneAgents: string[] = []
|
|
1110
|
-
const others: string[] = []
|
|
1111
|
-
for (const r of fresh) {
|
|
1112
|
-
const m = /^✅ (.+?) concluiu$/.exec(r.text)
|
|
1113
|
-
if (m?.[1] !== undefined) doneAgents.push(m[1])
|
|
1114
|
-
else others.push(r.text)
|
|
1115
|
-
}
|
|
1116
|
-
const lines: string[] = []
|
|
1117
|
-
if (doneAgents.length > 0) {
|
|
1118
|
-
lines.push(
|
|
1119
|
-
doneAgents.length === 1
|
|
1120
|
-
? `✅ ${doneAgents[0]} concluiu`
|
|
1121
|
-
: `✅ ${doneAgents.length} agentes concluídos (${doneAgents.sort().join(', ')})`,
|
|
1122
|
-
)
|
|
1123
|
-
}
|
|
1124
|
-
lines.push(...others)
|
|
1125
|
-
if (lines.length === 0) return
|
|
1126
|
-
|
|
1127
|
-
// ONE aggregated entry, fresh timestamp — chat.message delivers it next.
|
|
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.
|
|
1132
1032
|
void reportFailure(
|
|
1133
1033
|
ctx,
|
|
1134
|
-
|
|
1135
|
-
{
|
|
1034
|
+
'[pantheon-hooks:idle-flush] delegation state reset',
|
|
1035
|
+
{
|
|
1036
|
+
script: 'idle-flush',
|
|
1037
|
+
},
|
|
1136
1038
|
'info',
|
|
1137
1039
|
)
|
|
1138
1040
|
}
|
|
@@ -1213,7 +1115,7 @@ const plugin: Plugin = async ({ client, directory }) => {
|
|
|
1213
1115
|
// merges the global config (npm-package plugin paths) with the project
|
|
1214
1116
|
// config (repo source paths), so this module loads from TWO filesystem
|
|
1215
1117
|
// paths in the same process and the factory would otherwise run twice —
|
|
1216
|
-
// every hook (tool.execute.before/after
|
|
1118
|
+
// every lifecycle hook (tool.execute.before/after and event) firing
|
|
1217
1119
|
// TWICE. The second invocation becomes a no-op (empty hooks object) so
|
|
1218
1120
|
// hooks register exactly once.
|
|
1219
1121
|
if (pantheonPluginOnce('pantheon:hooks')) {
|
|
@@ -1439,51 +1341,6 @@ const plugin: Plugin = async ({ client, directory }) => {
|
|
|
1439
1341
|
// Never let a hook take down the opencode session.
|
|
1440
1342
|
}
|
|
1441
1343
|
},
|
|
1442
|
-
// 1.18.13 fallback (Path C, oh-my-openagent pattern): the TUI drops
|
|
1443
|
-
// tui.toast.show events on opencode 1.18.13 (untagged — see header), so
|
|
1444
|
-
// queued agent-lifecycle signals (delegation start/done, hook failures,
|
|
1445
|
-
// olympians/council summaries) are injected here as ONE <system-reminder> text
|
|
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.
|
|
1455
|
-
'chat.message': async (input, output) => {
|
|
1456
|
-
try {
|
|
1457
|
-
if (ENABLED_TOAST_CATEGORIES.size === 0) return
|
|
1458
|
-
if (pendingChatReminders.length === 0) return
|
|
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
|
|
1466
|
-
// Minimal TextPart — the core backfills messageID/sessionID for
|
|
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).
|
|
1470
|
-
output.parts.push({
|
|
1471
|
-
id: `prt_${crypto.randomUUID()}`,
|
|
1472
|
-
sessionID: input.sessionID,
|
|
1473
|
-
messageID: input.messageID,
|
|
1474
|
-
type: 'text',
|
|
1475
|
-
text: `<system-reminder>\n${body}\n</system-reminder>`,
|
|
1476
|
-
} satisfies Part)
|
|
1477
|
-
await reportFailure(
|
|
1478
|
-
ctx,
|
|
1479
|
-
`[pantheon-hooks:chat-reminder] ${body}`,
|
|
1480
|
-
{ script: 'chat-reminder' },
|
|
1481
|
-
'info',
|
|
1482
|
-
)
|
|
1483
|
-
} catch {
|
|
1484
|
-
// Never let a hook take down the opencode session.
|
|
1485
|
-
}
|
|
1486
|
-
},
|
|
1487
1344
|
event: async ({ event: ev }) => {
|
|
1488
1345
|
try {
|
|
1489
1346
|
if (ev.type === 'session.created') {
|
|
@@ -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":";;KAo7BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EASA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBAkHmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAWhD,sBACd,cAAc,mBACd,sBACA,cACA,4BACC;;cASU;;;;cAKA;;;;;;;;;iBAUG,mBACd,OAAO,iBACP,aACA,uBACC;;;iBAWa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAQ9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAc5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KAmFS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;iBAac,wBACd,MAAM,oBACN,eACC;;;iBA+Ea,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBA+Fc,sBACd,KAAK,YAAY,sBACjB;;;;;;iBAiBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA2Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;iBASnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBA0Ca,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KAiLU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAucI,QAAQ;EAAoB"}
|