pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.11

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 (83) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +1054 -0
  3. package/README.md +171 -16
  4. package/README.pt-BR.md +99 -13
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +23 -15
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +65 -0
  9. package/scripts/code_mode_server.py +431 -55
  10. package/scripts/cost.mjs +30 -14
  11. package/scripts/dev-tui.sh +48 -0
  12. package/scripts/doctor.mjs +207 -9
  13. package/scripts/install/cli-ui.mjs +17 -11
  14. package/scripts/install/config-migration.mjs +4 -6
  15. package/scripts/install/opencode.mjs +137 -35
  16. package/scripts/install/plugin.mjs +3 -33
  17. package/scripts/install/shared.mjs +43 -2
  18. package/scripts/install/strings.mjs +129 -0
  19. package/scripts/install-mcp.mjs +1 -0
  20. package/scripts/manifest-inventory.mjs +410 -0
  21. package/scripts/mcp_persistence_server.py +537 -128
  22. package/scripts/package-evidence.mjs +242 -0
  23. package/scripts/probe-context-rehydrate.mjs +240 -0
  24. package/scripts/probe-pantheon-cost.mjs +313 -0
  25. package/scripts/release-beta-version.mjs +56 -60
  26. package/scripts/sync-artifacts.mjs +169 -0
  27. package/scripts/sync-tui.mjs +121 -53
  28. package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
  29. package/scripts/validation-policy.mjs +7 -7
  30. package/scripts/version-check.mjs +113 -108
  31. package/scripts/versioning.mjs +230 -86
  32. package/src/agents/mnemosyne.md +1 -1
  33. package/src/agents/zeus.md +2 -2
  34. package/src/mcp/code_mode_server.py +431 -55
  35. package/src/mcp/mcp_persistence_server.py +537 -128
  36. package/src/pantheon/auto-wake.ts +1 -5
  37. package/src/pantheon/background-job-board.ts +43 -1
  38. package/src/pantheon/compaction-handoff.ts +304 -0
  39. package/src/pantheon/cost-command.ts +345 -62
  40. package/src/pantheon/delegate-manager.ts +390 -8
  41. package/src/pantheon/delegation-finalize.ts +32 -0
  42. package/src/pantheon/delegation.ts +235 -18
  43. package/src/pantheon/file-persistence-salvage.ts +64 -0
  44. package/src/pantheon/file-persistence.ts +194 -45
  45. package/src/pantheon/native-probe.ts +234 -0
  46. package/src/pantheon/native-task-status.ts +139 -0
  47. package/src/pantheon/provider-capabilities.ts +221 -0
  48. package/src/pantheon/provider-stream-tracker.ts +182 -0
  49. package/src/pantheon/shared-board.ts +37 -0
  50. package/src/pantheon/task-result-guard.ts +107 -0
  51. package/src/pantheon/token-opt.ts +146 -12
  52. package/src/pantheon/tool-ceiling.ts +159 -0
  53. package/src/pantheon/v2-events.ts +18 -1
  54. package/src/plugin-v2.ts +2 -14
  55. package/src/plugin.ts +28 -17
  56. package/src/plugins/pantheon-hooks.ts +51 -0
  57. package/src/plugins/tui/dist/server.d.ts +1 -1
  58. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  59. package/src/plugins/tui/dist/server.js +1 -1
  60. package/src/plugins/tui/dist/server.js.map +1 -1
  61. package/src/plugins/tui/dist/tui.d.ts +137 -27
  62. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  63. package/src/plugins/tui/dist/tui.js +459 -163
  64. package/src/plugins/tui/dist/tui.js.map +1 -1
  65. package/src/plugins/tui/dist/tui.tsx +506 -103
  66. package/src/plugins/tui/package-lock.json +2 -2
  67. package/src/plugins/tui/package.json +1 -1
  68. package/src/plugins/tui/src/index.tsx +506 -103
  69. package/.pantheon/code-mode/.gitkeep +0 -0
  70. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
  71. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
  72. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
  73. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
  74. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
  75. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
  76. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
  77. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
  78. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
  79. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
  80. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
  81. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
  82. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
  83. package/src/pantheon/delegate-task-adapter.ts +0 -444
@@ -23,7 +23,16 @@
23
23
  * @module delegate-manager
24
24
  */
25
25
 
26
+ import { z } from 'zod'
26
27
  import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
28
+ import type { DelegationToolset } from './delegation.ts'
29
+ import {
30
+ DELEGATION_DEFAULTS,
31
+ type DelegationClient,
32
+ readDelegationReport,
33
+ } from './delegation-finalize.ts'
34
+ import { createNativeProbe, type NativeProbeFn, type ProbeClient } from './native-probe.ts'
35
+ import { classifyNativeResult, type NativeTaskStatus } from './native-task-status.ts'
27
36
  import {
28
37
  buildStopInstruction,
29
38
  cappedSummary,
@@ -59,6 +68,7 @@ export interface ManagerReceipt {
59
68
  id: string
60
69
  agent: string
61
70
  state: 'reconciled' | 'error'
71
+ status: NativeTaskStatus
62
72
  line: string
63
73
  }
64
74
 
@@ -95,10 +105,15 @@ export interface DelegateManagerOptions {
95
105
  keepCompleted?: number
96
106
  /** Per-agent step budgets (routing.yml max_steps). */
97
107
  stepCap?: StepCapTracker
108
+ /** Lazy capability probe, invoked once before the first native dispatch. */
109
+ nativeProbe?: NativeProbeFn
110
+ /** V1 client used by the lazy capability probe. */
111
+ probeClient?: ProbeClient
98
112
  }
99
113
 
100
114
  export interface DelegateManager {
101
115
  launch(args: LaunchArgs): Promise<ManagerReceipt>
116
+ ensureNativeAvailable(): Promise<NativeTaskStatus | undefined>
102
117
  read(idOrAlias: string): Promise<string>
103
118
  list(): Promise<string[]>
104
119
  recover(): Promise<void>
@@ -113,13 +128,40 @@ export function isDelegationEnabled(
113
128
  return (env.PANTHEON_DELEGATION ?? '').trim().toLowerCase() !== 'off'
114
129
  }
115
130
 
131
+ export type DelegateMode = 'native' | 'legacy'
132
+ export function resolveDelegateMode(
133
+ env: Record<string, string | undefined> = process.env,
134
+ ): DelegateMode {
135
+ return (env.PANTHEON_DELEGATE_MODE ?? '').trim().toLowerCase() === 'native' ? 'native' : 'legacy'
136
+ }
137
+
116
138
  // ─── Helpers ───────────────────────────────────────────────────────────
117
139
 
118
140
  const LINE_MAX = 160
119
141
 
142
+ /** Remove terminal escapes and control characters from untrusted child output. */
143
+ export function sanitizeReceiptText(text: string): string {
144
+ let clean = ''
145
+ let skippingAnsi = false
146
+ for (const char of text) {
147
+ const code = char.charCodeAt(0)
148
+ if (skippingAnsi) {
149
+ if (code === 0x5b) continue
150
+ if (code >= 0x40 && code <= 0x7e) skippingAnsi = false
151
+ continue
152
+ }
153
+ if (code === 0x1b) {
154
+ skippingAnsi = true
155
+ continue
156
+ }
157
+ if (char === '\n' || char === '\t' || (code >= 0x20 && code <= 0x7e)) clean += char
158
+ }
159
+ return clean
160
+ }
161
+
120
162
  function firstLine(text: string, max = 80): string {
121
163
  const line =
122
- (text ?? '')
164
+ sanitizeReceiptText(text ?? '')
123
165
  .split('\n')
124
166
  .map((l) => l.trim())
125
167
  .find((l) => l !== '') ?? ''
@@ -168,21 +210,30 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
168
210
  task,
169
211
  parentSessionID,
170
212
  env = process.env,
171
- models = [],
213
+ models: modelsOpt = [],
172
214
  timeoutMs = 120_000,
173
215
  readTimeoutMs = 60_000,
174
- foregroundFallback = true,
216
+ foregroundFallback: fgFallbackOpt = true,
175
217
  maxEntries = 50,
176
218
  keepCompleted = 10,
177
219
  stepCap,
220
+ nativeProbe,
221
+ probeClient,
178
222
  } = options
179
223
 
224
+ // Strict native mode: foregroundFallback and models are overridden.
225
+ // The user-facing options are ignored when PANTHEON_DELEGATE_MODE=native.
226
+ const isNativeMode = resolveDelegateMode(env) === 'native'
227
+ const foregroundFallback = isNativeMode ? false : fgFallbackOpt
228
+ const models = isNativeMode ? [] : modelsOpt
229
+
180
230
  // Synchronous in-flight reservations per agent: launch() must decide
181
231
  // background vs foreground BEFORE its first await, otherwise concurrent
182
232
  // launches without interleaved awaits would all see an empty board.
183
233
  const pendingByAgent = new Map<string, number>()
184
234
  let seq = 0
185
235
  const foregroundRecent: { agent: string; line: string; at: number }[] = []
236
+ let probeResult: Awaited<ReturnType<NativeProbeFn>> | null = null
186
237
 
187
238
  function pending(agent: string): number {
188
239
  return pendingByAgent.get(agent.toLowerCase()) ?? 0
@@ -194,6 +245,12 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
194
245
  }
195
246
  }
196
247
 
248
+ async function ensureNativeAvailable(): Promise<NativeTaskStatus | undefined> {
249
+ if (!isNativeMode || nativeProbe === undefined || probeClient === undefined) return undefined
250
+ probeResult ??= await nativeProbe({ client: probeClient, parentSessionID })
251
+ return probeResult.status === 'AVAILABLE' ? undefined : probeResult.status
252
+ }
253
+
197
254
  async function runTask(
198
255
  agent: string,
199
256
  prompt: string,
@@ -239,6 +296,7 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
239
296
  // acknowledgment that the result was consumed.
240
297
  const terminalState = job.state
241
298
  const failed = verifyError !== undefined || terminalState === 'error'
299
+ const status = classifyNativeResult(job, verifyError)
242
300
  // Auto-reconcile + delete the signal file on the spot.
243
301
  if (job.state !== 'reconciled') await board.markReconciled(taskID)
244
302
  await board.deleteSignal(alias)
@@ -252,7 +310,7 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
252
310
  LINE_MAX,
253
311
  )
254
312
  : boardLine(settled ?? job)
255
- return { id: taskID, agent: job.agent, state: failed ? 'error' : 'reconciled', line }
313
+ return { id: taskID, agent: job.agent, state: failed ? 'error' : 'reconciled', status, line }
256
314
  }
257
315
 
258
316
  /** Apply a terminal transition best-effort: a racing hook may have gotten there first. */
@@ -319,7 +377,33 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
319
377
  )
320
378
  foregroundRecent.push({ agent: args.agent, line, at: Date.now() })
321
379
  if (foregroundRecent.length > 20) foregroundRecent.splice(0, foregroundRecent.length - 20)
322
- return { id, agent: args.agent, state: ok ? 'reconciled' : 'error', line }
380
+ const status = classifyNativeResult(
381
+ {
382
+ taskID: id,
383
+ parentSessionID,
384
+ agent: args.agent,
385
+ description: args.description ?? firstLine(args.prompt, 120),
386
+ state: ok ? 'completed' : 'error',
387
+ timedOut: false,
388
+ alias: id,
389
+ launchedAt: Date.now(),
390
+ updatedAt: Date.now(),
391
+ totalErrors: 0,
392
+ timeoutCount: 0,
393
+ terminalUnreconciled: false,
394
+ contextFiles: [],
395
+ ...(ok
396
+ ? {
397
+ resultSummary: firstLine(
398
+ (outcome as { ok: true; result: NativeTaskResult }).result.content ?? '',
399
+ 500,
400
+ ),
401
+ }
402
+ : {}),
403
+ },
404
+ ok ? undefined : (outcome as { ok: false; error?: string }).error,
405
+ )
406
+ return { id, agent: args.agent, state: ok ? 'reconciled' : 'error', status, line }
323
407
  }
324
408
 
325
409
  return {
@@ -332,13 +416,31 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
332
416
  const maxSteps = stepCap.maxStepsFor(args.agent)
333
417
  if (stepCap.isCapped(args.agent)) {
334
418
  const line = cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS).slice(0, LINE_MAX)
335
- return { id: `cap-${Date.now()}`, agent: args.agent, state: 'reconciled', line }
419
+ return {
420
+ id: `cap-${Date.now()}`,
421
+ agent: args.agent,
422
+ state: 'reconciled',
423
+ status: 'ESCALATE',
424
+ line,
425
+ }
336
426
  }
337
427
  const rec = stepCap.recordStep(args.agent)
338
428
  if (rec.capped && rec.maxSteps !== undefined) {
339
429
  prompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
340
430
  }
341
431
  }
432
+ if (isNativeMode) {
433
+ const unavailableStatus = await ensureNativeAvailable()
434
+ if (unavailableStatus !== undefined) {
435
+ return {
436
+ id: `probe-${parentSessionID}`,
437
+ agent: args.agent,
438
+ state: 'error',
439
+ status: unavailableStatus,
440
+ line: `[native] ${args.agent} — native API ${unavailableStatus.toLowerCase()} — ERR`,
441
+ }
442
+ }
443
+ }
342
444
  // Synchronous routing decision: background when a slot is free,
343
445
  // foreground fallback when full, clean rejection otherwise.
344
446
  const free =
@@ -349,6 +451,7 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
349
451
  `pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}"`,
350
452
  )
351
453
  },
454
+ ensureNativeAvailable,
352
455
 
353
456
  async read(idOrAlias: string): Promise<string> {
354
457
  guardEnabled()
@@ -362,11 +465,11 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
362
465
  if (terminal.state !== 'reconciled') await board.markReconciled(terminal.taskID)
363
466
  await board.deleteSignal(terminal.alias)
364
467
  if (terminal.state === 'error' && !terminal.resultSummary) {
365
- return `# Delegation Report — ${terminal.alias}\n\nERROR: ${terminal.lastStatusError ?? 'failed'}\n`
468
+ return `# Delegation Report — ${terminal.alias}\n\nERROR: ${sanitizeReceiptText(terminal.lastStatusError ?? 'failed')}\n`
366
469
  }
367
470
  return (
368
471
  `# Delegation Report — ${terminal.alias}\n\n` +
369
- `${terminal.resultSummary ?? terminal.lastStatusError ?? terminal.state}\n`
472
+ `${sanitizeReceiptText(terminal.resultSummary ?? terminal.lastStatusError ?? terminal.state)}\n`
370
473
  )
371
474
  },
372
475
 
@@ -382,3 +485,282 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
382
485
  },
383
486
  }
384
487
  }
488
+
489
+ // Native delegation tools live with the manager so the plugin mounts the
490
+ // manager directly; there is intentionally no adapter or kill-switch wrapper.
491
+ const delegateArgs = {
492
+ prompt: z.string().min(1),
493
+ agent: z.string().min(1),
494
+ description: z.string().optional(),
495
+ read_only: z.boolean().optional(),
496
+ model: z.string().optional(),
497
+ } satisfies z.ZodRawShape
498
+ const readArgs = { id: z.string().min(1) } satisfies z.ZodRawShape
499
+ const listArgs = {} satisfies z.ZodRawShape
500
+ export interface ToolContextLike {
501
+ sessionID: string
502
+ agent?: string
503
+ }
504
+ export interface SessionTaskOptions {
505
+ client: DelegationClient
506
+ board: BackgroundJobBoard
507
+ outputDir?: string
508
+ promptTimeoutMs?: number
509
+ settleTimeoutMs?: number
510
+ }
511
+ export function parseModelRef(model: string): { id: string; providerID: string } | undefined {
512
+ const idx = model.indexOf('/')
513
+ return idx > 0 && idx < model.length - 1
514
+ ? { providerID: model.slice(0, idx), id: model.slice(idx + 1) }
515
+ : undefined
516
+ }
517
+ export function extractOutputSection(md: string | undefined): string {
518
+ if (md === undefined) return ''
519
+ const at = md.indexOf('## Output')
520
+ const body = at < 0 ? md : md.slice(at + 9)
521
+ const errAt = body.indexOf('\n**Error**:')
522
+ return (errAt < 0 ? body : body.slice(0, errAt)).trim()
523
+ }
524
+ async function promptAccept(
525
+ client: DelegationClient,
526
+ taskID: string,
527
+ agent: string,
528
+ prompt: string,
529
+ model: string | undefined,
530
+ signal: AbortSignal | undefined,
531
+ timeoutMs: number,
532
+ ): Promise<void> {
533
+ const modelRef = model === undefined ? undefined : parseModelRef(model)
534
+ let timer: ReturnType<typeof setTimeout> | undefined
535
+ try {
536
+ await Promise.race([
537
+ client.session
538
+ .promptAsync({
539
+ path: { id: taskID },
540
+ body: {
541
+ agent,
542
+ ...(modelRef === undefined ? {} : { model: modelRef }),
543
+ parts: [{ type: 'text', text: prompt }],
544
+ },
545
+ ...(signal === undefined ? {} : { signal }),
546
+ })
547
+ .then(undefined, (error: unknown) => {
548
+ throw new Error(
549
+ `promptAsync rejected: ${error instanceof Error ? error.message : String(error)}`,
550
+ )
551
+ }),
552
+ new Promise<never>((_, reject) => {
553
+ timer = setTimeout(
554
+ () => reject(new Error(`promptAsync timed out after ${timeoutMs}ms`)),
555
+ timeoutMs,
556
+ )
557
+ timer.unref?.()
558
+ }),
559
+ ])
560
+ } finally {
561
+ if (timer !== undefined) clearTimeout(timer)
562
+ }
563
+ }
564
+ export function createSessionTaskFn(options: SessionTaskOptions): NativeTaskFn {
565
+ const {
566
+ client,
567
+ board,
568
+ outputDir = DELEGATION_DEFAULTS.outputDir,
569
+ promptTimeoutMs = 60_000,
570
+ settleTimeoutMs = DELEGATION_DEFAULTS.timeoutMs,
571
+ } = options
572
+ return async ({ agent, prompt, model, signal, taskID }) => {
573
+ await promptAccept(client, taskID, agent, prompt, model, signal, promptTimeoutMs)
574
+ const terminal = await board.waitForTerminal(taskID, settleTimeoutMs)
575
+ const md = await readDelegationReport(outputDir, terminal)
576
+ return { content: extractOutputSection(md) || terminal.resultSummary || '' }
577
+ }
578
+ }
579
+ export interface NativeDelegateWiring {
580
+ board: BackgroundJobBoard
581
+ client: DelegationClient
582
+ outputDir?: string
583
+ env?: Record<string, string | undefined>
584
+ isRootSession: (sessionID: string) => boolean
585
+ registerChildSession?: (sessionID: string, parentID: string) => void
586
+ registerReadOnlySession?: (
587
+ sessionID: string,
588
+ info: { agent: string; readOnlyFlag?: boolean },
589
+ ) => void
590
+ isReadOnlyAgent?: (agent: string) => boolean
591
+ agentModel?: (agent: string) => string | undefined
592
+ managerOptions?: Partial<
593
+ Omit<DelegateManagerOptions, 'board' | 'task' | 'parentSessionID' | 'env'>
594
+ >
595
+ stepCap?: StepCapTracker
596
+ }
597
+ export interface NativeDelegateToolset {
598
+ pantheon_delegate: {
599
+ description: string
600
+ args: typeof delegateArgs
601
+ execute(args: z.infer<z.ZodObject<typeof delegateArgs>>, ctx: ToolContextLike): Promise<string>
602
+ }
603
+ pantheon_delegation_read: {
604
+ description: string
605
+ args: typeof readArgs
606
+ execute(args: z.infer<z.ZodObject<typeof readArgs>>, ctx: ToolContextLike): Promise<string>
607
+ }
608
+ pantheon_delegation_list: {
609
+ description: string
610
+ args: typeof listArgs
611
+ execute(args: z.infer<z.ZodObject<typeof listArgs>>, ctx: ToolContextLike): Promise<string>
612
+ }
613
+ }
614
+ function shortFirstLine(text: string, max = 60): string {
615
+ const line =
616
+ sanitizeReceiptText(text)
617
+ .split('\n')
618
+ .map((item) => item.trim())
619
+ .find(Boolean) ?? ''
620
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line
621
+ }
622
+ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeDelegateToolset {
623
+ const {
624
+ board,
625
+ client,
626
+ env = process.env,
627
+ isRootSession,
628
+ registerChildSession,
629
+ registerReadOnlySession,
630
+ isReadOnlyAgent,
631
+ agentModel,
632
+ managerOptions = {},
633
+ stepCap,
634
+ } = wiring
635
+ const task = createSessionTaskFn({
636
+ client,
637
+ board,
638
+ ...(wiring.outputDir === undefined ? {} : { outputDir: wiring.outputDir }),
639
+ ...(managerOptions.timeoutMs === undefined
640
+ ? {}
641
+ : { promptTimeoutMs: managerOptions.timeoutMs }),
642
+ })
643
+ const managers = new Map<string, DelegateManager>()
644
+ const managerFor = (parentSessionID: string): DelegateManager => {
645
+ const existing = managers.get(parentSessionID)
646
+ if (existing !== undefined) return existing
647
+ const nativeProbe = createNativeProbe()
648
+ const manager = createDelegateManager({
649
+ ...managerOptions,
650
+ board,
651
+ task,
652
+ parentSessionID,
653
+ env,
654
+ ...(stepCap === undefined ? {} : { stepCap }),
655
+ nativeProbe,
656
+ probeClient: client,
657
+ })
658
+ managers.set(parentSessionID, manager)
659
+ return manager
660
+ }
661
+ return {
662
+ pantheon_delegate: {
663
+ description: 'Dispatch a background agent as a child session and return a verified receipt.',
664
+ args: delegateArgs,
665
+ execute: async (args, ctx) => {
666
+ if (!isDelegationEnabled(env))
667
+ throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
668
+ if (!isRootSession(ctx.sessionID))
669
+ throw new Error(
670
+ `pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
671
+ )
672
+ const manager = managerFor(ctx.sessionID)
673
+ const unavailableStatus = await manager.ensureNativeAvailable()
674
+ if (unavailableStatus !== undefined) {
675
+ return `status: ${unavailableStatus}\n[native] ${args.agent} — native API ${unavailableStatus.toLowerCase()} — ERR`
676
+ }
677
+ const model = args.model ?? agentModel?.(args.agent)
678
+ let childID: string
679
+ try {
680
+ const modelRef = model === undefined ? undefined : parseModelRef(model)
681
+ const created = await client.session.create({
682
+ body: {
683
+ parentID: ctx.sessionID,
684
+ title: args.description ?? shortFirstLine(args.prompt),
685
+ ...(modelRef === undefined ? {} : { model: modelRef }),
686
+ },
687
+ })
688
+ childID = created.id
689
+ } catch (error) {
690
+ return `pantheon_delegate failed: session.create rejected: ${sanitizeReceiptText(error instanceof Error ? error.message : String(error))}`
691
+ }
692
+ registerChildSession?.(childID, ctx.sessionID)
693
+ if (args.read_only === true || isReadOnlyAgent?.(args.agent) === true)
694
+ registerReadOnlySession?.(childID, {
695
+ agent: args.agent,
696
+ ...(args.read_only === undefined ? {} : { readOnlyFlag: args.read_only }),
697
+ })
698
+ const receipt = await manager.launch({
699
+ agent: args.agent,
700
+ prompt: args.prompt,
701
+ ...(args.description === undefined ? {} : { description: args.description }),
702
+ ...(args.model === undefined ? {} : { model: args.model }),
703
+ taskID: childID,
704
+ })
705
+ return `status: ${receipt.status}\n${receipt.line}`
706
+ },
707
+ },
708
+ pantheon_delegation_read: {
709
+ description: 'Block until a delegation finishes, then return its verified report.',
710
+ args: readArgs,
711
+ execute: async (args, ctx) => {
712
+ if (!isDelegationEnabled(env))
713
+ throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
714
+ return managerFor(ctx.sessionID).read(args.id)
715
+ },
716
+ },
717
+ pantheon_delegation_list: {
718
+ description: 'List background delegations.',
719
+ args: listArgs,
720
+ execute: async (_args, ctx) => {
721
+ if (!isDelegationEnabled(env))
722
+ throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
723
+ const lines = await managerFor(ctx.sessionID).list()
724
+ return lines.length > 0 ? lines.join('\n') : 'No delegations.'
725
+ },
726
+ },
727
+ }
728
+ }
729
+ export interface PluginNativeDelegationWiring {
730
+ board: BackgroundJobBoard
731
+ client: DelegationClient
732
+ isRootSession: (sessionID: string) => boolean
733
+ registerChildSession: (sessionID: string, parentID: string) => void
734
+ registerReadOnlySession: (
735
+ sessionID: string,
736
+ info: { agent: string; readOnlyFlag?: boolean },
737
+ ) => void
738
+ readOnlyAgents: ReadonlySet<string>
739
+ agentModels: Record<string, string | undefined>
740
+ stepCap: StepCapTracker
741
+ wallClockTimeoutMs?: number
742
+ finalizeDelegation: DelegationToolset['finalizeDelegation']
743
+ }
744
+ export type PluginNativeDelegation = NativeDelegateToolset & {
745
+ finalizeDelegation: DelegationToolset['finalizeDelegation']
746
+ }
747
+ export function buildPluginNativeDelegation(
748
+ wiring: PluginNativeDelegationWiring,
749
+ ): PluginNativeDelegation {
750
+ return {
751
+ ...createNativeDelegateTools({
752
+ board: wiring.board,
753
+ client: wiring.client,
754
+ isRootSession: wiring.isRootSession,
755
+ registerChildSession: wiring.registerChildSession,
756
+ registerReadOnlySession: wiring.registerReadOnlySession,
757
+ isReadOnlyAgent: (agent) => wiring.readOnlyAgents.has(agent.toLowerCase()),
758
+ agentModel: (agent) => wiring.agentModels[agent.toLowerCase()],
759
+ stepCap: wiring.stepCap,
760
+ ...(wiring.wallClockTimeoutMs === undefined
761
+ ? {}
762
+ : { managerOptions: { timeoutMs: wiring.wallClockTimeoutMs } }),
763
+ }),
764
+ finalizeDelegation: wiring.finalizeDelegation,
765
+ }
766
+ }
@@ -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
  }