pantheon-opencode 1.4.0 → 1.4.1

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  <h1 align="center">Pantheon</h1>
3
3
 
4
4
  <p align="center">
5
- <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v1.3.6--beta-blue" alt="Version"></a>
5
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v1.4.1--candidate-blue" alt="Version"></a>
6
6
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License"></a>
7
7
  <a href="docs/agents/README.md"><img src="https://img.shields.io/badge/agents-14-purple" alt="Agents"></a>
8
8
  <a href="src/skills/README.md"><img src="https://img.shields.io/badge/skills-21-orange" alt="Skills"></a>
@@ -239,17 +239,16 @@ Fail-open: if the child session messages are unavailable (or empty), the read
239
239
  returns the report exactly as before — the activity sampling never breaks the
240
240
  delegation read.
241
241
 
242
- **Completion visibility — no chat injection.** User policy: ZERO delegation
243
- notifications in the chat transcript — no `<task-notification>` block is ever
244
- injected into a `chat.message` output (the old queue + flush channel was
245
- removed). When a job reaches a terminal state (completion observed via
246
- `session.idle`/`session.error` on the child, or the timeout finalize path), the
247
- plugin writes a file-only audit log line (echo opt-in via `PANTHEON_HOOKS_LOG`).
248
- Completion visibility lives in the legitimate channels: the board `[unread]`
249
- marker (`pantheon_delegation_list`), `pantheon_delegation_read`, TUI toasts
250
- (pantheon-hooks, `PANTHEON_TOASTS` gate) and compaction carry-forward. Reconcile
251
- is an acknowledgment, not a completion — it never re-fires the terminal
252
- transition.
242
+ **Completion visibility — zero chat noise.** Delegation notifications are never
243
+ injected into the chat transcript: neither `chat.message` nor any other chat
244
+ hook or transcript marker is used as a delivery channel. When a job reaches a
245
+ terminal state (completion observed via `session.idle`/`session.error` on the
246
+ child, or the timeout finalize path), the plugin writes a file-only audit log
247
+ line (echo opt-in via `PANTHEON_HOOKS_LOG`). Completion visibility lives in the
248
+ legitimate channels: the board `[unread]` marker
249
+ (`pantheon_delegation_list`), `pantheon_delegation_read`, TUI toasts
250
+ (`PANTHEON_TOASTS` gate), and compaction carry-forward. Reconcile is an
251
+ acknowledgment, not a completion — it never re-fires the terminal transition.
253
252
 
254
253
  **Timeout:** `background_delegation.timeout_ms` (default `900000` = 15 min). A job
255
254
  that has not reached a terminal state is finalized as `error`/`timedOut`.
@@ -338,10 +337,9 @@ logged warn and pass-through.
338
337
 
339
338
  **Post-compaction state re-assertion (1.3.4):** on `session.compacted`, fresh
340
339
  state — running/unread delegations from the board + active goals, capped at 10
341
- lines — is re-injected as a `<system-reminder>` into the session's next
342
- message via the shared chat-reminder buffer (`chat-reminders.ts`, the same P0
343
- messageID guard that protects subagent fires). A session with nothing to
344
- assert is a silent skip.
340
+ lines — remains available through compaction carry-forward and the board
341
+ (`pantheon_delegation_list` / `pantheon_delegation_read`). A session with
342
+ nothing to assert is a silent skip; no chat transcript injection is performed.
345
343
 
346
344
  **Preemptive compaction check (1.3.4):** a pure threshold core
347
345
  (`preemptive-compact.ts`) warns the model before the context fills: at 78%
@@ -350,36 +348,22 @@ dormant / ready-to-wire — opencode 1.18.x exposes no runtime context-usage
350
348
  percentage, so nothing observes it yet; when a source appears, the caller
351
349
  wires it in (the enqueue callback is injected).
352
350
 
353
- **Model API-key validation (1.3.4):** `pantheon_delegate` gates the resolved
354
- child model's provider before dispatching (single source of truth:
355
- `routing.yml` preset definitions' `apiKeyEnv` — the same check `applyPreset`
356
- enforces at startup). If an **auto-resolved** model (`options.agentModels` or
357
- the active preset) points to a provider that requires an API key
358
- (`apiKeyEnv`) and the env var is unset, the delegate falls back to
359
- `opencode-go/deepseek-v4-flash` (validated the same way). If the fallback is
360
- also unusable, the tool returns a clear error **text** (never throws) naming
361
- the missing env var — and registers **no job** on the board. An **explicit**
362
- `model` passed by the caller is always respected (warned, not overridden).
363
- Setting `PANTHEON_MODEL_PRESET` to a preset whose providers have keys, or
364
- filling the required `PANTHEON_*_API_KEY` env var for the preset's provider,
365
- resolves the fallback path. If nothing resolves at all (no model, no preset),
366
- the child keeps using opencode's default model (warned).
367
-
368
- **agentModels wiring (1.3.4):** the delegate now also resolves the child model
369
- via `routing.yml` — `loadRoutingAgentModels` extracts the per-agent models of
370
- the FIRST-listed preset (the static default, `go-deepseek` today) and passes
371
- them as `options.agentModels`, branch (b) of the resolve precedence: explicit
372
- `model` > `options.agentModels` > active preset > opencode default. Delegation
373
- no longer depends exclusively on the active preset — a delegated child gets a
374
- sane per-agent model even when no preset is active. Fail-open: a missing or
375
- unparseable routing.yml yields `{}` (previous behavior, warned).
351
+ **Model selection (1.4.1):** the child session inherits the parent session's
352
+ model through OpenCode. A model override is supplied only when the active
353
+ routing profile defines one; no model API-key validation is performed here.
354
+
355
+ **agentModels wiring (1.3.4):** `loadRoutingAgentModels` extracts only the
356
+ per-agent models from the explicitly active routing profile and passes them as
357
+ `options.agentModels`. No active profile, missing profile entry, or profile
358
+ without `model` produces an empty map; there is no implicit first-preset or
359
+ DeepSeek fallback. A missing or unparseable `routing.yml` is fail-open and
360
+ omits the child model.
376
361
 
377
362
  **Delegation log hygiene (1.3.4):** `delegations.log` now records the real
378
363
  `task_id` (omitted when empty, never `""`) and a **numeric** `duration_ms`
379
- (null when unset) so downstream aggregation works. The idle-flush log entry is
380
- deduplicated: the flush logs a summary (count + aggregated line count) and the
381
- reminder content is logged exactly once, at `chat.message` delivery — no more
382
- duplicate lines for the same notification.
364
+ (null when unset) so downstream aggregation works. Terminal events are
365
+ file-only audit entries; completion is surfaced by TUI toast (when enabled),
366
+ the board, and the list/read tools — never by chat delivery.
383
367
 
384
368
  **Env vars & config:**
385
369
 
@@ -1040,11 +1024,17 @@ Use the isolated sandbox (`~/pantheon-sandbox/`) — **never** the dev environme
1040
1024
 
1041
1025
  **Happy path (clean edit):** any normal tool call (exit 0) is **silent** — no console output by design, even though the audit hooks append their log files (`sessions.log`, `delegations.log`). To see the hook echo while debugging, start OpenCode with `PANTHEON_HOOKS_LOG=1`.
1042
1026
 
1043
- **Delegation path (chat.message reminders):** on OpenCode 1.18.13 the TUI drops `tui.toast.show` events, so delegation signals are injected into the next user message as a single `<system-reminder>` text part (oh-my-openagent fallback pattern). Ask something that dispatches subagents (e.g. *"dispare 2 subagentes apollo em paralelo para listar arquivos e comparar resultados"*) and expect:
1027
+ **Delegation path (zero chat noise):** delegation signals are surfaced through
1028
+ TUI toasts (when enabled), the board's `[unread]` marker, and the
1029
+ `pantheon_delegation_list` / `pantheon_delegation_read` tools. Ask something
1030
+ that dispatches subagents (e.g. *"dispare 2 subagentes apollo em paralelo para
1031
+ listar arquivos e comparar resultados"*) and expect:
1044
1032
 
1045
- - A `<system-reminder>` in the chat with `🚀 apollo em execução` / `✅ apollo concluiu` (or the aggregate `✅ N agentes concluídos (...)` for 3+ agents completing within 6s)
1033
+ - A TUI toast with `🚀 apollo em execução` / `✅ apollo concluiu` when
1034
+ `PANTHEON_TOASTS` permits it
1035
+ - `[unread]` in `pantheon_delegation_list` until the terminal result is read
1046
1036
  - An honest append to `logs/agent-sessions/delegations.log`: the **real agent name** (extracted from `tool_input.subagent_type`, never `unknown` when present) and a **non-fabricated status** — `success` only on explicit completion evidence, `failure` for refusals/errors, `unknown` otherwise
1047
- - With `export PANTHEON_TOASTS=off` before starting OpenCode: no toasts/reminders at all
1037
+ - With `export PANTHEON_TOASTS=off` before starting OpenCode: no toasts; board/list/read and audit-log visibility remain available
1048
1038
 
1049
1039
  ### Pre-commit hooks (local secret gate)
1050
1040
 
package/opencode.json CHANGED
@@ -14,8 +14,6 @@
14
14
  "experimental": {
15
15
  "subagent_depth": 2
16
16
  },
17
- "model": "opencode-go/deepseek-v4-flash",
18
- "small_model": "opencode-go/deepseek-v4-flash",
19
17
  "permission": {
20
18
  "skill": {
21
19
  "*": "allow"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -1,79 +1,9 @@
1
1
  /**
2
- * Chat Reminders (release-134 Phase 4) — the SHARED <system-reminder> buffer.
2
+ * Retired transcript-notification module.
3
3
  *
4
- * WHY a shared module (not owned by pantheon-hooks.ts):
5
- * 1. opencode's legacy plugin loader (verified in the 1.18.16 source,
6
- * packages/opencode/src/plugin/index.ts getLegacyPlugins) does
7
- * `Object.values(mod)` and treats EVERY export as a plugin factory —
8
- * a non-function export throws `TypeError("Plugin export is not a
9
- * function")` and a function export is invoked with a PluginInput.
10
- * pantheon-hooks.ts therefore cannot EXPORT the enqueue API.
11
- * 2. src/plugin.ts (a separate plugin file) must ENQUEUE into the SAME
12
- * buffer the pantheon-hooks `chat.message` hook drains — a local copy
13
- * would enqueue into a buffer that is never delivered.
14
- * 3. opencode loads plugins via plain `await import(entry)` (PluginLoader),
15
- * so both plugin files importing this module resolve to ONE module
16
- * instance (Bun/Node module cache) → one shared buffer.
17
- *
18
- * release-134 Phase 4 uses this buffer for the post-compaction state
19
- * re-assertion: `reassertAfterCompaction` (compaction-assert.ts) enqueues a
20
- * fresh-state block here, and the existing chat.message delivery path
21
- * (pantheon-hooks.ts) injects it as a <system-reminder> into the session's
22
- * next message. Zero new delivery code — the P0 messageID guard already
23
- * protects subagent promptAsync fires.
24
- *
25
- * Bounded: at most CHAT_REMINDER_MAX entries, each expiring
26
- * CHAT_REMINDER_TTL_MS after enqueue. Full-buffer enqueues are SKIPPED,
27
- * never unbounded. No background timers — expiry is only checked on enqueue
28
- * and on delivery (drainFreshChatReminders).
4
+ * Delegation lifecycle state must not be queued for delivery into user
5
+ * messages. Visibility is provided by the board/list/read tools and TUI
6
+ * toasts; this intentionally exports no delivery API.
29
7
  *
30
8
  * @module chat-reminders
31
9
  */
32
-
33
- /**
34
- * Hard cap on queued reminder entries (anti-spam philosophy, matching the
35
- * "throttled signals are skipped, never backlogged" rule).
36
- */
37
- export const CHAT_REMINDER_MAX = 10
38
-
39
- /** How long a queued reminder stays deliverable (pruned after ~60s). */
40
- export const CHAT_REMINDER_TTL_MS = 60_000
41
-
42
- /** One queued reminder: the text plus its enqueue timestamp. */
43
- export interface ChatReminderEntry {
44
- text: string
45
- at: number
46
- }
47
-
48
- /** The shared buffer — module-level state, consumed by the chat.message hook. */
49
- export const pendingChatReminders: ChatReminderEntry[] = []
50
-
51
- /**
52
- * Queue one chat-reminder entry, pruning expired entries first. Never throws.
53
- * A full buffer skips the push (bounded, never backlogged).
54
- */
55
- export function enqueueChatReminder(text: string): void {
56
- const now = Date.now()
57
- for (let i = pendingChatReminders.length - 1; i >= 0; i--) {
58
- const r = pendingChatReminders[i]
59
- if (r !== undefined && now - r.at > CHAT_REMINDER_TTL_MS) pendingChatReminders.splice(i, 1)
60
- }
61
- if (pendingChatReminders.length >= CHAT_REMINDER_MAX) return
62
- pendingChatReminders.push({ text, at: now })
63
- }
64
-
65
- /**
66
- * Deliver (drain) every FRESH reminder as one joined block: expired entries
67
- * are dropped, the buffer is cleared in all cases (no leak). Returns
68
- * undefined when nothing fresh remains — the caller skips injection.
69
- *
70
- * This is the delivery helper the pantheon-hooks `chat.message` hook calls
71
- * AFTER its P0 messageID guard (empty messageID → early return, buffer kept).
72
- */
73
- export function drainFreshChatReminders(): string | undefined {
74
- const now = Date.now()
75
- const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
76
- pendingChatReminders.length = 0
77
- if (fresh.length === 0) return undefined
78
- return fresh.map((r) => r.text).join('\n')
79
- }
@@ -1,37 +1,31 @@
1
1
  /**
2
- * Compaction Re-Assert (release-134 Phase 4) — re-inject fresh state after
3
- * compaction.
2
+ * Compaction Re-Assert (release-134 Phase 4) — detect fresh state after
3
+ * compaction without writing to the chat transcript.
4
4
  *
5
5
  * WHY: after a session is compacted, the system state may have changed since
6
6
  * the summary was built (new background delegations launched, results
7
7
  * delivered, goals updated). The compacted session's context is frozen at
8
8
  * summary time — the model would operate on stale state.
9
9
  *
10
- * HOW: the `session.compacted` event hook calls `reassertAfterCompaction`,
11
- * which builds a COMPACT fresh-state block (running + unread delegations from
12
- * the board, active goals from the GoalStore — same gating as Phase 2) and
13
- * enqueues it into the SHARED chat-reminder buffer (chat-reminders.ts). The
14
- * existing pantheon-hooks `chat.message` hook delivers it as a
15
- * <system-reminder> into the session's next message — zero new delivery
16
- * code, and the P0 messageID guard already protects subagent fires.
10
+ * HOW: the compaction lifecycle keeps the board and goal stores authoritative;
11
+ * the `experimental.session.compacting` hook builds the context that is
12
+ * consumed by the compaction itself. Chat is never used as a delivery channel.
17
13
  *
18
14
  * Deliberately NOT `session.promptAsync({noReply:true})`: the sprint 4+7
19
15
  * spike refuted noReply as a push mechanism on this opencode build (nothing
20
16
  * is delivered to the parent).
21
17
  *
22
- * FAIL-OPEN: every source (board list, goals list, enqueue) is wrapped —
18
+ * FAIL-OPEN: every source (board list and goals list) is wrapped —
23
19
  * a failure logs a warn via the silence-by-default logger and the hook never
24
20
  * throws. A session with NO fresh state (nothing running/unread/goals) is a
25
- * SILENT skip (no warn, no reminder). The block is capped at
21
+ * SILENT skip (no warn). The block is capped at
26
22
  * REASSERT_MAX_LINES item lines (1 header) so it stays compact.
27
23
  *
28
- * Pure TypeScript — zero runtime deps beyond the board/goal types and the
29
- * shared reminder buffer.
24
+ * Pure TypeScript — zero runtime deps beyond the board/goal types.
30
25
  *
31
26
  * @module compaction-assert
32
27
  */
33
28
  import type { BackgroundJobBoard } from './background-job-board.ts'
34
- import { enqueueChatReminder } from './chat-reminders.ts'
35
29
  import type { CompactionGoalSource } from './delegation-compaction.ts'
36
30
  import { createPantheonLogger } from './logger.ts'
37
31
 
@@ -51,16 +45,16 @@ export const REASSERT_HEADER = 'State re-assertion after compaction — the summ
51
45
 
52
46
  /** Dependencies threaded to reassertAfterCompaction (wired from plugin.ts). */
53
47
  export interface CompactionAssertDeps {
54
- /** The compacted session whose next message receives the reminder. */
48
+ /** The session whose compaction context is being prepared. */
55
49
  sessionID: string
56
50
  /** Board query surface — `list` is all the re-assertion needs. */
57
51
  board: Pick<BackgroundJobBoard, 'list'>
58
52
  /** Goal source for the goals block (optional — same gating as Phase 2). */
59
53
  goals?: CompactionGoalSource
60
- /** Injectable enqueue (testable). Defaults to the shared chat buffer. */
61
- enqueue?: (text: string) => void
62
54
  /** Injectable warn logger (testable). Defaults to silence-by-default. */
63
55
  logger?: { warn: (message: string) => void }
56
+ /** Non-chat delivery sink (for example a pending `output.context` block). */
57
+ deliverContext?: (context: string[]) => void | Promise<void>
64
58
  }
65
59
 
66
60
  // ─── Formatting ────────────────────────────────────────────────────────
@@ -135,26 +129,32 @@ async function buildStateLines(
135
129
  }
136
130
 
137
131
  /**
138
- * Re-assert fresh state for a compacted session: build the compact block and
139
- * enqueue it as a chat reminder (delivered on the session's next message).
132
+ * Re-assert fresh state for a compacted session and return the context block
133
+ * to the non-chat caller. The caller can hand this array to OpenCode's
134
+ * `output.context` or persist it through the board; constructing it here and
135
+ * throwing it away would make the hook a no-op.
140
136
  *
141
- * Returns true when a reminder was enqueued; false when there was nothing to
142
- * assert (silent skip) or a source failed (warned). NEVER throws — the event
143
- * hook must never break the session.
137
+ * Returns the fresh-state context when found, or undefined when there was
138
+ * nothing to assert (silent skip) or a source failed (warned). NEVER throws —
139
+ * the event hook must never break the session.
144
140
  */
145
- export async function reassertAfterCompaction(deps: CompactionAssertDeps): Promise<boolean> {
141
+ export async function reassertAfterCompaction(
142
+ deps: CompactionAssertDeps,
143
+ ): Promise<string[] | undefined> {
146
144
  // Silence-by-default logger: file-only warn, console echo opt-in via
147
145
  // PANTHEON_HOOKS_LOG=1 (TUI pollution policy, pantheon-hooks L42-58).
148
146
  const warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
149
147
  try {
150
148
  const lines = await buildStateLines(deps, warn)
151
- if (lines === undefined) return false
152
- const enqueue = deps.enqueue ?? enqueueChatReminder
153
- enqueue(`${REASSERT_HEADER}\n${lines.join('\n')}`)
154
- return true
149
+ if (lines === undefined) return undefined
150
+ // This is a context return value, not a chat message. The caller owns the
151
+ // supported delivery path (output.context or persisted board state).
152
+ const context = [REASSERT_HEADER, ...lines]
153
+ await deps.deliverContext?.(context)
154
+ return context
155
155
  } catch (err: unknown) {
156
156
  const reason = err instanceof Error ? err.message : String(err)
157
157
  warn(`compaction re-assert failed for session ${deps.sessionID}: ${reason}`)
158
- return false
158
+ return undefined
159
159
  }
160
160
  }
@@ -172,24 +172,48 @@ export const DELEGATION_DEFAULTS = {
172
172
 
173
173
  // ─── Output pulling ────────────────────────────────────────────────────
174
174
 
175
- /** Concatenate every non-empty text part across the child's messages. */
176
- async function pullOutput(client: DelegationClient, childSessionID: string): Promise<string> {
175
+ interface PulledOutput {
176
+ text: string
177
+ /** True only when the child produced an assistant message or tool activity. */
178
+ hasWork: boolean
179
+ }
180
+
181
+ /** Pull visible text and structurally identify real child work. */
182
+ async function pullOutput(client: DelegationClient, childSessionID: string): Promise<PulledOutput> {
177
183
  if (typeof client.session.messages !== 'function')
178
- return '(child session messages API unavailable)'
184
+ return { text: '(child session messages API unavailable)', hasWork: false }
179
185
  try {
180
186
  const bundles = await client.session.messages({ path: { id: childSessionID } })
181
187
  const lines: string[] = []
188
+ let hasWork = false
182
189
  for (const bundle of bundles) {
190
+ const hasToolPart = (bundle.parts ?? []).some((part) => part.type === 'tool')
191
+ const hasTextPart = (bundle.parts ?? []).some(
192
+ (part) => part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '',
193
+ )
194
+ if (
195
+ bundle.info?.role === 'assistant' &&
196
+ bundle.info.error === undefined &&
197
+ (hasToolPart || hasTextPart)
198
+ ) {
199
+ hasWork = true
200
+ }
183
201
  for (const part of bundle.parts ?? []) {
202
+ if (
203
+ part.type === 'tool' &&
204
+ bundle.info?.role === 'assistant' &&
205
+ bundle.info.error === undefined
206
+ )
207
+ hasWork = true
184
208
  if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
185
209
  lines.push(part.text)
186
210
  }
187
211
  }
188
212
  }
189
- return lines.join('\n\n')
213
+ return { text: lines.join('\n\n'), hasWork }
190
214
  } catch (err: unknown) {
191
215
  const reason = err instanceof Error ? err.message : String(err)
192
- return `(failed to pull child session output: ${reason})`
216
+ return { text: `(failed to pull child session output: ${reason})`, hasWork: false }
193
217
  }
194
218
  }
195
219
 
@@ -311,9 +335,18 @@ export async function finalizeDelegation(
311
335
  const job = deps.board.get(childSessionID)
312
336
  if (!job) return undefined
313
337
 
314
- const output = await pullOutput(deps.client, childSessionID)
338
+ const pulled = await pullOutput(deps.client, childSessionID)
339
+ const output = pulled.text
340
+ const effectiveOpts: FinalizeInput =
341
+ opts.state === 'completed' && !pulled.hasWork
342
+ ? {
343
+ state: 'error',
344
+ error: opts.error ?? 'Child session produced no assistant or tool output',
345
+ ...(opts.timedOut !== undefined ? { timedOut: opts.timedOut } : {}),
346
+ }
347
+ : opts
315
348
  const outputDir = deps.options.outputDir ?? DELEGATION_DEFAULTS.outputDir
316
- await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, opts))
349
+ await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, effectiveOpts))
317
350
 
318
351
  const status: {
319
352
  taskID: string
@@ -323,15 +356,15 @@ export async function finalizeDelegation(
323
356
  resultSummary?: string
324
357
  } = {
325
358
  taskID: childSessionID,
326
- state: opts.state,
359
+ state: effectiveOpts.state,
327
360
  resultSummary: summarizeOutput(output),
328
361
  }
329
- if (opts.error !== undefined) status.error = opts.error
330
- if (opts.timedOut !== undefined) status.timedOut = opts.timedOut
362
+ if (effectiveOpts.error !== undefined) status.error = effectiveOpts.error
363
+ if (effectiveOpts.timedOut !== undefined) status.timedOut = effectiveOpts.timedOut
331
364
 
332
365
  const current = deps.board.get(childSessionID)
333
366
  if (current !== undefined) {
334
- if (current.state === 'running' || current.state === opts.state) {
367
+ if (current.state === 'running' || current.state === effectiveOpts.state) {
335
368
  await deps.board.updateStatus(status)
336
369
  }
337
370
  // Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
@@ -0,0 +1,73 @@
1
+ import type { BackgroundJobRecord } from './background-job-board.ts'
2
+
3
+ type ToastCategory = 'errors' | 'delegations' | 'council'
4
+
5
+ const toastMode = (process.env.PANTHEON_TOASTS ?? '').trim().toLowerCase()
6
+ const enabledToastCategories: ReadonlySet<ToastCategory> = (() => {
7
+ switch (toastMode) {
8
+ case 'off':
9
+ return new Set()
10
+ case 'errors':
11
+ return new Set(['errors'])
12
+ case 'council':
13
+ return new Set(['errors', 'council'])
14
+ case 'all':
15
+ return new Set(['errors', 'delegations', 'council'])
16
+ default:
17
+ return new Set(['errors', 'delegations', 'council'])
18
+ }
19
+ })()
20
+ const terminalToastsShown = new Set<string>()
21
+ const terminalStates = new Set(['completed', 'error', 'cancelled'])
22
+
23
+ /** Match the lifecycle toast gate used by pantheon-hooks. */
24
+ export function delegationToastEnabled(): boolean {
25
+ return enabledToastCategories.has('delegations')
26
+ }
27
+
28
+ export type ToastClient = {
29
+ tui?: {
30
+ showToast?: (input: {
31
+ body: {
32
+ title?: string
33
+ message: string
34
+ variant: 'info' | 'success' | 'warning' | 'error'
35
+ duration: number
36
+ }
37
+ }) => Promise<unknown>
38
+ }
39
+ }
40
+
41
+ /** Best-effort visual completion signal; never affects board finalization. */
42
+ export async function showDelegationTerminalToast(
43
+ client: ToastClient,
44
+ job: Pick<BackgroundJobRecord, 'taskID' | 'alias' | 'agent' | 'state' | 'timedOut'>,
45
+ ): Promise<void> {
46
+ try {
47
+ if (!delegationToastEnabled()) return
48
+ const isTerminal = job.timedOut || terminalStates.has(job.state)
49
+ if (isTerminal) {
50
+ if (terminalToastsShown.has(job.taskID)) return
51
+ terminalToastsShown.add(job.taskID)
52
+ }
53
+ const showToast = client.tui?.showToast
54
+ if (typeof showToast !== 'function') return
55
+ const state = job.timedOut ? 'timeout' : job.state
56
+ const variant =
57
+ state === 'completed'
58
+ ? 'success'
59
+ : state === 'error' || state === 'timeout'
60
+ ? 'error'
61
+ : 'warning'
62
+ await showToast({
63
+ body: {
64
+ title: 'Delegação concluída',
65
+ message: `${job.alias} (${job.agent}) — ${state}`,
66
+ variant,
67
+ duration: 4000,
68
+ },
69
+ })
70
+ } catch {
71
+ // TUI is optional (and may disappear during shutdown); completion is durable.
72
+ }
73
+ }
@@ -58,14 +58,9 @@ import {
58
58
  finalizeDelegation as finalizeDelegationReport,
59
59
  readDelegationReport,
60
60
  } from './delegation-finalize.ts'
61
- import {
62
- type IdleChildScanDeps,
63
- finalizeIdleChildrenWithoutMd,
64
- startIdleChildScan,
65
- } from './delegation-notify.ts'
61
+ import { finalizeIdleChildrenWithoutMd } from './delegation-notify.ts'
66
62
  import { createPantheonLogger } from './logger.ts'
67
63
  import { buildAgentListDescription } from './permission-globs.ts'
68
- import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
69
64
  import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
70
65
 
71
66
  export type {
@@ -217,8 +212,10 @@ async function bootstrapChild(
217
212
  const remainingMs = timeoutMs - (now() - startedAt)
218
213
  if (hasMessagesAPI) {
219
214
  try {
215
+ const messagesAPI = client.session.messages
216
+ if (messagesAPI === undefined) continue
220
217
  const messages = await boundedProbe(
221
- client.session.messages!({ path: { id: childSessionID } }),
218
+ messagesAPI({ path: { id: childSessionID } }),
222
219
  remainingMs,
223
220
  )
224
221
  if (messages !== undefined) {
@@ -231,8 +228,10 @@ async function bootstrapChild(
231
228
  }
232
229
  if (hasStatusAPI) {
233
230
  try {
231
+ const statusAPI = client.session.get
232
+ if (statusAPI === undefined) continue
234
233
  const status = await boundedProbe(
235
- client.session.get!({ path: { id: childSessionID } }),
234
+ statusAPI({ path: { id: childSessionID } }),
236
235
  timeoutMs - (now() - startedAt),
237
236
  )
238
237
  if (status !== undefined) {
@@ -591,20 +590,6 @@ export interface ChildSessionModelRef {
591
590
  providerID: string
592
591
  }
593
592
 
594
- /**
595
- * Known-good fallback model (P1, 2026-08-11 — release 1.3.4, updated
596
- * 2026-08-21 for sandbox without PANTHEON_OPENCODE_API_KEY): when an
597
- * AUTO-RESOLVED model (routing.yml agent entry / active preset) points at a
598
- * provider whose API key is not configured, the child is dispatched on the
599
- * `opencode-go` provider instead — `opencode-go/deepseek-v4-flash`
600
- * via https://opencode.ai/zen/go/v1 works without an external API key
601
- * (Go subscription / opencode auth, sandbox default). Validated with the
602
- * same providerKeyConfigured gate as the resolved model, with an
603
- * opencode-go exception for the sandbox; an explicit caller-supplied
604
- * `model` always wins over this fallback.
605
- */
606
- const FALLBACK_MODEL = 'opencode-go/deepseek-v4-flash'
607
-
608
593
  /**
609
594
  * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
610
595
  * into the `{ id, providerID }` ref the session.create body expects.
@@ -617,12 +602,10 @@ function splitModelRef(model: string): ChildSessionModelRef | undefined {
617
602
  }
618
603
 
619
604
  /**
620
- * Resolve the child session model with priority:
621
- * 1. explicit `model` option passed to the delegate tool;
622
- * 2. `options.agentModels[agent]` (routing.yml agent entry, case-insensitive);
623
- * 3. the active preset's agent entry via resolveActivePreset();
624
- * 4. fallback: undefined — the child is created without a model (the
625
- * caller warns) so opencode's default applies.
605
+ * Resolve only an explicitly supplied model. A routing profile's `model` is
606
+ * passed through as `options.agentModels`; profiles without one, and agents
607
+ * without a profile, intentionally resolve to undefined so the OpenCode
608
+ * parent session can provide inheritance.
626
609
  */
627
610
  function resolveChildModel(
628
611
  options: DelegationOptions,
@@ -641,43 +624,14 @@ function resolveChildModel(
641
624
  const ref = splitModelRef(mapped)
642
625
  if (ref !== undefined) return ref
643
626
  }
644
- // (c) active preset agent entry (resolveActivePreset reads routing.yml
645
- // presets + .pantheon/active-preset.json). Guarded — a broken routing.yml
646
- // must never kill the delegate.
647
- try {
648
- const presetModel = resolveActivePreset({
649
- ...(options.presetEnv !== undefined ? { env: options.presetEnv } : {}),
650
- ...(options.logger !== undefined ? { logger: options.logger } : {}),
651
- })?.agents?.[key]?.model
652
- if (presetModel !== undefined && presetModel !== '') {
653
- const ref = splitModelRef(presetModel)
654
- if (ref !== undefined) return ref
655
- }
656
- } catch (err: unknown) {
657
- const reason = err instanceof Error ? err.message : String(err)
658
- options.logger?.warn?.(`[pantheon-delegate] active preset resolution failed: ${reason}`)
659
- }
660
627
  return undefined
661
628
  }
662
629
 
663
630
  /**
664
- * Resolve a USABLE child model for a delegate dispatch, gating the resolved
665
- * provider's API key (P1, 2026-08-11 — release 1.3.4):
666
- *
667
- * 1. resolve the model with the existing precedence (explicit `model` >
668
- * options.agentModels > active preset) — see resolveChildModel.
669
- * 2. provider key gate: the resolved provider requires its apiKeyEnv env
670
- * var (routing.yml preset defs — same source applyPreset enforces at
671
- * startup). No gate (native providers) or key present → usable as-is.
672
- * 3. key MISSING + EXPLICIT caller model → respected anyway (user intent),
673
- * warned.
674
- * 4. key MISSING + AUTO-resolved model → fall back to FALLBACK_MODEL
675
- * (validated the same way). Fallback also unusable → returns an `error`
676
- * TEXT (the caller returns it from the tool — never throws) and the
677
- * caller registers NO job on the board.
678
- *
679
- * The no-model case (nothing resolved) keeps the pre-existing behavior:
680
- * warn and let the child use opencode's default.
631
+ * Resolve a child model without selecting a provider or model on the caller's
632
+ * behalf. OpenCode owns model inheritance when this returns no model; if the
633
+ * host requires one, its session.create/promptAsync error is returned with
634
+ * the normal diagnostic rather than silently choosing a fallback.
681
635
  *
682
636
  * @returns `{ model, error }` — at most one of `model` / `error` is set;
683
637
  * both may be unset (no model resolved, warn emitted).
@@ -687,50 +641,15 @@ function resolveUsableChildModel(
687
641
  agent: string,
688
642
  explicitModel: string | undefined,
689
643
  ): { model: ChildSessionModelRef | undefined; error: string | undefined } {
690
- const env = options.presetEnv ?? process.env
691
- const explicit = explicitModel !== undefined && explicitModel !== ''
692
- const warn = (msg: string) => options.logger?.warn?.(msg)
693
-
694
644
  const model = resolveChildModel(options, agent, explicitModel)
695
645
  if (model === undefined) {
696
- warn(
646
+ options.logger?.warn?.(
697
647
  `[pantheon-delegate] no model resolved for agent "${agent}" — ` +
698
- "child session will use opencode's default model (may require API keys)",
648
+ 'omitting model so OpenCode can inherit it from the parent session',
699
649
  )
700
650
  return { model: undefined, error: undefined }
701
651
  }
702
-
703
- const missingVar = missingProviderKeyEnv(model.providerID, { env })
704
- if (missingVar === undefined) return { model, error: undefined }
705
-
706
- if (explicit) {
707
- // Rule of precedence: the caller's explicit model is respected — warn only.
708
- warn(
709
- `[pantheon-delegate] model "${explicitModel}" provider "${model.providerID}" requires ` +
710
- `API key ${missingVar} (unset) — respecting the explicit model anyway`,
711
- )
712
- return { model, error: undefined }
713
- }
714
-
715
- // Auto-resolved (agentModels / preset) → try the known-good fallback.
716
- const fallback = splitModelRef(FALLBACK_MODEL)
717
- if (
718
- fallback !== undefined &&
719
- (fallback.providerID === 'opencode-go' ||
720
- missingProviderKeyEnv(fallback.providerID, { env }) === undefined)
721
- ) {
722
- warn(
723
- `[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
724
- `(unset) — falling back to ${FALLBACK_MODEL}`,
725
- )
726
- return { model: fallback, error: undefined }
727
- }
728
-
729
- const message =
730
- `pantheon_delegate: no usable model for agent "${agent}" — provider "${model.providerID}" ` +
731
- `requires API key (set ${missingVar} or PANTHEON_MODEL_PRESET)`
732
- warn(`[pantheon-delegate] ${message}`)
733
- return { model: undefined, error: message }
652
+ return { model, error: undefined }
734
653
  }
735
654
 
736
655
  // ─── Factory ───────────────────────────────────────────────────────────
@@ -900,13 +819,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
900
819
  // session.create failure → return a clear error as TEXT (tools return
901
820
  // errors as text, not thrown) and register NO job on the board — an
902
821
  // unhandled rejection here would otherwise lose the failure entirely.
903
- // The child model is resolved with a provider API-key gate (P1): the
904
- // resolved model (explicit `model` > options.agentModels > active
905
- // preset) is used when its provider key is configured; an explicit
906
- // caller model is always respected (warned); an auto-resolved model
907
- // whose provider key is missing falls back to the known-good
908
- // opencode-go/deepseek-v4-flash; if nothing usable remains the
909
- // failure is returned as TEXT below — no session, no board job.
822
+ // Only an explicit delegate model or routing profile model is sent.
823
+ // Otherwise omit it so OpenCode inherits the parent model.
910
824
  const resolved = resolveUsableChildModel(options, args.agent, args.model)
911
825
  if (resolved.error !== undefined) {
912
826
  if (budgetReserved && budgetKey !== undefined)
@@ -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 routing.yml's FIRST
94
- * (default) preset — the static source for the delegation toolset's
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,
@@ -345,46 +345,42 @@ export function loadRoutingPermissionTask({ routingPath, logger = console } = {}
345
345
  }
346
346
 
347
347
  /**
348
- * Load the DEFAULT agent → model mapping from routing.yml for the delegation
348
+ * Load the active routing profile's agent → model mapping for the delegation
349
349
  * toolset (Fase 6 — wiring agentModels).
350
350
  *
351
351
  * routing.yml keeps per-agent models ONLY inside presets
352
352
  * (`presets.<name>.agents.<agent>.model`) — the top-level `agents:` section
353
- * carries no model field. The FIRST-listed preset is the static default
354
- * (go-deepseek today): a mapping INDEPENDENT of the active preset, so a
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)).
353
+ * carries no model field. No profile means an empty mapping; there is no
354
+ * implicit first/default preset or fallback model.
358
355
  *
359
- * Fail-open: a missing or unparseable routing.yml yields {} (the caller keeps
360
- * the previous behavior — active preset / opencode default) and warns via the
356
+ * Fail-open: a missing or unparseable routing.yml yields {} and warns via the
361
357
  * optional logger; it NEVER throws at startup.
362
358
  *
363
359
  * @param {object} [opts]
364
360
  * @param {string} [opts.routingPath]
361
+ * @param {string[]} [opts.candidates]
362
+ * @param {Record<string, string|undefined>} [opts.env]
365
363
  * @param {{warn?: Function}} [opts.logger]
366
364
  * @returns {Record<string, string>} lowercase agent → "provider/model"
367
365
  */
368
- export function loadRoutingAgentModels({ routingPath, logger = console } = {}) {
369
- let defs
366
+ export function loadRoutingAgentModels({ routingPath, candidates, env, logger = console } = {}) {
370
367
  try {
371
- defs = loadPresetDefs(routingPath)
368
+ const active = resolveActivePreset({ routingPath, candidates, env, logger })
369
+ if (active === null) return {}
370
+ const models = {}
371
+ for (const [agent, spec] of Object.entries(active.agents ?? {})) {
372
+ if (spec && typeof spec === 'object' && typeof spec.model === 'string' && spec.model !== '') {
373
+ models[agent.toLowerCase()] = spec.model
374
+ }
375
+ }
376
+ return models
372
377
  } catch (err) {
373
378
  const reason = err instanceof Error ? err.message : String(err)
374
379
  logger.warn?.(
375
- `presets: routing.yml agent models unavailable (${reason}) — delegation falls back to the active preset/default`,
380
+ `presets: routing.yml active profile models unavailable (${reason}) — child model omitted`,
376
381
  )
377
382
  return {}
378
383
  }
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
384
  }
389
385
 
390
386
  /**
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,6 +28,7 @@ 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'
@@ -62,17 +62,6 @@ import { activePresetCandidates, createVisionHandler } from './pantheon/vision.t
62
62
  // .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
63
63
  const log = createPantheonLogger({ module: 'pantheon-plugin' })
64
64
 
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
65
  // R4: per-agent step caps from routing.yml `agents.<name>.max_steps`
77
66
  // (fail-open → {} = no agent capped). R1 retry_policy/cooldown are NOT
78
67
  // wired here — the plugin has no retry path (opencode cannot intercept task
@@ -90,6 +79,7 @@ const board = new BackgroundJobBoard({
90
79
  })
91
80
  const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
92
81
  board.setPersistence(persistence)
82
+ let terminalToastClient: ToastClient = {}
93
83
  board
94
84
  .recoverRunningJobs()
95
85
  .catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
@@ -108,6 +98,8 @@ board.onTerminal((taskID: string) => {
108
98
  log.info(
109
99
  `[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
110
100
  )
101
+ // Deliberately direct-to-TUI: onTerminal must not inject chat/transcript text.
102
+ void showDelegationTerminalToast(terminalToastClient, job)
111
103
  })
112
104
 
113
105
  // Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
@@ -223,8 +215,7 @@ function adaptDelegationClient(client: PluginInput['client']): DelegationClient
223
215
  model?: { id: string; providerID: string }
224
216
  } = { parentID: input.body.parentID }
225
217
  if (input.body.title !== undefined) body.title = input.body.title
226
- // Forward the routed child model (E2E fix): without it the child
227
- // falls back to the key-gated default model and dies at startup.
218
+ // Forward a model only when the active routing profile supplied one.
228
219
  if (input.body.model !== undefined) body.model = input.body.model
229
220
  const result = await client.session.create({ body })
230
221
  if (result.error) throw new Error(sdkErrorMessage(result.error))
@@ -343,6 +334,7 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
343
334
  * Helpers live in src/pantheon/vision.ts and are imported from there directly.
344
335
  */
345
336
  const plugin: Plugin = async (input: PluginInput) => {
337
+ terminalToastClient = input.client as unknown as ToastClient
346
338
  // release-134: runtime guard against DOUBLE registration (duplicate toasts +
347
339
  // duplicate task_ids in one process — 2026-08-12 live logs). opencode
348
340
  // merges the global config (npm-package plugin paths) with the project
@@ -377,7 +369,12 @@ const plugin: Plugin = async (input: PluginInput) => {
377
369
  },
378
370
  enforceRuntimeMatrix: true,
379
371
  readOnlyAgents: new Set(['apollo', 'gaia']),
380
- agentModels: routingAgentModels,
372
+ // Only an explicitly active routing profile may provide child models.
373
+ // An empty map deliberately lets OpenCode inherit the parent model.
374
+ agentModels: loadRoutingAgentModels({
375
+ candidates: activePresetCandidates(),
376
+ logger: log,
377
+ }),
381
378
  delegationBudgets,
382
379
  stepCapTracker,
383
380
  ...(routingPermissionTask != null ? { permissionTask: routingPermissionTask } : {}),
@@ -546,9 +543,7 @@ const plugin: Plugin = async (input: PluginInput) => {
546
543
  'chat.message': async (hookInput, output) => {
547
544
  await vision.chatMessage(hookInput, output)
548
545
  // No delegation notification delivery here — the user policy is ZERO
549
- // notification text injected into the chat transcript (previously the
550
- // notifier.flushQueue prepend). pantheon-hooks' chat-reminders.ts keeps
551
- // its own <system-reminder> channel, untouched.
546
+ // notification text injected into the chat transcript.
552
547
  // Wave 1: a user message is activity — the todo enforcer's
553
548
  // user-activity gate skips injection for userActivityQuietMs afterwards.
554
549
  todoEnforcer.noteUserActivity(hookInput.sessionID)
@@ -573,17 +568,6 @@ const plugin: Plugin = async (input: PluginInput) => {
573
568
  // rewritten with the exact list (see todo-preserve.ts). Fail-open.
574
569
  if (ev.type === 'session.compacted') {
575
570
  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
571
  }
588
572
  // Phase 3: observe completion on child sessions → finalizeDelegation.
589
573
  // 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 + chat-reminder fallback ONLY — every fired toast is also
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 flush (2026-08-06): the `event` hook additionally handles
100
- * `session.idle`. When the orchestrator goes idle, pending completion
101
- * reminders are flushed as ONE aggregated chat reminder (individual
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
- * oh-my-openagent fallback pattern: a `chat.message` hook injects queued
126
- * signals (see pendingChatReminders / enqueueChatReminder below) as ONE
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
- * chat-reminder fallback ONLY — the structured log + hooks.log channels
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
- * 1.18.13 chat.message fallback buffer (oh-my-openagent pattern — see header):
431
- * opencode 1.18.13 drops tui.toast.show events (untagged), so every signal
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/chat-reminder/hooks.log trail never exposes a raw secret.
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
- `[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 },
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, chat.message, event) firing
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,12 +1,12 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pantheon-tui",
9
- "version": "1.3.2",
9
+ "version": "1.4.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@opencode-ai/plugin": ">=1.14.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
6
6
  "license": "MIT",
7
7
  "exports": {