pantheon-opencode 1.3.2 → 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 (57) hide show
  1. package/README.md +154 -1
  2. package/bin/pantheon-init.mjs +160 -120
  3. package/opencode.json +0 -1
  4. package/package.json +10 -5
  5. package/scripts/cost.mjs +69 -0
  6. package/scripts/doctor.mjs +56 -22
  7. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  8. package/scripts/install/opencode.mjs +132 -32
  9. package/scripts/install/plugin.mjs +112 -28
  10. package/scripts/install-mcp.mjs +1 -1
  11. package/scripts/redaction-gate.mjs +69 -0
  12. package/scripts/release-notes.mjs +317 -0
  13. package/scripts/test-context-compression.sh +5 -2
  14. package/scripts/validate-package.mjs +56 -0
  15. package/scripts/versioning.mjs +117 -18
  16. package/src/agents/zeus.md +80 -0
  17. package/src/pantheon/background-job-board.ts +115 -40
  18. package/src/pantheon/chat-reminders.ts +79 -0
  19. package/src/pantheon/compaction-assert.ts +160 -0
  20. package/src/pantheon/cost-command.ts +208 -0
  21. package/src/pantheon/cost-tracker.ts +108 -0
  22. package/src/pantheon/delegation-compaction.ts +237 -0
  23. package/src/pantheon/delegation-enforce.ts +144 -0
  24. package/src/pantheon/delegation-finalize.ts +277 -0
  25. package/src/pantheon/delegation-notify.ts +100 -0
  26. package/src/pantheon/delegation.ts +690 -0
  27. package/src/pantheon/dispatch-guard.ts +107 -0
  28. package/src/pantheon/file-persistence.ts +10 -6
  29. package/src/pantheon/goal-loop.ts +300 -0
  30. package/src/pantheon/goal-store.ts +97 -0
  31. package/src/pantheon/hashline/core.ts +91 -0
  32. package/src/pantheon/hashline/read-enhancer.ts +66 -0
  33. package/src/pantheon/hashline/tool.ts +260 -0
  34. package/src/pantheon/hashline/xxhash.ts +53 -0
  35. package/src/pantheon/idle-continuation.ts +54 -0
  36. package/src/pantheon/logger.ts +117 -0
  37. package/src/pantheon/plugin-once.ts +76 -0
  38. package/src/pantheon/preemptive-compact.ts +76 -0
  39. package/src/pantheon/presets.d.mts +31 -0
  40. package/src/pantheon/presets.mjs +105 -3
  41. package/src/pantheon/todo-enforcer.ts +421 -0
  42. package/src/pantheon/todo-preserve.ts +295 -0
  43. package/src/plugin.ts +406 -13
  44. package/src/plugins/pantheon-hooks.ts +52 -30
  45. package/src/plugins/tui/dist/server.d.ts +17 -0
  46. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  47. package/src/plugins/tui/dist/server.js +20 -0
  48. package/src/plugins/tui/dist/server.js.map +1 -0
  49. package/src/plugins/tui/dist/tui.d.ts +303 -1
  50. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  51. package/src/plugins/tui/dist/tui.js +977 -350
  52. package/src/plugins/tui/dist/tui.tsx +1427 -150
  53. package/src/plugins/tui/package.json +4 -3
  54. package/src/plugins/tui/src/index.tsx +1427 -150
  55. package/src/plugins/tui/src/server.ts +15 -0
  56. package/src/plugins/tui/tsdown.config.ts +1 -1
  57. package/src/routing.yml +35 -2
@@ -0,0 +1,690 @@
1
+ /**
2
+ * Delegation Core (Phase 2) — the three background-delegation tools plus the
3
+ * finalize lifecycle hook, built on the BackgroundJobBoard.
4
+ *
5
+ * Tools (STRUCTURAL shape — matches what opencode expects: `{description,
6
+ * args (zod shape), execute(args, ctx)}`; zod is imported directly — the
7
+ * `@opencode-ai/plugin` package is a devDep and is NEVER runtime-imported):
8
+ * - `pantheon_delegate({prompt, agent, description?})` — create a child
9
+ * session (parentID = caller), register it on the board, arm a timeout,
10
+ * and fire-and-forget `session.promptAsync` on the child (NO noReply —
11
+ * the spike refuted noReply as a push mechanism). Completion is observed
12
+ * via `session.idle` on the child and finalized through
13
+ * `finalizeDelegation` (exposed on the toolset for the Phase 3 event hook).
14
+ * - `pantheon_delegation_read({id})` — block via `board.waitForTerminal`,
15
+ * return the report markdown, mark the job reconciled.
16
+ * - `pantheon_delegation_list()` — job list with `[unread]` for terminal-
17
+ * unreconciled jobs.
18
+ *
19
+ * Depth guard (root-session detection): the ToolContext carries NO parentID
20
+ * (spike), so root detection is configurable. Resolution order:
21
+ * 1. `options.isRootSession(sessionID)` custom predicate;
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.
35
+ *
36
+ * Read-only enforcement is Phase 4 — `read_only` is exposed on the delegate
37
+ * args and the child session is registered in the read-only registry when the
38
+ * delegate is read-only (explicit flag or agent ∈ readOnlyAgents), which the
39
+ * plugin's `tool.execute.before` guard enforces.
40
+ *
41
+ * @module delegation
42
+ */
43
+
44
+ import { z } from 'zod'
45
+
46
+ import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
47
+ import { readOnlyRegistry } from './delegation-enforce.ts'
48
+ import {
49
+ DELEGATION_DEFAULTS,
50
+ type DelegationClient,
51
+ type DelegationDeps,
52
+ type DelegationMessageBundle,
53
+ type DelegationOptions,
54
+ type FinalizeInput,
55
+ finalizeDelegation as finalizeDelegationReport,
56
+ readDelegationReport,
57
+ } from './delegation-finalize.ts'
58
+ import { createPantheonLogger } from './logger.ts'
59
+ import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
60
+
61
+ export type {
62
+ DelegationClient,
63
+ DelegationClientSession,
64
+ DelegationMessageBundle,
65
+ DelegationOptions,
66
+ FinalizeInput,
67
+ } from './delegation-finalize.ts'
68
+ export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
69
+
70
+ // ─── Types ─────────────────────────────────────────────────────────────
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
+
77
+ /** Structural view of the tool context opencode passes to execute(). */
78
+ export interface ToolContextLike {
79
+ sessionID: string
80
+ directory?: string
81
+ worktree?: string
82
+ agent?: string
83
+ }
84
+
85
+ /** A delegation tool: description + zod args shape + execute. */
86
+ export interface DelegationTool<Args extends z.ZodRawShape = z.ZodRawShape> {
87
+ description: string
88
+ args: Args
89
+ execute(args: z.infer<z.ZodObject<Args>>, ctx: ToolContextLike): Promise<string>
90
+ }
91
+
92
+ /** The full toolset returned by createDelegationTools(). */
93
+ export interface DelegationToolset {
94
+ pantheon_delegate: DelegationTool<typeof delegateArgs>
95
+ pantheon_delegation_read: DelegationTool<typeof readArgs>
96
+ pantheon_delegation_list: DelegationTool<typeof listArgs>
97
+ /** Lifecycle hook for the Phase 3 event wiring (session.idle on the child). */
98
+ finalizeDelegation: (
99
+ childSessionID: string,
100
+ opts: FinalizeInput,
101
+ ) => Promise<BackgroundJobRecord | undefined>
102
+ }
103
+
104
+ /** Input for createDelegationTools(). */
105
+ export interface CreateDelegationToolsInput {
106
+ board: BackgroundJobBoard
107
+ client: DelegationClient
108
+ options?: DelegationOptions
109
+ }
110
+
111
+ // ─── Args schemas ──────────────────────────────────────────────────────
112
+
113
+ const delegateArgs = {
114
+ prompt: z.string().min(1).describe('Task prompt delivered to the background agent.'),
115
+ agent: z.string().min(1).describe('Agent name, e.g. "apollo" or "hermes".'),
116
+ description: z.string().optional().describe('Human-readable description shown on the job board.'),
117
+ read_only: z.boolean().optional().describe('Advisory flag for Phase 4 read-only enforcement.'),
118
+ model: z
119
+ .string()
120
+ .optional()
121
+ .describe(
122
+ 'Explicit model for the child session (provider/model, e.g. "opencode/deepseek-v4-flash-free").',
123
+ ),
124
+ } satisfies z.ZodRawShape
125
+
126
+ const readArgs = {
127
+ id: z.string().min(1).describe('Job alias (e.g. "apo-1") or task ID to read.'),
128
+ } satisfies z.ZodRawShape
129
+
130
+ const listArgs = {} satisfies z.ZodRawShape
131
+
132
+ // ─── State labels ──────────────────────────────────────────────────────
133
+
134
+ function stateLabel(state: BackgroundJobRecord['state']): string {
135
+ switch (state) {
136
+ case 'running':
137
+ return 'RUNNING'
138
+ case 'completed':
139
+ return 'OK'
140
+ case 'error':
141
+ return 'ERR'
142
+ case 'cancelled':
143
+ return 'CANCELLED'
144
+ case 'reconciled':
145
+ return 'RECONCILED'
146
+ }
147
+ }
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
+
310
+ // ─── Model resolution ──────────────────────────────────────────────────
311
+
312
+ /** Session model ref accepted by the opencode server's session.create. */
313
+ export interface ChildSessionModelRef {
314
+ id: string
315
+ providerID: string
316
+ }
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
+
329
+ /**
330
+ * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
331
+ * into the `{ id, providerID }` ref the session.create body expects.
332
+ * Returns undefined for malformed IDs (no slash, empty segments).
333
+ */
334
+ function splitModelRef(model: string): ChildSessionModelRef | undefined {
335
+ const slash = model.indexOf('/')
336
+ if (slash <= 0 || slash === model.length - 1) return undefined
337
+ return { providerID: model.slice(0, slash), id: model.slice(slash + 1) }
338
+ }
339
+
340
+ /**
341
+ * Resolve the child session model with priority:
342
+ * 1. explicit `model` option passed to the delegate tool;
343
+ * 2. `options.agentModels[agent]` (routing.yml agent entry, case-insensitive);
344
+ * 3. the active preset's agent entry via resolveActivePreset();
345
+ * 4. fallback: undefined — the child is created without a model (the
346
+ * caller warns) so opencode's default applies.
347
+ */
348
+ function resolveChildModel(
349
+ options: DelegationOptions,
350
+ agent: string,
351
+ explicitModel?: string,
352
+ ): ChildSessionModelRef | undefined {
353
+ // (a) explicit model option on the delegate tool call
354
+ if (explicitModel !== undefined && explicitModel !== '') {
355
+ const ref = splitModelRef(explicitModel)
356
+ if (ref !== undefined) return ref
357
+ }
358
+ const key = agent.toLowerCase()
359
+ // (b) routing.yml agent entry wired through options.agentModels
360
+ const mapped = options.agentModels?.[key]
361
+ if (mapped !== undefined && mapped !== '') {
362
+ const ref = splitModelRef(mapped)
363
+ if (ref !== undefined) return ref
364
+ }
365
+ // (c) active preset agent entry (resolveActivePreset reads routing.yml
366
+ // presets + .pantheon/active-preset.json). Guarded — a broken routing.yml
367
+ // must never kill the delegate.
368
+ try {
369
+ const presetModel = resolveActivePreset({
370
+ ...(options.presetEnv !== undefined ? { env: options.presetEnv } : {}),
371
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
372
+ })?.agents?.[key]?.model
373
+ if (presetModel !== undefined && presetModel !== '') {
374
+ const ref = splitModelRef(presetModel)
375
+ if (ref !== undefined) return ref
376
+ }
377
+ } catch (err: unknown) {
378
+ const reason = err instanceof Error ? err.message : String(err)
379
+ options.logger?.warn?.(`[pantheon-delegate] active preset resolution failed: ${reason}`)
380
+ }
381
+ return undefined
382
+ }
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
+
453
+ // ─── Factory ───────────────────────────────────────────────────────────
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
+
474
+ /**
475
+ * Build the delegation toolset. Keeps per-instance state: the set of child
476
+ * sessions WE created (depth-guard default) and the per-job timeout timers
477
+ * (cleared on finalize, `.unref()`'d so they never hold the process open).
478
+ */
479
+ export function createDelegationTools(input: CreateDelegationToolsInput): DelegationToolset {
480
+ const { board, client } = input
481
+ const options: DelegationOptions = input.options ?? {}
482
+ const deps: DelegationDeps = { board, client, options }
483
+ const outputDir = options.outputDir ?? DELEGATION_DEFAULTS.outputDir
484
+ const timeoutMs = options.timeoutMs ?? DELEGATION_DEFAULTS.timeoutMs
485
+ const readTimeoutMs = options.readTimeoutMs ?? DELEGATION_DEFAULTS.readTimeoutMs
486
+
487
+ const knownChildren = new Set<string>()
488
+ const timers = new Map<string, NodeJS.Timeout>()
489
+
490
+ function isRootSession(sessionID: string): boolean {
491
+ if (options.isRootSession !== undefined) return options.isRootSession(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
+ }
504
+ return !knownChildren.has(sessionID)
505
+ }
506
+
507
+ function clearTimer(childSessionID: string): void {
508
+ const timer = timers.get(childSessionID)
509
+ if (timer !== undefined) {
510
+ clearTimeout(timer)
511
+ timers.delete(childSessionID)
512
+ }
513
+ }
514
+
515
+ /** Bound finalize: clear the job timer, then run the report + transition. */
516
+ async function finalize(
517
+ childSessionID: string,
518
+ opts: FinalizeInput,
519
+ ): Promise<BackgroundJobRecord | undefined> {
520
+ clearTimer(childSessionID)
521
+ return finalizeDelegationReport(deps, childSessionID, opts)
522
+ }
523
+
524
+ const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
525
+ description:
526
+ 'Dispatch a background agent as a child session and register it on the job board. ' +
527
+ 'Returns the readable alias (e.g. "apo-1"); read the result with ' +
528
+ 'pantheon_delegation_read. Only root sessions may delegate.',
529
+ args: delegateArgs,
530
+ execute: async (args, ctx) => {
531
+ if (!isRootSession(ctx.sessionID)) {
532
+ throw new Error(
533
+ `pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
534
+ )
535
+ }
536
+ if (!board.canDispatch(args.agent)) {
537
+ throw new Error(
538
+ `pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}" ` +
539
+ `(${board.getRunningCount(args.agent)} running)`,
540
+ )
541
+ }
542
+
543
+ // session.create failure → return a clear error as TEXT (tools return
544
+ // errors as text, not thrown) and register NO job on the board — an
545
+ // unhandled rejection here would otherwise lose the failure entirely.
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
556
+ let created: { id: string }
557
+ try {
558
+ created = await client.session.create({
559
+ body: {
560
+ parentID: ctx.sessionID,
561
+ title: args.description ?? args.prompt.slice(0, 80),
562
+ ...(childModel !== undefined ? { model: childModel } : {}),
563
+ },
564
+ })
565
+ } catch (err: unknown) {
566
+ const reason = err instanceof Error ? err.message : String(err)
567
+ return `pantheon_delegate failed to create child session: ${reason}`
568
+ }
569
+ const childSessionID = created.id
570
+ knownChildren.add(childSessionID)
571
+
572
+ // Phase 4 read-only enforcement: register the child session in the
573
+ // read-only registry when the delegate is read-only — explicit
574
+ // `read_only: true` on the call, or the agent ∈ readOnlyAgents
575
+ // (case-insensitive). The plugin's tool.execute.before guard then
576
+ // denies edit/write/bash/task inside that session.
577
+ const readOnly =
578
+ args.read_only === true || (options.readOnlyAgents?.has(args.agent.toLowerCase()) ?? false)
579
+ if (readOnly) {
580
+ const entry: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
581
+ if (args.read_only !== undefined) entry.readOnlyFlag = args.read_only
582
+ readOnlyRegistry.register(childSessionID, entry)
583
+ }
584
+
585
+ const job = await board.registerLaunch({
586
+ taskID: childSessionID,
587
+ parentSessionID: ctx.sessionID,
588
+ agent: args.agent,
589
+ description: args.description ?? args.prompt,
590
+ objective: args.prompt,
591
+ })
592
+
593
+ // Timeout manager: cleared on finalize, unref'd so the timer never
594
+ // keeps the process alive on its own.
595
+ const timer = setTimeout(() => {
596
+ timers.delete(childSessionID)
597
+ void finalize(childSessionID, {
598
+ state: 'error',
599
+ error: `Delegation [${job.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
600
+ timedOut: true,
601
+ }).catch((err: unknown) => log.error('[pantheon-delegate] timeout finalize failed:', err))
602
+ }, timeoutMs)
603
+ timer.unref()
604
+ timers.set(childSessionID, timer)
605
+
606
+ // Fire-and-forget — completion is observed via session.idle on the
607
+ // child (spike: noReply does NOT deliver anything to the parent).
608
+ void client.session
609
+ .promptAsync({
610
+ path: { id: childSessionID },
611
+ body: { agent: args.agent, parts: [{ type: 'text', text: args.prompt }] },
612
+ })
613
+ .catch((err: unknown) => log.error('[pantheon-delegate] promptAsync failed:', err))
614
+
615
+ return (
616
+ `Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
617
+ `Read the result with pantheon_delegation_read({ id: "${job.alias}" }).`
618
+ )
619
+ },
620
+ }
621
+
622
+ const pantheon_delegation_read: DelegationTool<typeof readArgs> = {
623
+ description:
624
+ 'Block until a background delegation finishes (completed/error/cancelled), then return its ' +
625
+ 'report markdown (with a trailing agent-activity section) and mark the job reconciled. ' +
626
+ 'Resolves by alias or task ID.',
627
+ args: readArgs,
628
+ execute: async (args, ctx) => {
629
+ const job = board.resolve(ctx.sessionID, args.id)
630
+ if (!job) {
631
+ return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
632
+ }
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 }
637
+ let terminal: BackgroundJobRecord
638
+ try {
639
+ terminal = await waitForTerminalWithActivity(
640
+ board,
641
+ client,
642
+ job.taskID,
643
+ readTimeoutMs,
644
+ collector,
645
+ )
646
+ } catch {
647
+ return `Timed out after ${readTimeoutMs}ms waiting for delegation "${args.id}" ([${job.alias}]).`
648
+ }
649
+
650
+ const md = await readDelegationReport(outputDir, terminal)
651
+ if (md === undefined) {
652
+ return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
653
+ }
654
+
655
+ await board.markReconciled(job.taskID)
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)}`
659
+ },
660
+ }
661
+
662
+ const pantheon_delegation_list: DelegationTool<typeof listArgs> = {
663
+ description:
664
+ 'List background delegations for the current session, with [unread] for finished jobs.',
665
+ args: listArgs,
666
+ execute: async (_args, ctx) => {
667
+ const jobs = board.list(ctx.sessionID)
668
+ if (jobs.length === 0) return 'No background delegations for this session.'
669
+
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
+ )
680
+ return `Background Delegations (${jobs.length}):\n${lines.join('\n')}`
681
+ },
682
+ }
683
+
684
+ return {
685
+ pantheon_delegate,
686
+ pantheon_delegation_read,
687
+ pantheon_delegation_list,
688
+ finalizeDelegation: finalize,
689
+ }
690
+ }