pantheon-opencode 1.3.2 → 1.3.3

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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Delegation Notify (Phase 3) — completion notifications for background jobs.
3
+ *
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.
18
+ *
19
+ * `handleDelegationEvent` wires the `event` hook: `session.idle`/`session.error`
20
+ * 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.
24
+ *
25
+ * Pure TypeScript — zero runtime dependencies beyond the board types.
26
+ *
27
+ * Follow-up (Themis review, Phase 5): `extractErrorMessage` handles the two
28
+ * observed `session.error` error shapes (string, { message }). The opencode SDK
29
+ * MAY expose other shapes (e.g. Error-like with non-string `message`) — if a
30
+ * real session.error ever carries one, the extraction degrades to the generic
31
+ * "session error" label. No code change: there is no evidence any other shape
32
+ * occurs in practice; revisit if a session.error shows an unexpected payload.
33
+ *
34
+ * @module delegation-notify
35
+ */
36
+
37
+ import type { BackgroundJobRecord } from './background-job-board.ts'
38
+ import type { FinalizeInput } from './delegation-finalize.ts'
39
+
40
+ export type { FinalizeInput } from './delegation-finalize.ts'
41
+
42
+ // ─── Types ─────────────────────────────────────────────────────────────
43
+
44
+ /** One queued notification: text + enqueue timestamp. */
45
+ export interface QueuedNotification {
46
+ text: string
47
+ at: number
48
+ }
49
+
50
+ /** Structural view of the event payload the `event` hook receives. */
51
+ export interface DelegationEventLike {
52
+ type?: string
53
+ properties?: Record<string, unknown>
54
+ }
55
+
56
+ /** Dependencies threaded to handleDelegationEvent (board lookup + finalize). */
57
+ export interface DelegationEventDeps {
58
+ /** Look up a board job by session ID (child session IDs are task IDs). */
59
+ board: { get(taskID: string): BackgroundJobRecord | undefined }
60
+ /** Bound Phase 2 finalize — clears the timeout timer + writes the report. */
61
+ finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
62
+ }
63
+
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
+ // ─── Event → finalize wiring ───────────────────────────────────────────
196
+
197
+ function extractErrorMessage(error: unknown): string {
198
+ if (typeof error === 'string' && error.trim() !== '') return error
199
+ if (error && typeof error === 'object' && 'message' in error) {
200
+ const message = (error as { message: unknown }).message
201
+ if (typeof message === 'string' && message.trim() !== '') return message
202
+ }
203
+ return 'session error'
204
+ }
205
+
206
+ /**
207
+ * Handle a lifecycle event for background delegations:
208
+ * - `session.idle` on a board job → finalize as completed;
209
+ * - `session.error` on a board job → finalize as error (message extracted);
210
+ * - any other event, a missing session ID, or an unknown session → no-op.
211
+ *
212
+ * Finalize transitions the board, which fires `onTerminal` — the single point
213
+ * that queues the completion notification (no double-notify). The board's
214
+ * same-terminal idempotency makes repeated idles harmless (finalize rewrites
215
+ * the report but never transitions a terminal job to a different state).
216
+ *
217
+ * @returns True when the event named a board job (handled).
218
+ */
219
+ export async function handleDelegationEvent(
220
+ ev: DelegationEventLike,
221
+ deps: DelegationEventDeps,
222
+ ): Promise<boolean> {
223
+ const type = ev?.type
224
+ if (type !== 'session.idle' && type !== 'session.error') return false
225
+ const sessionID = ev.properties?.sessionID
226
+ if (typeof sessionID !== 'string' || sessionID === '') return false
227
+ if (deps.board.get(sessionID) === undefined) return false
228
+
229
+ if (type === 'session.idle') {
230
+ await deps.finalize(sessionID, { state: 'completed' })
231
+ } else {
232
+ await deps.finalize(sessionID, {
233
+ state: 'error',
234
+ error: extractErrorMessage(ev.properties?.error),
235
+ })
236
+ }
237
+ return true
238
+ }
@@ -0,0 +1,388 @@
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;
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.
27
+ *
28
+ * Read-only enforcement is Phase 4 — `read_only` is exposed on the delegate
29
+ * args and the child session is registered in the read-only registry when the
30
+ * delegate is read-only (explicit flag or agent ∈ readOnlyAgents), which the
31
+ * plugin's `tool.execute.before` guard enforces.
32
+ *
33
+ * @module delegation
34
+ */
35
+
36
+ import { z } from 'zod'
37
+
38
+ import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
39
+ import { readOnlyRegistry } from './delegation-enforce.ts'
40
+ import {
41
+ DELEGATION_DEFAULTS,
42
+ type DelegationClient,
43
+ type DelegationDeps,
44
+ type DelegationOptions,
45
+ type FinalizeInput,
46
+ finalizeDelegation as finalizeDelegationReport,
47
+ readDelegationReport,
48
+ } from './delegation-finalize.ts'
49
+ import { resolveActivePreset } from './presets.mjs'
50
+
51
+ export type {
52
+ DelegationClient,
53
+ DelegationClientSession,
54
+ DelegationMessageBundle,
55
+ DelegationOptions,
56
+ FinalizeInput,
57
+ } from './delegation-finalize.ts'
58
+ export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
59
+
60
+ // ─── Types ─────────────────────────────────────────────────────────────
61
+
62
+ /** Structural view of the tool context opencode passes to execute(). */
63
+ export interface ToolContextLike {
64
+ sessionID: string
65
+ directory?: string
66
+ worktree?: string
67
+ agent?: string
68
+ }
69
+
70
+ /** A delegation tool: description + zod args shape + execute. */
71
+ export interface DelegationTool<Args extends z.ZodRawShape = z.ZodRawShape> {
72
+ description: string
73
+ args: Args
74
+ execute(args: z.infer<z.ZodObject<Args>>, ctx: ToolContextLike): Promise<string>
75
+ }
76
+
77
+ /** The full toolset returned by createDelegationTools(). */
78
+ export interface DelegationToolset {
79
+ pantheon_delegate: DelegationTool<typeof delegateArgs>
80
+ pantheon_delegation_read: DelegationTool<typeof readArgs>
81
+ pantheon_delegation_list: DelegationTool<typeof listArgs>
82
+ /** Lifecycle hook for the Phase 3 event wiring (session.idle on the child). */
83
+ finalizeDelegation: (
84
+ childSessionID: string,
85
+ opts: FinalizeInput,
86
+ ) => Promise<BackgroundJobRecord | undefined>
87
+ }
88
+
89
+ /** Input for createDelegationTools(). */
90
+ export interface CreateDelegationToolsInput {
91
+ board: BackgroundJobBoard
92
+ client: DelegationClient
93
+ options?: DelegationOptions
94
+ }
95
+
96
+ // ─── Args schemas ──────────────────────────────────────────────────────
97
+
98
+ const delegateArgs = {
99
+ prompt: z.string().min(1).describe('Task prompt delivered to the background agent.'),
100
+ agent: z.string().min(1).describe('Agent name, e.g. "apollo" or "hermes".'),
101
+ description: z.string().optional().describe('Human-readable description shown on the job board.'),
102
+ read_only: z.boolean().optional().describe('Advisory flag for Phase 4 read-only enforcement.'),
103
+ model: z
104
+ .string()
105
+ .optional()
106
+ .describe(
107
+ 'Explicit model for the child session (provider/model, e.g. "opencode/deepseek-v4-flash-free").',
108
+ ),
109
+ } satisfies z.ZodRawShape
110
+
111
+ const readArgs = {
112
+ id: z.string().min(1).describe('Job alias (e.g. "apo-1") or task ID to read.'),
113
+ } satisfies z.ZodRawShape
114
+
115
+ const listArgs = {} satisfies z.ZodRawShape
116
+
117
+ // ─── State labels ──────────────────────────────────────────────────────
118
+
119
+ function stateLabel(state: BackgroundJobRecord['state']): string {
120
+ switch (state) {
121
+ case 'running':
122
+ return 'RUNNING'
123
+ case 'completed':
124
+ return 'OK'
125
+ case 'error':
126
+ return 'ERR'
127
+ case 'cancelled':
128
+ return 'CANCELLED'
129
+ case 'reconciled':
130
+ return 'RECONCILED'
131
+ }
132
+ }
133
+
134
+ // ─── Model resolution ──────────────────────────────────────────────────
135
+
136
+ /** Session model ref accepted by the opencode server's session.create. */
137
+ export interface ChildSessionModelRef {
138
+ id: string
139
+ providerID: string
140
+ }
141
+
142
+ /**
143
+ * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
144
+ * into the `{ id, providerID }` ref the session.create body expects.
145
+ * Returns undefined for malformed IDs (no slash, empty segments).
146
+ */
147
+ function splitModelRef(model: string): ChildSessionModelRef | undefined {
148
+ const slash = model.indexOf('/')
149
+ if (slash <= 0 || slash === model.length - 1) return undefined
150
+ return { providerID: model.slice(0, slash), id: model.slice(slash + 1) }
151
+ }
152
+
153
+ /**
154
+ * Resolve the child session model with priority:
155
+ * 1. explicit `model` option passed to the delegate tool;
156
+ * 2. `options.agentModels[agent]` (routing.yml agent entry, case-insensitive);
157
+ * 3. the active preset's agent entry via resolveActivePreset();
158
+ * 4. fallback: undefined — the child is created without a model (the
159
+ * caller warns) so opencode's default applies.
160
+ */
161
+ function resolveChildModel(
162
+ options: DelegationOptions,
163
+ agent: string,
164
+ explicitModel?: string,
165
+ ): ChildSessionModelRef | undefined {
166
+ // (a) explicit model option on the delegate tool call
167
+ if (explicitModel !== undefined && explicitModel !== '') {
168
+ const ref = splitModelRef(explicitModel)
169
+ if (ref !== undefined) return ref
170
+ }
171
+ const key = agent.toLowerCase()
172
+ // (b) routing.yml agent entry wired through options.agentModels
173
+ const mapped = options.agentModels?.[key]
174
+ if (mapped !== undefined && mapped !== '') {
175
+ const ref = splitModelRef(mapped)
176
+ if (ref !== undefined) return ref
177
+ }
178
+ // (c) active preset agent entry (resolveActivePreset reads routing.yml
179
+ // presets + .pantheon/active-preset.json). Guarded — a broken routing.yml
180
+ // must never kill the delegate.
181
+ try {
182
+ const presetModel = resolveActivePreset({
183
+ ...(options.presetEnv !== undefined ? { env: options.presetEnv } : {}),
184
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
185
+ })?.agents?.[key]?.model
186
+ if (presetModel !== undefined && presetModel !== '') {
187
+ const ref = splitModelRef(presetModel)
188
+ if (ref !== undefined) return ref
189
+ }
190
+ } catch (err: unknown) {
191
+ const reason = err instanceof Error ? err.message : String(err)
192
+ options.logger?.warn?.(`[pantheon-delegate] active preset resolution failed: ${reason}`)
193
+ }
194
+ return undefined
195
+ }
196
+
197
+ // ─── Factory ───────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Build the delegation toolset. Keeps per-instance state: the set of child
201
+ * sessions WE created (depth-guard default) and the per-job timeout timers
202
+ * (cleared on finalize, `.unref()`'d so they never hold the process open).
203
+ */
204
+ export function createDelegationTools(input: CreateDelegationToolsInput): DelegationToolset {
205
+ const { board, client } = input
206
+ const options: DelegationOptions = input.options ?? {}
207
+ const deps: DelegationDeps = { board, client, options }
208
+ const outputDir = options.outputDir ?? DELEGATION_DEFAULTS.outputDir
209
+ const timeoutMs = options.timeoutMs ?? DELEGATION_DEFAULTS.timeoutMs
210
+ const readTimeoutMs = options.readTimeoutMs ?? DELEGATION_DEFAULTS.readTimeoutMs
211
+
212
+ const knownChildren = new Set<string>()
213
+ const timers = new Map<string, NodeJS.Timeout>()
214
+
215
+ function isRootSession(sessionID: string): boolean {
216
+ if (options.isRootSession !== undefined) return options.isRootSession(sessionID)
217
+ if (options.rootSessions !== undefined) return options.rootSessions.has(sessionID)
218
+ return !knownChildren.has(sessionID)
219
+ }
220
+
221
+ function clearTimer(childSessionID: string): void {
222
+ const timer = timers.get(childSessionID)
223
+ if (timer !== undefined) {
224
+ clearTimeout(timer)
225
+ timers.delete(childSessionID)
226
+ }
227
+ }
228
+
229
+ /** Bound finalize: clear the job timer, then run the report + transition. */
230
+ async function finalize(
231
+ childSessionID: string,
232
+ opts: FinalizeInput,
233
+ ): Promise<BackgroundJobRecord | undefined> {
234
+ clearTimer(childSessionID)
235
+ return finalizeDelegationReport(deps, childSessionID, opts)
236
+ }
237
+
238
+ const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
239
+ description:
240
+ 'Dispatch a background agent as a child session and register it on the job board. ' +
241
+ 'Returns the readable alias (e.g. "apo-1"); read the result with ' +
242
+ 'pantheon_delegation_read. Only root sessions may delegate.',
243
+ args: delegateArgs,
244
+ execute: async (args, ctx) => {
245
+ if (!isRootSession(ctx.sessionID)) {
246
+ throw new Error(
247
+ `pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
248
+ )
249
+ }
250
+ if (!board.canDispatch(args.agent)) {
251
+ throw new Error(
252
+ `pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}" ` +
253
+ `(${board.getRunningCount(args.agent)} running)`,
254
+ )
255
+ }
256
+
257
+ // session.create failure → return a clear error as TEXT (tools return
258
+ // errors as text, not thrown) and register NO job on the board — an
259
+ // 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
+ }
270
+ let created: { id: string }
271
+ try {
272
+ created = await client.session.create({
273
+ body: {
274
+ parentID: ctx.sessionID,
275
+ title: args.description ?? args.prompt.slice(0, 80),
276
+ ...(childModel !== undefined ? { model: childModel } : {}),
277
+ },
278
+ })
279
+ } catch (err: unknown) {
280
+ const reason = err instanceof Error ? err.message : String(err)
281
+ return `pantheon_delegate failed to create child session: ${reason}`
282
+ }
283
+ const childSessionID = created.id
284
+ knownChildren.add(childSessionID)
285
+
286
+ // Phase 4 read-only enforcement: register the child session in the
287
+ // read-only registry when the delegate is read-only — explicit
288
+ // `read_only: true` on the call, or the agent ∈ readOnlyAgents
289
+ // (case-insensitive). The plugin's tool.execute.before guard then
290
+ // denies edit/write/bash/task inside that session.
291
+ const readOnly =
292
+ args.read_only === true || (options.readOnlyAgents?.has(args.agent.toLowerCase()) ?? false)
293
+ if (readOnly) {
294
+ const entry: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
295
+ if (args.read_only !== undefined) entry.readOnlyFlag = args.read_only
296
+ readOnlyRegistry.register(childSessionID, entry)
297
+ }
298
+
299
+ const job = await board.registerLaunch({
300
+ taskID: childSessionID,
301
+ parentSessionID: ctx.sessionID,
302
+ agent: args.agent,
303
+ description: args.description ?? args.prompt,
304
+ objective: args.prompt,
305
+ })
306
+
307
+ // Timeout manager: cleared on finalize, unref'd so the timer never
308
+ // keeps the process alive on its own.
309
+ const timer = setTimeout(() => {
310
+ timers.delete(childSessionID)
311
+ void finalize(childSessionID, {
312
+ state: 'error',
313
+ error: `Delegation [${job.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
314
+ timedOut: true,
315
+ }).catch((err: unknown) =>
316
+ console.error('[pantheon-delegate] timeout finalize failed:', err),
317
+ )
318
+ }, timeoutMs)
319
+ timer.unref()
320
+ timers.set(childSessionID, timer)
321
+
322
+ // Fire-and-forget — completion is observed via session.idle on the
323
+ // child (spike: noReply does NOT deliver anything to the parent).
324
+ void client.session
325
+ .promptAsync({
326
+ path: { id: childSessionID },
327
+ body: { agent: args.agent, parts: [{ type: 'text', text: args.prompt }] },
328
+ })
329
+ .catch((err: unknown) => console.error('[pantheon-delegate] promptAsync failed:', err))
330
+
331
+ return (
332
+ `Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
333
+ `Read the result with pantheon_delegation_read({ id: "${job.alias}" }).`
334
+ )
335
+ },
336
+ }
337
+
338
+ const pantheon_delegation_read: DelegationTool<typeof readArgs> = {
339
+ description:
340
+ '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.',
342
+ args: readArgs,
343
+ execute: async (args, ctx) => {
344
+ const job = board.resolve(ctx.sessionID, args.id)
345
+ if (!job) {
346
+ return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
347
+ }
348
+
349
+ let terminal: BackgroundJobRecord
350
+ try {
351
+ terminal = await board.waitForTerminal(job.taskID, readTimeoutMs)
352
+ } catch {
353
+ return `Timed out after ${readTimeoutMs}ms waiting for delegation "${args.id}" ([${job.alias}]).`
354
+ }
355
+
356
+ const md = await readDelegationReport(outputDir, terminal)
357
+ if (md === undefined) {
358
+ return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
359
+ }
360
+
361
+ await board.markReconciled(job.taskID)
362
+ return md
363
+ },
364
+ }
365
+
366
+ const pantheon_delegation_list: DelegationTool<typeof listArgs> = {
367
+ description:
368
+ 'List background delegations for the current session, with [unread] for finished jobs.',
369
+ args: listArgs,
370
+ execute: async (_args, ctx) => {
371
+ const jobs = board.list(ctx.sessionID)
372
+ if (jobs.length === 0) return 'No background delegations for this session.'
373
+
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
+ })
378
+ return `Background Delegations (${jobs.length}):\n${lines.join('\n')}`
379
+ },
380
+ }
381
+
382
+ return {
383
+ pantheon_delegate,
384
+ pantheon_delegation_read,
385
+ pantheon_delegation_list,
386
+ finalizeDelegation: finalize,
387
+ }
388
+ }