pantheon-opencode 1.5.0-beta.6 → 1.5.0-beta.8

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.
@@ -15,14 +15,19 @@
15
15
  *
16
16
  * @module cost-command
17
17
  */
18
- import { existsSync } from 'node:fs'
18
+ import { execFile } from 'node:child_process'
19
+ import { accessSync, constants, existsSync, statSync } from 'node:fs'
19
20
  import { homedir } from 'node:os'
20
- import { join } from 'node:path'
21
+ import { delimiter, dirname, join } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
21
23
  import { z } from 'zod'
22
24
 
23
25
  import type { ToolContextLike } from './delegation.ts'
24
26
  import type { NativeTaskStatus } from './native-task-status.ts'
25
27
 
28
+ const __filename = fileURLToPath(import.meta.url)
29
+ const __dirname = dirname(__filename)
30
+
26
31
  /** One aggregated token row (per agent and phase). */
27
32
  export interface CostRow {
28
33
  agent: string
@@ -37,6 +42,8 @@ export interface CostCommandOptions {
37
42
  dbPath?: string
38
43
  /** Injectable loader for tests; production always loads node:sqlite. */
39
44
  sqliteLoader?: () => Promise<SqliteModule>
45
+ /** Injectable CLI-fallback process boundary (tests / unusual runtimes). */
46
+ cliFallback?: CliFallbackDeps
40
47
  }
41
48
 
42
49
  export type OpenCodeVersion = 'v1' | 'v2'
@@ -220,6 +227,230 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
220
227
  return lines.join('\n')
221
228
  }
222
229
 
230
+ // ─── CLI fallback: resolve a REAL Node, not the plugin runtime ──────────
231
+ //
232
+ // Under Bun (the runtime that motivates this fallback), process.execPath IS
233
+ // the Bun binary, which has no node:sqlite — so spawning it could never work.
234
+ // Resolve a genuine Node instead: PANTHEON_NODE → `node` on PATH → clear error.
235
+
236
+ /** First Node release exposing the built-in `node:sqlite` module. */
237
+ const NODE_SQLITE_MIN_VERSION = '22.5'
238
+
239
+ /** Minimal injectable child-process boundary (tests / unusual runtimes). */
240
+ export type ExecFileLike = (
241
+ file: string,
242
+ args: readonly string[],
243
+ ) => Promise<{ stdout: string; stderr?: string }>
244
+
245
+ /** Injectables so the fallback never depends on the real machine in tests. */
246
+ export interface CliFallbackDeps {
247
+ /** Environment for PANTHEON_NODE / PATH lookup. Default: process.env. */
248
+ env?: NodeJS.ProcessEnv
249
+ /** Child-process boundary. Default: node:child_process.execFile. */
250
+ execFile?: ExecFileLike
251
+ /** Executable predicate used by the PANTHEON_NODE / PATH lookups. */
252
+ isExecutable?: (path: string) => boolean
253
+ }
254
+
255
+ /** A real Node executable located for the CLI fallback. */
256
+ export interface ResolvedNode {
257
+ path: string
258
+ source: 'env' | 'path'
259
+ }
260
+
261
+ type ExecFailure = Error & { stdout?: string; stderr?: string }
262
+
263
+ function defaultIsExecutable(path: string): boolean {
264
+ try {
265
+ accessSync(path, constants.X_OK)
266
+ return statSync(path).isFile()
267
+ } catch {
268
+ return false
269
+ }
270
+ }
271
+
272
+ function defaultExecFile(
273
+ file: string,
274
+ args: readonly string[],
275
+ ): Promise<{ stdout: string; stderr: string }> {
276
+ return new Promise((resolve, reject) => {
277
+ execFile(file, [...args], { encoding: 'utf8' }, (error, stdout, stderr) => {
278
+ if (error) {
279
+ // execFile's callback error does not always carry the captured output;
280
+ // attach it so callers can surface cost.mjs's structured {ok:false}.
281
+ const failure = error as ExecFailure
282
+ if (typeof failure.stdout !== 'string') failure.stdout = stdout
283
+ if (typeof failure.stderr !== 'string') failure.stderr = stderr
284
+ reject(failure)
285
+ return
286
+ }
287
+ resolve({ stdout, stderr })
288
+ })
289
+ })
290
+ }
291
+
292
+ /** Scan PATH (no shell) for a `node` executable. */
293
+ export function resolveNodeFromPath(
294
+ env: NodeJS.ProcessEnv,
295
+ isExecutable: (path: string) => boolean = defaultIsExecutable,
296
+ ): string | undefined {
297
+ const pathValue = env.PATH ?? env.Path ?? ''
298
+ if (pathValue === '') return undefined
299
+ const isWindows = process.platform === 'win32'
300
+ const extensions = isWindows
301
+ ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter((ext) => ext !== '')
302
+ : ['']
303
+ for (const dir of pathValue.split(delimiter)) {
304
+ if (dir === '') continue
305
+ for (const ext of extensions) {
306
+ const candidate = join(dir, `node${ext}`)
307
+ if (isExecutable(candidate)) return candidate
308
+ }
309
+ }
310
+ return undefined
311
+ }
312
+
313
+ /**
314
+ * Resolve a REAL Node.js binary for the CLI fallback.
315
+ *
316
+ * Order: PANTHEON_NODE (must exist and be executable — an explicit override
317
+ * that is unusable fails fast instead of being silently ignored) → `node`
318
+ * from PATH. Returns undefined only when neither source is available.
319
+ */
320
+ export function resolveNodeExecutable(
321
+ env: NodeJS.ProcessEnv = process.env,
322
+ isExecutable: (path: string) => boolean = defaultIsExecutable,
323
+ ): ResolvedNode | undefined {
324
+ const explicit = env.PANTHEON_NODE
325
+ if (explicit !== undefined && explicit.trim() !== '') {
326
+ if (isExecutable(explicit)) return { path: explicit, source: 'env' }
327
+ throw new Error(
328
+ `PANTHEON_NODE is set to "${explicit}" but that is not an existing executable file`,
329
+ )
330
+ }
331
+ const fromPath = resolveNodeFromPath(env, isExecutable)
332
+ return fromPath === undefined ? undefined : { path: fromPath, source: 'path' }
333
+ }
334
+
335
+ function execErrorMessage(err: unknown): string {
336
+ if (err instanceof Error) {
337
+ const failure = err as ExecFailure
338
+ const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
339
+ if (stderr !== '') return stderr
340
+ return failure.message
341
+ }
342
+ return String(err)
343
+ }
344
+
345
+ function tryParseJson(text: string): unknown {
346
+ try {
347
+ return JSON.parse(text)
348
+ } catch {
349
+ return undefined
350
+ }
351
+ }
352
+
353
+ function truncateForError(text: string): string {
354
+ const trimmed = text.trim()
355
+ return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed
356
+ }
357
+
358
+ /** Probe the resolved binary for built-in node:sqlite support. */
359
+ async function assertNodeSqliteSupport(
360
+ nodePath: string,
361
+ execFileAsync: ExecFileLike,
362
+ ): Promise<void> {
363
+ try {
364
+ await execFileAsync(nodePath, ['-e', "require('node:sqlite')"])
365
+ } catch (err: unknown) {
366
+ throw new Error(
367
+ `resolved Node binary "${nodePath}" does not support node:sqlite (Node.js >= ${NODE_SQLITE_MIN_VERSION} required): ${execErrorMessage(err)}`,
368
+ )
369
+ }
370
+ }
371
+
372
+ /** Pull the structured failure message cost.mjs prints to stdout (exit 1). */
373
+ function extractScriptFailure(err: unknown): string {
374
+ const failure = err as { stdout?: unknown; stderr?: unknown }
375
+ const stdout = typeof failure.stdout === 'string' ? failure.stdout : ''
376
+ const parsed = tryParseJson(stdout)
377
+ if (parsed !== null && typeof parsed === 'object') {
378
+ const record = parsed as { ok?: unknown; error?: unknown }
379
+ if (record.ok === false && typeof record.error === 'string' && record.error !== '') {
380
+ return record.error
381
+ }
382
+ }
383
+ const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
384
+ if (stderr !== '') return stderr
385
+ return execErrorMessage(err)
386
+ }
387
+
388
+ /**
389
+ * Spawn `node scripts/cost.mjs` as a CLI fallback when node:sqlite is
390
+ * unavailable in the plugin process (e.g. Bun runtime). Returns CostRow[] by
391
+ * parsing the JSON the script writes to stdout.
392
+ *
393
+ * Resolves a REAL Node binary (PANTHEON_NODE → PATH): process.execPath under
394
+ * Bun is the Bun binary, which cannot run node:sqlite. The resolved binary is
395
+ * probed for node:sqlite support and the script is always run read-only with
396
+ * an argument array (never a shell).
397
+ */
398
+ export async function queryWithCliFallback(
399
+ dbPath: string,
400
+ days: number,
401
+ deps?: CliFallbackDeps,
402
+ ): Promise<CostRow[]> {
403
+ const env = deps?.env ?? process.env
404
+ const execFileAsync = deps?.execFile ?? defaultExecFile
405
+ const isExecutable = deps?.isExecutable ?? defaultIsExecutable
406
+
407
+ const costScript = join(__dirname, '..', '..', 'scripts', 'cost.mjs')
408
+ if (!existsSync(costScript)) {
409
+ throw new Error(`CLI fallback script not found: ${costScript}`)
410
+ }
411
+
412
+ const node = resolveNodeExecutable(env, isExecutable)
413
+ if (node === undefined) {
414
+ throw new Error(
415
+ `no Node.js executable found for the node:sqlite CLI fallback: set PANTHEON_NODE to a Node.js >= ${NODE_SQLITE_MIN_VERSION} binary or put "node" on PATH`,
416
+ )
417
+ }
418
+
419
+ await assertNodeSqliteSupport(node.path, execFileAsync)
420
+
421
+ let stdout: string
422
+ try {
423
+ ;({ stdout } = await execFileAsync(node.path, [
424
+ costScript,
425
+ '--db-path',
426
+ dbPath,
427
+ '--days',
428
+ String(days),
429
+ ]))
430
+ } catch (err: unknown) {
431
+ // cost.mjs sets exitCode=1 (execFile rejects) and writes {ok:false,error}
432
+ // to stdout before exiting — surface that structured failure.
433
+ throw new Error(`CLI fallback failed: ${extractScriptFailure(err)}`)
434
+ }
435
+
436
+ const parsed = tryParseJson(stdout)
437
+ if (parsed === null || typeof parsed !== 'object') {
438
+ throw new Error(`CLI fallback returned non-JSON output: ${truncateForError(stdout)}`)
439
+ }
440
+ const record = parsed as { ok?: unknown; rows?: unknown; error?: unknown }
441
+ if (record.ok !== true) {
442
+ const detail =
443
+ typeof record.error === 'string' && record.error !== ''
444
+ ? record.error
445
+ : 'unexpected response shape from scripts/cost.mjs'
446
+ throw new Error(`CLI fallback failed: ${detail}`)
447
+ }
448
+ if (!Array.isArray(record.rows)) {
449
+ throw new Error('CLI fallback failed: scripts/cost.mjs response is missing the rows array')
450
+ }
451
+ return record.rows as CostRow[]
452
+ }
453
+
223
454
  export function createCostCommand(options?: CostCommandOptions): CostCommand {
224
455
  const loadSqlite =
225
456
  options?.sqliteLoader ?? (async () => (await import('node:sqlite')) as SqliteModule)
@@ -269,7 +500,22 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
269
500
  `opencode.db not found (expected at ${expected}). No token history is available until opencode has stored session data.`,
270
501
  )
271
502
  }
272
- const sqlite = await loadSqlite()
503
+ let sqlite: SqliteModule | undefined
504
+ try {
505
+ sqlite = await loadSqlite()
506
+ } catch (loadErr: unknown) {
507
+ // node:sqlite unavailable (e.g. Bun runtime) — fall back to CLI.
508
+ const status = statusForError(loadErr)
509
+ if (status === 'UNSUPPORTED') {
510
+ try {
511
+ const rows = await queryWithCliFallback(dbPath, days, options?.cliFallback)
512
+ return `status: OK\n${renderMarkdown(rows, days)}`
513
+ } catch (cliErr: unknown) {
514
+ return failure(statusForError(cliErr), diagnostic(cliErr))
515
+ }
516
+ }
517
+ throw loadErr
518
+ }
273
519
  const rows = await queryWithNodeSqlite(dbPath, days, sqlite)
274
520
  return `status: OK\n${renderMarkdown(rows, days)}`
275
521
  } catch (err: unknown) {
@@ -144,9 +144,25 @@ export interface DelegationOptions {
144
144
  promptTimeoutMs?: number
145
145
  /** Poll interval used by the bootstrap watchdog. */
146
146
  bootstrapPollIntervalMs?: number
147
+ /**
148
+ * Max completed jobs to keep per agent after pruning (default: 10).
149
+ * Matches the native delegate-manager default.
150
+ */
151
+ keepCompleted?: number
152
+ /**
153
+ * Hard cap on total board entries (default: 50).
154
+ * Matches the native delegate-manager default.
155
+ */
156
+ maxEntries?: number
147
157
  /** Injectable clock/sleeper for deterministic watchdog tests. */
148
158
  now?: () => number
149
159
  sleep?: (ms: number) => Promise<void>
160
+ /**
161
+ * Kill-switch: when false, all delegation tools (delegate/read/list) throw
162
+ * immediately. Mirrors `PANTHEON_DELEGATION=off` from the native manager.
163
+ * Defaults to true (delegation enabled) when not specified.
164
+ */
165
+ delegationEnabled?: boolean
150
166
  }
151
167
 
152
168
  /** Dependencies threaded to the finalize path. */
@@ -370,5 +386,21 @@ export async function finalizeDelegation(
370
386
  // Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
371
387
  // report above still captures this finalize; the board state stands.
372
388
  }
389
+
390
+ // P1-1: Delete the auto-wake signal file to prevent leaks. The board
391
+ // writes .signal.json on every terminal transition, but the legacy path
392
+ // never called deleteSignal — only the native delegate-manager did.
393
+ // The consumer (auto-wake.ts consumeWakeSignals) is intentionally NOT
394
+ // wired in the event hook: finalizeDelegation already handles the
395
+ // terminal transition, so consuming signals would be redundant and risks
396
+ // double-processing. deleteSignal on finalize is sufficient.
397
+ await deps.board.deleteSignal(job.alias)
398
+
399
+ // P1-4: Prune old completed jobs and enforce the hard entry cap. The
400
+ // native delegate-manager called these in settle(), but the legacy
401
+ // finalize path omitted them, allowing unbounded board growth.
402
+ await deps.board.pruneCompleted(deps.options.keepCompleted ?? 10)
403
+ await deps.board.enforceEntryCap(deps.options.maxEntries ?? 50)
404
+
373
405
  return deps.board.get(childSessionID) ?? job
374
406
  }
@@ -61,6 +61,7 @@ import {
61
61
  import { finalizeIdleChildrenWithoutMd } from './delegation-notify.ts'
62
62
  import { createPantheonLogger } from './logger.ts'
63
63
  import { buildAgentListDescription } from './permission-globs.ts'
64
+ import { safeSessionPath } from './session-guard.ts'
64
65
  import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
65
66
 
66
67
  export type {
@@ -96,6 +97,40 @@ function readIntegerEnv(
96
97
  return Number.isSafeInteger(value) && value >= minimum ? value : fallback
97
98
  }
98
99
 
100
+ // ─── B1: unresolved session template guard ─────────────────────────────
101
+ //
102
+ // The TUI/runtime can hand a plugin surface the UNSUBSTITUTED template literal
103
+ // `{sessionID}` (URL-encoded `%7BsessionID%7D`) when no session is focused.
104
+ // Forwarding it to a session API makes the opencode server reject every call
105
+ // (`Expected a string starting with "ses", got "%7BsessionID%7D"`), which the
106
+ // beta.7 E2E recorded at ~1k errors/s. The delegation layer is a defensive
107
+ // choke point too: it is downstream of the plugin adapter in production, but
108
+ // tests, older hosts, and direct embedders can inject a raw client.
109
+ //
110
+ // This guard targets the placeholder ONLY. It is deliberately NOT the full
111
+ // `isValidSessionId` contract: structural test hosts use non-"ses" ids on
112
+ // purpose (delegation-bootstrap.test.ts), and rejecting those would be a
113
+ // behavior change unrelated to this bug. `session-guard.ts` remains the
114
+ // strict choke point for the plugin adapters.
115
+ const UNRESOLVED_SESSION_TEMPLATE = /(?:\{sessionID\}|%7BsessionID%7D)/i
116
+
117
+ /** True when a session id is an unresolved `{sessionID}` template placeholder. */
118
+ export function isUnresolvedSessionTemplate(id: unknown): boolean {
119
+ return typeof id === 'string' && UNRESOLVED_SESSION_TEMPLATE.test(id)
120
+ }
121
+
122
+ /**
123
+ * Refuse a placeholder session id before it reaches a session API. Returns
124
+ * true (and logs a clear warning) when the caller must skip the operation.
125
+ */
126
+ function refuseUnresolvedSessionTemplate(id: unknown, operation: string): boolean {
127
+ if (!isUnresolvedSessionTemplate(id)) return false
128
+ log.warn(
129
+ `[pantheon-delegate] ${operation} skipped: unresolved session template "${String(id)}" — the host did not substitute the session id`,
130
+ )
131
+ return true
132
+ }
133
+
99
134
  type BootstrapOutcome =
100
135
  | { status: 'started'; elapsedMs: number; reason: string }
101
136
  | { status: 'startup_failed' | 'bootstrap_unknown'; elapsedMs: number; reason: string }
@@ -115,6 +150,9 @@ async function promptWithTimeout(
115
150
  input: Parameters<DelegationClient['session']['promptAsync']>[0],
116
151
  timeoutMs: number,
117
152
  ): Promise<PromptOutcome> {
153
+ if (refuseUnresolvedSessionTemplate(input.path.id, 'promptAsync')) {
154
+ return { status: 'rejected', reason: `invalid sessionID: ${input.path.id}` }
155
+ }
118
156
  const controller = new AbortController()
119
157
  let timer: NodeJS.Timeout | undefined
120
158
  const operation = client.session.promptAsync({ ...input, signal: controller.signal }).then(
@@ -190,6 +228,14 @@ async function bootstrapChild(
190
228
  const hasStatusAPI = typeof client.session.get === 'function'
191
229
  const acceptedRunning = isRunningSession(accepted)
192
230
 
231
+ if (refuseUnresolvedSessionTemplate(childSessionID, 'bootstrap probe')) {
232
+ return {
233
+ status: 'bootstrap_unknown',
234
+ elapsedMs: 0,
235
+ reason: `invalid child sessionID "${childSessionID}"`,
236
+ }
237
+ }
238
+
193
239
  if (!hasMessagesAPI && !hasStatusAPI) {
194
240
  return acceptedRunning
195
241
  ? {
@@ -403,6 +449,72 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
403
449
  }
404
450
  }
405
451
 
452
+ // ─── B2: session-scoped delegation target resolution ───────────────────
453
+ //
454
+ // Board aliases restart per parent session (apo-1 in every root), so an alias
455
+ // lookup MUST be scoped to the calling session. `pantheon_delegation_read`
456
+ // previously delegated to the board's first-match resolver and, when the id
457
+ // was not found in the current session, the generic "Unknown delegation"
458
+ // message hid the real cause (the alias belonged to another session). This
459
+ // helper makes the scoping explicit and returns a CLEAR error for each
460
+ // cross-session / ambiguous case.
461
+
462
+ /** Resolution outcome for a delegation read by task ID or alias. */
463
+ export type DelegationTargetResolution =
464
+ | { kind: 'found'; job: BackgroundJobRecord }
465
+ | { kind: 'error'; message: string }
466
+
467
+ /**
468
+ * Resolve a `pantheon_delegation_read` target with strict session scoping:
469
+ * - exact task-ID match first (must belong to `sessionID`);
470
+ * - then alias matches WITHIN `sessionID` (exactly one required);
471
+ * - an alias that exists only in another session → clear cross-session
472
+ * error (never another session's job);
473
+ * - two matches in this session → clear ambiguity error.
474
+ */
475
+ export function resolveDelegationTarget(
476
+ board: Pick<BackgroundJobBoard, 'list'>,
477
+ sessionID: string,
478
+ idOrAlias: string,
479
+ ): DelegationTargetResolution {
480
+ const jobs = board.list()
481
+
482
+ const byTaskID = jobs.find((job) => job.taskID === idOrAlias)
483
+ if (byTaskID !== undefined) {
484
+ if (byTaskID.parentSessionID === sessionID) return { kind: 'found', job: byTaskID }
485
+ return {
486
+ kind: 'error',
487
+ message:
488
+ `Delegation "${idOrAlias}" belongs to a different session ` +
489
+ `(${byTaskID.parentSessionID}) and cannot be read from ${sessionID}.`,
490
+ }
491
+ }
492
+
493
+ const aliasMatches = jobs.filter((job) => job.alias === idOrAlias)
494
+ const inSession = aliasMatches.filter((job) => job.parentSessionID === sessionID)
495
+ if (inSession.length === 1) return { kind: 'found', job: inSession[0]! }
496
+ if (inSession.length > 1) {
497
+ return {
498
+ kind: 'error',
499
+ message:
500
+ `Delegation alias "${idOrAlias}" is ambiguous in session ${sessionID} ` +
501
+ `(${inSession.length} matches); read it by task ID instead.`,
502
+ }
503
+ }
504
+ if (aliasMatches.length > 0) {
505
+ return {
506
+ kind: 'error',
507
+ message:
508
+ `Delegation alias "${idOrAlias}" belongs to a different session — ` +
509
+ 'aliases are session-scoped. Use pantheon_delegation_list in the session that created it.',
510
+ }
511
+ }
512
+ return {
513
+ kind: 'error',
514
+ message: `Unknown delegation "${idOrAlias}" for this session. Use pantheon_delegation_list to see active delegations.`,
515
+ }
516
+ }
517
+
406
518
  /** Normalize the SDK-provided caller agent without inventing an identity. */
407
519
  const normalizeToolAgent = normalizeDelegationAgent
408
520
 
@@ -509,6 +621,7 @@ async function sampleChildActivity(
509
621
  collector: ActivityCollector,
510
622
  ): Promise<void> {
511
623
  if (typeof client.session.messages !== 'function') return
624
+ if (refuseUnresolvedSessionTemplate(childSessionID, 'activity sample')) return
512
625
  try {
513
626
  const bundles = await client.session.messages({ path: { id: childSessionID } })
514
627
  if (!Array.isArray(bundles)) return
@@ -534,6 +647,7 @@ async function lastChildActivity(
534
647
  childSessionID: string,
535
648
  ): Promise<string | undefined> {
536
649
  if (typeof client.session.messages !== 'function') return undefined
650
+ if (refuseUnresolvedSessionTemplate(childSessionID, 'last activity fetch')) return undefined
537
651
  try {
538
652
  const bundles = await client.session.messages({ path: { id: childSessionID } })
539
653
  if (!Array.isArray(bundles)) return undefined
@@ -740,10 +854,59 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
740
854
  return finalizeDelegationReport(deps, childSessionID, opts)
741
855
  }
742
856
 
857
+ // Kill-switch gate: when delegationEnabled is explicitly false (mirrors
858
+ // PANTHEON_DELEGATION=off from the native manager), all legacy tools throw.
859
+ function guardEnabled(): void {
860
+ if (options.delegationEnabled === false) {
861
+ throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
862
+ }
863
+ }
864
+
865
+ /**
866
+ * Best-effort teardown of a child session whose `registerLaunch` was refused
867
+ * because the per-agent concurrency limit was hit between the pre-check and
868
+ * the registration (a race with another concurrent dispatch). Removes every
869
+ * side effect of the aborted dispatch — known-child marker, read-only
870
+ * registry entry and any reserved budget slot — then deletes the session when
871
+ * the host client exposes `session.delete`. Never throws: cleanup must not
872
+ * mask the original rejection.
873
+ */
874
+ async function abortRejectedLaunch(
875
+ childSessionID: string,
876
+ wasReadOnly: boolean,
877
+ refund?: { key: string | undefined; reserved: boolean },
878
+ ): Promise<void> {
879
+ knownChildren.delete(childSessionID)
880
+ if (wasReadOnly) readOnlyRegistry.unregister(childSessionID)
881
+ if (refund?.reserved && refund.key !== undefined) {
882
+ budgetUsage.set(refund.key, Math.max(0, (budgetUsage.get(refund.key) ?? 1) - 1))
883
+ }
884
+ // The plugin's adapted client (adaptDelegationClient) does not expose
885
+ // session.delete, and older hosts may not either. Delete best-effort so the
886
+ // abandoned session is not left behind; the registry cleanup above already
887
+ // removes the side effects that matter for read-only enforcement.
888
+ const session = client.session as {
889
+ delete?: (input: { path: { id: string } }) => Promise<unknown>
890
+ }
891
+ if (typeof session.delete !== 'function') return
892
+ const sessionPath = safeSessionPath(childSessionID)
893
+ if (sessionPath === null) {
894
+ log.warn(`[pantheon-delegate] session.delete skipped: invalid sessionID "${childSessionID}"`)
895
+ return
896
+ }
897
+ try {
898
+ await session.delete(sessionPath)
899
+ } catch (err: unknown) {
900
+ const reason = err instanceof Error ? err.message : String(err)
901
+ log.warn(`[pantheon-delegate] failed to delete orphan session ${childSessionID}: ${reason}`)
902
+ }
903
+ }
904
+
743
905
  const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
744
906
  description: buildDelegateDescription(options),
745
907
  args: delegateArgs,
746
908
  execute: async (args, ctx) => {
909
+ guardEnabled()
747
910
  if (!isRootSession(ctx.sessionID)) {
748
911
  throw new Error(
749
912
  `pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
@@ -855,6 +1018,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
855
1018
  return `pantheon_delegate failed to create child session: ${reason}`
856
1019
  }
857
1020
  const childSessionID = created.id
1021
+ // B1: a host that passes through the unresolved template must never
1022
+ // produce a board job / bootstrap poll — reject it here with a clear
1023
+ // message and refund the reserved budget (no child was started).
1024
+ if (isUnresolvedSessionTemplate(childSessionID)) {
1025
+ if (budgetReserved && budgetKey !== undefined) {
1026
+ budgetUsage.set(budgetKey, Math.max(0, (budgetUsage.get(budgetKey) ?? 1) - 1))
1027
+ }
1028
+ log.warn(
1029
+ `[pantheon-delegate] session.create returned unresolved session template "${childSessionID}" — child ignored`,
1030
+ )
1031
+ return `pantheon_delegate failed: host returned an unresolved child session template ("${childSessionID}"); expected an id starting with "ses".`
1032
+ }
858
1033
  knownChildren.add(childSessionID)
859
1034
  options.registerChildSession?.(childSessionID, ctx.sessionID)
860
1035
 
@@ -871,13 +1046,27 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
871
1046
  readOnlyRegistry.register(childSessionID, entry)
872
1047
  }
873
1048
 
874
- const job = await board.registerLaunch({
875
- taskID: childSessionID,
876
- parentSessionID: ctx.sessionID,
877
- agent: args.agent,
878
- description: args.description ?? effectivePrompt,
879
- objective: effectivePrompt,
880
- })
1049
+ let job: BackgroundJobRecord
1050
+ try {
1051
+ job = await board.registerLaunch({
1052
+ taskID: childSessionID,
1053
+ parentSessionID: ctx.sessionID,
1054
+ agent: args.agent,
1055
+ description: args.description ?? effectivePrompt,
1056
+ objective: effectivePrompt,
1057
+ })
1058
+ } catch (err: unknown) {
1059
+ // Concurrency race: another dispatch claimed the last slot after the
1060
+ // pre-check above but before this registration. Undo every side effect
1061
+ // of the aborted dispatch so no orphan session/registry survives, then
1062
+ // surface a clear rejection.
1063
+ const reason = err instanceof Error ? err.message : String(err)
1064
+ await abortRejectedLaunch(childSessionID, readOnly, {
1065
+ key: budgetKey,
1066
+ reserved: budgetReserved,
1067
+ })
1068
+ return `pantheon_delegate rejected: ${reason}`
1069
+ }
881
1070
 
882
1071
  // Timeout manager: cleared on finalize, unref'd so the timer never
883
1072
  // keeps the process alive on its own.
@@ -966,15 +1155,42 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
966
1155
  return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; retry creation failed: ${reason}`
967
1156
  }
968
1157
  const retryID = retry.id
1158
+ if (isUnresolvedSessionTemplate(retryID)) {
1159
+ log.warn(
1160
+ `[pantheon-delegate] retry session.create returned unresolved session template "${retryID}" — retry ignored`,
1161
+ )
1162
+ return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; host returned an unresolved retry session template ("${retryID}")`
1163
+ }
969
1164
  knownChildren.add(retryID)
970
1165
  options.registerChildSession?.(retryID, ctx.sessionID)
971
- const retryJob = await board.registerLaunch({
972
- taskID: retryID,
973
- parentSessionID: ctx.sessionID,
974
- agent: args.agent,
975
- description: args.description ?? effectivePrompt,
976
- objective: effectivePrompt,
977
- })
1166
+
1167
+ // Phase 4 read-only enforcement for the retry: recalculate readOnly
1168
+ // (same logic as the initial child at :866-872) and register the retry
1169
+ // session so the plugin's tool.execute.before guard enforces it.
1170
+ const retryReadOnly =
1171
+ args.read_only === true || (options.readOnlyAgents?.has(args.agent.toLowerCase()) ?? false)
1172
+ if (retryReadOnly) {
1173
+ const entry: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
1174
+ if (args.read_only !== undefined) entry.readOnlyFlag = args.read_only
1175
+ readOnlyRegistry.register(retryID, entry)
1176
+ }
1177
+
1178
+ let retryJob: BackgroundJobRecord
1179
+ try {
1180
+ retryJob = await board.registerLaunch({
1181
+ taskID: retryID,
1182
+ parentSessionID: ctx.sessionID,
1183
+ agent: args.agent,
1184
+ description: args.description ?? effectivePrompt,
1185
+ objective: effectivePrompt,
1186
+ })
1187
+ } catch (err: unknown) {
1188
+ // Same race as the initial child: the retry registration can be
1189
+ // refused. Clean up the retry session + registry entry before failing.
1190
+ const reason = err instanceof Error ? err.message : String(err)
1191
+ await abortRejectedLaunch(retryID, retryReadOnly)
1192
+ return `pantheon_delegate rejected: ${reason} (retry)`
1193
+ }
978
1194
  const retryTimer = setTimeout(() => {
979
1195
  timers.delete(retryID)
980
1196
  void finalize(retryID, {
@@ -1035,10 +1251,10 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1035
1251
  'Resolves by alias or task ID.',
1036
1252
  args: readArgs,
1037
1253
  execute: async (args, ctx) => {
1038
- const job = board.resolve(ctx.sessionID, args.id)
1039
- if (!job) {
1040
- return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
1041
- }
1254
+ guardEnabled()
1255
+ const resolution = resolveDelegationTarget(board, ctx.sessionID, args.id)
1256
+ if (resolution.kind === 'error') return resolution.message
1257
+ const job = resolution.job
1042
1258
 
1043
1259
  // Sample the child's messages while waiting so the caller sees what the
1044
1260
  // agent is doing — fail-open: no messages support → report as before.
@@ -1093,6 +1309,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1093
1309
  'List background delegations for the current session, with [unread] for finished jobs.',
1094
1310
  args: listArgs,
1095
1311
  execute: async (_args, ctx) => {
1312
+ guardEnabled()
1096
1313
  const jobs = board.list(ctx.sessionID)
1097
1314
  if (jobs.length === 0) return 'No background delegations for this session.'
1098
1315