pantheon-opencode 1.3.3 → 1.3.4

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 (47) hide show
  1. package/README.md +115 -12
  2. package/bin/pantheon-init.mjs +160 -120
  3. package/opencode.json +0 -4
  4. package/package.json +7 -4
  5. package/scripts/doctor.mjs +56 -22
  6. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  7. package/scripts/install/opencode.mjs +108 -26
  8. package/scripts/install/plugin.mjs +112 -28
  9. package/scripts/install-mcp.mjs +1 -1
  10. package/scripts/redaction-gate.mjs +69 -0
  11. package/scripts/release-notes.mjs +317 -0
  12. package/scripts/test-context-compression.sh +5 -2
  13. package/scripts/validate-package.mjs +56 -0
  14. package/scripts/versioning.mjs +117 -18
  15. package/src/agents/zeus.md +6 -2
  16. package/src/pantheon/background-job-board.ts +24 -38
  17. package/src/pantheon/chat-reminders.ts +79 -0
  18. package/src/pantheon/compaction-assert.ts +160 -0
  19. package/src/pantheon/delegation-compaction.ts +143 -16
  20. package/src/pantheon/delegation-finalize.ts +8 -1
  21. package/src/pantheon/delegation-notify.ts +19 -157
  22. package/src/pantheon/delegation.ts +330 -28
  23. package/src/pantheon/dispatch-guard.ts +9 -4
  24. package/src/pantheon/goal-loop.ts +7 -4
  25. package/src/pantheon/hashline/tool.ts +2 -1
  26. package/src/pantheon/logger.ts +117 -0
  27. package/src/pantheon/plugin-once.ts +76 -0
  28. package/src/pantheon/preemptive-compact.ts +76 -0
  29. package/src/pantheon/presets.d.mts +31 -0
  30. package/src/pantheon/presets.mjs +105 -3
  31. package/src/pantheon/todo-enforcer.ts +79 -27
  32. package/src/pantheon/todo-preserve.ts +295 -0
  33. package/src/plugin.ts +177 -50
  34. package/src/plugins/pantheon-hooks.ts +35 -22
  35. package/src/plugins/tui/dist/server.d.ts +17 -0
  36. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  37. package/src/plugins/tui/dist/server.js +20 -0
  38. package/src/plugins/tui/dist/server.js.map +1 -0
  39. package/src/plugins/tui/dist/tui.d.ts +303 -1
  40. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  41. package/src/plugins/tui/dist/tui.js +977 -350
  42. package/src/plugins/tui/dist/tui.tsx +1427 -150
  43. package/src/plugins/tui/package.json +4 -3
  44. package/src/plugins/tui/src/index.tsx +1427 -150
  45. package/src/plugins/tui/src/server.ts +15 -0
  46. package/src/plugins/tui/tsdown.config.ts +1 -1
  47. package/src/routing.yml +3 -1
@@ -1,26 +1,25 @@
1
1
  /**
2
- * Delegation Notify (Phase 3) — completion notifications for background jobs.
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`.
3
7
  *
4
- * The spike refuted `promptAsync({noReply:true})` as a push mechanism — nothing
5
- * is delivered to the parent session. The working channel is a QUEUE + flush:
6
- *
7
- * 1. When a board job reaches a terminal state (`board.onTerminal`), the
8
- * plugin calls `notifyParent(job)` → a `<task-notification>` block is
9
- * queued in-memory for `job.parentSessionID` (FIFO, bounded per parent).
10
- * 2. The next `chat.message` hook fire for that parent session calls
11
- * `flushQueue(parentSessionID, output)`, which prepends the pending
12
- * notifications onto the first text part of the parent's user message —
13
- * the same graceful-degradation channel pantheon-hooks uses for its
14
- * <system-reminder> fallback buffer (TUI toasts are broken on 1.18.13).
15
- *
16
- * If the parent never sends another message the notification stays queued —
17
- * the graceful-degradation path. There is deliberately NO client push.
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.
18
16
  *
19
17
  * `handleDelegationEvent` wires the `event` hook: `session.idle`/`session.error`
20
18
  * on a child session that is a board job → `finalizeDelegation` (Phase 2),
21
- * which transitions the board and fires `onTerminal` → notify. That makes the
22
- * onTerminal listener the SINGLE notification point — finalize never notifies
23
- * directly, so there is no double-notify. Unknown sessions are ignored.
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.
24
23
  *
25
24
  * Pure TypeScript — zero runtime dependencies beyond the board types.
26
25
  *
@@ -41,12 +40,6 @@ export type { FinalizeInput } from './delegation-finalize.ts'
41
40
 
42
41
  // ─── Types ─────────────────────────────────────────────────────────────
43
42
 
44
- /** One queued notification: text + enqueue timestamp. */
45
- export interface QueuedNotification {
46
- text: string
47
- at: number
48
- }
49
-
50
43
  /** Structural view of the event payload the `event` hook receives. */
51
44
  export interface DelegationEventLike {
52
45
  type?: string
@@ -61,137 +54,6 @@ export interface DelegationEventDeps {
61
54
  finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
62
55
  }
63
56
 
64
- // ─── Notification text ─────────────────────────────────────────────────
65
-
66
- /** Cap for description/summary text embedded in a notification. */
67
- const NOTIFICATION_TEXT_MAX = 120
68
-
69
- function truncate(text: string, max: number): string {
70
- const trimmed = text.trim()
71
- return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed
72
- }
73
-
74
- /**
75
- * Build the self-contained `<task-notification>` block for a terminal job.
76
- * Carries the task ID, alias, agent, state, timeout flag, description and
77
- * result summary so the parent model can act on the completion without extra
78
- * tool calls.
79
- */
80
- export function buildTaskNotification(job: BackgroundJobRecord): string {
81
- const stateLabel =
82
- job.state === 'completed' ? 'completed' : job.state === 'error' ? 'failed' : job.state
83
- const timeout = job.timedOut ? ' (timed out)' : ''
84
- // The `Result:` marker makes the child output origin explicit in the parent
85
- // context — the summary text came from the CHILD's output, not the parent's
86
- // own context (Themis mitigation, Phase 5).
87
- const summary = job.resultSummary
88
- ? ` — Result: ${truncate(job.resultSummary, NOTIFICATION_TEXT_MAX)}`
89
- : ''
90
- return [
91
- '<task-notification>',
92
- `<task id="${job.taskID}" alias="${job.alias}" agent="${job.agent}" state="${job.state}">`,
93
- `<summary>Delegation [${job.alias}] (${job.agent}) ${stateLabel}${timeout}: ${truncate(job.description, NOTIFICATION_TEXT_MAX)}${summary}</summary>`,
94
- `<read>Read the full report with pantheon_delegation_read({ id: "${job.alias}" }).</read>`,
95
- '</task>',
96
- '</task-notification>',
97
- ].join('\n')
98
- }
99
-
100
- // ─── Notifier (queue + flush) ──────────────────────────────────────────
101
-
102
- /** Per-parent queue bound — a full queue drops new entries, never grows unbounded. */
103
- const MAX_PER_PARENT = 10
104
-
105
- function isTextPartLike(value: unknown): value is { type: 'text'; text: string } {
106
- if (!value || typeof value !== 'object') return false
107
- const part = value as { type?: unknown; text?: unknown }
108
- return part.type === 'text' && typeof part.text === 'string'
109
- }
110
-
111
- /**
112
- * In-memory completion notification queue, keyed by parent session ID.
113
- * FIFO per parent, bounded (MAX_PER_PARENT — anti-spam philosophy, matching
114
- * pantheon-hooks' CHAT_REMINDER_MAX). Delivered by `flushQueue` into the
115
- * parent's next chat.message; delivered notifications are marked sent.
116
- */
117
- export class DelegationNotifier {
118
- private readonly queue = new Map<string, QueuedNotification[]>()
119
- private readonly sent = new Map<string, number>()
120
- private readonly warn: (message: string) => void
121
-
122
- /**
123
- * @param logger Optional injected logger (testable). Defaults to
124
- * `console.warn` with the `[pantheon-delegation]` prefix, matching the
125
- * plugin's structured-log style.
126
- */
127
- constructor(logger?: { warn: (message: string) => void }) {
128
- this.warn =
129
- logger?.warn ?? ((message: string) => console.warn(`[pantheon-delegation] ${message}`))
130
- }
131
-
132
- /** Notifications awaiting delivery for a parent session. */
133
- pendingCount(parentSessionID: string): number {
134
- return this.queue.get(parentSessionID)?.length ?? 0
135
- }
136
-
137
- /** Whether any notifications await delivery for a parent session. */
138
- hasPending(parentSessionID: string): boolean {
139
- return this.pendingCount(parentSessionID) > 0
140
- }
141
-
142
- /** Notifications already injected into a parent's chat.message output. */
143
- sentCount(parentSessionID: string): number {
144
- return this.sent.get(parentSessionID) ?? 0
145
- }
146
-
147
- /**
148
- * Queue a completion notification for a parent session. The channel: there
149
- * is NO client push — the notification is delivered by the next
150
- * `flushQueue` for that parent. Returns false when the queue is full.
151
- */
152
- queueNotification(parentSessionID: string, text: string): boolean {
153
- const entries = this.queue.get(parentSessionID) ?? []
154
- if (entries.length >= MAX_PER_PARENT) {
155
- this.warn(
156
- `notification dropped for parent session ${parentSessionID}: ` +
157
- `queue full (${MAX_PER_PARENT} pending)`,
158
- )
159
- return false
160
- }
161
- entries.push({ text, at: Date.now() })
162
- this.queue.set(parentSessionID, entries)
163
- return true
164
- }
165
-
166
- /** Build + queue the notification for a terminal job. False when full. */
167
- notifyParent(job: BackgroundJobRecord): boolean {
168
- return this.queueNotification(job.parentSessionID, buildTaskNotification(job))
169
- }
170
-
171
- /**
172
- * Inject all pending notifications for a parent into a `chat.message` output
173
- * (prepend onto the first text part — matching the pantheon-hooks reminder
174
- * injection pattern; with no text part the block is unshifted as a new one).
175
- * Delivered entries are marked sent. Returns how many were delivered (0 when
176
- * nothing was pending).
177
- */
178
- flushQueue(parentSessionID: string, output: { parts: unknown[] }): number {
179
- const entries = this.queue.get(parentSessionID)
180
- if (entries === undefined || entries.length === 0) return 0
181
- this.queue.delete(parentSessionID)
182
-
183
- const block = entries.map((entry) => entry.text).join('\n')
184
- const firstText = output.parts.find(isTextPartLike)
185
- if (firstText !== undefined) {
186
- firstText.text = `${block}\n\n${firstText.text}`
187
- } else {
188
- output.parts.unshift({ type: 'text', text: block })
189
- }
190
- this.sent.set(parentSessionID, (this.sent.get(parentSessionID) ?? 0) + entries.length)
191
- return entries.length
192
- }
193
- }
194
-
195
57
  // ─── Event → finalize wiring ───────────────────────────────────────────
196
58
 
197
59
  function extractErrorMessage(error: unknown): string {
@@ -209,8 +71,8 @@ function extractErrorMessage(error: unknown): string {
209
71
  * - `session.error` on a board job → finalize as error (message extracted);
210
72
  * - any other event, a missing session ID, or an unknown session → no-op.
211
73
  *
212
- * Finalize transitions the board, which fires `onTerminal` — the single point
213
- * that queues the completion notification (no double-notify). The board's
74
+ * Finalize transitions the board, which fires `onTerminal` (the plugin's
75
+ * file-only audit log — no chat delivery; see module header). The board's
214
76
  * same-terminal idempotency makes repeated idles harmless (finalize rewrites
215
77
  * the report but never transitions a terminal job to a different state).
216
78
  *
@@ -19,11 +19,19 @@
19
19
  * Depth guard (root-session detection): the ToolContext carries NO parentID
20
20
  * (spike), so root detection is configurable. Resolution order:
21
21
  * 1. `options.isRootSession(sessionID)` custom predicate;
22
- * 2. `options.rootSessions` explicit allowlist;
23
- * 3. default: any session WE created via `session.create` is a sub-session;
24
- * everything else is treated as root. Phase 3 should pass the root
25
- * registry derived from `session.created` events (Session.parentID
26
- * undefined ⇒ root) for authoritative enforcement.
22
+ * 2. `options.rootSessions` explicit allowlist — with a knownChildren
23
+ * fallback (compaction-134): a session is a sub-session ONLY if WE
24
+ * created it as a child (`session.create` via pantheon_delegate).
25
+ * Sessions in neither set are treated as root, so a resumed root can
26
+ * delegate after a restart even when the allowlist is incomplete
27
+ * (opencode does not replay `session.created` events for pre-existing
28
+ * sessions — the plugin seeds the registry from session.list() at
29
+ * startup, and this fallback covers a failed/incomplete seed);
30
+ * 3. default (no rootSessions): any session WE created via
31
+ * `session.create` is a sub-session; everything else is treated as
32
+ * root. Phase 3 should pass the root registry derived from
33
+ * `session.created` events (Session.parentID undefined ⇒ root) for
34
+ * authoritative enforcement.
27
35
  *
28
36
  * Read-only enforcement is Phase 4 — `read_only` is exposed on the delegate
29
37
  * args and the child session is registered in the read-only registry when the
@@ -41,12 +49,14 @@ import {
41
49
  DELEGATION_DEFAULTS,
42
50
  type DelegationClient,
43
51
  type DelegationDeps,
52
+ type DelegationMessageBundle,
44
53
  type DelegationOptions,
45
54
  type FinalizeInput,
46
55
  finalizeDelegation as finalizeDelegationReport,
47
56
  readDelegationReport,
48
57
  } from './delegation-finalize.ts'
49
- import { resolveActivePreset } from './presets.mjs'
58
+ import { createPantheonLogger } from './logger.ts'
59
+ import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
50
60
 
51
61
  export type {
52
62
  DelegationClient,
@@ -59,6 +69,11 @@ export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
59
69
 
60
70
  // ─── Types ─────────────────────────────────────────────────────────────
61
71
 
72
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
73
+ // plugin renders into the opencode TUI — the "lixo". Errors go to
74
+ // .pantheon/logs/hooks.log; console echo is opt-in via PANTHEON_HOOKS_LOG=1.
75
+ const log = createPantheonLogger({ module: 'pantheon-delegate' })
76
+
62
77
  /** Structural view of the tool context opencode passes to execute(). */
63
78
  export interface ToolContextLike {
64
79
  sessionID: string
@@ -131,6 +146,167 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
131
146
  }
132
147
  }
133
148
 
149
+ // ─── Agent activity sampling ────────────────────────────────────────────
150
+ //
151
+ // Visibility for `pantheon_delegation_read` / `pantheon_delegation_list`
152
+ // (issue: the read blocks silently while the child works). While a read waits
153
+ // on `waitForTerminal` it periodically samples the CHILD session's messages
154
+ // via the same `client.session.messages` the finalize path uses, and the
155
+ // collected lines are appended to the report as `## Agent Activity`.
156
+ //
157
+ // Fail-open by design: a missing/throwing `session.messages` (or empty
158
+ // response) degrades to the current behavior — report without a section —
159
+ // and NEVER breaks the read. No streaming/SSE, no persistent timers: the
160
+ // poll lives inside the read's existing wait and is cleared when it settles.
161
+
162
+ /** Cap for one activity line (~200 chars, spec). */
163
+ const ACTIVITY_LINE_MAX = 200
164
+ /** How often the read re-samples child activity while waiting (ms). */
165
+ const ACTIVITY_POLL_MS = 2000
166
+ /** Keep only the latest N readable entries per sample. */
167
+ const ACTIVITY_LINES = 3
168
+
169
+ /**
170
+ * Mutable collector shared between the wait loop and the read result.
171
+ * `sampled` distinguishes "messages unavailable/never succeeded" (fail-open
172
+ * → NO activity section, current behavior) from "messages OK but nothing
173
+ * readable" (→ `_no activity captured_` section).
174
+ */
175
+ interface ActivityCollector {
176
+ /** Readable activity lines captured so far (latest window). */
177
+ lines: string[]
178
+ /** True once session.messages returned a bundle list at least once. */
179
+ sampled: boolean
180
+ }
181
+
182
+ /** Collapse whitespace and cap a single line at ~200 chars. */
183
+ function truncateActivityLine(text: string, max = ACTIVITY_LINE_MAX): string {
184
+ const clean = text.replace(/\s+/g, ' ').trim()
185
+ if (clean.length <= max) return clean
186
+ return `${clean.slice(0, max - 1)}…`
187
+ }
188
+
189
+ /** Readable tool args from a tool part (SDK ToolPart.metadata.input), or ''. */
190
+ function toolArgsFromPart(part: { metadata?: { input?: unknown } }): string {
191
+ const meta = part.metadata
192
+ const raw = meta !== undefined && 'input' in meta ? meta.input : meta
193
+ if (raw === undefined) return ''
194
+ try {
195
+ const s = typeof raw === 'string' ? raw : JSON.stringify(raw)
196
+ return s === '' || s === '{}' ? '' : s
197
+ } catch {
198
+ return ''
199
+ }
200
+ }
201
+
202
+ /**
203
+ * One readable activity line per message bundle — tool calls first (highest
204
+ * signal), then the first non-empty text part. Image/attachment/reasoning
205
+ * parts are skipped (only `text` and `tool` parts are user-visible).
206
+ */
207
+ function activityLineFromBundle(bundle: DelegationMessageBundle): string | undefined {
208
+ const role = bundle.info?.role
209
+ const parts = bundle.parts ?? []
210
+ for (const part of parts) {
211
+ if (part.type === 'tool' && typeof part.tool === 'string' && part.tool !== '') {
212
+ const args = toolArgsFromPart(part)
213
+ return truncateActivityLine(`tool: ${part.tool}${args !== '' ? ` ${args}` : ''}`)
214
+ }
215
+ }
216
+ for (const part of parts) {
217
+ if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
218
+ const label = role === 'user' ? 'user' : 'assistant'
219
+ return truncateActivityLine(`${label}: ${part.text.trim()}`)
220
+ }
221
+ }
222
+ return undefined
223
+ }
224
+
225
+ /**
226
+ * Sample the child's latest readable activity into `collector` (replace
227
+ * window). Fail-open: unavailable/throwing messages leaves the collector
228
+ * untouched (previous lines, or none → `_no activity captured_`), while a
229
+ * successful (even empty) response marks the collector as sampled.
230
+ */
231
+ async function sampleChildActivity(
232
+ client: DelegationClient,
233
+ childSessionID: string,
234
+ collector: ActivityCollector,
235
+ ): Promise<void> {
236
+ if (typeof client.session.messages !== 'function') return
237
+ try {
238
+ const bundles = await client.session.messages({ path: { id: childSessionID } })
239
+ if (!Array.isArray(bundles)) return
240
+ collector.sampled = true
241
+ if (bundles.length === 0) return
242
+ const fresh = bundles
243
+ .map((b) => activityLineFromBundle(b))
244
+ .filter((l): l is string => l !== undefined)
245
+ .slice(-ACTIVITY_LINES)
246
+ if (fresh.length > 0) {
247
+ collector.lines.length = 0
248
+ collector.lines.push(...fresh)
249
+ }
250
+ } catch (err: unknown) {
251
+ const reason = err instanceof Error ? err.message : String(err)
252
+ log.warn(`[pantheon-delegate] activity sample failed (fail-open): ${reason}`)
253
+ }
254
+ }
255
+
256
+ /** Last readable activity line for a child, or undefined when unavailable. */
257
+ async function lastChildActivity(
258
+ client: DelegationClient,
259
+ childSessionID: string,
260
+ ): Promise<string | undefined> {
261
+ if (typeof client.session.messages !== 'function') return undefined
262
+ try {
263
+ const bundles = await client.session.messages({ path: { id: childSessionID } })
264
+ if (!Array.isArray(bundles)) return undefined
265
+ for (let i = bundles.length - 1; i >= 0; i--) {
266
+ const bundle = bundles[i]
267
+ if (bundle === undefined) continue
268
+ const line = activityLineFromBundle(bundle)
269
+ if (line !== undefined) return line
270
+ }
271
+ return undefined
272
+ } catch (err: unknown) {
273
+ const reason = err instanceof Error ? err.message : String(err)
274
+ log.warn(`[pantheon-delegate] last activity fetch failed (fail-open): ${reason}`)
275
+ return undefined
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Wait for a terminal board state while sampling the child's activity every
281
+ * ACTIVITY_POLL_MS (first sample fires immediately — a quick finalize can
282
+ * still capture something). The interval is cleared when the wait settles;
283
+ * collection never delays the terminal resolution.
284
+ */
285
+ async function waitForTerminalWithActivity(
286
+ board: BackgroundJobBoard,
287
+ client: DelegationClient,
288
+ taskID: string,
289
+ timeoutMs: number,
290
+ collector: ActivityCollector,
291
+ ): Promise<BackgroundJobRecord> {
292
+ void sampleChildActivity(client, taskID, collector)
293
+ const timer = setInterval(() => {
294
+ void sampleChildActivity(client, taskID, collector)
295
+ }, ACTIVITY_POLL_MS)
296
+ timer.unref()
297
+ try {
298
+ return await board.waitForTerminal(taskID, timeoutMs)
299
+ } finally {
300
+ clearInterval(timer)
301
+ }
302
+ }
303
+
304
+ /** Render the trailing activity section appended to the read result. */
305
+ export function formatActivitySection(lines: string[]): string {
306
+ if (lines.length === 0) return '## Agent Activity\n\n_no activity captured_'
307
+ return `## Agent Activity\n\n${lines.map((l) => `- ${l}`).join('\n')}`
308
+ }
309
+
134
310
  // ─── Model resolution ──────────────────────────────────────────────────
135
311
 
136
312
  /** Session model ref accepted by the opencode server's session.create. */
@@ -139,6 +315,17 @@ export interface ChildSessionModelRef {
139
315
  providerID: string
140
316
  }
141
317
 
318
+ /**
319
+ * Known-good fallback model (P1, 2026-08-11 — release 1.3.4): when an
320
+ * AUTO-RESOLVED model (routing.yml agent entry / active preset) points at a
321
+ * provider whose API key is not configured, the child is dispatched on the
322
+ * native `opencode` provider instead — verified to work in the field without
323
+ * an external provider key. Validated with the same providerKeyConfigured
324
+ * gate as the resolved model; an explicit caller-supplied `model` always wins
325
+ * over this fallback.
326
+ */
327
+ const FALLBACK_MODEL = 'opencode/deepseek-v4-flash-free'
328
+
142
329
  /**
143
330
  * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
144
331
  * into the `{ id, providerID }` ref the session.create body expects.
@@ -194,8 +381,96 @@ function resolveChildModel(
194
381
  return undefined
195
382
  }
196
383
 
384
+ /**
385
+ * Resolve a USABLE child model for a delegate dispatch, gating the resolved
386
+ * provider's API key (P1, 2026-08-11 — release 1.3.4):
387
+ *
388
+ * 1. resolve the model with the existing precedence (explicit `model` >
389
+ * options.agentModels > active preset) — see resolveChildModel.
390
+ * 2. provider key gate: the resolved provider requires its apiKeyEnv env
391
+ * var (routing.yml preset defs — same source applyPreset enforces at
392
+ * startup). No gate (native providers) or key present → usable as-is.
393
+ * 3. key MISSING + EXPLICIT caller model → respected anyway (user intent),
394
+ * warned.
395
+ * 4. key MISSING + AUTO-resolved model → fall back to FALLBACK_MODEL
396
+ * (validated the same way). Fallback also unusable → returns an `error`
397
+ * TEXT (the caller returns it from the tool — never throws) and the
398
+ * caller registers NO job on the board.
399
+ *
400
+ * The no-model case (nothing resolved) keeps the pre-existing behavior:
401
+ * warn and let the child use opencode's default.
402
+ *
403
+ * @returns `{ model, error }` — at most one of `model` / `error` is set;
404
+ * both may be unset (no model resolved, warn emitted).
405
+ */
406
+ function resolveUsableChildModel(
407
+ options: DelegationOptions,
408
+ agent: string,
409
+ explicitModel: string | undefined,
410
+ ): { model: ChildSessionModelRef | undefined; error: string | undefined } {
411
+ const env = options.presetEnv ?? process.env
412
+ const explicit = explicitModel !== undefined && explicitModel !== ''
413
+ const warn = (msg: string) => options.logger?.warn?.(msg)
414
+
415
+ const model = resolveChildModel(options, agent, explicitModel)
416
+ if (model === undefined) {
417
+ warn(
418
+ `[pantheon-delegate] no model resolved for agent "${agent}" — ` +
419
+ "child session will use opencode's default model (may require API keys)",
420
+ )
421
+ return { model: undefined, error: undefined }
422
+ }
423
+
424
+ const missingVar = missingProviderKeyEnv(model.providerID, { env })
425
+ if (missingVar === undefined) return { model, error: undefined }
426
+
427
+ if (explicit) {
428
+ // Rule of precedence: the caller's explicit model is respected — warn only.
429
+ warn(
430
+ `[pantheon-delegate] model "${explicitModel}" provider "${model.providerID}" requires ` +
431
+ `API key ${missingVar} (unset) — respecting the explicit model anyway`,
432
+ )
433
+ return { model, error: undefined }
434
+ }
435
+
436
+ // Auto-resolved (agentModels / preset) → try the known-good fallback.
437
+ const fallback = splitModelRef(FALLBACK_MODEL)
438
+ if (fallback !== undefined && missingProviderKeyEnv(fallback.providerID, { env }) === undefined) {
439
+ warn(
440
+ `[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
441
+ `(unset) — falling back to ${FALLBACK_MODEL}`,
442
+ )
443
+ return { model: fallback, error: undefined }
444
+ }
445
+
446
+ const message =
447
+ `pantheon_delegate: no usable model for agent "${agent}" — provider "${model.providerID}" ` +
448
+ `requires API key (set ${missingVar} or PANTHEON_MODEL_PRESET)`
449
+ warn(`[pantheon-delegate] ${message}`)
450
+ return { model: undefined, error: message }
451
+ }
452
+
197
453
  // ─── Factory ───────────────────────────────────────────────────────────
198
454
 
455
+ /**
456
+ * Pure helper: collect the IDs of root sessions (parentID === undefined)
457
+ * from a session list. Used to SEED the depth-guard allowlist at startup —
458
+ * opencode does not replay `session.created` events for sessions that exist
459
+ * before plugin load, so a resumed root would never enter `rootSessions`
460
+ * and the depth guard would reject its pantheon_delegate calls after a
461
+ * restart. Fail-open contract lives at the call site (plugin.ts): a failed
462
+ * `session.list()` leaves the registry untouched, never crashes startup.
463
+ */
464
+ export function collectRootSessionIDs(
465
+ sessions: ReadonlyArray<{ id: string; parentID?: string }>,
466
+ ): Set<string> {
467
+ const roots = new Set<string>()
468
+ for (const session of sessions) {
469
+ if (session.parentID === undefined) roots.add(session.id)
470
+ }
471
+ return roots
472
+ }
473
+
199
474
  /**
200
475
  * Build the delegation toolset. Keeps per-instance state: the set of child
201
476
  * sessions WE created (depth-guard default) and the per-job timeout timers
@@ -214,7 +489,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
214
489
 
215
490
  function isRootSession(sessionID: string): boolean {
216
491
  if (options.isRootSession !== undefined) return options.isRootSession(sessionID)
217
- if (options.rootSessions !== undefined) return options.rootSessions.has(sessionID)
492
+ if (options.rootSessions !== undefined) {
493
+ // compaction-134 (fix layer 2): the allowlist alone is NOT
494
+ // authoritative. opencode does not replay `session.created` events for
495
+ // pre-existing sessions, so after a restart the resumed root never
496
+ // entered rootSessions (the plugin seeds it from session.list() at
497
+ // startup, but that seed is fail-open). A session is a sub-session
498
+ // ONLY if WE created it as a child (knownChildren — filled on every
499
+ // pantheon_delegate dispatch). Everything unknown is treated as root:
500
+ // resumed/unknown sessions can delegate, while the real depth guard
501
+ // (a child of a delegate cannot re-delegate) is preserved.
502
+ return options.rootSessions.has(sessionID) || !knownChildren.has(sessionID)
503
+ }
218
504
  return !knownChildren.has(sessionID)
219
505
  }
220
506
 
@@ -257,16 +543,16 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
257
543
  // session.create failure → return a clear error as TEXT (tools return
258
544
  // errors as text, not thrown) and register NO job on the board — an
259
545
  // unhandled rejection here would otherwise lose the failure entirely.
260
- // The child model is resolved from (a) the explicit `model` option,
261
- // (b) options.agentModels (routing.yml), (c) the active preset — else
262
- // left unset so opencode's default applies (warned below).
263
- const childModel = resolveChildModel(options, args.agent, args.model)
264
- if (childModel === undefined) {
265
- options.logger?.warn?.(
266
- `[pantheon-delegate] no model resolved for agent "${args.agent}" — ` +
267
- "child session will use opencode's default model (may require API keys)",
268
- )
269
- }
546
+ // The child model is resolved with a provider API-key gate (P1): the
547
+ // resolved model (explicit `model` > options.agentModels > active
548
+ // preset) is used when its provider key is configured; an explicit
549
+ // caller model is always respected (warned); an auto-resolved model
550
+ // whose provider key is missing falls back to the known-good
551
+ // opencode/deepseek-v4-flash-free; if nothing usable remains the
552
+ // failure is returned as TEXT below — no session, no board job.
553
+ const resolved = resolveUsableChildModel(options, args.agent, args.model)
554
+ if (resolved.error !== undefined) return resolved.error
555
+ const childModel = resolved.model
270
556
  let created: { id: string }
271
557
  try {
272
558
  created = await client.session.create({
@@ -312,9 +598,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
312
598
  state: 'error',
313
599
  error: `Delegation [${job.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
314
600
  timedOut: true,
315
- }).catch((err: unknown) =>
316
- console.error('[pantheon-delegate] timeout finalize failed:', err),
317
- )
601
+ }).catch((err: unknown) => log.error('[pantheon-delegate] timeout finalize failed:', err))
318
602
  }, timeoutMs)
319
603
  timer.unref()
320
604
  timers.set(childSessionID, timer)
@@ -326,7 +610,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
326
610
  path: { id: childSessionID },
327
611
  body: { agent: args.agent, parts: [{ type: 'text', text: args.prompt }] },
328
612
  })
329
- .catch((err: unknown) => console.error('[pantheon-delegate] promptAsync failed:', err))
613
+ .catch((err: unknown) => log.error('[pantheon-delegate] promptAsync failed:', err))
330
614
 
331
615
  return (
332
616
  `Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
@@ -338,7 +622,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
338
622
  const pantheon_delegation_read: DelegationTool<typeof readArgs> = {
339
623
  description:
340
624
  'Block until a background delegation finishes (completed/error/cancelled), then return its ' +
341
- 'report markdown and mark the job reconciled. Resolves by alias or task ID.',
625
+ 'report markdown (with a trailing agent-activity section) and mark the job reconciled. ' +
626
+ 'Resolves by alias or task ID.',
342
627
  args: readArgs,
343
628
  execute: async (args, ctx) => {
344
629
  const job = board.resolve(ctx.sessionID, args.id)
@@ -346,9 +631,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
346
631
  return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
347
632
  }
348
633
 
634
+ // Sample the child's messages while waiting so the caller sees what the
635
+ // agent is doing — fail-open: no messages support → report as before.
636
+ const collector: ActivityCollector = { lines: [], sampled: false }
349
637
  let terminal: BackgroundJobRecord
350
638
  try {
351
- terminal = await board.waitForTerminal(job.taskID, readTimeoutMs)
639
+ terminal = await waitForTerminalWithActivity(
640
+ board,
641
+ client,
642
+ job.taskID,
643
+ readTimeoutMs,
644
+ collector,
645
+ )
352
646
  } catch {
353
647
  return `Timed out after ${readTimeoutMs}ms waiting for delegation "${args.id}" ([${job.alias}]).`
354
648
  }
@@ -359,7 +653,9 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
359
653
  }
360
654
 
361
655
  await board.markReconciled(job.taskID)
362
- return md
656
+ // Fail-open: if session.messages never succeeded, keep the report as-is.
657
+ if (!collector.sampled && collector.lines.length === 0) return md
658
+ return `${md.replace(/\n+$/, '')}\n\n${formatActivitySection(collector.lines)}`
363
659
  },
364
660
  }
365
661
 
@@ -371,10 +667,16 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
371
667
  const jobs = board.list(ctx.sessionID)
372
668
  if (jobs.length === 0) return 'No background delegations for this session.'
373
669
 
374
- const lines = jobs.map((j) => {
375
- const unread = j.terminalUnreconciled ? ' [unread]' : ''
376
- return ` [${j.alias}] ${j.agent} — ${j.description} — ${stateLabel(j.state)}${unread}`
377
- })
670
+ const lines = await Promise.all(
671
+ jobs.map(async (j) => {
672
+ const unread = j.terminalUnreconciled ? ' [unread]' : ''
673
+ const base = ` [${j.alias}] ${j.agent} — ${j.description} — ${stateLabel(j.state)}${unread}`
674
+ // Running jobs get a live `last activity:` line (fail-open fetch).
675
+ if (j.state !== 'running') return base
676
+ const last = await lastChildActivity(client, j.taskID)
677
+ return last === undefined ? base : `${base}\n last activity: ${last}`
678
+ }),
679
+ )
378
680
  return `Background Delegations (${jobs.length}):\n${lines.join('\n')}`
379
681
  },
380
682
  }