pantheon-opencode 1.5.0-beta.16 → 1.5.0-beta.18

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 (41) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +13 -25
  3. package/README.pt-BR.md +4 -5
  4. package/package.json +1 -1
  5. package/plugin.json +1 -1
  6. package/pyproject.toml +1 -1
  7. package/scripts/mcp_persistence_server.py +1 -1
  8. package/src/agents/apollo.md +1 -0
  9. package/src/agents/gaia.md +1 -0
  10. package/src/agents/zeus.md +8 -29
  11. package/src/mcp/mcp_persistence_server.py +1 -1
  12. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  13. package/src/pantheon/cost-command.ts +1 -2
  14. package/src/pantheon/delegation-enforce.ts +59 -14
  15. package/src/pantheon/goal-loop.ts +1 -1
  16. package/src/pantheon/goal-store.ts +1 -1
  17. package/src/pantheon/idle-continuation.ts +4 -6
  18. package/src/pantheon/model-command.ts +1 -2
  19. package/src/pantheon/native-task-status.ts +2 -2
  20. package/src/pantheon/session-id.ts +30 -0
  21. package/src/pantheon/task-result-guard.ts +2 -3
  22. package/src/pantheon/todo-enforcer.ts +3 -3
  23. package/src/pantheon/tool-context.ts +23 -0
  24. package/src/pantheon/v2-bridge.ts +1 -4
  25. package/src/plugin-v2.ts +4 -15
  26. package/src/plugin.ts +61 -245
  27. package/src/plugins/tui/dist/tui.d.ts +49 -79
  28. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  29. package/src/plugins/tui/dist/tui.js +48 -189
  30. package/src/plugins/tui/package-lock.json +2 -2
  31. package/src/plugins/tui/package.json +1 -1
  32. package/src/plugins/tui/src/index.tsx +97 -295
  33. package/src/skills/context-compression/SKILL.md +1 -1
  34. package/src/pantheon/delegate-manager.ts +0 -851
  35. package/src/pantheon/delegation-classifier.ts +0 -306
  36. package/src/pantheon/delegation-finalize.ts +0 -468
  37. package/src/pantheon/delegation-notify.ts +0 -212
  38. package/src/pantheon/delegation-toast.ts +0 -73
  39. package/src/pantheon/delegation.ts +0 -1349
  40. package/src/pantheon/native-probe.ts +0 -234
  41. package/src/pantheon/step-cap.ts +0 -110
@@ -1,851 +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 { join } from 'node:path'
27
- import { z } from 'zod'
28
- import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
29
- import type { DelegationToolset } from './delegation.ts'
30
- import {
31
- capReportOutput,
32
- DELEGATION_DEFAULTS,
33
- type DelegationClient,
34
- readDelegationReport,
35
- resolveReadMaxChars,
36
- } from './delegation-finalize.ts'
37
- import { createNativeProbe, type NativeProbeFn, type ProbeClient } from './native-probe.ts'
38
- import { classifyNativeResult, type NativeTaskStatus } from './native-task-status.ts'
39
- import {
40
- buildStopInstruction,
41
- cappedSummary,
42
- DEFAULT_MAX_STEPS,
43
- type StepCapTracker,
44
- } from './step-cap.ts'
45
-
46
- // ─── Types ─────────────────────────────────────────────────────────────
47
-
48
- /** Input shape for the native task() call (injected — fake in tests). */
49
- export interface NativeTaskInput {
50
- agent: string
51
- prompt: string
52
- model?: string
53
- signal?: AbortSignal
54
- /** Board taskID assigned by the manager (== child session id on V1 hosts). */
55
- taskID: string
56
- parentSessionID: string
57
- }
58
-
59
- /** Result shape for the native task() call. */
60
- export interface NativeTaskResult {
61
- content?: string | null
62
- tokensInput?: number
63
- tokensOutput?: number
64
- }
65
-
66
- /** task()-shaped function: run one agent turn and return its result. */
67
- export type NativeTaskFn = (input: NativeTaskInput) => Promise<NativeTaskResult>
68
-
69
- /** Minimal launch receipt: id, agent, state, one short line. */
70
- export interface ManagerReceipt {
71
- id: string
72
- /** Board alias of the job (e.g. `apo-1`) — report files are keyed by it. */
73
- alias: string
74
- agent: string
75
- state: 'reconciled' | 'error'
76
- status: NativeTaskStatus
77
- line: string
78
- /** Verified child output (when the task completed with content). */
79
- output?: string
80
- }
81
-
82
- export interface LaunchArgs {
83
- agent: string
84
- prompt: string
85
- description?: string
86
- model?: string
87
- /**
88
- * Board taskID to register. The V1 session adapter supplies the child
89
- * session id here so the idle hook and the manager share one record.
90
- * Defaults to a generated `native-<ts>-<seq>` id.
91
- */
92
- taskID?: string
93
- }
94
-
95
- export interface DelegateManagerOptions {
96
- board: BackgroundJobBoard
97
- task: NativeTaskFn
98
- parentSessionID: string
99
- /** Env mapping (default: process.env). Kill-switch reads PANTHEON_DELEGATION. */
100
- env?: Record<string, string | undefined>
101
- /** Ordered model list for failover (default: single attempt, inherited model). */
102
- models?: string[]
103
- /** Per-attempt wall-clock budget in ms (default: 120_000). */
104
- timeoutMs?: number
105
- /** read() wait budget in ms (default: 60_000). */
106
- readTimeoutMs?: number
107
- /** Run inline without a board record when concurrency is full (default: true). */
108
- foregroundFallback?: boolean
109
- /** Hard cap on board entries; oldest terminal jobs evicted first (default: 50). */
110
- maxEntries?: number
111
- /** Aggressive prune: completed/reconciled jobs kept (default: 10). */
112
- keepCompleted?: number
113
- /** Per-agent step budgets (routing.yml max_steps). */
114
- stepCap?: StepCapTracker
115
- /** Lazy capability probe, invoked once before the first native dispatch. */
116
- nativeProbe?: NativeProbeFn
117
- /** V1 client used by the lazy capability probe. */
118
- probeClient?: ProbeClient
119
- /** Base directory for delegation reports (default `.pantheon/delegations`). */
120
- outputDir?: string
121
- /**
122
- * Inline cap (chars) for report content returned to the caller. Oversized
123
- * reports keep head+tail with an explicit `[TRUNCATED …]` marker pointing
124
- * at the full report file. Default: PANTHEON_DELEGATION_READ_MAX_CHARS
125
- * or 50_000.
126
- */
127
- readMaxChars?: number
128
- }
129
-
130
- export interface DelegateManager {
131
- launch(args: LaunchArgs): Promise<ManagerReceipt>
132
- ensureNativeAvailable(): Promise<NativeTaskStatus | undefined>
133
- read(idOrAlias: string): Promise<string>
134
- list(): Promise<string[]>
135
- recover(): Promise<void>
136
- }
137
-
138
- // ─── Kill-switch ───────────────────────────────────────────────────────
139
-
140
- /** False only when `PANTHEON_DELEGATION=off` (case-insensitive). */
141
- export function isDelegationEnabled(
142
- env: Record<string, string | undefined> = process.env,
143
- ): boolean {
144
- return (env.PANTHEON_DELEGATION ?? '').trim().toLowerCase() !== 'off'
145
- }
146
-
147
- export type DelegateMode = 'native' | 'legacy'
148
- export function resolveDelegateMode(
149
- env: Record<string, string | undefined> = process.env,
150
- ): DelegateMode {
151
- return (env.PANTHEON_DELEGATE_MODE ?? '').trim().toLowerCase() === 'native' ? 'native' : 'legacy'
152
- }
153
-
154
- // ─── Helpers ───────────────────────────────────────────────────────────
155
-
156
- const LINE_MAX = 160
157
-
158
- /** Remove terminal escapes and control characters from untrusted child output.
159
- * UTF-8 text (accents, emoji) is PRESERVED — only ANSI sequences, C0/C1
160
- * controls and DEL are stripped; receipt/report lines stay human-readable. */
161
- export function sanitizeReceiptText(text: string): string {
162
- let clean = ''
163
- let skippingAnsi = false
164
- for (const char of text) {
165
- const code = char.charCodeAt(0)
166
- if (skippingAnsi) {
167
- if (code === 0x5b) continue
168
- if (code >= 0x40 && code <= 0x7e) skippingAnsi = false
169
- continue
170
- }
171
- if (code === 0x1b) {
172
- skippingAnsi = true
173
- continue
174
- }
175
- const isControl = code < 0x20 || (code >= 0x7f && code <= 0x9f)
176
- if (!isControl || char === '\n' || char === '\t') clean += char
177
- }
178
- return clean
179
- }
180
-
181
- function firstLine(text: string, max = 80): string {
182
- const line =
183
- sanitizeReceiptText(text ?? '')
184
- .split('\n')
185
- .map((l) => l.trim())
186
- .find((l) => l !== '') ?? ''
187
- return line.length > max ? `${line.slice(0, max - 1)}…` : line
188
- }
189
-
190
- function shortState(state: BackgroundJobRecord['state']): string {
191
- switch (state) {
192
- case 'completed':
193
- return 'OK'
194
- case 'error':
195
- return 'ERR'
196
- case 'cancelled':
197
- return 'CAN'
198
- case 'running':
199
- return 'RUN'
200
- default:
201
- return 'REC'
202
- }
203
- }
204
-
205
- function boardLine(job: BackgroundJobRecord): string {
206
- const desc = firstLine(job.description, 80)
207
- return `[pantheon:${job.alias}] ${job.agent} — ${desc} — ${shortState(job.state)}`.slice(
208
- 0,
209
- LINE_MAX,
210
- )
211
- }
212
-
213
- function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
214
- let timer: ReturnType<typeof setTimeout> | undefined
215
- const guard = new Promise<never>((_, reject) => {
216
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
217
- timer.unref?.()
218
- })
219
- return Promise.race([promise, guard]).finally(() => {
220
- if (timer !== undefined) clearTimeout(timer)
221
- })
222
- }
223
-
224
- // ─── Factory ───────────────────────────────────────────────────────────
225
-
226
- export function createDelegateManager(options: DelegateManagerOptions): DelegateManager {
227
- const {
228
- board,
229
- task,
230
- parentSessionID,
231
- env = process.env,
232
- models: modelsOpt = [],
233
- timeoutMs = 120_000,
234
- readTimeoutMs = 60_000,
235
- foregroundFallback: fgFallbackOpt = true,
236
- maxEntries = 50,
237
- keepCompleted = 10,
238
- stepCap,
239
- nativeProbe,
240
- probeClient,
241
- outputDir = DELEGATION_DEFAULTS.outputDir,
242
- readMaxChars,
243
- } = options
244
- const inlineMaxChars = readMaxChars ?? resolveReadMaxChars(env)
245
-
246
- /** Report file path for a job alias (the durable full report). */
247
- const reportPathFor = (alias: string): string => join(outputDir, parentSessionID, `${alias}.md`)
248
-
249
- // Strict native mode: foregroundFallback and models are overridden.
250
- // The user-facing options are ignored when PANTHEON_DELEGATE_MODE=native.
251
- const isNativeMode = resolveDelegateMode(env) === 'native'
252
- const foregroundFallback = isNativeMode ? false : fgFallbackOpt
253
- const models = isNativeMode ? [] : modelsOpt
254
-
255
- // Synchronous in-flight reservations per agent: launch() must decide
256
- // background vs foreground BEFORE its first await, otherwise concurrent
257
- // launches without interleaved awaits would all see an empty board.
258
- const pendingByAgent = new Map<string, number>()
259
- let seq = 0
260
- const foregroundRecent: { agent: string; line: string; at: number }[] = []
261
- let probeResult: Awaited<ReturnType<NativeProbeFn>> | null = null
262
-
263
- function pending(agent: string): number {
264
- return pendingByAgent.get(agent.toLowerCase()) ?? 0
265
- }
266
-
267
- function guardEnabled(): void {
268
- if (!isDelegationEnabled(env)) {
269
- throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
270
- }
271
- }
272
-
273
- async function ensureNativeAvailable(): Promise<NativeTaskStatus | undefined> {
274
- if (!isNativeMode || nativeProbe === undefined || probeClient === undefined) return undefined
275
- probeResult ??= await nativeProbe({ client: probeClient, parentSessionID })
276
- return probeResult.status === 'AVAILABLE' ? undefined : probeResult.status
277
- }
278
-
279
- async function runTask(
280
- agent: string,
281
- prompt: string,
282
- explicitModel: string | undefined,
283
- taskID: string,
284
- ): Promise<{ ok: true; result: NativeTaskResult } | { ok: false; error: string }> {
285
- const attempts: (string | undefined)[] =
286
- explicitModel !== undefined ? [explicitModel] : models.length > 0 ? models : [undefined]
287
- let lastError = 'no model attempts configured'
288
- for (const model of attempts) {
289
- try {
290
- const controller = new AbortController()
291
- const result = await withTimeout(
292
- task({
293
- agent,
294
- prompt,
295
- signal: controller.signal,
296
- taskID,
297
- parentSessionID,
298
- ...(model !== undefined ? { model } : {}),
299
- }),
300
- timeoutMs,
301
- `task(${agent})`,
302
- )
303
- return { ok: true, result }
304
- } catch (err) {
305
- lastError = err instanceof Error ? err.message : String(err)
306
- }
307
- }
308
- return { ok: false, error: lastError }
309
- }
310
-
311
- async function settle(
312
- taskID: string,
313
- alias: string,
314
- verifyError?: string,
315
- ): Promise<ManagerReceipt> {
316
- const job = board.get(taskID)
317
- if (!job) throw new Error(`job ${taskID} vanished after terminal state`)
318
- // The receipt reports the VERIFIED outcome, not the raw terminal state:
319
- // a `completed` the manager could not verify stays `error` for the
320
- // caller ("complete ONLY after verified result"). Reconcile is only the
321
- // acknowledgment that the result was consumed.
322
- const terminalState = job.state
323
- const failed = verifyError !== undefined || terminalState === 'error'
324
- const status = classifyNativeResult(job, verifyError)
325
- // Auto-reconcile + delete the signal file on the spot.
326
- if (job.state !== 'reconciled') await board.markReconciled(taskID)
327
- await board.deleteSignal(alias)
328
- // Teto de entradas + prune agressivo de completed.
329
- await board.pruneCompleted(keepCompleted)
330
- await board.enforceEntryCap(maxEntries)
331
- const settled = board.get(taskID)
332
- const line = failed
333
- ? `[pantheon:${alias}] ${job.agent} — ERROR: ${firstLine(verifyError ?? settled?.lastStatusError ?? job.lastStatusError ?? 'failed', 90)}`.slice(
334
- 0,
335
- LINE_MAX,
336
- )
337
- : boardLine(settled ?? job)
338
- return {
339
- id: taskID,
340
- alias,
341
- agent: job.agent,
342
- state: failed ? 'error' : 'reconciled',
343
- status,
344
- line,
345
- }
346
- }
347
-
348
- /** Apply a terminal transition best-effort: a racing hook may have gotten there first. */
349
- async function applyTerminal(
350
- taskID: string,
351
- state: 'completed' | 'error',
352
- fields: { resultSummary?: string; error?: string },
353
- ): Promise<void> {
354
- try {
355
- await board.updateStatus({ taskID, state, ...fields })
356
- } catch (err) {
357
- const msg = err instanceof Error ? err.message : String(err)
358
- if (!msg.startsWith('Invalid state transition')) throw err
359
- // Lost the race to another terminal writer (e.g. the V1 idle hook) —
360
- // the record stands; verification still governs the receipt.
361
- }
362
- }
363
-
364
- async function launchBackground(args: LaunchArgs, prompt: string): Promise<ManagerReceipt> {
365
- const key = args.agent.toLowerCase()
366
- pendingByAgent.set(key, pending(args.agent) + 1)
367
- seq += 1
368
- const taskID = args.taskID ?? `native-${Date.now()}-${seq}`
369
- try {
370
- const record = await board.registerLaunch({
371
- taskID,
372
- parentSessionID,
373
- agent: args.agent,
374
- description: args.description ?? firstLine(args.prompt, 120),
375
- })
376
- const outcome = await runTask(args.agent, prompt, args.model, taskID)
377
- if (!outcome.ok) {
378
- await applyTerminal(taskID, 'error', { error: outcome.error })
379
- return await settle(taskID, record.alias, outcome.error)
380
- }
381
- if (typeof outcome.result.content !== 'string' || outcome.result.content.trim() === '') {
382
- const verifyError = 'task result empty — not verified, never completed'
383
- await applyTerminal(taskID, 'error', { error: verifyError })
384
- return await settle(taskID, record.alias, verifyError)
385
- }
386
- await applyTerminal(taskID, 'completed', {
387
- resultSummary: firstLine(outcome.result.content, 500),
388
- })
389
- const settled = await settle(taskID, record.alias)
390
- // The task() call already blocked until completion — return the verified
391
- // output in the receipt (the parent reads the full report via the alias
392
- // when needed). The tool layer caps it inline.
393
- return { ...settled, output: outcome.result.content }
394
- } finally {
395
- pendingByAgent.set(key, Math.max(0, pending(args.agent) - 1))
396
- }
397
- }
398
-
399
- async function launchForeground(args: LaunchArgs, prompt: string): Promise<ManagerReceipt> {
400
- seq += 1
401
- const id = args.taskID ?? `fg-${Date.now()}-${seq}`
402
- const outcome = await runTask(args.agent, prompt, args.model, id)
403
- const ok =
404
- outcome.ok &&
405
- typeof outcome.result.content === 'string' &&
406
- outcome.result.content.trim() !== ''
407
- const detail = ok
408
- ? firstLine((outcome as { ok: true; result: NativeTaskResult }).result.content ?? '', 90)
409
- : `ERROR: ${(outcome as { ok: false; error: string }).error ?? 'empty result — not verified'}`
410
- const line = `[native] ${args.agent} — ${detail} — ${ok ? 'OK' : 'ERR'} (foreground)`.slice(
411
- 0,
412
- LINE_MAX,
413
- )
414
- foregroundRecent.push({ agent: args.agent, line, at: Date.now() })
415
- if (foregroundRecent.length > 20) foregroundRecent.splice(0, foregroundRecent.length - 20)
416
- const status = classifyNativeResult(
417
- {
418
- taskID: id,
419
- parentSessionID,
420
- agent: args.agent,
421
- description: args.description ?? firstLine(args.prompt, 120),
422
- state: ok ? 'completed' : 'error',
423
- timedOut: false,
424
- alias: id,
425
- launchedAt: Date.now(),
426
- updatedAt: Date.now(),
427
- totalErrors: 0,
428
- timeoutCount: 0,
429
- terminalUnreconciled: false,
430
- contextFiles: [],
431
- ...(ok
432
- ? {
433
- resultSummary: firstLine(
434
- (outcome as { ok: true; result: NativeTaskResult }).result.content ?? '',
435
- 500,
436
- ),
437
- }
438
- : {}),
439
- },
440
- ok ? undefined : (outcome as { ok: false; error?: string }).error,
441
- )
442
- return {
443
- id,
444
- alias: id,
445
- agent: args.agent,
446
- state: ok ? 'reconciled' : 'error',
447
- status,
448
- line,
449
- ...(ok
450
- ? { output: (outcome as { ok: true; result: NativeTaskResult }).result.content ?? '' }
451
- : {}),
452
- }
453
- }
454
-
455
- return {
456
- async launch(args: LaunchArgs): Promise<ManagerReceipt> {
457
- guardEnabled()
458
- // R4 step budgets (routing.yml max_steps, every agent): capped agents
459
- // skip dispatch with a stop summary — no session, no board job.
460
- let prompt = args.prompt
461
- if (stepCap !== undefined) {
462
- const maxSteps = stepCap.maxStepsFor(args.agent)
463
- if (stepCap.isCapped(args.agent)) {
464
- const line = cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS).slice(0, LINE_MAX)
465
- const cappedId = `cap-${Date.now()}`
466
- return {
467
- id: cappedId,
468
- alias: cappedId,
469
- agent: args.agent,
470
- state: 'reconciled',
471
- status: 'ESCALATE',
472
- line,
473
- }
474
- }
475
- const rec = stepCap.recordStep(args.agent)
476
- if (rec.capped && rec.maxSteps !== undefined) {
477
- prompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
478
- }
479
- }
480
- if (isNativeMode) {
481
- const unavailableStatus = await ensureNativeAvailable()
482
- if (unavailableStatus !== undefined) {
483
- const probeId = `probe-${parentSessionID}`
484
- return {
485
- id: probeId,
486
- alias: probeId,
487
- agent: args.agent,
488
- state: 'error',
489
- status: unavailableStatus,
490
- line: `[native] ${args.agent} — native API ${unavailableStatus.toLowerCase()} — ERR`,
491
- }
492
- }
493
- }
494
- // Synchronous routing decision: background when a slot is free,
495
- // foreground fallback when full, clean rejection otherwise.
496
- const free =
497
- board.canDispatch(args.agent) && pending(args.agent) < board.maxConcurrentPerAgent
498
- if (free) return launchBackground(args, prompt)
499
- if (foregroundFallback) return launchForeground(args, prompt)
500
- throw new Error(
501
- `pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}"`,
502
- )
503
- },
504
- ensureNativeAvailable,
505
-
506
- async read(idOrAlias: string): Promise<string> {
507
- guardEnabled()
508
- const job = board.resolve(parentSessionID, idOrAlias) ?? board.get(idOrAlias)
509
- if (!job) throw new Error(`delegation not found: ${idOrAlias}`)
510
- const terminal = await withTimeout(
511
- board.waitForTerminal(job.taskID, readTimeoutMs),
512
- readTimeoutMs + 5_000,
513
- `read(${idOrAlias})`,
514
- )
515
- if (terminal.state !== 'reconciled') await board.markReconciled(terminal.taskID)
516
- await board.deleteSignal(terminal.alias)
517
- // Full report first (same contract as the legacy engine): the md file
518
- // written by finalizeDelegation carries the complete child output. The
519
- // one-line resultSummary is only a fallback when no report was written.
520
- const md = await readDelegationReport(outputDir, terminal)
521
- if (md !== undefined) {
522
- return capReportOutput(md, inlineMaxChars, reportPathFor(terminal.alias))
523
- }
524
- if (terminal.state === 'error' && !terminal.resultSummary) {
525
- return `# Delegation Report — ${terminal.alias}\n\nERROR: ${sanitizeReceiptText(terminal.lastStatusError ?? 'failed')}\n`
526
- }
527
- return (
528
- `# Delegation Report — ${terminal.alias}\n\n` +
529
- `${sanitizeReceiptText(terminal.resultSummary ?? terminal.lastStatusError ?? terminal.state)}\n`
530
- )
531
- },
532
-
533
- async list(): Promise<string[]> {
534
- guardEnabled()
535
- const lines = board.list(parentSessionID).map((job) => boardLine(job))
536
- for (const fg of foregroundRecent) lines.push(fg.line)
537
- return lines
538
- },
539
-
540
- async recover(): Promise<void> {
541
- await board.recoverRunningJobs()
542
- },
543
- }
544
- }
545
-
546
- // Native delegation tools live with the manager so the plugin mounts the
547
- // manager directly; there is intentionally no adapter or kill-switch wrapper.
548
- const delegateArgs = {
549
- prompt: z.string().min(1),
550
- agent: z.string().min(1),
551
- description: z.string().optional(),
552
- read_only: z.boolean().optional(),
553
- model: z.string().optional(),
554
- } satisfies z.ZodRawShape
555
- const readArgs = { id: z.string().min(1) } satisfies z.ZodRawShape
556
- const listArgs = {} satisfies z.ZodRawShape
557
- export interface ToolContextLike {
558
- sessionID: string
559
- agent?: string
560
- }
561
- export interface SessionTaskOptions {
562
- client: DelegationClient
563
- board: BackgroundJobBoard
564
- outputDir?: string
565
- promptTimeoutMs?: number
566
- settleTimeoutMs?: number
567
- }
568
- export function parseModelRef(model: string): { id: string; providerID: string } | undefined {
569
- const idx = model.indexOf('/')
570
- return idx > 0 && idx < model.length - 1
571
- ? { providerID: model.slice(0, idx), id: model.slice(idx + 1) }
572
- : undefined
573
- }
574
- export function extractOutputSection(md: string | undefined): string {
575
- if (md === undefined) return ''
576
- const at = md.indexOf('## Output')
577
- const body = at < 0 ? md : md.slice(at + 9)
578
- const errAt = body.indexOf('\n**Error**:')
579
- return (errAt < 0 ? body : body.slice(0, errAt)).trim()
580
- }
581
- async function promptAccept(
582
- client: DelegationClient,
583
- taskID: string,
584
- agent: string,
585
- prompt: string,
586
- model: string | undefined,
587
- signal: AbortSignal | undefined,
588
- timeoutMs: number,
589
- ): Promise<void> {
590
- const modelRef = model === undefined ? undefined : parseModelRef(model)
591
- let timer: ReturnType<typeof setTimeout> | undefined
592
- try {
593
- await Promise.race([
594
- client.session
595
- .promptAsync({
596
- path: { id: taskID },
597
- body: {
598
- agent,
599
- ...(modelRef === undefined ? {} : { model: modelRef }),
600
- parts: [{ type: 'text', text: prompt }],
601
- },
602
- ...(signal === undefined ? {} : { signal }),
603
- })
604
- .then(undefined, (error: unknown) => {
605
- throw new Error(
606
- `promptAsync rejected: ${error instanceof Error ? error.message : String(error)}`,
607
- )
608
- }),
609
- new Promise<never>((_, reject) => {
610
- timer = setTimeout(
611
- () => reject(new Error(`promptAsync timed out after ${timeoutMs}ms`)),
612
- timeoutMs,
613
- )
614
- timer.unref?.()
615
- }),
616
- ])
617
- } finally {
618
- if (timer !== undefined) clearTimeout(timer)
619
- }
620
- }
621
- export function createSessionTaskFn(options: SessionTaskOptions): NativeTaskFn {
622
- const {
623
- client,
624
- board,
625
- outputDir = DELEGATION_DEFAULTS.outputDir,
626
- promptTimeoutMs = 60_000,
627
- settleTimeoutMs = DELEGATION_DEFAULTS.timeoutMs,
628
- } = options
629
- return async ({ agent, prompt, model, signal, taskID }) => {
630
- await promptAccept(client, taskID, agent, prompt, model, signal, promptTimeoutMs)
631
- const terminal = await board.waitForTerminal(taskID, settleTimeoutMs)
632
- const md = await readDelegationReport(outputDir, terminal)
633
- return { content: extractOutputSection(md) || terminal.resultSummary || '' }
634
- }
635
- }
636
- export interface NativeDelegateWiring {
637
- board: BackgroundJobBoard
638
- client: DelegationClient
639
- outputDir?: string
640
- env?: Record<string, string | undefined>
641
- isRootSession: (sessionID: string) => boolean
642
- registerChildSession?: (sessionID: string, parentID: string) => void
643
- registerReadOnlySession?: (
644
- sessionID: string,
645
- info: { agent: string; readOnlyFlag?: boolean },
646
- ) => void
647
- isReadOnlyAgent?: (agent: string) => boolean
648
- agentModel?: (agent: string) => string | undefined
649
- managerOptions?: Partial<
650
- Omit<DelegateManagerOptions, 'board' | 'task' | 'parentSessionID' | 'env'>
651
- >
652
- stepCap?: StepCapTracker
653
- }
654
- export interface NativeDelegateToolset {
655
- pantheon_delegate: {
656
- description: string
657
- args: typeof delegateArgs
658
- execute(args: z.infer<z.ZodObject<typeof delegateArgs>>, ctx: ToolContextLike): Promise<string>
659
- }
660
- pantheon_delegation_read: {
661
- description: string
662
- args: typeof readArgs
663
- execute(args: z.infer<z.ZodObject<typeof readArgs>>, ctx: ToolContextLike): Promise<string>
664
- }
665
- pantheon_delegation_list: {
666
- description: string
667
- args: typeof listArgs
668
- execute(args: z.infer<z.ZodObject<typeof listArgs>>, ctx: ToolContextLike): Promise<string>
669
- }
670
- }
671
- function shortFirstLine(text: string, max = 60): string {
672
- const line =
673
- sanitizeReceiptText(text)
674
- .split('\n')
675
- .map((item) => item.trim())
676
- .find(Boolean) ?? ''
677
- return line.length > max ? `${line.slice(0, max - 1)}…` : line
678
- }
679
- export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeDelegateToolset {
680
- const {
681
- board,
682
- client,
683
- env = process.env,
684
- isRootSession,
685
- registerChildSession,
686
- registerReadOnlySession,
687
- isReadOnlyAgent,
688
- agentModel,
689
- managerOptions = {},
690
- stepCap,
691
- } = wiring
692
- const task = createSessionTaskFn({
693
- client,
694
- board,
695
- ...(wiring.outputDir === undefined ? {} : { outputDir: wiring.outputDir }),
696
- ...(managerOptions.timeoutMs === undefined
697
- ? {}
698
- : { promptTimeoutMs: managerOptions.timeoutMs }),
699
- })
700
- const inlineMaxChars = managerOptions.readMaxChars ?? resolveReadMaxChars(env)
701
- const managers = new Map<string, DelegateManager>()
702
- const managerFor = (parentSessionID: string): DelegateManager => {
703
- const existing = managers.get(parentSessionID)
704
- if (existing !== undefined) return existing
705
- const nativeProbe = createNativeProbe()
706
- const manager = createDelegateManager({
707
- ...managerOptions,
708
- board,
709
- task,
710
- parentSessionID,
711
- env,
712
- ...(stepCap === undefined ? {} : { stepCap }),
713
- ...(wiring.outputDir === undefined ? {} : { outputDir: wiring.outputDir }),
714
- nativeProbe,
715
- probeClient: client,
716
- })
717
- managers.set(parentSessionID, manager)
718
- return manager
719
- }
720
- return {
721
- pantheon_delegate: {
722
- description: 'Dispatch a background agent as a child session and return a verified receipt.',
723
- args: delegateArgs,
724
- execute: async (args, ctx) => {
725
- if (!isDelegationEnabled(env))
726
- throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
727
- if (!isRootSession(ctx.sessionID))
728
- throw new Error(
729
- `pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
730
- )
731
- const manager = managerFor(ctx.sessionID)
732
- const unavailableStatus = await manager.ensureNativeAvailable()
733
- if (unavailableStatus !== undefined) {
734
- return `status: ${unavailableStatus}\n[native] ${args.agent} — native API ${unavailableStatus.toLowerCase()} — ERR`
735
- }
736
- const model = args.model ?? agentModel?.(args.agent)
737
- let childID: string
738
- try {
739
- const modelRef = model === undefined ? undefined : parseModelRef(model)
740
- const created = await client.session.create({
741
- body: {
742
- parentID: ctx.sessionID,
743
- title: args.description ?? shortFirstLine(args.prompt),
744
- ...(modelRef === undefined ? {} : { model: modelRef }),
745
- },
746
- })
747
- childID = created.id
748
- } catch (error) {
749
- return `pantheon_delegate failed: session.create rejected: ${sanitizeReceiptText(error instanceof Error ? error.message : String(error))}`
750
- }
751
- registerChildSession?.(childID, ctx.sessionID)
752
- if (args.read_only === true || isReadOnlyAgent?.(args.agent) === true)
753
- registerReadOnlySession?.(childID, {
754
- agent: args.agent,
755
- ...(args.read_only === undefined ? {} : { readOnlyFlag: args.read_only }),
756
- })
757
- const receipt = await manager.launch({
758
- agent: args.agent,
759
- prompt: args.prompt,
760
- ...(args.description === undefined ? {} : { description: args.description }),
761
- ...(args.model === undefined ? {} : { model: args.model }),
762
- taskID: childID,
763
- })
764
- // The launch blocked until the child finished — return the verified
765
- // output inline (capped head+tail with a pointer to the full report),
766
- // not just a status line. Foreground receipts carry no report file,
767
- // so the marker points at pantheon_delegation_read instead.
768
- const header = `status: ${receipt.status}\n${receipt.line}`
769
- if (receipt.output === undefined || receipt.output.trim() === '') return header
770
- const reportPath =
771
- receipt.alias === receipt.id
772
- ? undefined
773
- : join(
774
- wiring.outputDir ?? DELEGATION_DEFAULTS.outputDir,
775
- ctx.sessionID,
776
- `${receipt.alias}.md`,
777
- )
778
- return `${header}\n\n${capReportOutput(receipt.output, inlineMaxChars, reportPath)}`
779
- },
780
- },
781
- pantheon_delegation_read: {
782
- description: 'Block until a delegation finishes, then return its verified report.',
783
- args: readArgs,
784
- execute: async (args, ctx) => {
785
- if (!isDelegationEnabled(env))
786
- throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
787
- return managerFor(ctx.sessionID).read(args.id)
788
- },
789
- },
790
- pantheon_delegation_list: {
791
- description: 'List background delegations.',
792
- args: listArgs,
793
- execute: async (_args, ctx) => {
794
- if (!isDelegationEnabled(env))
795
- throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
796
- const lines = await managerFor(ctx.sessionID).list()
797
- return lines.length > 0 ? lines.join('\n') : 'No delegations.'
798
- },
799
- },
800
- }
801
- }
802
- export interface PluginNativeDelegationWiring {
803
- board: BackgroundJobBoard
804
- client: DelegationClient
805
- isRootSession: (sessionID: string) => boolean
806
- registerChildSession: (sessionID: string, parentID: string) => void
807
- registerReadOnlySession: (
808
- sessionID: string,
809
- info: { agent: string; readOnlyFlag?: boolean },
810
- ) => void
811
- readOnlyAgents: ReadonlySet<string>
812
- agentModels: Record<string, string | undefined>
813
- stepCap: StepCapTracker
814
- wallClockTimeoutMs?: number
815
- /** Base directory for delegation reports (default `.pantheon/delegations`). */
816
- outputDir?: string
817
- /** Inline cap (chars) for delegation report content (default env or 50_000). */
818
- readMaxChars?: number
819
- finalizeDelegation: DelegationToolset['finalizeDelegation']
820
- }
821
- export type PluginNativeDelegation = NativeDelegateToolset & {
822
- finalizeDelegation: DelegationToolset['finalizeDelegation']
823
- }
824
- export function buildPluginNativeDelegation(
825
- wiring: PluginNativeDelegationWiring,
826
- ): PluginNativeDelegation {
827
- return {
828
- ...createNativeDelegateTools({
829
- board: wiring.board,
830
- client: wiring.client,
831
- ...(wiring.outputDir === undefined ? {} : { outputDir: wiring.outputDir }),
832
- isRootSession: wiring.isRootSession,
833
- registerChildSession: wiring.registerChildSession,
834
- registerReadOnlySession: wiring.registerReadOnlySession,
835
- isReadOnlyAgent: (agent) => wiring.readOnlyAgents.has(agent.toLowerCase()),
836
- agentModel: (agent) => wiring.agentModels[agent.toLowerCase()],
837
- stepCap: wiring.stepCap,
838
- ...(wiring.wallClockTimeoutMs === undefined && wiring.readMaxChars === undefined
839
- ? {}
840
- : {
841
- managerOptions: {
842
- ...(wiring.wallClockTimeoutMs === undefined
843
- ? {}
844
- : { timeoutMs: wiring.wallClockTimeoutMs }),
845
- ...(wiring.readMaxChars === undefined ? {} : { readMaxChars: wiring.readMaxChars }),
846
- },
847
- }),
848
- }),
849
- finalizeDelegation: wiring.finalizeDelegation,
850
- }
851
- }