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.
- package/AGENTS.md +1 -0
- package/README.md +43 -4
- package/opencode.json +16 -0
- package/package.json +1 -1
- package/scripts/hooks/log-session-start.sh +19 -1
- package/scripts/hooks/on-subagent-delegation-start.sh +116 -6
- package/scripts/hooks/on-subagent-delegation-stop.sh +391 -9
- package/scripts/hooks/scan-secrets.sh +61 -19
- package/scripts/hooks/validate-talos-scope.sh +7 -0
- package/src/agents/zeus.md +33 -5
- package/src/plugins/pantheon-hooks.ts +1391 -41
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/dist/tui.tsx +1 -1
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +1 -1
|
@@ -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})
|
|
24
|
-
*
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
43
|
-
import {
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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 —
|
|
78
|
-
|
|
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
|
-
/**
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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.
|