pantheon-opencode 1.2.2 → 1.3.0

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.
@@ -20,36 +20,182 @@
20
20
  *
21
21
  * Notes:
22
22
  * - `agent_id` is NOT present in the opencode tool.execute hook payloads
23
- * ({tool, sessionID, callID}), so it is passed as '' — validate-talos-scope
24
- * then takes its safe skip path; the agent-agnostic scan-secrets and
23
+ * ({tool, sessionID, callID}). P0-4 (2026-08-06): a `chat.params` hook
24
+ * records the active agent per session (sessionAgent map), so payloadFor()
25
+ * now resolves the real agent_id — validate-talos-scope.sh enforces the
26
+ * Talos file-count boundary; the agent-agnostic scan-secrets and
25
27
  * validate-tool-safety protections remain fully active.
26
- * - tool.execute hooks cannot hard-deny the tool call through this API
27
- * (the output only mutates args; real deny is the permission system), so
28
- * violations surface as stderr + non-zero exit codes logged below.
29
- * - Logging policy: non-zero exit → console.error (the security signal);
30
- * zero exit → SILENT by default. Audit scripts (log-session-start,
28
+ * - tool.execute.before CAN hard-deny — the old "cannot hard-deny" claim
29
+ * below this line is OUTDATED for 1.18.x (verified 2026-08-06 on the
30
+ * INSTALLED 1.18.13 binary + the opencode plugin docs): a `throw new
31
+ * Error(...)` from the hook propagates because Plugin.trigger invokes
32
+ * each hook as `yield* v.promise(async () => hook(input, output))` with
33
+ * NO try/catch — the rejection fails the tool's execute promise BEFORE
34
+ * the tool body runs (the docs' .env-protection example relies on this).
35
+ * The permission system remains the primary deny path, but a thrown
36
+ * hook error is now a supported hard block (hybrid blocking, Fix 2
37
+ * 2026-08-06): scan-secrets.sh exits 2 on high-confidence token matches
38
+ * → the plugin logs via reportFailure (non-TUI channels) and THEN throws
39
+ * the DELIBERATE block error — the ONLY throw in this plugin. Exit 1
40
+ * (low-confidence header/KEY names) stays advisory: toast + logs, no
41
+ * block.
42
+ * - Logging policy (P0 fix 2026-08-06): NON-ZERO exit → NEVER console.
43
+ * `console.error` in a plugin writes to the process stderr, which the
44
+ * opencode TUI renders directly into the terminal — that polluted the
45
+ * chat with `[pantheon-hooks:scan-secrets.sh] exit 1: [SECRET SCAN]...`
46
+ * spam on every tool call. Non-zero exits now go to THREE non-TUI channels:
47
+ * 1. client.tui.showToast() — one short, deduped TUI toast (per
48
+ * (script, exit code, masked match) per session) so the user gets a
49
+ * single visible signal instead of terminal spam;
50
+ * 2. client.app.log() — structured entry in the opencode log file
51
+ * (service "pantheon-hooks", level "error") — never the TUI;
52
+ * 3. .pantheon/logs/hooks.log — project-local one-line append, matching
53
+ * the audit-hook pattern of persisting to log files on disk.
54
+ * Zero exit → SILENT by default. Audit scripts (log-session-start,
31
55
  * on-subagent-delegation-*) still write their log FILES (sessions.log,
32
- * delegations.log) from inside the .sh scripts — only the console echo is
33
- * suppressed so the chat/TUI stays clean. Set PANTHEON_HOOKS_LOG=1 (or
34
- * "debug") to re-enable the zero-exit console echo for debugging.
56
+ * delegations.log) from inside the .sh scripts. Set PANTHEON_HOOKS_LOG=1
57
+ * (or "debug") to re-enable the zero-exit audit echo for debugging — it is
58
+ * routed to the structured log + hooks.log, never the TUI.
35
59
  * The env var is read once at plugin load time (opencode startup).
36
- * - Hooks NEVER throw: every body is try/catch'd and only logs.
60
+ * - Hooks NEVER throw: every body is try/catch'd and only logs. All three
61
+ * reporting channels (toast, app.log, file append) are individually
62
+ * try/catch'd so a failure in one can never take down the session.
63
+ * EXCEPTION (Fix 2, 2026-08-06): the deliberate scan-secrets high-
64
+ * confidence block — when scan-secrets.sh exits 2, the hook awaits
65
+ * reportFailure FIRST (app.log + hooks.log) and only then throws. The
66
+ * throw is deliberately NOT swallowed by the surrounding try/catch
67
+ * (handled via a blockError variable thrown after it), because that
68
+ * rejection is what opencode propagates to fail the tool call.
69
+ * - TUI lifecycle toasts (PANTHEON_TOASTS gate, read once at plugin load):
70
+ * the base toast mechanism (notifyToast → client.tui.showToast) surfaces
71
+ * hook-failure error toasts plus subagent delegation lifecycle events —
72
+ * "🚀 <agent> em execução" (tool.execute.before), "✅ <agent> concluiu"
73
+ * (tool.execute.after), and olympians/council summaries. The gate is now
74
+ * CATEGORY-based — each notification declares one of `errors`,
75
+ * `delegations` or `council` and only fires when its category is enabled:
76
+ * PANTHEON_TOASTS=off → no categories (empty set)
77
+ * PANTHEON_TOASTS=errors → {errors} (hook/agent failures only)
78
+ * PANTHEON_TOASTS=delegations → {errors, delegations, council} (default)
79
+ * PANTHEON_TOASTS=council → {errors, council}
80
+ * PANTHEON_TOASTS=all → {errors, delegations, council}
81
+ * Unknown values fall back to the default. The gate controls the TUI
82
+ * display + chat-reminder fallback ONLY — every fired toast is also
83
+ * recorded to the structured log + hooks.log (script 'toast', level
84
+ * 'info') so the toast trail stays auditable at any setting.
85
+ * - Severity priority (2026-08-06): errors/blockers — hook failures
86
+ * ("⚠️ Hook ..."), derivable agent failures ("⚠️ <agent> falhou: ...")
87
+ * and session.error events — ALWAYS notify individually, exempt from the
88
+ * 2s throttle and from aggregation. Success/completion notifications
89
+ * aggregate (olympians summaries / 3-in-6s group), never individually
90
+ * when the agent belongs to a detected group.
91
+ * - Olympians detection (2026-08-06): 2+ delegation task calls within
92
+ * OLYMPIANS_DETECT_WINDOW_MS collapse into ONE group. The 2nd call fires a
93
+ * single "⚙️ Olympians: N agentes em formação"; later members only update
94
+ * the group buffer. When every member's completion has been observed the
95
+ * group fires one "✅ Olympians: N/N concluídos (...)" aggregate.
96
+ * One group buffer at a time (groups rarely overlap); a task call more
97
+ * than OLYMPIANS_DETECT_WINDOW_MS after the previous one starts a fresh
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.
108
+ * - Council notifications (new category 2026-08-06): a `task` whose prompt/
109
+ * description matches "council"/"conselho"/"synthesi" is treated as council
110
+ * activity — "🏛️ Council: especialistas consultados" on start and
111
+ * "✅ Veredito pronto" once every dispatched council specialist completes.
112
+ * Category `council` (gated independently of `delegations`).
113
+ * - OpenCode 1.18.13 DROPS tui.toast.show (diagnosed 2026-08-06 via sandbox
114
+ * probe: headless `opencode serve` on 1.18.13 + /event SSE observation):
115
+ * the TUI core handler gates on `if (workspace !== workspace.current())
116
+ * return;` and /tui/show-toast publishes events with NO workspace tag
117
+ * (properties are {title, message, variant, duration} only). The
118
+ * `?directory=` query param even routes the event OFF the global event
119
+ * stream (to a directory-scoped stream a plugin client never subscribes
120
+ * to), and `?workspace=` requires a per-TUI-client `wrk_` id a plugin
121
+ * cannot discover (non-wrk values → HTTP 500 "Expected a string starting
122
+ * with wrk"). The upstream fix (server-side workspace tagging) only exists
123
+ * in NEWER opencode versions. THEREFORE on 1.18.13 the toast path below is
124
+ * 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.
130
+ * - tool.execute.after payload fix (P1-1 follow-up, 2026-08-06): the after
131
+ * hook receives `(input, output)` where `output` = {title, output,
132
+ * metadata} — the REAL tool result. It is now merged into the payload
133
+ * passed to the stop-hook scripts as `tool_output` (additive key) plus a
134
+ * plugin-derived `status` ('success' | 'failure' | 'dispatched' |
135
+ * 'unknown'), so on-subagent-delegation-stop.sh can log honest completion
136
+ * instead of the old args-only 'unknown'. Payload schema stays compatible
137
+ * — scripts read stdin JSON; new keys are additive.
138
+ * - P0-5 (2026-08-06, user test battery): the task tool's BACKGROUND dispatch
139
+ * arrives as free TEXT — `<task id="ses_..." state="running">\n<summary>
140
+ * Background task started</summary>...` — NOT JSON (verified in the
141
+ * installed 1.18.14 binary; metadata carries `background:true` + `jobId`,
142
+ * no state key). deriveResultStatus now detects the TEXT signature (+
143
+ * `metadata.background === true`) so background launches record
144
+ * 'dispatched' instead of the previous false 'success'; extractTaskId
145
+ * pulls the real `ses_...` id via `task id="ses_..."`. appendHookLog
146
+ * splits multi-line messages so EVERY hooks.log line carries the ISO
147
+ * prefix. GAP C: the bash tool's non-zero exit codes now fire an
148
+ * individual ⚠️ error notification (signal: `metadata.exit`, verified
149
+ * 1.18.14 — {title, output, metadata: {output, exit, truncated}}).
150
+ * - Background-mode contract (P0-1/P0-2, 2026-08-06, 2nd council session):
151
+ * the `task` tool in BACKGROUND mode returns IMMEDIATELY with
152
+ * {task_id, state:'running'} — tool.execute.after therefore observes
153
+ * DISPATCH, not completion. The stop-hook payload now separates the
154
+ * delegation-LIFECYCLE fields (agent, delegation_id, task_id, status,
155
+ * duration_ms) from the free-text result blob (tool_output): agent, status
156
+ * and session_id are never derived from result text (prim() sanitizer
157
+ * strips non-primitive junk), and a background launch is recorded as
158
+ * status:'dispatched' — never 'success'. REAL completion of background
159
+ * tasks is NOT observable via tool.execute.after on opencode 1.18.13 (no
160
+ * second after-event fires) — documented platform limitation, not a
161
+ * telemetry bug. delegation_id is generated in tool.execute.before
162
+ * (crypto.randomUUID), keyed by callID, and threaded to BOTH the start and
163
+ * the stop hook so delegations.log joins Start↔Stop on it; duration_ms is
164
+ * computed from the before-record start timestamp; task_id is extracted
165
+ * from the result into its OWN payload field.
166
+ * - P0-5 (2026-08-06, user test battery): the runtime ACTUALLY delivers the
167
+ * background dispatch as free TEXT, not JSON — `<task id="ses_..."
168
+ * state="running">\n<summary>Background task started</summary>...` (verified
169
+ * in the installed 1.18.14 binary; metadata carries `background:true` +
170
+ * `jobId`, no state key). deriveResultStatus now detects that TEXT
171
+ * signature (+ `metadata.background === true`) and extractTaskId pulls the
172
+ * `ses_...` id via `task id="ses_..."`. The stop-hook script's TSV handoff
173
+ * (8 fields read with `cut -fN`) was replaced by a JSON round-trip because
174
+ * a REAL newline in result_snippet shifted fields 5-8 into garbage;
175
+ * result_snippet is newline/tab-collapsed + 200-char-truncated before the
176
+ * JSON. appendHookLog splits multi-line messages so EVERY line gets the
177
+ * ISO prefix. GAP C: bash tool non-zero exit codes now fire an individual
178
+ * ⚠️ error notification (signal: `metadata.exit`, verified 1.18.14).
37
179
  *
38
180
  * IMPORTANT (OpenCode 1.18.11 legacy loader): this module must export EXACTLY
39
181
  * ONE function-valued export — the default plugin (see src/plugin.ts L47-52).
40
182
  * Helpers live in ./hook-runner.ts and are imported from there.
41
183
  */
42
- import type { Plugin } from '@opencode-ai/plugin'
43
- import { runHook, type HookPayload } from './hook-runner.ts'
184
+
185
+ import { appendFile, mkdir } from 'node:fs/promises'
186
+ import { join } from 'node:path'
187
+ import type { Plugin, PluginInput } from '@opencode-ai/plugin'
188
+ import type { Part } from '@opencode-ai/sdk'
189
+ import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
44
190
 
45
191
  /** Tools that represent a subagent delegation (opencode `task` tool etc.). */
46
192
  const DELEGATION_TOOL_RE = /^(task|.*delegate.*|.*subagent.*)$/i
47
193
 
48
194
  /**
49
195
  * When set to a truthy value ("1" or "debug"), zero-exit audit-hook output is
50
- * echoed to the console (opt-in debugging). Default (unset): fully silent on
51
- * success — the .sh scripts still persist their log FILES on disk; only the
52
- * console echo is suppressed. Read at plugin load time.
196
+ * echoed for debugging (opt-in). Default (unset): fully silent on success —
197
+ * the .sh scripts still persist their log FILES on disk. Read at plugin load.
198
+ * The debug echo goes to the structured log + hooks.log, never the TUI.
53
199
  */
54
200
  const AUDIT_LOG_ENABLED = (process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
55
201
 
@@ -61,6 +207,272 @@ const AUDIT_HOOKS = new Set([
61
207
  'validate-post-conditions.sh',
62
208
  ])
63
209
 
210
+ /**
211
+ * Dedupe keys for TUI toasts: `${script}|${exit code}|${masked match}`.
212
+ * Same security signal is toasted at most once per session — repeated false
213
+ * positives on every tool call must not re-toast and annoy the user.
214
+ */
215
+ const toastShown = new Set<string>()
216
+
217
+ /** Notification categories gated by PANTHEON_TOASTS (see header). */
218
+ type ToastCategory = 'errors' | 'delegations' | 'council'
219
+
220
+ /**
221
+ * TUI toast gate — PANTHEON_TOASTS=off|errors|delegations|council|all, read
222
+ * once at plugin load (opencode startup). Default "delegations" = {errors,
223
+ * delegations, council}: hook-failure + delegation-lifecycle + council toasts. The gate is a Set
224
+ * of enabled categories; every notification declares its category and is
225
+ * skipped unless enabled. "off" = empty set, "all" = every category. Unknown
226
+ * values fall back to the default. The gate controls the TUI display +
227
+ * chat-reminder fallback ONLY — the structured log + hooks.log channels
228
+ * always write (every fired toast is recorded via reportFailure with script
229
+ * 'toast').
230
+ */
231
+ const rawToastMode = (process.env.PANTHEON_TOASTS ?? '').trim().toLowerCase()
232
+ function parseToastCategories(mode: string): ReadonlySet<ToastCategory> {
233
+ switch (mode) {
234
+ case 'off':
235
+ return new Set()
236
+ case 'errors':
237
+ return new Set(['errors'])
238
+ case 'council':
239
+ return new Set(['errors', 'council'])
240
+ case 'all':
241
+ return new Set(['errors', 'delegations', 'council'])
242
+ default:
243
+ // Unknown value (or unset) → default: errors + delegations + council.
244
+ return new Set(['errors', 'delegations', 'council'])
245
+ }
246
+ }
247
+ const ENABLED_TOAST_CATEGORIES: ReadonlySet<ToastCategory> = parseToastCategories(rawToastMode)
248
+
249
+ function toastCategoryEnabled(cat: ToastCategory): boolean {
250
+ return ENABLED_TOAST_CATEGORIES.has(cat)
251
+ }
252
+
253
+ /**
254
+ * Delegation toast anti-spam: olympians dispatch up to 5 subagents in parallel,
255
+ * so raw per-agent toasts would spam the TUI. Two guards:
256
+ * - Rate limit: at most ONE delegation toast per 2000ms (lastToastAt).
257
+ * Throttled toasts are skipped, never backlogged — the aggregate summary
258
+ * covers them. Error toasts bypass this throttle (severity priority).
259
+ * - Aggregation: 3+ distinct agents completing within a 6s window collapse
260
+ * into a single "✅ N agentes concluídos (...)" toast via a small rolling
261
+ * buffer (Map<agent, completion timestamp>) flushed on the 3rd in-window
262
+ * completion. Agents belonging to a detected group use olympians
263
+ * aggregation instead (see OLYMPIANS_DETECT_WINDOW_MS below).
264
+ */
265
+ const TOAST_MIN_INTERVAL_MS = 2000
266
+ const GROUP_COMPLETE_WINDOW_MS = 6000
267
+ const GROUP_COMPLETE_MIN = 3
268
+ let lastToastAt = 0
269
+ const completedAgents = new Map<string, number>()
270
+
271
+ /**
272
+ * Olympians detection (see header): 2+ delegation task calls within the window
273
+ * collapse into ONE group. `detected` flips on the 2nd in-window call (the
274
+ * olympians-start notification fires exactly once); `pendingCompletions`
275
+ * tracks members whose completion has NOT yet been observed, so the idle
276
+ * flush can report partial groups. A task call more than
277
+ * OLYMPIANS_DETECT_WINDOW_MS after the previous one (lastTaskAt) starts a
278
+ * fresh group buffer.
279
+ */
280
+ const OLYMPIANS_DETECT_WINDOW_MS = 10_000
281
+
282
+ type Olympians = {
283
+ id: string
284
+ agents: Set<string>
285
+ startedAt: number
286
+ lastTaskAt: number
287
+ pendingCompletions: Set<string>
288
+ doneCount: number
289
+ detected: boolean
290
+ }
291
+
292
+ let activeOlympians: Olympians | null = null
293
+ let olympiansSeq = 0
294
+
295
+ /**
296
+ * Council notifications (new category, see header): a `task` whose prompt/
297
+ * description matches the council markers is treated as council activity.
298
+ * `councilStartNotified` fires "🏛️ Council: especialistas consultados" once;
299
+ * `pendingCouncilCalls` tracks the callIDs of dispatched council specialists
300
+ * (the after-hook args do NOT repeat the description, so completion is matched
301
+ * by callID); `✅ Veredito pronto` fires when every dispatched council
302
+ * specialist has completed. Counters reset on fire and at session.idle (an
303
+ * interrupted council must not block a later council's verdict).
304
+ */
305
+ const COUNCIL_RE = /council|conselho|synthesi/i
306
+ let councilStartNotified = false
307
+ let councilDispatchCount = 0
308
+ let councilDoneCount = 0
309
+ const pendingCouncilCalls = new Set<string>()
310
+
311
+ /**
312
+ * P0-4 session→agent map: opencode tool.execute hooks carry NO agent_id in
313
+ * their payload ({tool, sessionID, callID}), so validate-talos-scope.sh never
314
+ * knows who called the tool. The `chat.params` hook fires per assistant turn
315
+ * with the active agent name — record it per session here, then payloadFor()
316
+ * resolves agent_id from this map (falling back to an inline agent field when
317
+ * present). Bounded: entries older than 30min are pruned when the map exceeds
318
+ * 50 sessions, so stale sessions cannot accumulate.
319
+ */
320
+ const sessionAgent = new Map<string, { agent: string; at: number }>()
321
+
322
+ /**
323
+ * P0-4 advisory content guard: risky edit/write/bash targets (migrations,
324
+ * alembic, models/, auth, secrets) or bulk multi-file operations (>3 unique
325
+ * file-ish args) surface as a 'warn' log line via reportFailure — advisory
326
+ * ONLY, never blocks (the permission system is the real deny path).
327
+ */
328
+ const RISKY_PATH_RE = /migration|schema\.sql|alembic|models\/|auth|password|token|secret/i
329
+
330
+ /**
331
+ * Fix 1 (2026-08-06): high-confidence secret token formats — mirrors
332
+ * scripts/hooks/scan-secrets.sh HIGH_CONFIDENCE_PATTERNS. Used by maskSecret()
333
+ * to REDACT secret VALUES from any plugin log line: the talos-guard must
334
+ * never write a raw credential to hooks.log (a real `sk-bf-...` token leaked
335
+ * in plain text there on 2026-08-06). Header/KEY names (the Bifrost header alone,
336
+ * api_key=...) are deliberately NOT matched — they are not secret values.
337
+ */
338
+ const SECRET_MASK_RE =
339
+ /(sk-bf-[A-Za-z0-9_-]{8,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{36,}|glpat-[A-Za-z0-9_-]{20}|sk-[a-zA-Z0-9]{20,}|sk_live_[a-zA-Z0-9]{20,}|sk_test_[a-zA-Z0-9]{20,}|xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}|bearer\s+[a-zA-Z0-9_.-]{20,}|eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*)/gi
340
+
341
+ /**
342
+ * Fix 1: replace every high-confidence secret match with '****' so log lines
343
+ * (hooks.log, structured log, toasts, chat reminders) can never expose a raw
344
+ * token value. Applied to every path that echoes tool-arg or tool-result
345
+ * content (talos-guard hit, safeRun script output, agentFailedToast reason).
346
+ */
347
+ function maskSecret(text: string): string {
348
+ return text.replace(SECRET_MASK_RE, '****')
349
+ }
350
+
351
+ /**
352
+ * Fix 2 (2026-08-06): message thrown when scan-secrets.sh exits 2
353
+ * (HIGH_CONFIDENCE token match). This is the ONLY deliberate throw in the
354
+ * plugin — it is thrown AFTER reportFailure so the log channels always write
355
+ * first, and it is NOT swallowed by the hook's try/catch: opencode's
356
+ * Plugin.trigger has no catch around hook invocations, so the rejection
357
+ * propagates and fails the tool's execute promise BEFORE the tool runs.
358
+ */
359
+ const SECRET_BLOCK_MESSAGE =
360
+ '[pantheon-hooks] Bloqueado: segredo de alta confiança detectado no tool input — consulte .pantheon/logs/hooks.log'
361
+
362
+ /**
363
+ * P2 (2026-08-06): talos-guard is restricted to WRITE-INTENT operations.
364
+ * Read-only tools (find/grep/search) legitimately match RISKY_PATH_RE (e.g.
365
+ * grepping for 'auth') and must NEVER trigger the advisory. edit/write/patch
366
+ * always write; bash only when the command contains a write operator
367
+ * (redirect to a file, mv/cp/rm/tee, sed -i, git commit/push). `2>&1` fd
368
+ * duplication is NOT a file write and does not trigger.
369
+ */
370
+ const WRITE_INTENT_TOOLS = new Set(['edit', 'write', 'patch'])
371
+ const BASH_WRITE_INTENT_RE = />>|>(?!&)|\b(mv|cp|rm|tee)\b|\bsed\s+-i\b|\bgit\s+(commit|push)\b/
372
+
373
+ /**
374
+ * Strong failure markers for the tool RESULT (tool.execute.after output).
375
+ * Deliberately conservative — a completed task whose output merely mentions
376
+ * an error word must not be mislabeled a failure. Mirrored by the stop-hook
377
+ * script's own markers.
378
+ */
379
+ const RESULT_FAILURE_MARKERS =
380
+ /\b(refused|denied|traceback|exception|unhandled|fatal)\b|failed to|unable to|timed out|error:/i
381
+
382
+ /**
383
+ * P0-2 delegation correlation: every delegation gets a delegation_id in
384
+ * tool.execute.before (crypto.randomUUID), threaded through the payload to
385
+ * BOTH the start and the stop hook so delegations.log joins Start↔Stop on it.
386
+ * Records are keyed by callID (stable across before/after for the same tool
387
+ * call — the council tracking already relies on this) and hold the BEFORE-time
388
+ * agent, so the after-hook agent can never be contaminated by a merged result
389
+ * blob. Bounded: at most DELEGATION_TRACK_MAX entries, entries older than
390
+ * DELEGATION_TRACK_TTL_MS are pruned.
391
+ */
392
+ type DelegationStart = {
393
+ delegation_id: string
394
+ agent: string
395
+ startedAt: number
396
+ }
397
+ const delegationStarts = new Map<string, DelegationStart>()
398
+ const DELEGATION_TRACK_MAX = 100
399
+ const DELEGATION_TRACK_TTL_MS = 30 * 60 * 1000
400
+
401
+ /** P0-1: background-dispatch state values (dispatch, not completion). */
402
+ const BACKGROUND_STATE_RE = /^(running|queued|pending|scheduled|dispatched)$/i
403
+
404
+ /** P0-1: free-text task_id extraction from a JSON-ish tool result. */
405
+ const TASK_ID_RE = /"task_id"\s*:\s*"?([^",\s}]+)"?/i
406
+
407
+ /**
408
+ * P0-5 (2026-08-06): TEXT background-dispatch signature — the ACTUAL runtime
409
+ * format of the task tool in BACKGROUND mode on opencode 1.18.x (verified in
410
+ * the installed 1.18.14 binary): the tool returns IMMEDIATELY with free TEXT
411
+ * (not JSON) `<task id="ses_..." state="running">\n<summary>Background task
412
+ * started</summary>...` plus `metadata.background === true` and a `jobId`.
413
+ * Attribute order is id-then-state (as emitted by the runtime); the `i` flag
414
+ * tolerates case differences. Matched against title+output text.
415
+ */
416
+ const BACKGROUND_DISPATCH_TEXT_RE =
417
+ /<task\s+[^>]*\bid="(ses_[^"]+)"[^>]*\bstate="(running|queued|pending|scheduled|dispatched)"/i
418
+
419
+ /** P0-5: extract the REAL task id from the TEXT form `task id="ses_..."`. */
420
+ const TASK_ID_TEXT_RE = /\btask\s+id="(ses_[^"]+)"/i
421
+
422
+ /**
423
+ * 1.18.13 chat.message fallback buffer (oh-my-openagent pattern — see header):
424
+ * opencode 1.18.13 drops tui.toast.show events (untagged), so every signal
425
+ * that would fire a toast is ALSO queued here and injected into the next user
426
+ * message by the 'chat.message' hook as a single <system-reminder> text part.
427
+ * Bounded: at most CHAT_REMINDER_MAX entries, each expiring
428
+ * CHAT_REMINDER_TTL_MS after its tool event. Consumed (and cleared) by the
429
+ * chat.message hook. Full-buffer enqueues are SKIPPED, never unbounded — the
430
+ * olympians/3-in-6s aggregates still cover delegation groups, matching the
431
+ * "throttled toasts are skipped, never backlogged" anti-spam philosophy.
432
+ * session.idle flushes the buffer into ONE fresh aggregated entry (see
433
+ * flushIdleReminders) so completions survive until the next user message.
434
+ */
435
+ const CHAT_REMINDER_MAX = 10
436
+ const CHAT_REMINDER_TTL_MS = 60_000
437
+ const pendingChatReminders: { text: string; at: number }[] = []
438
+
439
+ /** Queue one chat-reminder line, pruning expired entries first. Never throws. */
440
+ function enqueueChatReminder(text: string): void {
441
+ const now = Date.now()
442
+ for (let i = pendingChatReminders.length - 1; i >= 0; i--) {
443
+ const r = pendingChatReminders[i]
444
+ if (r !== undefined && now - r.at > CHAT_REMINDER_TTL_MS) pendingChatReminders.splice(i, 1)
445
+ }
446
+ if (pendingChatReminders.length >= CHAT_REMINDER_MAX) return
447
+ pendingChatReminders.push({ text, at: now })
448
+ }
449
+
450
+ /** Variants accepted by the opencode TUI showToast (TuiShowToastData.body.variant). */
451
+ type ToastVariant = 'info' | 'success' | 'warning' | 'error'
452
+
453
+ /** Options for notifyToast: message, TUI variant, category gate, duration, optional session dedupe key. */
454
+ type ToastOptions = {
455
+ message: string
456
+ variant: ToastVariant
457
+ /** Gate category — the toast only fires when PANTHEON_TOASTS enables it. */
458
+ category: ToastCategory
459
+ duration?: number
460
+ dedupeKey?: string
461
+ /** Optional TUI toast headline (opencode showToast body.title). */
462
+ title?: string
463
+ /**
464
+ * Optional text for the 1.18.13 chat.message <system-reminder> fallback.
465
+ * Defaults to `message` when omitted — the reminder mirrors the toast.
466
+ */
467
+ reminder?: string
468
+ }
469
+
470
+ /** Logging context threaded through safeRun — provided by the plugin input. */
471
+ type HookContext = Pick<PluginInput, 'client' | 'directory'>
472
+
473
+ /** The tool.execute.after hook result ({title, output, metadata} — see header). */
474
+ type ToolExecuteAfterResult = { title: string; output: string; metadata: unknown }
475
+
64
476
  function trim(s: string, max = 2000): string {
65
477
  const t = s.trim()
66
478
  return t.length > max ? `${t.slice(0, max)}…` : t
@@ -70,65 +482,982 @@ function trim(s: string, max = 2000): string {
70
482
  function payloadFor(
71
483
  input: { tool: string; sessionID: string },
72
484
  toolInput: unknown,
485
+ extra?: Record<string, unknown>,
73
486
  ): HookPayload {
74
487
  return {
75
488
  tool_name: input.tool,
76
489
  tool_input: toolInput ?? {},
77
- // tool.execute hooks carry no agent — '' lets talos-scope skip safely.
78
- agent_id: (input as { agent?: { id?: string } }).agent?.id ?? '',
490
+ // tool.execute hooks carry no agent — resolve it from the sessionAgent map
491
+ // (populated by the chat.params hook) with an inline agent field fallback.
492
+ agent_id:
493
+ sessionAgent.get(input.sessionID)?.agent ??
494
+ (input as { agent?: { id?: string } }).agent?.id ??
495
+ '',
79
496
  session_id: input.sessionID,
497
+ ...(extra ?? {}),
498
+ }
499
+ }
500
+
501
+ /**
502
+ * P0-1/P0-2: the tool.execute.after payload. Separates the delegation-
503
+ * LIFECYCLE fields (agent, delegation_id, task_id, status, duration_ms) from
504
+ * the free-text result blob (tool_output). `start` is the tool.execute.before
505
+ * record keyed by callID — when present, its agent wins (the after-hook args
506
+ * may carry the MERGED background result {task_id, state}, never a
507
+ * subagent_type). Additive keys only — scripts read stdin JSON, so the schema
508
+ * stays compatible with older payloads.
509
+ */
510
+ function afterPayloadFor(
511
+ input: { tool: string; sessionID: string },
512
+ args: unknown,
513
+ result: ToolExecuteAfterResult | null,
514
+ start?: DelegationStart,
515
+ ): HookPayload {
516
+ const extra: Record<string, unknown> = {
517
+ tool_output: result,
518
+ status: deriveResultStatus(result),
519
+ }
520
+ if (start !== undefined) {
521
+ extra.agent_id = start.agent
522
+ extra.delegation_id = start.delegation_id
523
+ extra.duration_ms = Math.max(0, Date.now() - start.startedAt)
524
+ }
525
+ const taskID = extractTaskId(result)
526
+ if (taskID !== '') extra.task_id = taskID
527
+ return payloadFor(input, args, extra)
528
+ }
529
+
530
+ /**
531
+ * Derive 'success' | 'failure' | 'dispatched' | 'unknown' from the tool
532
+ * result. Precedence:
533
+ * 1. background-dispatch signature → 'dispatched': the task tool in
534
+ * BACKGROUND mode returns immediately with {task_id, state:'running'}
535
+ * (or 'queued'/'pending') — that is DISPATCH, not completion (P0-1);
536
+ * 2. explicit status-ish fields in metadata (existing logic);
537
+ * 3. failure markers in the result text;
538
+ * 4. non-empty output (real completion evidence) → 'success';
539
+ * 5. 'unknown'. Never throws.
540
+ */
541
+ function deriveResultStatus(
542
+ result: ToolExecuteAfterResult | null | undefined,
543
+ ): 'success' | 'failure' | 'dispatched' | 'unknown' {
544
+ if (result === null || result === undefined) return 'unknown'
545
+ const meta = result.metadata
546
+ if (meta !== null && typeof meta === 'object') {
547
+ const m = meta as Record<string, unknown>
548
+ // P0-1: background-dispatch state values first ('running'/'queued'/...).
549
+ for (const key of ['status', 'state']) {
550
+ const v = m[key]
551
+ if (typeof v === 'string' && BACKGROUND_STATE_RE.test(v.trim())) return 'dispatched'
552
+ }
553
+ for (const key of ['status', 'state', 'ok', 'success', 'error', 'failed']) {
554
+ if (key in m) {
555
+ const v = m[key]
556
+ if (typeof v === 'boolean') return v ? 'success' : 'failure'
557
+ if (typeof v === 'string') {
558
+ const low = v.trim().toLowerCase()
559
+ if (
560
+ [
561
+ 'success',
562
+ 'successful',
563
+ 'completed',
564
+ 'complete',
565
+ 'done',
566
+ 'finished',
567
+ 'ok',
568
+ 'true',
569
+ '1',
570
+ ].includes(low)
571
+ ) {
572
+ return 'success'
573
+ }
574
+ if (
575
+ [
576
+ 'failure',
577
+ 'failed',
578
+ 'error',
579
+ 'refused',
580
+ 'denied',
581
+ 'exception',
582
+ 'false',
583
+ '0',
584
+ 'cancelled',
585
+ 'canceled',
586
+ 'aborted',
587
+ ].includes(low)
588
+ ) {
589
+ return 'failure'
590
+ }
591
+ }
592
+ }
593
+ }
594
+ // P0-1: task_id field with no terminal status above — background dispatch.
595
+ if (m.task_id !== undefined || m.taskId !== undefined) return 'dispatched'
596
+ // P0-5: `background: true` marks a background launch (verified 1.18.14:
597
+ // the dispatch result metadata is {..., background: true, jobId}).
598
+ if (m.background === true) return 'dispatched'
599
+ }
600
+ const text = `${result.title ?? ''} ${result.output ?? ''}`
601
+ // P0-1: JSON-as-string output {"task_id":..., "state":"running"} — the
602
+ // background dispatch signature delivered as free text inside the result.
603
+ if (typeof result.output === 'string' && result.output.trim() !== '') {
604
+ const parsed = tryParseJsonObject(result.output)
605
+ if (parsed !== null && (parsed.task_id !== undefined || parsed.taskId !== undefined)) {
606
+ return 'dispatched'
607
+ }
608
+ // P0-5: TEXT background-dispatch signature — the ACTUAL runtime format on
609
+ // 1.18.x: `<task id="ses_..." state="running">\n<summary>Background task
610
+ // started</summary>...`. Without this, the blob fell through to the
611
+ // non-empty-output → 'success' branch below — the false success observed
612
+ // by the user's test battery (2026-08-06).
613
+ if (BACKGROUND_DISPATCH_TEXT_RE.test(text)) return 'dispatched'
80
614
  }
615
+ if (RESULT_FAILURE_MARKERS.test(text)) return 'failure'
616
+ if (typeof result.output === 'string' && result.output.trim() !== '') return 'success'
617
+ return 'unknown'
618
+ }
619
+
620
+ /**
621
+ * P0-1 sanitizer: coerce a payload value to a plain string; non-primitive junk
622
+ * (objects/arrays from a merged result blob) becomes ''. Guards the
623
+ * delegation-lifecycle fields so agent/status/task_id can never be polluted.
624
+ */
625
+ function prim(v: unknown): string {
626
+ if (typeof v === 'string') return v
627
+ if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint') return String(v)
628
+ return ''
81
629
  }
82
630
 
83
- /** Run one hook with the logging policy; NEVER throw out of the hook. */
84
- async function safeRun(script: string, payload: HookPayload): Promise<void> {
631
+ /** Parse a JSON-object string into a plain record (null when not JSON). */
632
+ function tryParseJsonObject(text: string): Record<string, unknown> | null {
633
+ const t = text.trim()
634
+ if (!t.startsWith('{')) return null
635
+ try {
636
+ const v = JSON.parse(t) as unknown
637
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
638
+ return v as Record<string, unknown>
639
+ }
640
+ } catch {
641
+ // Not JSON — null.
642
+ }
643
+ return null
644
+ }
645
+
646
+ /**
647
+ * P0-1: extract the background `task_id` into its OWN clean field — never
648
+ * concatenated into agent/status. Sources: metadata (task_id/taskId) →
649
+ * JSON-as-string in output → free-text regex over the result.
650
+ */
651
+ function extractTaskId(result: ToolExecuteAfterResult | null | undefined): string {
652
+ if (result === null || result === undefined) return ''
653
+ const meta = result.metadata
654
+ if (meta !== null && typeof meta === 'object') {
655
+ const m = meta as Record<string, unknown>
656
+ const direct = m.task_id ?? m.taskId
657
+ if (direct !== undefined) return prim(direct)
658
+ }
659
+ if (typeof result.output === 'string' && result.output.trim() !== '') {
660
+ const parsed = tryParseJsonObject(result.output)
661
+ if (parsed !== null) {
662
+ const direct = parsed.task_id ?? parsed.taskId
663
+ if (direct !== undefined) return prim(direct)
664
+ }
665
+ const m = TASK_ID_RE.exec(result.output)
666
+ if (m?.[1] !== undefined) return m[1]
667
+ // P0-5: TEXT form `<task id="ses_..." state="running">` — the runtime
668
+ // background-dispatch format (id sits in its own attribute, not JSON).
669
+ const mt = TASK_ID_TEXT_RE.exec(result.output)
670
+ if (mt?.[1] !== undefined) return mt[1]
671
+ }
672
+ return ''
673
+ }
674
+
675
+ /** P0-2: prune stale delegation records; over the cap, drop oldest first. */
676
+ function pruneDelegationStarts(now: number): void {
677
+ for (const [callID, rec] of delegationStarts) {
678
+ if (now - rec.startedAt > DELEGATION_TRACK_TTL_MS) delegationStarts.delete(callID)
679
+ }
680
+ while (delegationStarts.size >= DELEGATION_TRACK_MAX) {
681
+ let oldestID: string | null = null
682
+ let oldestAt = Infinity
683
+ for (const [callID, rec] of delegationStarts) {
684
+ if (rec.startedAt < oldestAt) {
685
+ oldestAt = rec.startedAt
686
+ oldestID = callID
687
+ }
688
+ }
689
+ if (oldestID === null) break
690
+ delegationStarts.delete(oldestID)
691
+ }
692
+ }
693
+
694
+ /** Append one line to .pantheon/logs/hooks.log under the project directory. */
695
+ async function appendHookLog(directory: string, line: string): Promise<void> {
696
+ const dir = join(directory, '.pantheon', 'logs')
697
+ await mkdir(dir, { recursive: true })
698
+ // P2 (2026-08-06): every line carries an ISO timestamp so the hook trail is
699
+ // chronologically readable — [2026-08-06T13:00:00.000Z] message.
700
+ // P0-5 (2026-08-06): a message containing real newlines (e.g. multi-line
701
+ // stderr) used to produce continuation lines WITHOUT the timestamp prefix
702
+ // (user evidence: `[SECRET SCAN] High-confidence hardcoded secret
703
+ // detected…` with no prefix). Split on \n, trim each piece, skip empties,
704
+ // and write `[ISO] piece` per line — one message → N fully-timestamped
705
+ // lines. Same ISO stamp for the whole message (they are one event).
706
+ const stamp = new Date().toISOString()
707
+ const body = line
708
+ .split('\n')
709
+ .map((p) => p.trim())
710
+ .filter((p) => p !== '')
711
+ .map((p) => `[${stamp}] ${p}`)
712
+ .join('\n')
713
+ if (body === '') return
714
+ await appendFile(join(dir, 'hooks.log'), `${body}\n`, 'utf8')
715
+ }
716
+
717
+ /**
718
+ * Persist a hook result line to the non-TUI channels: the opencode structured
719
+ * log (client.app.log) and the project hooks.log file. Both are best-effort
720
+ * and individually guarded — a reporting failure must never throw out of a hook.
721
+ */
722
+ async function reportFailure(
723
+ ctx: HookContext,
724
+ message: string,
725
+ extra: Record<string, unknown>,
726
+ level: 'error' | 'info' | 'warn' = 'error',
727
+ ): Promise<void> {
728
+ try {
729
+ await ctx.client.app.log({
730
+ body: { service: 'pantheon-hooks', level, message, extra },
731
+ })
732
+ } catch {
733
+ // Structured log is best-effort; the file append below still runs.
734
+ }
735
+ try {
736
+ await appendHookLog(ctx.directory, message)
737
+ } catch {
738
+ // File log is best-effort; nothing else to fall back to.
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Show ONE short, single-line TUI toast with an optional dedupe key. Deduped
744
+ * per key per session (key added before showing, so repeated signals toast at
745
+ * most once). Gated by the notification's category (PANTHEON_TOASTS). Best-
746
+ * effort: headless mode or a missing TUI endpoint must never throw out of a
747
+ * hook. Every fired toast is also recorded via reportFailure (script 'toast',
748
+ * level 'info') so the toast trail is auditable — the log channels write
749
+ * regardless of the display gate.
750
+ */
751
+ async function notifyToast(ctx: HookContext, opts: ToastOptions): Promise<void> {
752
+ try {
753
+ if (opts.dedupeKey) {
754
+ if (toastShown.has(opts.dedupeKey)) return
755
+ toastShown.add(opts.dedupeKey)
756
+ }
757
+ if (!toastCategoryEnabled(opts.category)) return
758
+ // 1.18.13 fallback: queue the same signal for the chat.message hook (the
759
+ // TUI drops tui.toast.show on 1.18.13 — see header). Enqueued even if
760
+ // showToast fails, so headless/no-TUI servers still surface feedback.
761
+ enqueueChatReminder(opts.reminder ?? opts.message)
762
+ await ctx.client.tui.showToast({
763
+ body: {
764
+ ...(opts.title !== undefined ? { title: opts.title } : {}),
765
+ message: opts.message,
766
+ variant: opts.variant,
767
+ duration: opts.duration ?? 4000,
768
+ },
769
+ })
770
+ await reportFailure(
771
+ ctx,
772
+ `[pantheon-hooks:toast] ${opts.message}`,
773
+ {
774
+ script: 'toast',
775
+ category: opts.category,
776
+ title: opts.title ?? null,
777
+ variant: opts.variant,
778
+ duration: opts.duration ?? 4000,
779
+ dedupeKey: opts.dedupeKey ?? null,
780
+ },
781
+ 'info',
782
+ )
783
+ } catch {
784
+ // No TUI / headless: toast is best-effort, logs already persisted.
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Rate-limited delegation toast: at most ONE per TOAST_MIN_INTERVAL_MS.
790
+ * Throttled toasts are skipped (never queued/backlogged) — the aggregated
791
+ * completion summary covers the group. Only applies to delegation lifecycle
792
+ * toasts; error toasts (hook failures, agent failures) bypass the throttle
793
+ * entirely (severity priority) and keep their plain session dedupe.
794
+ */
795
+ async function throttledToast(ctx: HookContext, opts: ToastOptions): Promise<void> {
796
+ const now = Date.now()
797
+ if (now - lastToastAt < TOAST_MIN_INTERVAL_MS) return
798
+ lastToastAt = now
799
+ await notifyToast(ctx, opts)
800
+ }
801
+
802
+ /**
803
+ * Extract the subagent type from a delegation tool's args (opencode `task`
804
+ * tool passes `subagent_type`, e.g. 'apollo' / 'hermes'). Falls back to the
805
+ * tool name when the field is missing so the toast still identifies what ran.
806
+ * Never throws — malformed args are treated as empty.
807
+ */
808
+ function delegationAgent(tool: string, args?: unknown): string {
809
+ try {
810
+ const a = (args ?? {}) as Record<string, unknown>
811
+ const candidate = a.subagent_type ?? a.subagentType
812
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
813
+ } catch {
814
+ // Malformed args — fall through to the tool name.
815
+ }
816
+ return tool
817
+ }
818
+
819
+ /** 🚀 delegation-started toast (variant 'info', 2500ms), deduped per agent. */
820
+ async function delegationStartedToast(ctx: HookContext, agent: string): Promise<void> {
821
+ await throttledToast(ctx, {
822
+ title: 'Delegação',
823
+ message: `🚀 ${agent} em execução`,
824
+ variant: 'info',
825
+ duration: 2500,
826
+ category: 'delegations',
827
+ dedupeKey: `${agent}|start`,
828
+ })
829
+ }
830
+
831
+ /**
832
+ * ✅ delegation-completed toast (variant 'success', 3000ms), deduped per agent.
833
+ * Anti-spam aggregation: when 3+ distinct agents complete within a 6s window,
834
+ * the individual toasts are REPLACED by one "✅ N agentes concluídos (...)"
835
+ * toast and the buffer is flushed. The aggregate bypasses the 2s throttle so
836
+ * the group summary is always visible — it is inherently rare (≥3 agents / 6s).
837
+ * Agents belonging to a detected group are handled by olympians aggregation
838
+ * instead (handleOlympiansCompletion) and never reach this path.
839
+ */
840
+ async function delegationDoneToast(ctx: HookContext, agent: string): Promise<void> {
841
+ const now = Date.now()
842
+ for (const [name, at] of completedAgents) {
843
+ if (now - at > GROUP_COMPLETE_WINDOW_MS) completedAgents.delete(name)
844
+ }
845
+ completedAgents.set(agent, now)
846
+ if (completedAgents.size >= GROUP_COMPLETE_MIN) {
847
+ const count = completedAgents.size
848
+ const names = [...completedAgents.keys()].sort().join(', ')
849
+ completedAgents.clear()
850
+ await notifyToast(ctx, {
851
+ title: 'Delegação',
852
+ message: `✅ ${count} agentes concluídos (${names})`,
853
+ variant: 'success',
854
+ duration: 4000,
855
+ category: 'delegations',
856
+ dedupeKey: `group-complete|${names}`,
857
+ })
858
+ return
859
+ }
860
+ await throttledToast(ctx, {
861
+ title: 'Delegação',
862
+ message: `✅ ${agent} concluiu`,
863
+ variant: 'success',
864
+ duration: 3000,
865
+ category: 'delegations',
866
+ dedupeKey: `${agent}|done`,
867
+ })
868
+ }
869
+
870
+ /** ⚙️ olympians-detected toast — fires ONCE per group (on the 2nd in-window task call). */
871
+ async function notifyOlympiansStarted(ctx: HookContext, olympians: Olympians): Promise<void> {
872
+ const count = olympians.agents.size
873
+ await notifyToast(ctx, {
874
+ title: 'Olympians',
875
+ message: `⚙️ Olympians: ${count} agentes em formação`,
876
+ variant: 'info',
877
+ duration: 3000,
878
+ category: 'delegations',
879
+ dedupeKey: `olympians-start|${olympians.id}`,
880
+ })
881
+ }
882
+
883
+ /** ✅ olympians-complete toast — fires once when every group member has completed. */
884
+ async function notifyOlympiansComplete(ctx: HookContext, olympians: Olympians): Promise<void> {
885
+ const names = [...olympians.agents].sort().join(', ')
886
+ await notifyToast(ctx, {
887
+ title: 'Olympians',
888
+ message: `✅ Olympians: ${olympians.doneCount}/${olympians.agents.size} concluídos (${names})`,
889
+ variant: 'success',
890
+ duration: 4000,
891
+ category: 'delegations',
892
+ dedupeKey: `olympians-complete|${olympians.id}`,
893
+ })
894
+ }
895
+
896
+ /**
897
+ * ⚠️ agent-failed toast (severity priority — category 'errors', individual,
898
+ * exempt from throttle and aggregation). Fired when the tool result carries
899
+ * failure evidence. Never deduped: every failure surfaces.
900
+ */
901
+ async function agentFailedToast(
902
+ ctx: HookContext,
903
+ agent: string,
904
+ result: ToolExecuteAfterResult | null,
905
+ ): Promise<void> {
906
+ const raw = result?.output ?? result?.title ?? ''
907
+ // Fix 1: the failure reason echoes tool RESULT content — mask any token so
908
+ // the toast/chat-reminder/hooks.log trail never exposes a raw secret.
909
+ const reason = maskSecret(
910
+ trim(typeof raw === 'string' && raw.trim() !== '' ? raw : 'tool failed', 120),
911
+ )
912
+ await notifyToast(ctx, {
913
+ title: 'Delegação',
914
+ message: `⚠️ ${agent} falhou: ${reason}`,
915
+ variant: 'error',
916
+ duration: 5000,
917
+ category: 'errors',
918
+ })
919
+ }
920
+
921
+ /**
922
+ * GAP C (2026-08-06): ⚠️ bash-command-failed notification (severity priority
923
+ * — category 'errors', individual, exempt from the 2s throttle and from
924
+ * aggregation, never deduped). Fired when the bash tool result reports a
925
+ * non-zero exit code; exit 0 → silent. The exit-code signal is
926
+ * `metadata.exit` on the after-hook result — empirically verified against the
927
+ * installed opencode 1.18.14 binary: the shell tool returns
928
+ * {title, output, metadata: {output, exit, truncated}} where `exit` is the
929
+ * real process exit code (number, or null on timeout/abort). Fix 1: the
930
+ * reason echoes tool RESULT content — mask any token so the trail never
931
+ * exposes a raw secret.
932
+ */
933
+ async function bashFailedToast(
934
+ ctx: HookContext,
935
+ exitCode: number | null,
936
+ result: ToolExecuteAfterResult | null,
937
+ ): Promise<void> {
938
+ const raw = result?.output ?? result?.title ?? ''
939
+ const firstLine = typeof raw === 'string' ? (raw.split('\n')[0] ?? '').trim() : ''
940
+ const reason = maskSecret(trim(firstLine, 100))
941
+ await notifyToast(ctx, {
942
+ title: 'Comando',
943
+ message:
944
+ exitCode !== null
945
+ ? `⚠️ comando falhou: exit ${exitCode}${reason !== '' ? ` — ${reason}` : ''}`
946
+ : `⚠️ comando falhou${reason !== '' ? `: ${reason}` : ''}`,
947
+ variant: 'error',
948
+ duration: 5000,
949
+ category: 'errors',
950
+ })
951
+ }
952
+
953
+ /**
954
+ * Olympians-aware delegation start. Returns nothing; side effects:
955
+ * - 1st task of a window → starts a group buffer + fires the individual 🚀
956
+ * - 2nd+ task within the window → joins the group; the 2nd call fires the
957
+ * single ⚙️ olympians notification (later members only update the buffer)
958
+ */
959
+ function onDelegationStart(ctx: HookContext, agent: string): void {
960
+ const now = Date.now()
961
+ if (activeOlympians !== null && now - activeOlympians.lastTaskAt <= OLYMPIANS_DETECT_WINDOW_MS) {
962
+ activeOlympians.lastTaskAt = now
963
+ activeOlympians.agents.add(agent)
964
+ activeOlympians.pendingCompletions.add(agent)
965
+ if (activeOlympians.agents.size === 2) {
966
+ activeOlympians.detected = true
967
+ void notifyOlympiansStarted(ctx, activeOlympians)
968
+ }
969
+ // No individual 🚀 toast for 2nd+ members — the group notification covers them.
970
+ return
971
+ }
972
+ activeOlympians = {
973
+ id: `olympians-${now}-${++olympiansSeq}`,
974
+ agents: new Set([agent]),
975
+ startedAt: now,
976
+ lastTaskAt: now,
977
+ pendingCompletions: new Set([agent]),
978
+ doneCount: 0,
979
+ detected: false,
980
+ }
981
+ void delegationStartedToast(ctx, agent)
982
+ }
983
+
984
+ /**
985
+ * Olympians-aware delegation completion. Returns true when the completion was
986
+ * consumed by the group (its individual ✅ is suppressed in favor of the group
987
+ * aggregate); false when the agent is not part of a detected group and should
988
+ * fall through to the normal 3-in-6s / individual completion toast. Also
989
+ * invoked for FAILED members (severity): the failure toast fires separately
990
+ * and the member still counts as done for the group summary.
991
+ */
992
+ function handleOlympiansCompletion(ctx: HookContext, agent: string): boolean {
993
+ if (activeOlympians === null || !activeOlympians.agents.has(agent)) return false
994
+ if (activeOlympians.pendingCompletions.has(agent)) {
995
+ activeOlympians.doneCount++
996
+ activeOlympians.pendingCompletions.delete(agent)
997
+ }
998
+ if (!activeOlympians.detected) {
999
+ // Never became a real group (only this agent) — release and fall through.
1000
+ activeOlympians = null
1001
+ return false
1002
+ }
1003
+ if (activeOlympians.doneCount >= activeOlympians.agents.size) {
1004
+ void notifyOlympiansComplete(ctx, activeOlympians)
1005
+ activeOlympians = null
1006
+ }
1007
+ return true
1008
+ }
1009
+
1010
+ /** Conservative council detection: description/prompt mentions council terms. */
1011
+ function isCouncilTask(tool: string, args?: unknown): boolean {
1012
+ if (!DELEGATION_TOOL_RE.test(tool)) return false
1013
+ try {
1014
+ const a = (args ?? {}) as Record<string, unknown>
1015
+ const text = [a.prompt, a.description, a.task, a.taskDesc, a.detail]
1016
+ .filter((x): x is string => typeof x === 'string')
1017
+ .join(' ')
1018
+ return COUNCIL_RE.test(text)
1019
+ } catch {
1020
+ // Malformed args — not a council task.
1021
+ return false
1022
+ }
1023
+ }
1024
+
1025
+ /** 🏛️ council-started notification — fires once per council (category 'council'). */
1026
+ function onCouncilStart(ctx: HookContext): void {
1027
+ councilDispatchCount++
1028
+ if (councilStartNotified) return
1029
+ councilStartNotified = true
1030
+ void notifyToast(ctx, {
1031
+ title: 'Council',
1032
+ message: '🏛️ Council: especialistas consultados',
1033
+ variant: 'info',
1034
+ duration: 3500,
1035
+ category: 'council',
1036
+ })
1037
+ }
1038
+
1039
+ /** ✅ council verdict notification — fires once all dispatched specialists finished. */
1040
+ function onCouncilDone(ctx: HookContext): void {
1041
+ councilDoneCount++
1042
+ if (councilDispatchCount > 0 && councilDoneCount >= councilDispatchCount) {
1043
+ void notifyToast(ctx, {
1044
+ title: 'Council',
1045
+ message: '✅ Veredito pronto',
1046
+ variant: 'success',
1047
+ duration: 4000,
1048
+ category: 'council',
1049
+ })
1050
+ // Reset so a subsequent council session starts fresh (verdicts never re-fire).
1051
+ councilDispatchCount = 0
1052
+ councilDoneCount = 0
1053
+ councilStartNotified = false
1054
+ pendingCouncilCalls.clear()
1055
+ }
1056
+ }
1057
+
1058
+ /**
1059
+ * session.idle flush (see header): aggregate any pending chat reminders into
1060
+ * ONE fresh <system-reminder> entry (individual ✅ completions collapse into
1061
+ * "✅ N agentes concluídos (...)"), report partial groups whose members never
1062
+ * all completed, and reset interrupted council counters. The chat.message
1063
+ * hook remains the delivery path — the flushed entry has a fresh timestamp so
1064
+ * it survives until the next user message instead of expiring while the user
1065
+ * reads the reply.
1066
+ */
1067
+ function flushIdleReminders(ctx: HookContext): void {
1068
+ if (ENABLED_TOAST_CATEGORIES.size === 0) {
1069
+ pendingChatReminders.length = 0
1070
+ activeOlympians = null
1071
+ return
1072
+ }
1073
+ const now = Date.now()
1074
+ const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
1075
+ pendingChatReminders.length = 0
1076
+
1077
+ // Partial group: some members completed but the group never finished (their
1078
+ // completion was suppressed waiting for the aggregate). Report the subset.
1079
+ const partialGroup = activeOlympians
1080
+ if (partialGroup !== null) {
1081
+ const done = [...partialGroup.agents].filter((a) => !partialGroup.pendingCompletions.has(a))
1082
+ if (partialGroup.detected && partialGroup.doneCount > 0 && done.length > 0) {
1083
+ fresh.push({
1084
+ text:
1085
+ done.length === 1
1086
+ ? `✅ ${done[0]} concluiu`
1087
+ : `✅ ${done.length} agentes concluídos (${done.sort().join(', ')})`,
1088
+ at: now,
1089
+ })
1090
+ }
1091
+ }
1092
+ activeOlympians = null
1093
+
1094
+ // Interrupted council counters must not block a later council's verdict —
1095
+ // but ONLY when no council specialist is still running. Synchronous
1096
+ // councils finish specialists at different times (nyx ~11s / prometheus
1097
+ // ~21s / themis ~38s): session.idle fires mid-flight, so resetting here
1098
+ // would drop the "✅ Veredito pronto" toast. Skip the reset while
1099
+ // pendingCouncilCalls is non-empty — onCouncilDone resets on the verdict.
1100
+ if (pendingCouncilCalls.size === 0) {
1101
+ councilDispatchCount = 0
1102
+ councilDoneCount = 0
1103
+ councilStartNotified = false
1104
+ }
1105
+
1106
+ // Collapse individual ✅ completions into one aggregate line.
1107
+ const doneAgents: string[] = []
1108
+ const others: string[] = []
1109
+ for (const r of fresh) {
1110
+ const m = /^✅ (.+?) concluiu$/.exec(r.text)
1111
+ if (m?.[1] !== undefined) doneAgents.push(m[1])
1112
+ else others.push(r.text)
1113
+ }
1114
+ const lines: string[] = []
1115
+ if (doneAgents.length > 0) {
1116
+ lines.push(
1117
+ doneAgents.length === 1
1118
+ ? `✅ ${doneAgents[0]} concluiu`
1119
+ : `✅ ${doneAgents.length} agentes concluídos (${doneAgents.sort().join(', ')})`,
1120
+ )
1121
+ }
1122
+ lines.push(...others)
1123
+ if (lines.length === 0) return
1124
+
1125
+ // ONE aggregated entry, fresh timestamp — chat.message delivers it next.
1126
+ enqueueChatReminder(lines.join('\n'))
1127
+ void reportFailure(
1128
+ ctx,
1129
+ `[pantheon-hooks:idle-flush] ${lines.join(' | ')}`,
1130
+ { script: 'idle-flush', count: fresh.length },
1131
+ 'info',
1132
+ )
1133
+ }
1134
+
1135
+ /**
1136
+ * Run one hook with the logging policy; NEVER throw out of the hook.
1137
+ *
1138
+ * Non-zero exit → structured log + hooks.log + one deduped TUI toast.
1139
+ * Zero exit → silent, unless PANTHEON_HOOKS_LOG is set for audit scripts
1140
+ * (echo routed to structured log + hooks.log, never the TUI).
1141
+ * No console.error / console.log anywhere in this path — the TUI stays clean.
1142
+ *
1143
+ * Returns the underlying HookResult (or null on unexpected failure) so the
1144
+ * tool.execute.before handler can inspect scan-secrets.sh's exit code for the
1145
+ * Fix-2 hybrid block (code 2 = high-confidence token → throw after logging).
1146
+ */
1147
+ async function safeRun(
1148
+ ctx: HookContext,
1149
+ script: string,
1150
+ payload: HookPayload,
1151
+ ): Promise<HookResult | null> {
85
1152
  try {
86
1153
  const result = await runHook(script, payload)
87
1154
  const tag = `[pantheon-hooks:${script}]`
1155
+ const extra = { script, code: result.code, timedOut: result.timedOut, tool: payload.tool_name }
88
1156
  if (result.code !== 0) {
89
- console.error(
90
- `${tag} exit ${result.code}${result.timedOut ? ' (timed out)' : ''}: ${trim(result.stderr || result.stdout || 'no output')}`,
91
- )
1157
+ // Fix 1: maskSecret() defense-in-depth — script stderr is normally
1158
+ // already masked by the .sh scripts, but the hook trail must never
1159
+ // echo a raw token even if a future script regresses.
1160
+ const message = `${tag} exit ${result.code}${result.timedOut ? ' (timed out)' : ''}: ${maskSecret(trim(result.stderr || result.stdout || 'no output'))}`
1161
+ await reportFailure(ctx, message, extra)
1162
+ const match = maskSecret(trim(result.stderr || result.stdout || 'no output', 120))
1163
+ const suffix = result.timedOut ? ' (timeout)' : ''
1164
+ void notifyToast(ctx, {
1165
+ title: 'Pantheon Hook',
1166
+ message: `⚠️ Hook ${script}: exit ${result.code}${suffix} — see log`,
1167
+ variant: 'error',
1168
+ duration: 4000,
1169
+ category: 'errors',
1170
+ dedupeKey: `${script}|${result.code}|${match}`,
1171
+ })
92
1172
  } else if (AUDIT_LOG_ENABLED && AUDIT_HOOKS.has(script) && result.stderr.trim()) {
93
1173
  // Opt-in only (PANTHEON_HOOKS_LOG=1): audit scripts echo their FILE
94
1174
  // writes here for debugging. Default is silence — the .sh scripts
95
- // already persist sessions.log / delegations.log on disk.
96
- console.log(`${tag} ${trim(result.stderr)}`)
1175
+ // already persist sessions.log / delegations.log on disk. Echo goes to
1176
+ // the structured log + hooks.log, never the TUI console.
1177
+ const message = `${tag} ${maskSecret(trim(result.stderr))}`
1178
+ await reportFailure(ctx, message, extra, 'info')
97
1179
  }
1180
+ return result
98
1181
  } catch (err) {
99
1182
  // runHook never throws, but the hook body must survive anything.
100
- console.error(
101
- `[pantheon-hooks:${script}] unexpected failure: ${err instanceof Error ? err.message : String(err)}`,
102
- )
1183
+ const message = `[pantheon-hooks:${script}] unexpected failure: ${maskSecret(err instanceof Error ? err.message : String(err))}`
1184
+ try {
1185
+ await ctx.client.app.log({
1186
+ body: {
1187
+ service: 'pantheon-hooks',
1188
+ level: 'error',
1189
+ message,
1190
+ extra: { script, tool: payload.tool_name },
1191
+ },
1192
+ })
1193
+ } catch {
1194
+ // Best-effort; never throw out of the hook.
1195
+ }
1196
+ try {
1197
+ await appendHookLog(ctx.directory, message)
1198
+ } catch {
1199
+ // Best-effort; never throw out of the hook.
1200
+ }
1201
+ return null
103
1202
  }
104
1203
  }
105
1204
 
106
- const plugin: Plugin = async () => {
1205
+ const plugin: Plugin = async ({ client, directory }) => {
1206
+ const ctx: HookContext = { client, directory }
107
1207
  return {
108
1208
  'tool.execute.before': async (input, output) => {
1209
+ // Fix 2 (2026-08-06): the ONLY deliberate throw in this plugin. Set
1210
+ // inside the try but thrown AFTER it — every log channel (safeRun's
1211
+ // reportFailure + the explicit block reportFailure below) is awaited
1212
+ // BEFORE the throw, and the throw is not swallowed by the hook's
1213
+ // catch: opencode propagates the rejection to fail the tool call.
1214
+ let blockError: Error | null = null
109
1215
  try {
110
- const payload = payloadFor(input, output.args ?? {})
111
- const hooks = [
112
- 'validate-talos-scope.sh',
113
- 'scan-secrets.sh',
114
- 'validate-tool-safety.sh',
115
- ]
1216
+ const hooks = ['validate-talos-scope.sh', 'scan-secrets.sh', 'validate-tool-safety.sh']
1217
+ // P0-2: the delegation record must exist BEFORE the payload is built
1218
+ // so the start hook receives the delegation_id the stop hook joins on.
1219
+ let payload = payloadFor(input, output?.args ?? {})
116
1220
  if (DELEGATION_TOOL_RE.test(input.tool)) {
117
1221
  hooks.push('on-subagent-delegation-start.sh')
1222
+ // tool.execute.before carries args on `output` (typed); `input.args`
1223
+ // also exists at runtime — read from whichever is present.
1224
+ const args = output?.args ?? (input as { args?: unknown }).args
1225
+ const start: DelegationStart = {
1226
+ delegation_id: crypto.randomUUID(),
1227
+ agent: delegationAgent(input.tool, args),
1228
+ startedAt: Date.now(),
1229
+ }
1230
+ delegationStarts.set(input.callID, start)
1231
+ pruneDelegationStarts(start.startedAt)
1232
+ // P0-1: agent_id + delegation_id ride on the payload — the stop
1233
+ // hook resolves its agent from the START record, never from
1234
+ // after-time args that may carry the merged background result.
1235
+ payload = payloadFor(input, args, {
1236
+ agent_id: start.agent,
1237
+ delegation_id: start.delegation_id,
1238
+ })
1239
+ if (isCouncilTask(input.tool, args)) {
1240
+ // Track by callID — the after-hook args do NOT repeat the
1241
+ // council description, so completion is matched via this set.
1242
+ pendingCouncilCalls.add(input.callID)
1243
+ onCouncilStart(ctx)
1244
+ }
1245
+ onDelegationStart(ctx, start.agent)
1246
+ }
1247
+ // P2 (2026-08-06): talos-guard is restricted to WRITE-INTENT operations
1248
+ // — edit/write/patch, or bash with a write operator (>, >>, mv, cp, rm,
1249
+ // sed -i, tee, git commit/push). Read-only find/grep must NEVER trigger
1250
+ // (they legitimately match RISKY_PATH_RE, e.g. grepping for 'auth').
1251
+ // Advisory only, never blocks, never throws.
1252
+ const guardArgs = (output?.args ?? (input as { args?: unknown }).args ?? {}) as Record<
1253
+ string,
1254
+ unknown
1255
+ >
1256
+ const rawCommand = guardArgs.command ?? guardArgs.cmd
1257
+ const bashCommand = typeof rawCommand === 'string' ? rawCommand : ''
1258
+ const isWriteIntent =
1259
+ WRITE_INTENT_TOOLS.has(input.tool) ||
1260
+ (input.tool === 'bash' && BASH_WRITE_INTENT_RE.test(bashCommand))
1261
+ if (isWriteIntent) {
1262
+ try {
1263
+ const values: string[] = []
1264
+ const scan = (v: unknown): void => {
1265
+ if (typeof v === 'string') values.push(v)
1266
+ else if (Array.isArray(v)) v.forEach(scan)
1267
+ else if (v !== null && typeof v === 'object') {
1268
+ for (const x of Object.values(v)) scan(x)
1269
+ }
1270
+ }
1271
+ scan(guardArgs)
1272
+ let hit: string | null = null
1273
+ for (const v of values) {
1274
+ if (RISKY_PATH_RE.test(v)) {
1275
+ hit = v
1276
+ break
1277
+ }
1278
+ }
1279
+ if (hit !== null) {
1280
+ // Fix 1: NEVER log the raw matched value — a real secret in a
1281
+ // write-intent arg (e.g. bash `old_token = "sk-bf-..."`) leaked
1282
+ // in plain text to hooks.log on 2026-08-06. maskSecret() keeps
1283
+ // only the tool name + masked path/arg context + signal count.
1284
+ void reportFailure(
1285
+ ctx,
1286
+ `[talos-guard] ⚠️ Operação em área sensível: ${maskSecret(hit.slice(0, 160))}`,
1287
+ { script: 'talos-guard', tool: input.tool, category: 'errors' },
1288
+ 'warn',
1289
+ )
1290
+ } else {
1291
+ const uniqueFiles = new Set(
1292
+ values.filter((v) => v.includes('/') && /(^|\/)[^/\s]+\.[a-z0-9]+$/i.test(v)),
1293
+ )
1294
+ if (uniqueFiles.size > 3) {
1295
+ void reportFailure(
1296
+ ctx,
1297
+ `[talos-guard] ⚠️ Operação multi-arquivo: ${uniqueFiles.size} arquivos`,
1298
+ {
1299
+ script: 'talos-guard',
1300
+ tool: input.tool,
1301
+ category: 'errors',
1302
+ count: uniqueFiles.size,
1303
+ },
1304
+ 'warn',
1305
+ )
1306
+ }
1307
+ }
1308
+ } catch {
1309
+ // Advisory only — a failure here must never take down the hook.
1310
+ }
1311
+ }
1312
+ // Fix 2 hybrid blocking: safeRun returns each script's HookResult;
1313
+ // scan-secrets.sh exits 2 on HIGH_CONFIDENCE token matches
1314
+ // (sk-bf-*, AKIA..., ghp_..., glpat-..., JWT, bearer...) → BLOCK the
1315
+ // tool call. Exit 1 (low-confidence header/KEY names) stays advisory
1316
+ // — safeRun already logged + toasted it, no block. The toast for
1317
+ // code 1/2 fires inside safeRun BEFORE we reach the throw, and the
1318
+ // reportFailure below is awaited first — log channels always write.
1319
+ const results = await Promise.all(
1320
+ hooks.map(async (script) => ({ script, result: await safeRun(ctx, script, payload) })),
1321
+ )
1322
+ const scan = results.find((r) => r.script === 'scan-secrets.sh')
1323
+ if (scan?.result?.code === 2) {
1324
+ await reportFailure(
1325
+ ctx,
1326
+ SECRET_BLOCK_MESSAGE,
1327
+ { script: 'scan-secrets', tool: input.tool, blocked: true, code: 2 },
1328
+ 'error',
1329
+ )
1330
+ blockError = new Error(SECRET_BLOCK_MESSAGE)
118
1331
  }
119
- await Promise.all(hooks.map((script) => safeRun(script, payload)))
120
1332
  } catch {
121
- // Never let a hook take down the opencode session.
1333
+ // Never let a hook take down the opencode session — except the
1334
+ // deliberate high-confidence block thrown below.
122
1335
  }
1336
+ if (blockError !== null) throw blockError
123
1337
  },
124
- 'tool.execute.after': async (input) => {
1338
+ 'tool.execute.after': async (input, output) => {
125
1339
  try {
126
- const payload = payloadFor(input, input.args ?? {})
1340
+ const args = (input as { args?: unknown }).args ?? {}
1341
+ // P1-1 fix: `output` is the REAL tool result ({title, output,
1342
+ // metadata}). P0-1/P0-2: the lifecycle fields (agent, delegation_id,
1343
+ // task_id, status, duration_ms) are derived from the before-record +
1344
+ // result — never from result text blobs.
1345
+ const result: ToolExecuteAfterResult | null = output ?? null
1346
+ // GAP C (2026-08-06): monitor command-runner exit codes. Signal used:
1347
+ // `metadata.exit` on the after-hook result — empirically verified
1348
+ // against the installed opencode 1.18.14 binary (the shell tool
1349
+ // returns {title, output, metadata: {output, exit, truncated}}). The
1350
+ // exit code is a real number; non-zero → individual error
1351
+ // notification (severity priority — never throttled/aggregated);
1352
+ // exit 0 → silent. Fallback for custom runners where `exit` is
1353
+ // absent: strong failure markers in the output trigger the same
1354
+ // notification (documented signal: marker match).
1355
+ if (input.tool === 'bash' && result !== null) {
1356
+ let exitCode: number | null = null
1357
+ if (result.metadata !== null && typeof result.metadata === 'object') {
1358
+ const m = result.metadata as Record<string, unknown>
1359
+ if (typeof m.exit === 'number') exitCode = m.exit
1360
+ }
1361
+ if (exitCode !== null && exitCode !== 0) {
1362
+ void bashFailedToast(ctx, exitCode, result)
1363
+ } else if (
1364
+ exitCode === null &&
1365
+ typeof result.output === 'string' &&
1366
+ RESULT_FAILURE_MARKERS.test(result.output)
1367
+ ) {
1368
+ void bashFailedToast(ctx, null, result)
1369
+ }
1370
+ }
1371
+ let payload = afterPayloadFor(input, args, result)
127
1372
  const hooks = ['format-multi-language.sh']
128
1373
  if (DELEGATION_TOOL_RE.test(input.tool)) {
129
1374
  hooks.push('on-subagent-delegation-stop.sh')
1375
+ const start = delegationStarts.get(input.callID)
1376
+ // P0-1: agent comes from the START record (before-time args are
1377
+ // clean); the after-hook args may hold the MERGED background result
1378
+ // {task_id, state} with no subagent_type — never derive from those.
1379
+ const agent = start?.agent ?? delegationAgent(input.tool, args)
1380
+ const resultStatus = deriveResultStatus(result)
1381
+ if (start !== undefined) {
1382
+ payload = afterPayloadFor(input, args, result, start)
1383
+ delegationStarts.delete(input.callID)
1384
+ }
1385
+ // Severity (c): agent failures notify individually — never
1386
+ // throttled, never aggregated. Success/completion aggregates.
1387
+ if (resultStatus === 'failure') {
1388
+ void agentFailedToast(ctx, agent, result)
1389
+ void handleOlympiansCompletion(ctx, agent)
1390
+ } else if (resultStatus === 'dispatched') {
1391
+ // P0-1: BACKGROUND launch — tool.execute.after fires at dispatch
1392
+ // (the task tool returns {task_id, state:'running'} immediately).
1393
+ // NOT a completion: do not mark the group done and do not fire ✅
1394
+ // — that would corrupt olympians aggregation. Real completion of
1395
+ // background tasks is unobservable on 1.18.13 (see header).
1396
+ } else {
1397
+ const handledByOlympians = handleOlympiansCompletion(ctx, agent)
1398
+ if (!handledByOlympians) void delegationDoneToast(ctx, agent)
1399
+ }
1400
+ if (pendingCouncilCalls.delete(input.callID)) onCouncilDone(ctx)
130
1401
  }
131
- await Promise.all(hooks.map((script) => safeRun(script, payload)))
1402
+ await Promise.all(hooks.map((script) => safeRun(ctx, script, payload)))
1403
+ } catch {
1404
+ // Never let a hook take down the opencode session.
1405
+ }
1406
+ },
1407
+ // P0-4 session→agent map: fires per assistant turn with the active agent
1408
+ // name — record it so tool.execute hooks can resolve agent_id for
1409
+ // validate-talos-scope.sh. 'title' (session-title turns) is ignored.
1410
+ // Bounded: prunes entries older than 30min once the map exceeds 50.
1411
+ 'chat.params': async (input) => {
1412
+ try {
1413
+ if (input.agent && input.agent !== 'title') {
1414
+ sessionAgent.set(input.sessionID, { agent: input.agent, at: Date.now() })
1415
+ if (sessionAgent.size > 50) {
1416
+ const cutoff = Date.now() - 30 * 60 * 1000
1417
+ for (const [k, v] of sessionAgent) {
1418
+ if (v.at < cutoff) sessionAgent.delete(k)
1419
+ }
1420
+ }
1421
+ }
1422
+ } catch {
1423
+ // Never let a hook take down the opencode session.
1424
+ }
1425
+ },
1426
+ // 1.18.13 fallback (Path C, oh-my-openagent pattern): the TUI drops
1427
+ // tui.toast.show events on opencode 1.18.13 (untagged — see header), so
1428
+ // queued agent-lifecycle signals (delegation start/done, hook failures,
1429
+ // olympians/council summaries) are injected here as ONE <system-reminder> text
1430
+ // part into the next user message, then cleared. chat.message fires only
1431
+ // for user-initiated messages (subagent prompts use a different path), so
1432
+ // this surfaces in the main session exactly when the user is watching.
1433
+ // session.idle flushes the buffer into ONE fresh aggregated entry first
1434
+ // (flushIdleReminders). Same PANTHEON_TOASTS gate as toasts: the buffer
1435
+ // only receives gated signals, and the empty-set check below is a
1436
+ // defensive backstop.
1437
+ 'chat.message': async (input, output) => {
1438
+ try {
1439
+ if (ENABLED_TOAST_CATEGORIES.size === 0) return
1440
+ if (pendingChatReminders.length === 0) return
1441
+ const now = Date.now()
1442
+ const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
1443
+ pendingChatReminders.length = 0
1444
+ if (fresh.length === 0) return
1445
+ const body = fresh.map((r) => r.text).join('\n')
1446
+ // Minimal TextPart — the core backfills messageID/sessionID for
1447
+ // hook-injected parts (see opencode chat.message trigger pipeline).
1448
+ output.parts.push({
1449
+ id: `prt_${crypto.randomUUID()}`,
1450
+ sessionID: input.sessionID,
1451
+ messageID: input.messageID ?? '',
1452
+ type: 'text',
1453
+ text: `<system-reminder>\n${body}\n</system-reminder>`,
1454
+ } satisfies Part)
1455
+ await reportFailure(
1456
+ ctx,
1457
+ `[pantheon-hooks:chat-reminder] ${body}`,
1458
+ { script: 'chat-reminder', count: fresh.length },
1459
+ 'info',
1460
+ )
132
1461
  } catch {
133
1462
  // Never let a hook take down the opencode session.
134
1463
  }
@@ -139,9 +1468,30 @@ const plugin: Plugin = async () => {
139
1468
  const payload: HookPayload = { session_id: ev.properties.info.id }
140
1469
  await Promise.all(
141
1470
  ['log-session-start.sh', 'validate-post-conditions.sh'].map((script) =>
142
- safeRun(script, payload),
1471
+ safeRun(ctx, script, payload),
143
1472
  ),
144
1473
  )
1474
+ } else if (ev.type === 'session.idle') {
1475
+ // Primary flush trigger for pending completion reminders (see header).
1476
+ flushIdleReminders(ctx)
1477
+ } else if (ev.type === 'session.error') {
1478
+ // Severity (c): any ❌/⚠️ event notifies individually, never
1479
+ // aggregated or throttled.
1480
+ const err: unknown = ev.properties.error
1481
+ const msg =
1482
+ typeof err === 'object' &&
1483
+ err !== null &&
1484
+ 'message' in err &&
1485
+ typeof (err as { message: unknown }).message === 'string'
1486
+ ? String((err as { message: string }).message)
1487
+ : 'session error'
1488
+ void notifyToast(ctx, {
1489
+ title: 'Pantheon',
1490
+ message: `❌ ${trim(msg, 120)}`,
1491
+ variant: 'error',
1492
+ duration: 5000,
1493
+ category: 'errors',
1494
+ })
145
1495
  }
146
1496
  } catch {
147
1497
  // Never let a hook take down the opencode session.