pantheon-opencode 1.5.0-beta.8 → 1.5.0

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 (82) hide show
  1. package/AGENTS.md +14 -63
  2. package/CHANGELOG.md +320 -0
  3. package/README.md +14 -27
  4. package/README.pt-BR.md +5 -6
  5. package/commands/pantheon.md +1 -35
  6. package/package.json +1 -2
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +2 -5
  9. package/scripts/doctor.mjs +32 -3
  10. package/scripts/install/plugin.mjs +9 -12
  11. package/scripts/mcp_persistence_server.py +1 -1
  12. package/scripts/sync-tui.mjs +8 -11
  13. package/src/agents/apollo.md +1 -0
  14. package/src/agents/gaia.md +1 -0
  15. package/src/agents/zeus.md +9 -52
  16. package/src/instructions/memory-protocol.instructions.md +6 -3
  17. package/src/instructions/zeus-anti-stall.instructions.md +2 -4
  18. package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
  19. package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
  20. package/src/mcp/mcp_persistence_server.py +1 -1
  21. package/src/mcp/toon_codec.py +2 -4
  22. package/src/pantheon/background-job-board.ts +4 -4
  23. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  24. package/src/pantheon/cost-command.ts +1 -2
  25. package/src/pantheon/delegation-enforce.ts +59 -14
  26. package/src/pantheon/goal-loop.ts +1 -1
  27. package/src/pantheon/goal-store.ts +1 -1
  28. package/src/pantheon/idle-continuation.ts +4 -6
  29. package/src/pantheon/model-command.ts +1 -2
  30. package/src/pantheon/native-task-status.ts +2 -2
  31. package/src/pantheon/presets.d.mts +0 -20
  32. package/src/pantheon/presets.mjs +0 -64
  33. package/src/pantheon/session-guard.ts +2 -2
  34. package/src/pantheon/session-id.ts +30 -0
  35. package/src/pantheon/task-result-guard.ts +2 -3
  36. package/src/pantheon/todo-enforcer.ts +3 -3
  37. package/src/pantheon/tool-context.ts +23 -0
  38. package/src/pantheon/v2-bridge.ts +1 -4
  39. package/src/plugin-v2.ts +8 -21
  40. package/src/plugin.ts +61 -245
  41. package/src/plugins/pantheon-hooks.ts +1 -1
  42. package/src/plugins/tui/dist/tui.d.ts +229 -77
  43. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  44. package/src/plugins/tui/dist/tui.js +509 -259
  45. package/src/plugins/tui/package-lock.json +2 -2
  46. package/src/plugins/tui/package.json +2 -2
  47. package/src/plugins/tui/src/index.tsx +753 -279
  48. package/src/routing.yml +0 -16
  49. package/src/skills/context-compression/SKILL.md +1 -1
  50. package/scripts/generate-preset-docs.mjs +0 -300
  51. package/scripts/generate-prompts.sh +0 -222
  52. package/scripts/hash_verify.py +0 -192
  53. package/scripts/install/component.mjs +0 -24
  54. package/scripts/install/migrate-config.mjs +0 -302
  55. package/scripts/test-context-compression.sh +0 -169
  56. package/src/pantheon/auto-wake.ts +0 -85
  57. package/src/pantheon/chat-reminders.ts +0 -9
  58. package/src/pantheon/comment-checker.ts +0 -48
  59. package/src/pantheon/compaction-assert.ts +0 -160
  60. package/src/pantheon/compaction-handoff.ts +0 -304
  61. package/src/pantheon/cost-tracker.ts +0 -108
  62. package/src/pantheon/delegate-manager.ts +0 -766
  63. package/src/pantheon/delegation-classifier.ts +0 -307
  64. package/src/pantheon/delegation-finalize.ts +0 -406
  65. package/src/pantheon/delegation-notify.ts +0 -212
  66. package/src/pantheon/delegation-toast.ts +0 -73
  67. package/src/pantheon/delegation.ts +0 -1337
  68. package/src/pantheon/dispatch-guard.ts +0 -109
  69. package/src/pantheon/native-probe.ts +0 -234
  70. package/src/pantheon/permission-eval.ts +0 -284
  71. package/src/pantheon/preemptive-compact.ts +0 -76
  72. package/src/pantheon/provider-capabilities.ts +0 -221
  73. package/src/pantheon/provider-stream-tracker.ts +0 -182
  74. package/src/pantheon/retry-policy.ts +0 -212
  75. package/src/pantheon/step-cap.ts +0 -110
  76. package/src/pantheon/token-opt.ts +0 -689
  77. package/src/pantheon/toon-codec.ts +0 -481
  78. package/src/pantheon/v2-events.ts +0 -148
  79. package/src/pantheon/v2-hooks.ts +0 -287
  80. package/src/pantheon/v2-tool-definitions.ts +0 -331
  81. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
  82. package/src/plugins/tui/dist/tui.tsx +0 -2777
@@ -1,766 +0,0 @@
1
- /**
2
- * Delegate Manager (WS1, PR #94) — thin manager over the native task() call.
3
- *
4
- * Relaunch of the 1.4.2 delegation as a THIN manager: launch / monitor /
5
- * reconcile over an injected `task()`-shaped function. No monolithic
6
- * scheduler: no timers of its own beyond wait/timeout bounds, no idle-event
7
- * plumbing, no hidden queues. Completion is recorded ONLY after the result
8
- * is verified (non-empty content); anything else becomes `error`.
9
- *
10
- * - Minimal records: every launch returns `{ id, agent, state, line }`
11
- * (id, agent, state, one short line). Full reports only via `read()`.
12
- * - Auto-reconcile + signal delete on the spot after every terminal state.
13
- * - Entry cap + aggressive prune of completed/reconciled jobs.
14
- * - Concurrency limit (read live from the board) + foreground fallback.
15
- * - Model failover across an ordered model list.
16
- * - Step budgets reuse StepCapTracker (routing.yml max_steps, already
17
- * extended beyond zeus) + summarize-and-stop cut.
18
- * - Kill-switch: `PANTHEON_DELEGATION=off` disables launch/read/list.
19
- *
20
- * The V1 fire-and-forget path (delegation.ts) is untouched; the plugin wires
21
- * this manager in front of it behind `PANTHEON_DELEGATE_MODE`.
22
- *
23
- * @module delegate-manager
24
- */
25
-
26
- import { z } from 'zod'
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'
36
- import {
37
- buildStopInstruction,
38
- cappedSummary,
39
- DEFAULT_MAX_STEPS,
40
- type StepCapTracker,
41
- } from './step-cap.ts'
42
-
43
- // ─── Types ─────────────────────────────────────────────────────────────
44
-
45
- /** Input shape for the native task() call (injected — fake in tests). */
46
- export interface NativeTaskInput {
47
- agent: string
48
- prompt: string
49
- model?: string
50
- signal?: AbortSignal
51
- /** Board taskID assigned by the manager (== child session id on V1 hosts). */
52
- taskID: string
53
- parentSessionID: string
54
- }
55
-
56
- /** Result shape for the native task() call. */
57
- export interface NativeTaskResult {
58
- content?: string | null
59
- tokensInput?: number
60
- tokensOutput?: number
61
- }
62
-
63
- /** task()-shaped function: run one agent turn and return its result. */
64
- export type NativeTaskFn = (input: NativeTaskInput) => Promise<NativeTaskResult>
65
-
66
- /** Minimal launch receipt: id, agent, state, one short line. */
67
- export interface ManagerReceipt {
68
- id: string
69
- agent: string
70
- state: 'reconciled' | 'error'
71
- status: NativeTaskStatus
72
- line: string
73
- }
74
-
75
- export interface LaunchArgs {
76
- agent: string
77
- prompt: string
78
- description?: string
79
- model?: string
80
- /**
81
- * Board taskID to register. The V1 session adapter supplies the child
82
- * session id here so the idle hook and the manager share one record.
83
- * Defaults to a generated `native-<ts>-<seq>` id.
84
- */
85
- taskID?: string
86
- }
87
-
88
- export interface DelegateManagerOptions {
89
- board: BackgroundJobBoard
90
- task: NativeTaskFn
91
- parentSessionID: string
92
- /** Env mapping (default: process.env). Kill-switch reads PANTHEON_DELEGATION. */
93
- env?: Record<string, string | undefined>
94
- /** Ordered model list for failover (default: single attempt, inherited model). */
95
- models?: string[]
96
- /** Per-attempt wall-clock budget in ms (default: 120_000). */
97
- timeoutMs?: number
98
- /** read() wait budget in ms (default: 60_000). */
99
- readTimeoutMs?: number
100
- /** Run inline without a board record when concurrency is full (default: true). */
101
- foregroundFallback?: boolean
102
- /** Hard cap on board entries; oldest terminal jobs evicted first (default: 50). */
103
- maxEntries?: number
104
- /** Aggressive prune: completed/reconciled jobs kept (default: 10). */
105
- keepCompleted?: number
106
- /** Per-agent step budgets (routing.yml max_steps). */
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
112
- }
113
-
114
- export interface DelegateManager {
115
- launch(args: LaunchArgs): Promise<ManagerReceipt>
116
- ensureNativeAvailable(): Promise<NativeTaskStatus | undefined>
117
- read(idOrAlias: string): Promise<string>
118
- list(): Promise<string[]>
119
- recover(): Promise<void>
120
- }
121
-
122
- // ─── Kill-switch ───────────────────────────────────────────────────────
123
-
124
- /** False only when `PANTHEON_DELEGATION=off` (case-insensitive). */
125
- export function isDelegationEnabled(
126
- env: Record<string, string | undefined> = process.env,
127
- ): boolean {
128
- return (env.PANTHEON_DELEGATION ?? '').trim().toLowerCase() !== 'off'
129
- }
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
-
138
- // ─── Helpers ───────────────────────────────────────────────────────────
139
-
140
- const LINE_MAX = 160
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
-
162
- function firstLine(text: string, max = 80): string {
163
- const line =
164
- sanitizeReceiptText(text ?? '')
165
- .split('\n')
166
- .map((l) => l.trim())
167
- .find((l) => l !== '') ?? ''
168
- return line.length > max ? `${line.slice(0, max - 1)}…` : line
169
- }
170
-
171
- function shortState(state: BackgroundJobRecord['state']): string {
172
- switch (state) {
173
- case 'completed':
174
- return 'OK'
175
- case 'error':
176
- return 'ERR'
177
- case 'cancelled':
178
- return 'CAN'
179
- case 'running':
180
- return 'RUN'
181
- default:
182
- return 'REC'
183
- }
184
- }
185
-
186
- function boardLine(job: BackgroundJobRecord): string {
187
- const desc = firstLine(job.description, 80)
188
- return `[pantheon:${job.alias}] ${job.agent} — ${desc} — ${shortState(job.state)}`.slice(
189
- 0,
190
- LINE_MAX,
191
- )
192
- }
193
-
194
- function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
195
- let timer: ReturnType<typeof setTimeout> | undefined
196
- const guard = new Promise<never>((_, reject) => {
197
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
198
- timer.unref?.()
199
- })
200
- return Promise.race([promise, guard]).finally(() => {
201
- if (timer !== undefined) clearTimeout(timer)
202
- })
203
- }
204
-
205
- // ─── Factory ───────────────────────────────────────────────────────────
206
-
207
- export function createDelegateManager(options: DelegateManagerOptions): DelegateManager {
208
- const {
209
- board,
210
- task,
211
- parentSessionID,
212
- env = process.env,
213
- models: modelsOpt = [],
214
- timeoutMs = 120_000,
215
- readTimeoutMs = 60_000,
216
- foregroundFallback: fgFallbackOpt = true,
217
- maxEntries = 50,
218
- keepCompleted = 10,
219
- stepCap,
220
- nativeProbe,
221
- probeClient,
222
- } = options
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
-
230
- // Synchronous in-flight reservations per agent: launch() must decide
231
- // background vs foreground BEFORE its first await, otherwise concurrent
232
- // launches without interleaved awaits would all see an empty board.
233
- const pendingByAgent = new Map<string, number>()
234
- let seq = 0
235
- const foregroundRecent: { agent: string; line: string; at: number }[] = []
236
- let probeResult: Awaited<ReturnType<NativeProbeFn>> | null = null
237
-
238
- function pending(agent: string): number {
239
- return pendingByAgent.get(agent.toLowerCase()) ?? 0
240
- }
241
-
242
- function guardEnabled(): void {
243
- if (!isDelegationEnabled(env)) {
244
- throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
245
- }
246
- }
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
-
254
- async function runTask(
255
- agent: string,
256
- prompt: string,
257
- explicitModel: string | undefined,
258
- taskID: string,
259
- ): Promise<{ ok: true; result: NativeTaskResult } | { ok: false; error: string }> {
260
- const attempts: (string | undefined)[] =
261
- explicitModel !== undefined ? [explicitModel] : models.length > 0 ? models : [undefined]
262
- let lastError = 'no model attempts configured'
263
- for (const model of attempts) {
264
- try {
265
- const controller = new AbortController()
266
- const result = await withTimeout(
267
- task({
268
- agent,
269
- prompt,
270
- signal: controller.signal,
271
- taskID,
272
- parentSessionID,
273
- ...(model !== undefined ? { model } : {}),
274
- }),
275
- timeoutMs,
276
- `task(${agent})`,
277
- )
278
- return { ok: true, result }
279
- } catch (err) {
280
- lastError = err instanceof Error ? err.message : String(err)
281
- }
282
- }
283
- return { ok: false, error: lastError }
284
- }
285
-
286
- async function settle(
287
- taskID: string,
288
- alias: string,
289
- verifyError?: string,
290
- ): Promise<ManagerReceipt> {
291
- const job = board.get(taskID)
292
- if (!job) throw new Error(`job ${taskID} vanished after terminal state`)
293
- // The receipt reports the VERIFIED outcome, not the raw terminal state:
294
- // a `completed` the manager could not verify stays `error` for the
295
- // caller ("complete ONLY after verified result"). Reconcile is only the
296
- // acknowledgment that the result was consumed.
297
- const terminalState = job.state
298
- const failed = verifyError !== undefined || terminalState === 'error'
299
- const status = classifyNativeResult(job, verifyError)
300
- // Auto-reconcile + delete the signal file on the spot.
301
- if (job.state !== 'reconciled') await board.markReconciled(taskID)
302
- await board.deleteSignal(alias)
303
- // Teto de entradas + prune agressivo de completed.
304
- await board.pruneCompleted(keepCompleted)
305
- await board.enforceEntryCap(maxEntries)
306
- const settled = board.get(taskID)
307
- const line = failed
308
- ? `[pantheon:${alias}] ${job.agent} — ERROR: ${firstLine(verifyError ?? settled?.lastStatusError ?? job.lastStatusError ?? 'failed', 90)}`.slice(
309
- 0,
310
- LINE_MAX,
311
- )
312
- : boardLine(settled ?? job)
313
- return { id: taskID, agent: job.agent, state: failed ? 'error' : 'reconciled', status, line }
314
- }
315
-
316
- /** Apply a terminal transition best-effort: a racing hook may have gotten there first. */
317
- async function applyTerminal(
318
- taskID: string,
319
- state: 'completed' | 'error',
320
- fields: { resultSummary?: string; error?: string },
321
- ): Promise<void> {
322
- try {
323
- await board.updateStatus({ taskID, state, ...fields })
324
- } catch (err) {
325
- const msg = err instanceof Error ? err.message : String(err)
326
- if (!msg.startsWith('Invalid state transition')) throw err
327
- // Lost the race to another terminal writer (e.g. the V1 idle hook) —
328
- // the record stands; verification still governs the receipt.
329
- }
330
- }
331
-
332
- async function launchBackground(args: LaunchArgs, prompt: string): Promise<ManagerReceipt> {
333
- const key = args.agent.toLowerCase()
334
- pendingByAgent.set(key, pending(args.agent) + 1)
335
- seq += 1
336
- const taskID = args.taskID ?? `native-${Date.now()}-${seq}`
337
- try {
338
- const record = await board.registerLaunch({
339
- taskID,
340
- parentSessionID,
341
- agent: args.agent,
342
- description: args.description ?? firstLine(args.prompt, 120),
343
- })
344
- const outcome = await runTask(args.agent, prompt, args.model, taskID)
345
- if (!outcome.ok) {
346
- await applyTerminal(taskID, 'error', { error: outcome.error })
347
- return await settle(taskID, record.alias, outcome.error)
348
- }
349
- if (typeof outcome.result.content !== 'string' || outcome.result.content.trim() === '') {
350
- const verifyError = 'task result empty — not verified, never completed'
351
- await applyTerminal(taskID, 'error', { error: verifyError })
352
- return await settle(taskID, record.alias, verifyError)
353
- }
354
- await applyTerminal(taskID, 'completed', {
355
- resultSummary: firstLine(outcome.result.content, 500),
356
- })
357
- return await settle(taskID, record.alias)
358
- } finally {
359
- pendingByAgent.set(key, Math.max(0, pending(args.agent) - 1))
360
- }
361
- }
362
-
363
- async function launchForeground(args: LaunchArgs, prompt: string): Promise<ManagerReceipt> {
364
- seq += 1
365
- const id = args.taskID ?? `fg-${Date.now()}-${seq}`
366
- const outcome = await runTask(args.agent, prompt, args.model, id)
367
- const ok =
368
- outcome.ok &&
369
- typeof outcome.result.content === 'string' &&
370
- outcome.result.content.trim() !== ''
371
- const detail = ok
372
- ? firstLine((outcome as { ok: true; result: NativeTaskResult }).result.content ?? '', 90)
373
- : `ERROR: ${(outcome as { ok: false; error: string }).error ?? 'empty result — not verified'}`
374
- const line = `[native] ${args.agent} — ${detail} — ${ok ? 'OK' : 'ERR'} (foreground)`.slice(
375
- 0,
376
- LINE_MAX,
377
- )
378
- foregroundRecent.push({ agent: args.agent, line, at: Date.now() })
379
- if (foregroundRecent.length > 20) foregroundRecent.splice(0, foregroundRecent.length - 20)
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 }
407
- }
408
-
409
- return {
410
- async launch(args: LaunchArgs): Promise<ManagerReceipt> {
411
- guardEnabled()
412
- // R4 step budgets (routing.yml max_steps, every agent): capped agents
413
- // skip dispatch with a stop summary — no session, no board job.
414
- let prompt = args.prompt
415
- if (stepCap !== undefined) {
416
- const maxSteps = stepCap.maxStepsFor(args.agent)
417
- if (stepCap.isCapped(args.agent)) {
418
- const line = cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS).slice(0, LINE_MAX)
419
- return {
420
- id: `cap-${Date.now()}`,
421
- agent: args.agent,
422
- state: 'reconciled',
423
- status: 'ESCALATE',
424
- line,
425
- }
426
- }
427
- const rec = stepCap.recordStep(args.agent)
428
- if (rec.capped && rec.maxSteps !== undefined) {
429
- prompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
430
- }
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
- }
444
- // Synchronous routing decision: background when a slot is free,
445
- // foreground fallback when full, clean rejection otherwise.
446
- const free =
447
- board.canDispatch(args.agent) && pending(args.agent) < board.maxConcurrentPerAgent
448
- if (free) return launchBackground(args, prompt)
449
- if (foregroundFallback) return launchForeground(args, prompt)
450
- throw new Error(
451
- `pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}"`,
452
- )
453
- },
454
- ensureNativeAvailable,
455
-
456
- async read(idOrAlias: string): Promise<string> {
457
- guardEnabled()
458
- const job = board.resolve(parentSessionID, idOrAlias) ?? board.get(idOrAlias)
459
- if (!job) throw new Error(`delegation not found: ${idOrAlias}`)
460
- const terminal = await withTimeout(
461
- board.waitForTerminal(job.taskID, readTimeoutMs),
462
- readTimeoutMs + 5_000,
463
- `read(${idOrAlias})`,
464
- )
465
- if (terminal.state !== 'reconciled') await board.markReconciled(terminal.taskID)
466
- await board.deleteSignal(terminal.alias)
467
- if (terminal.state === 'error' && !terminal.resultSummary) {
468
- return `# Delegation Report — ${terminal.alias}\n\nERROR: ${sanitizeReceiptText(terminal.lastStatusError ?? 'failed')}\n`
469
- }
470
- return (
471
- `# Delegation Report — ${terminal.alias}\n\n` +
472
- `${sanitizeReceiptText(terminal.resultSummary ?? terminal.lastStatusError ?? terminal.state)}\n`
473
- )
474
- },
475
-
476
- async list(): Promise<string[]> {
477
- guardEnabled()
478
- const lines = board.list(parentSessionID).map((job) => boardLine(job))
479
- for (const fg of foregroundRecent) lines.push(fg.line)
480
- return lines
481
- },
482
-
483
- async recover(): Promise<void> {
484
- await board.recoverRunningJobs()
485
- },
486
- }
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
- }