pantheon-opencode 1.5.0-beta.9 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/AGENTS.md +14 -63
  2. package/CHANGELOG.md +326 -0
  3. package/README.md +77 -29
  4. package/README.pt-BR.md +5 -6
  5. package/commands/pantheon.md +1 -35
  6. package/package.json +2 -3
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +3 -5
  9. package/scripts/doctor.mjs +260 -5
  10. package/scripts/install/plugin.mjs +9 -12
  11. package/scripts/mcp_persistence_server.py +1 -1
  12. package/scripts/sync-tui.mjs +8 -11
  13. package/src/agents/apollo.md +1 -0
  14. package/src/agents/gaia.md +1 -0
  15. package/src/agents/mnemosyne.md +11 -1
  16. package/src/agents/zeus.md +10 -52
  17. package/src/instructions/memory-protocol.instructions.md +6 -3
  18. package/src/instructions/zeus-anti-stall.instructions.md +2 -4
  19. package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
  20. package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
  21. package/src/mcp/mcp_persistence_server.py +1 -1
  22. package/src/mcp/memory_mcp_server.py +81 -27
  23. package/src/pantheon/background-job-board.ts +4 -4
  24. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  25. package/src/pantheon/cost-command.ts +1 -2
  26. package/src/pantheon/delegation-enforce.ts +59 -14
  27. package/src/pantheon/goal-loop.ts +1 -1
  28. package/src/pantheon/goal-store.ts +1 -1
  29. package/src/pantheon/idle-continuation.ts +4 -6
  30. package/src/pantheon/model-command.ts +1 -2
  31. package/src/pantheon/native-task-status.ts +2 -2
  32. package/src/pantheon/presets.d.mts +0 -20
  33. package/src/pantheon/presets.mjs +0 -64
  34. package/src/pantheon/session-guard.ts +2 -2
  35. package/src/pantheon/session-id.ts +30 -0
  36. package/src/pantheon/task-result-guard.ts +2 -3
  37. package/src/pantheon/todo-enforcer.ts +3 -3
  38. package/src/pantheon/tool-context.ts +23 -0
  39. package/src/pantheon/v2-bridge.ts +1 -4
  40. package/src/plugin-v2.ts +8 -21
  41. package/src/plugin.ts +61 -245
  42. package/src/plugins/pantheon-hooks.ts +2 -2
  43. package/src/plugins/tui/dist/tui.d.ts +229 -103
  44. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  45. package/src/plugins/tui/dist/tui.js +495 -333
  46. package/src/plugins/tui/package-lock.json +5 -2
  47. package/src/plugins/tui/package.json +5 -2
  48. package/src/plugins/tui/src/index.tsx +710 -409
  49. package/src/routing.yml +0 -16
  50. package/src/skills/context-compression/SKILL.md +1 -1
  51. package/scripts/generate-preset-docs.mjs +0 -300
  52. package/scripts/generate-prompts.sh +0 -222
  53. package/scripts/hash_verify.py +0 -192
  54. package/scripts/install/component.mjs +0 -24
  55. package/scripts/install/migrate-config.mjs +0 -302
  56. package/scripts/test-context-compression.sh +0 -169
  57. package/src/mcp/toon_codec.py +0 -491
  58. package/src/pantheon/auto-wake.ts +0 -85
  59. package/src/pantheon/chat-reminders.ts +0 -9
  60. package/src/pantheon/comment-checker.ts +0 -48
  61. package/src/pantheon/compaction-assert.ts +0 -160
  62. package/src/pantheon/compaction-handoff.ts +0 -304
  63. package/src/pantheon/cost-tracker.ts +0 -108
  64. package/src/pantheon/delegate-manager.ts +0 -766
  65. package/src/pantheon/delegation-classifier.ts +0 -307
  66. package/src/pantheon/delegation-finalize.ts +0 -406
  67. package/src/pantheon/delegation-notify.ts +0 -212
  68. package/src/pantheon/delegation-toast.ts +0 -73
  69. package/src/pantheon/delegation.ts +0 -1337
  70. package/src/pantheon/dispatch-guard.ts +0 -109
  71. package/src/pantheon/native-probe.ts +0 -234
  72. package/src/pantheon/permission-eval.ts +0 -284
  73. package/src/pantheon/preemptive-compact.ts +0 -76
  74. package/src/pantheon/provider-capabilities.ts +0 -221
  75. package/src/pantheon/provider-stream-tracker.ts +0 -182
  76. package/src/pantheon/retry-policy.ts +0 -212
  77. package/src/pantheon/step-cap.ts +0 -110
  78. package/src/pantheon/token-opt.ts +0 -689
  79. package/src/pantheon/toon-codec.ts +0 -481
  80. package/src/pantheon/v2-events.ts +0 -148
  81. package/src/pantheon/v2-hooks.ts +0 -287
  82. package/src/pantheon/v2-tool-definitions.ts +0 -331
  83. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
  84. package/src/plugins/tui/dist/tui.tsx +0 -2853
@@ -1,406 +0,0 @@
1
- /**
2
- * Delegation finalize — PULL output from a child session and persist the
3
- * delegation report (markdown) + the terminal board transition.
4
- *
5
- * Completion is NOT pushed: the event hook observes `session.idle` on the
6
- * child session, then calls `finalizeDelegation()` which pulls the child's
7
- * messages via `client.session.messages()`, writes the report atomically
8
- * (tmp + rename) to `.pantheon/delegations/<rootSessionID>/<alias>.md`, and
9
- * moves the job to a terminal board state (persist-before-notify: the report
10
- * file exists before `board.updateStatus` resolves waiters).
11
- *
12
- * Pure TypeScript — zero runtime dependencies beyond Node.js builtins and the
13
- * BackgroundJobBoard. The client is a STRUCTURAL interface (fake-client
14
- * friendly) so this module is testable standalone, without the opencode SDK.
15
- *
16
- * @module delegation-finalize
17
- */
18
-
19
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
20
- import { join } from 'node:path'
21
-
22
- import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
23
- import type { PermissionTaskConfig } from './permission-globs.ts'
24
- import type { StepCapTracker } from './step-cap.ts'
25
-
26
- // ─── Types ─────────────────────────────────────────────────────────────
27
-
28
- /** A minimal structural view of one message bundle from session.messages(). */
29
- export interface DelegationMessageBundle {
30
- info?: { role?: string; finish?: string; error?: unknown }
31
- parts?: Array<{
32
- type?: string
33
- text?: string
34
- /** Tool name on `tool` parts — used by the activity sampler. */
35
- tool?: string
36
- /** Tool input on `tool` parts (opencode SDK ToolPart.metadata). */
37
- metadata?: { input?: unknown }
38
- }>
39
- }
40
-
41
- /** Minimal structural view of the opencode SDK session client we use. */
42
- export interface DelegationClientSession {
43
- create(input: {
44
- body: { parentID: string; title?: string; model?: { id: string; providerID: string } }
45
- }): Promise<{ id: string }>
46
- promptAsync(input: {
47
- path: { id: string }
48
- body: {
49
- agent: string
50
- model?: { id: string; providerID: string }
51
- parts: Array<{ type: 'text'; text: string }>
52
- }
53
- /** Request cancellation for hosts that support AbortSignal. */
54
- signal?: AbortSignal
55
- }): Promise<unknown>
56
- messages?(input: { path: { id: string } }): Promise<Array<DelegationMessageBundle>>
57
- /** Optional session metadata endpoint (older hosts do not expose it). */
58
- get?(input: { path: { id: string } }): Promise<unknown>
59
- }
60
-
61
- /** The client surface used by the delegation core. */
62
- export interface DelegationClient {
63
- session: DelegationClientSession
64
- }
65
-
66
- /** Tunables for the delegation core (all optional — see DELEGATION_DEFAULTS). */
67
- export interface DelegationOptions {
68
- /** Job timeout before the delegation is finalized as error/timedOut. */
69
- timeoutMs?: number
70
- /** Caller-side cap for pantheon_delegation_read's waitForTerminal. */
71
- readTimeoutMs?: number
72
- /** Base directory for delegation reports (default `.pantheon/delegations`). */
73
- outputDir?: string
74
- /** Explicit root-session allowlist for the depth guard. */
75
- rootSessions?: ReadonlySet<string>
76
- /** Custom depth-guard predicate — overrides rootSessions and the default. */
77
- isRootSession?: (sessionID: string) => boolean
78
- /** SDK-backed child predicate; authoritative when supplied by the plugin. */
79
- isChildSession?: (sessionID: string) => boolean
80
- /**
81
- * Enforce the runtime B2 delegation matrix; createDelegationTools defaults
82
- * this to true. Set false only for an explicitly legacy host that cannot
83
- * provide ToolContext.agent.
84
- */
85
- enforceRuntimeMatrix?: boolean
86
- /** Record a child returned by the SDK session.create call. */
87
- registerChildSession?: (sessionID: string, parentID: string) => void
88
- /**
89
- * Agents whose delegated child sessions are registered as read-only
90
- * (Phase 4 enforcement). Mirrors routing.yml
91
- * background_delegation.read_only_agents. Matching is case-insensitive.
92
- */
93
- readOnlyAgents?: ReadonlySet<string>
94
- /**
95
- * Per-agent model overrides for delegated child sessions, keyed by agent
96
- * name (lowercase, e.g. `apollo`). Values are opencode model IDs in
97
- * `provider/model` form (e.g. `opencode/deepseek-v4-flash-free`), sourced
98
- * from routing.yml agent entries. Higher priority than the active preset.
99
- */
100
- agentModels?: Readonly<Record<string, string>>
101
- /**
102
- * Fase B3: Per-exception delegation budgets. Keys use
103
- * `<parent-agent>-><target-agent>` (for example `athena->apollo`). The
104
- * count is tracked per parent session for this process. It is intentionally
105
- * not persisted across restart because this layer has no reliable store.
106
- * An absent key has no budget limit.
107
- */
108
- delegationBudgets?: ReadonlyMap<string, number>
109
- /**
110
- * Fase B3: Wall-clock timeout (ms) for individual delegation sessions.
111
- * The plugin resolves `PANTHEON_DELEGATION_TIMEOUT_MS` into this option;
112
- * direct callers may provide it here instead.
113
- */
114
- wallClockTimeoutMs?: number
115
- /**
116
- * Env override for the active-preset resolution (see resolveActivePreset).
117
- * Defaults to process.env. Primarily for tests.
118
- */
119
- presetEnv?: Record<string, string | undefined>
120
- /** Warning sink for the no-model fallback (defaults to console). */
121
- logger?: { warn?: (msg: string) => void }
122
- /**
123
- * R4: per-agent step-cap tracker (routing.yml `agents.<name>.max_steps`).
124
- * When an agent reaches max_steps the delegation is forced to
125
- * summarize-and-stop: skipped with a capped summary when already at the
126
- * cap, or the prompt is appended with a stop instruction when the cap is
127
- * hit by this dispatch.
128
- */
129
- stepCapTracker?: StepCapTracker
130
- /**
131
- * O5: permission.task glob rules controlling which subagents may be
132
- * invoked. Denied agents are removed from the delegate tool description
133
- * entirely (not just blocked at call time).
134
- */
135
- permissionTask?: PermissionTaskConfig
136
- /**
137
- * O5: candidate agent names for the delegate tool description. Defaults
138
- * to the canonical 14-agent roster.
139
- */
140
- agentNames?: readonly string[]
141
- /** Bounded post-prompt bootstrap watchdog window. */
142
- bootstrapTimeoutMs?: number
143
- /** Timeout for the promptAsync request itself (including AbortSignal). */
144
- promptTimeoutMs?: number
145
- /** Poll interval used by the bootstrap watchdog. */
146
- bootstrapPollIntervalMs?: number
147
- /**
148
- * Max completed jobs to keep per agent after pruning (default: 10).
149
- * Matches the native delegate-manager default.
150
- */
151
- keepCompleted?: number
152
- /**
153
- * Hard cap on total board entries (default: 50).
154
- * Matches the native delegate-manager default.
155
- */
156
- maxEntries?: number
157
- /** Injectable clock/sleeper for deterministic watchdog tests. */
158
- now?: () => number
159
- sleep?: (ms: number) => Promise<void>
160
- /**
161
- * Kill-switch: when false, all delegation tools (delegate/read/list) throw
162
- * immediately. Mirrors `PANTHEON_DELEGATION=off` from the native manager.
163
- * Defaults to true (delegation enabled) when not specified.
164
- */
165
- delegationEnabled?: boolean
166
- }
167
-
168
- /** Dependencies threaded to the finalize path. */
169
- export interface DelegationDeps {
170
- board: BackgroundJobBoard
171
- client: DelegationClient
172
- options: DelegationOptions
173
- }
174
-
175
- /** Terminal transition requested by the finalize path. */
176
- export interface FinalizeInput {
177
- state: 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
178
- error?: string
179
- timedOut?: boolean
180
- }
181
-
182
- /** Defaults matching routing.yml background_delegation (timeout_ms 900000). */
183
- export const DELEGATION_DEFAULTS = {
184
- timeoutMs: 900_000,
185
- readTimeoutMs: 900_000,
186
- outputDir: '.pantheon/delegations',
187
- } as const
188
-
189
- // ─── Output pulling ────────────────────────────────────────────────────
190
-
191
- interface PulledOutput {
192
- text: string
193
- /** True only when the child produced an assistant message or tool activity. */
194
- hasWork: boolean
195
- }
196
-
197
- /** Pull visible text and structurally identify real child work. */
198
- async function pullOutput(client: DelegationClient, childSessionID: string): Promise<PulledOutput> {
199
- if (typeof client.session.messages !== 'function')
200
- return { text: '(child session messages API unavailable)', hasWork: false }
201
- try {
202
- const bundles = await client.session.messages({ path: { id: childSessionID } })
203
- const lines: string[] = []
204
- let hasWork = false
205
- for (const bundle of bundles) {
206
- const hasToolPart = (bundle.parts ?? []).some((part) => part.type === 'tool')
207
- const hasTextPart = (bundle.parts ?? []).some(
208
- (part) => part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '',
209
- )
210
- if (
211
- bundle.info?.role === 'assistant' &&
212
- bundle.info.error === undefined &&
213
- (hasToolPart || hasTextPart)
214
- ) {
215
- hasWork = true
216
- }
217
- for (const part of bundle.parts ?? []) {
218
- if (
219
- part.type === 'tool' &&
220
- bundle.info?.role === 'assistant' &&
221
- bundle.info.error === undefined
222
- )
223
- hasWork = true
224
- if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
225
- lines.push(part.text)
226
- }
227
- }
228
- }
229
- return { text: lines.join('\n\n'), hasWork }
230
- } catch (err: unknown) {
231
- const reason = err instanceof Error ? err.message : String(err)
232
- return { text: `(failed to pull child session output: ${reason})`, hasWork: false }
233
- }
234
- }
235
-
236
- // ─── Markdown rendering ────────────────────────────────────────────────
237
-
238
- /** Render the delegation report body. Public for tests/Phase 3 reuse. */
239
- export function renderDelegationMarkdown(
240
- job: BackgroundJobRecord,
241
- output: string,
242
- opts: FinalizeInput & { at?: number },
243
- ): string {
244
- const at = opts.at ?? Date.now()
245
- const lines = [
246
- `# Delegation Report — ${job.alias}`,
247
- '',
248
- `- **Task ID**: \`${job.taskID}\``,
249
- `- **Agent**: ${job.agent}`,
250
- `- **Description**: ${job.description}`,
251
- `- **State**: ${opts.state}`,
252
- `- **Timed out**: ${opts.timedOut === true ? 'true' : 'false'}`,
253
- `- **Started**: ${new Date(job.launchedAt).toISOString()}`,
254
- `- **Finalized**: ${new Date(at).toISOString()}`,
255
- '',
256
- '## Output',
257
- '',
258
- output.trim() !== '' ? output : '_No output captured._',
259
- ]
260
- if (opts.error !== undefined) {
261
- lines.push('', `**Error**: ${opts.error}`)
262
- }
263
- if (opts.timedOut === true) {
264
- lines.push('', '[TIMEOUT REACHED]')
265
- }
266
- return `${lines.join('\n')}\n`
267
- }
268
-
269
- /** One-line result summary from the pulled output (for the board record). */
270
- function summarizeOutput(output: string): string {
271
- const firstLine = output
272
- .split('\n')
273
- .map((l) => l.trim())
274
- .find((l) => l !== '')
275
- if (firstLine === undefined) return 'No output captured'
276
- return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine
277
- }
278
-
279
- // ─── Report persistence ────────────────────────────────────────────────
280
-
281
- /**
282
- * Reject session IDs that could escape the delegation output directory.
283
- * `parentSessionID` is embedded in the report path
284
- * `<outputDir>/<parentSessionID>/<alias>.md` — `path.join` does NOT strip
285
- * `..`, so a traversing ID would write outside the sandbox. Called BEFORE any
286
- * path construction (F1 — path traversal hardening).
287
- *
288
- * @throws {Error} When the ID contains `..`, `/`, or `\`.
289
- */
290
- export function assertSafeParentSessionID(parentSessionID: string): void {
291
- if (
292
- parentSessionID.includes('..') ||
293
- parentSessionID.includes('/') ||
294
- parentSessionID.includes('\\')
295
- ) {
296
- throw new Error(`Invalid parentSessionID: ${parentSessionID}`)
297
- }
298
- }
299
-
300
- /** Atomic write (tmp + rename) of the report under <outputDir>/<parent>/<alias>.md. */
301
- async function writeDelegationReport(
302
- outputDir: string,
303
- job: BackgroundJobRecord,
304
- md: string,
305
- ): Promise<string> {
306
- assertSafeParentSessionID(job.parentSessionID)
307
- const dir = join(outputDir, job.parentSessionID)
308
- const filePath = join(dir, `${job.alias}.md`)
309
- const tmpPath = join(dir, `${job.alias}.md.tmp`)
310
- await mkdir(dir, { recursive: true })
311
- await writeFile(tmpPath, md, 'utf-8')
312
- await rename(tmpPath, filePath)
313
- return filePath
314
- }
315
-
316
- /** Read a previously written report; undefined when the file does not exist. */
317
- export async function readDelegationReport(
318
- outputDir: string,
319
- job: BackgroundJobRecord,
320
- ): Promise<string | undefined> {
321
- assertSafeParentSessionID(job.parentSessionID)
322
- const filePath = join(outputDir, job.parentSessionID, `${job.alias}.md`)
323
- try {
324
- return await readFile(filePath, 'utf-8')
325
- } catch (err: unknown) {
326
- if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
327
- return undefined
328
- }
329
- throw err
330
- }
331
- }
332
-
333
- // ─── Finalize ──────────────────────────────────────────────────────────
334
-
335
- /**
336
- * Finalize a delegation for a child session: pull output, write the report
337
- * atomically, then move the board job to the requested terminal state.
338
- *
339
- * The board transition is guarded: only `running` → terminal, or an
340
- * idempotent same-terminal re-notify, are applied. A finalize that races a
341
- * timeout (already `error`) never overwrites the terminal state — the report
342
- * file is still (re)written, so the marker reflects the latest observation.
343
- *
344
- * @returns The updated job record, or undefined when the session is unknown.
345
- */
346
- export async function finalizeDelegation(
347
- deps: DelegationDeps,
348
- childSessionID: string,
349
- opts: FinalizeInput,
350
- ): Promise<BackgroundJobRecord | undefined> {
351
- const job = deps.board.get(childSessionID)
352
- if (!job) return undefined
353
-
354
- const pulled = await pullOutput(deps.client, childSessionID)
355
- const output = pulled.text
356
- const effectiveOpts: FinalizeInput =
357
- opts.state === 'completed' && !pulled.hasWork
358
- ? {
359
- state: 'error',
360
- error: opts.error ?? 'Child session produced no assistant or tool output',
361
- ...(opts.timedOut !== undefined ? { timedOut: opts.timedOut } : {}),
362
- }
363
- : opts
364
- const outputDir = deps.options.outputDir ?? DELEGATION_DEFAULTS.outputDir
365
- await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, effectiveOpts))
366
-
367
- const status: {
368
- taskID: string
369
- state: 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
370
- error?: string
371
- timedOut?: boolean
372
- resultSummary?: string
373
- } = {
374
- taskID: childSessionID,
375
- state: effectiveOpts.state,
376
- resultSummary: summarizeOutput(output),
377
- }
378
- if (effectiveOpts.error !== undefined) status.error = effectiveOpts.error
379
- if (effectiveOpts.timedOut !== undefined) status.timedOut = effectiveOpts.timedOut
380
-
381
- const current = deps.board.get(childSessionID)
382
- if (current !== undefined) {
383
- if (current.state === 'running' || current.state === effectiveOpts.state) {
384
- await deps.board.updateStatus(status)
385
- }
386
- // Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
387
- // report above still captures this finalize; the board state stands.
388
- }
389
-
390
- // P1-1: Delete the auto-wake signal file to prevent leaks. The board
391
- // writes .signal.json on every terminal transition, but the legacy path
392
- // never called deleteSignal — only the native delegate-manager did.
393
- // The consumer (auto-wake.ts consumeWakeSignals) is intentionally NOT
394
- // wired in the event hook: finalizeDelegation already handles the
395
- // terminal transition, so consuming signals would be redundant and risks
396
- // double-processing. deleteSignal on finalize is sufficient.
397
- await deps.board.deleteSignal(job.alias)
398
-
399
- // P1-4: Prune old completed jobs and enforce the hard entry cap. The
400
- // native delegate-manager called these in settle(), but the legacy
401
- // finalize path omitted them, allowing unbounded board growth.
402
- await deps.board.pruneCompleted(deps.options.keepCompleted ?? 10)
403
- await deps.board.enforceEntryCap(deps.options.maxEntries ?? 50)
404
-
405
- return deps.board.get(childSessionID) ?? job
406
- }
@@ -1,212 +0,0 @@
1
- /**
2
- * Delegation event wiring — observe background-job lifecycle events and
3
- * route them to finalize. This module is the `event`-hook complement of the
4
- * board: `session.idle`/`session.error` on a child session that is a board
5
- * job → `finalizeDelegation` (Phase 2), which transitions the board and
6
- * fires `onTerminal`.
7
- *
8
- * NOT a notification channel. The user policy is ZERO delegation
9
- * notifications in the chat transcript — no `<task-notification>` block is
10
- * ever injected into a `chat.message` output, and no queued delivery path
11
- * exists. Completion visibility lives in the legitimate channels only:
12
- * the board `[unread]` marker (`pantheon_delegation_list`),
13
- * `pantheon_delegation_read`, TUI toasts (pantheon-hooks, PANTHEON_TOASTS
14
- * gate), and compaction carry-forward. The plugin's `onTerminal` listener
15
- * writes a file-only log line for the on-disk audit trail.
16
- *
17
- * `handleDelegationEvent` wires the `event` hook: `session.idle`/`session.error`
18
- * on a child session that is a board job → `finalizeDelegation` (Phase 2),
19
- * which transitions the board and fires `onTerminal`. Finalize never notifies
20
- * directly, and `markReconciled` does not re-fire onTerminal (reconcile is an
21
- * acknowledgment, not a completion), so every job gets exactly ONE terminal
22
- * transition — never a reconciled echo. Unknown sessions are ignored.
23
- *
24
- * `finalizeIdleChildrenWithoutMd` proactively finalizes children confirmed
25
- * idle by the V1 session-status API but with no MD report on disk — eliminates
26
- * the phantom "running" window during `pullOutput` without treating a board
27
- * record's `running` state as proof that the child is idle. Wired into a
28
- * periodic scan via `startIdleChildScan` (default 30 s cadence).
29
- *
30
- * Pure TypeScript — zero runtime dependencies beyond the board types.
31
- *
32
- * Follow-up (Themis review, Phase 5): `extractErrorMessage` handles the two
33
- * observed `session.error` error shapes (string, { message }). The opencode SDK
34
- * MAY expose other shapes (e.g. Error-like with non-string `message`) — if a
35
- * real session.error ever carries one, the extraction degrades to the generic
36
- * "session error" label. No code change: there is no evidence any other shape
37
- * occurs in practice; revisit if a session.error shows an unexpected payload.
38
- *
39
- * @module delegation-notify
40
- */
41
-
42
- import type { BackgroundJobRecord } from './background-job-board.ts'
43
- import type { FinalizeInput } from './delegation-finalize.ts'
44
-
45
- export type { FinalizeInput } from './delegation-finalize.ts'
46
-
47
- /** Default cadence for the proactive idle-child scan (30 s). */
48
- export const IDLE_SCAN_INTERVAL_MS = 30_000
49
-
50
- // ─── Types ─────────────────────────────────────────────────────────────
51
-
52
- /** Structural view of the event payload the `event` hook receives. */
53
- export interface DelegationEventLike {
54
- type?: string
55
- properties?: Record<string, unknown>
56
- }
57
-
58
- /** Dependencies threaded to handleDelegationEvent (board lookup + finalize). */
59
- export interface DelegationEventDeps {
60
- /** Look up a board job by session ID (child session IDs are task IDs). */
61
- board: { get(taskID: string): BackgroundJobRecord | undefined }
62
- /** Bound Phase 2 finalize — clears the timeout timer + writes the report. */
63
- finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
64
- }
65
-
66
- // ─── Event → finalize wiring ───────────────────────────────────────────
67
-
68
- function extractErrorMessage(error: unknown): string {
69
- if (typeof error === 'string' && error.trim() !== '') return error
70
- if (error && typeof error === 'object' && 'message' in error) {
71
- const message = (error as { message: unknown }).message
72
- if (typeof message === 'string' && message.trim() !== '') return message
73
- }
74
- return 'session error'
75
- }
76
-
77
- /**
78
- * Handle a lifecycle event for background delegations:
79
- * - `session.idle` on a board job → finalize as completed;
80
- * - `session.error` on a board job → finalize as error (message extracted);
81
- * - any other event, a missing session ID, or an unknown session → no-op.
82
- *
83
- * Finalize transitions the board, which fires `onTerminal` (the plugin's
84
- * file-only audit log — no chat delivery; see module header). The board's
85
- * same-terminal idempotency makes repeated idles harmless (finalize rewrites
86
- * the report but never transitions a terminal job to a different state).
87
- *
88
- * @returns True when the event named a board job (handled).
89
- */
90
- export async function handleDelegationEvent(
91
- ev: DelegationEventLike,
92
- deps: DelegationEventDeps,
93
- ): Promise<boolean> {
94
- const type = ev?.type
95
- if (type !== 'session.idle' && type !== 'session.error') return false
96
- const sessionID = ev.properties?.sessionID
97
- if (typeof sessionID !== 'string' || sessionID === '') return false
98
- if (deps.board.get(sessionID) === undefined) return false
99
-
100
- if (type === 'session.idle') {
101
- await deps.finalize(sessionID, { state: 'completed' })
102
- } else {
103
- await deps.finalize(sessionID, {
104
- state: 'error',
105
- error: extractErrorMessage(ev.properties?.error),
106
- })
107
- }
108
- return true
109
- }
110
-
111
- // ─── Proactive finalize (Fix 2) ────────────────────────────────────────
112
-
113
- /** Dependencies for idle-child scanning. */
114
- export interface IdleChildScanDeps {
115
- /** Look up a board job by session ID (child session IDs are task IDs). */
116
- board: {
117
- get(taskID: string): BackgroundJobRecord | undefined
118
- list(parentSessionID?: string): BackgroundJobRecord[]
119
- }
120
- /** Bound Phase 2 finalize — clears the timeout timer + writes the report. */
121
- finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
122
- /**
123
- * Confirm the child's native V1 session status. `undefined` means the
124
- * status is unavailable and MUST NOT be treated as idle.
125
- */
126
- isIdle?: (childSessionID: string) => Promise<boolean | undefined>
127
- /**
128
- * Check whether an MD report exists for the given board job.
129
- * Receives the full job record so the caller can construct the report
130
- * path from `job.parentSessionID` + `job.alias`.
131
- * If the function throws, the scan skips that child (fail-open).
132
- */
133
- hasReport: (job: BackgroundJobRecord) => boolean
134
- /** Optional logger for diagnostic messages. */
135
- logger?: { info?: (msg: string) => void; warn?: (msg: string) => void }
136
- }
137
-
138
- /**
139
- * Scan running board jobs for children whose V1 session status is confirmed
140
- * idle but have no MD report on disk, and proactively finalize them. This
141
- * eliminates the phantom "running" window that occurs between `session.idle`
142
- * firing and the `pullOutput`→`writeDelegationReport` cycle completing.
143
- *
144
- * Called from the periodic idle-child scan (`startIdleChildScan`, default 30 s).
145
- * The scan is O(N) where N = number of running jobs (typically < 10).
146
- *
147
- * A missing status probe is an explicit unknown state, not an idle signal, so
148
- * legacy embedders without the V1 API fail closed. Errors are caught
149
- * per-child so one failure never blocks the rest.
150
- *
151
- * @returns Number of children finalized (for diagnostics).
152
- */
153
- export async function finalizeIdleChildrenWithoutMd(deps: IdleChildScanDeps): Promise<number> {
154
- const runningJobs = deps.board.list().filter((j: BackgroundJobRecord) => j.state === 'running')
155
-
156
- let finalized = 0
157
- for (const job of runningJobs) {
158
- try {
159
- if (deps.isIdle === undefined) {
160
- deps.logger?.warn?.(
161
- `[delegation-notify] idle status unknown for ${job.taskID}; proactive finalize skipped`,
162
- )
163
- continue
164
- }
165
- const idle = await deps.isIdle(job.taskID)
166
- if (idle !== true) {
167
- if (idle === undefined) {
168
- deps.logger?.warn?.(
169
- `[delegation-notify] idle status unknown for ${job.taskID}; proactive finalize skipped`,
170
- )
171
- }
172
- continue
173
- }
174
- // If the child already has an MD report, finalize was already handled
175
- // by handleDelegationEvent — skip.
176
- if (deps.hasReport(job)) continue
177
- // Child is idle on the board but has no report — proactively finalize.
178
- await deps.finalize(job.taskID, { state: 'completed' })
179
- finalized++
180
- } catch (err) {
181
- deps.logger?.warn?.(`[delegation-notify] proactive finalize failed for ${job.taskID}: ${err}`)
182
- }
183
- }
184
- return finalized
185
- }
186
-
187
- // ─── Periodic scan launcher ─────────────────────────────────────────────
188
-
189
- /**
190
- * Start a periodic scan that proactively finalizes idle board children
191
- * without MD reports. The scan runs every `intervalMs` (default 30 s) and
192
- * is **unref'd** so it never holds the process open on its own.
193
- *
194
- * The timer is idempotent — calling this function multiple times is safe
195
- * (each call creates an independent timer, but the underlying finalize
196
- * operation is itself idempotent due to the board's same-terminal guard).
197
- *
198
- * @returns A `NodeJS.Timeout` handle that can be cleared with
199
- * `clearInterval(handle)` to stop the scan.
200
- */
201
- export function startIdleChildScan(
202
- deps: IdleChildScanDeps,
203
- intervalMs: number = IDLE_SCAN_INTERVAL_MS,
204
- ): NodeJS.Timeout {
205
- const timer = setInterval(() => {
206
- void finalizeIdleChildrenWithoutMd(deps).catch((err) => {
207
- deps.logger?.warn?.(`[delegation-notify] periodic idle scan failed: ${err}`)
208
- })
209
- }, intervalMs)
210
- timer.unref()
211
- return timer
212
- }
@@ -1,73 +0,0 @@
1
- import type { BackgroundJobRecord } from './background-job-board.ts'
2
-
3
- type ToastCategory = 'errors' | 'delegations' | 'council'
4
-
5
- const toastMode = (process.env.PANTHEON_TOASTS ?? '').trim().toLowerCase()
6
- const enabledToastCategories: ReadonlySet<ToastCategory> = (() => {
7
- switch (toastMode) {
8
- case 'off':
9
- return new Set()
10
- case 'errors':
11
- return new Set(['errors'])
12
- case 'council':
13
- return new Set(['errors', 'council'])
14
- case 'all':
15
- return new Set(['errors', 'delegations', 'council'])
16
- default:
17
- return new Set(['errors', 'delegations', 'council'])
18
- }
19
- })()
20
- const terminalToastsShown = new Set<string>()
21
- const terminalStates = new Set(['completed', 'error', 'cancelled'])
22
-
23
- /** Match the lifecycle toast gate used by pantheon-hooks. */
24
- export function delegationToastEnabled(): boolean {
25
- return enabledToastCategories.has('delegations')
26
- }
27
-
28
- export type ToastClient = {
29
- tui?: {
30
- showToast?: (input: {
31
- body: {
32
- title?: string
33
- message: string
34
- variant: 'info' | 'success' | 'warning' | 'error'
35
- duration: number
36
- }
37
- }) => Promise<unknown>
38
- }
39
- }
40
-
41
- /** Best-effort visual completion signal; never affects board finalization. */
42
- export async function showDelegationTerminalToast(
43
- client: ToastClient,
44
- job: Pick<BackgroundJobRecord, 'taskID' | 'alias' | 'agent' | 'state' | 'timedOut'>,
45
- ): Promise<void> {
46
- try {
47
- if (!delegationToastEnabled()) return
48
- const isTerminal = job.timedOut || terminalStates.has(job.state)
49
- if (isTerminal) {
50
- if (terminalToastsShown.has(job.taskID)) return
51
- terminalToastsShown.add(job.taskID)
52
- }
53
- const showToast = client.tui?.showToast
54
- if (typeof showToast !== 'function') return
55
- const state = job.timedOut ? 'timeout' : job.state
56
- const variant =
57
- state === 'completed'
58
- ? 'success'
59
- : state === 'error' || state === 'timeout'
60
- ? 'error'
61
- : 'warning'
62
- await showToast({
63
- body: {
64
- title: 'Delegação concluída',
65
- message: `${job.alias} (${job.agent}) — ${state}`,
66
- variant,
67
- duration: 4000,
68
- },
69
- })
70
- } catch {
71
- // TUI is optional (and may disappear during shutdown); completion is durable.
72
- }
73
- }