pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.20

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 (107) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/AGENTS.md +14 -63
  3. package/CHANGELOG.md +1260 -0
  4. package/README.md +162 -23
  5. package/README.pt-BR.md +104 -19
  6. package/bin/pantheon-init.mjs +178 -21
  7. package/commands/pantheon.md +1 -35
  8. package/package.json +23 -15
  9. package/plugin.json +1 -1
  10. package/pyproject.toml +62 -0
  11. package/scripts/code_mode_server.py +431 -55
  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 +12 -45
  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 +536 -164
  22. package/scripts/package-evidence.mjs +242 -0
  23. package/scripts/probe-pantheon-cost.mjs +10 -1
  24. package/scripts/release-beta-version.mjs +56 -60
  25. package/scripts/sync-artifacts.mjs +169 -0
  26. package/scripts/sync-tui.mjs +129 -64
  27. package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
  28. package/scripts/validation-policy.mjs +7 -7
  29. package/scripts/version-check.mjs +113 -108
  30. package/scripts/versioning.mjs +230 -86
  31. package/src/agents/apollo.md +1 -0
  32. package/src/agents/gaia.md +1 -0
  33. package/src/agents/mnemosyne.md +1 -1
  34. package/src/agents/zeus.md +11 -54
  35. package/src/instructions/memory-protocol.instructions.md +6 -3
  36. package/src/instructions/zeus-anti-stall.instructions.md +2 -4
  37. package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
  38. package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
  39. package/src/mcp/code_mode_server.py +431 -55
  40. package/src/mcp/mcp_persistence_server.py +536 -164
  41. package/src/mcp/toon_codec.py +2 -4
  42. package/src/pantheon/background-job-board.ts +47 -5
  43. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  44. package/src/pantheon/cost-command.ts +309 -44
  45. package/src/pantheon/delegation-enforce.ts +59 -14
  46. package/src/pantheon/file-persistence-salvage.ts +64 -0
  47. package/src/pantheon/file-persistence.ts +194 -45
  48. package/src/pantheon/goal-loop.ts +1 -1
  49. package/src/pantheon/goal-store.ts +1 -1
  50. package/src/pantheon/idle-continuation.ts +4 -6
  51. package/src/pantheon/model-command.ts +1 -2
  52. package/src/pantheon/native-task-status.ts +139 -0
  53. package/src/pantheon/presets.d.mts +0 -20
  54. package/src/pantheon/presets.mjs +0 -64
  55. package/src/pantheon/session-guard.ts +2 -2
  56. package/src/pantheon/session-id.ts +30 -0
  57. package/src/pantheon/shared-board.ts +37 -0
  58. package/src/pantheon/task-result-guard.ts +106 -0
  59. package/src/pantheon/todo-enforcer.ts +3 -3
  60. package/src/pantheon/tool-ceiling.ts +159 -0
  61. package/src/pantheon/tool-context.ts +23 -0
  62. package/src/pantheon/v2-bridge.ts +1 -4
  63. package/src/plugin-v2.ts +8 -21
  64. package/src/plugin.ts +83 -251
  65. package/src/plugins/pantheon-hooks.ts +51 -0
  66. package/src/plugins/tui/dist/server.d.ts +1 -1
  67. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  68. package/src/plugins/tui/dist/server.js +1 -1
  69. package/src/plugins/tui/dist/server.js.map +1 -1
  70. package/src/plugins/tui/dist/tui.d.ts +261 -77
  71. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  72. package/src/plugins/tui/dist/tui.js +640 -234
  73. package/src/plugins/tui/dist/tui.js.map +1 -1
  74. package/src/plugins/tui/package-lock.json +2 -2
  75. package/src/plugins/tui/package.json +2 -2
  76. package/src/plugins/tui/src/index.tsx +872 -226
  77. package/src/routing.yml +0 -16
  78. package/src/skills/context-compression/SKILL.md +1 -1
  79. package/scripts/generate-preset-docs.mjs +0 -300
  80. package/scripts/generate-prompts.sh +0 -222
  81. package/scripts/hash_verify.py +0 -192
  82. package/scripts/install/component.mjs +0 -24
  83. package/scripts/install/migrate-config.mjs +0 -302
  84. package/scripts/test-context-compression.sh +0 -169
  85. package/src/pantheon/auto-wake.ts +0 -89
  86. package/src/pantheon/chat-reminders.ts +0 -9
  87. package/src/pantheon/comment-checker.ts +0 -48
  88. package/src/pantheon/compaction-assert.ts +0 -160
  89. package/src/pantheon/cost-tracker.ts +0 -108
  90. package/src/pantheon/delegate-manager.ts +0 -669
  91. package/src/pantheon/delegation-classifier.ts +0 -307
  92. package/src/pantheon/delegation-finalize.ts +0 -374
  93. package/src/pantheon/delegation-notify.ts +0 -212
  94. package/src/pantheon/delegation-toast.ts +0 -73
  95. package/src/pantheon/delegation.ts +0 -1120
  96. package/src/pantheon/dispatch-guard.ts +0 -109
  97. package/src/pantheon/permission-eval.ts +0 -284
  98. package/src/pantheon/preemptive-compact.ts +0 -76
  99. package/src/pantheon/retry-policy.ts +0 -212
  100. package/src/pantheon/step-cap.ts +0 -110
  101. package/src/pantheon/token-opt.ts +0 -781
  102. package/src/pantheon/toon-codec.ts +0 -481
  103. package/src/pantheon/v2-events.ts +0 -131
  104. package/src/pantheon/v2-hooks.ts +0 -287
  105. package/src/pantheon/v2-tool-definitions.ts +0 -331
  106. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
  107. package/src/plugins/tui/dist/tui.tsx +0 -2605
@@ -1,307 +0,0 @@
1
- /**
2
- * Delegation Classifier — stuck-agent detection for delegation results.
3
- *
4
- * Classifies delegation results into actionable statuses:
5
- * - `content` / `success` — agent returned usable output
6
- * - `empty` (empty-mode1 / empty-mode2) — no or near-no output; retryable
7
- * - `budget-exhausted` — agent hit max_steps; partial result available
8
- * - `timeout` (timeout-with-partial / timeout-empty / timeout-exhausted)
9
- * - `error` — provider or infrastructure failure
10
- *
11
- * Integrates with the existing `dispatch-guard.ts` classification for
12
- * empty-response detection and provides a structured `DelegationResult`
13
- * that replaces raw-text returns.
14
- *
15
- * @module delegation-classifier
16
- */
17
-
18
- // ─── Types ─────────────────────────────────────────────────────────────
19
-
20
- /** Empty-response mode classification (matches dispatch-guard). */
21
- export type EmptyMode = 'empty-mode1' | 'empty-mode2'
22
-
23
- /** Timeout sub-type based on partial output and retry history. */
24
- export type TimeoutSubType = 'timeout-with-partial' | 'timeout-empty' | 'timeout-exhausted'
25
-
26
- /** Structured delegation result replacing raw-text returns. */
27
- export interface DelegationResult {
28
- status:
29
- | 'success'
30
- | 'empty'
31
- | 'budget-exhausted'
32
- | 'timeout'
33
- | 'error'
34
- | 'startup_failed'
35
- | 'bootstrap_unknown'
36
- content: string
37
- retryCount: number
38
- /** When status is 'empty', which empty mode was detected. */
39
- classification?: EmptyMode | undefined
40
- /** When status is 'timeout', the timeout sub-type. */
41
- subType?: TimeoutSubType | undefined
42
- /** Available when budget-exhausted or timeout-with-partial. */
43
- partialResult?: string | undefined
44
- /** Actionable recommendation for the orchestrator. */
45
- recommendation?: string | undefined
46
- }
47
-
48
- /** Result of empty-response classification. */
49
- export interface EmptyClassification {
50
- mode: EmptyMode | 'content'
51
- hasContent: boolean
52
- hasTokens: boolean
53
- }
54
-
55
- /** Timeout classification input. */
56
- export interface TimeoutClassifyInput {
57
- /** Report markdown (undefined if no report was written). */
58
- report: string | undefined
59
- /** Whether the child session had any messages at all. */
60
- hasMessages: boolean
61
- /** How many retries have already been attempted (0 or 1). */
62
- retryCount: number
63
- }
64
-
65
- /** Result of stuck-agent classification. */
66
- export interface StuckClassification {
67
- status: 'success' | 'budget-exhausted' | 'empty'
68
- partialResult?: string | undefined
69
- recommendation?: string | undefined
70
- }
71
-
72
- // ─── Patterns ──────────────────────────────────────────────────────────
73
-
74
- /** Matches budget-exhausted messages from the agent or step-cap system. */
75
- const BUDGET_EXHAUSTED_RE =
76
- /maximum\s+(?:steps?|step\s*budget)\s+(?:for\s+this\s+agent\s+)?(?:has|have)\s+been\s+reached|step\s*budget\s+(?:was\s+)?exhausted/i
77
-
78
- /** Output-captured marker in delegation reports. */
79
- const NO_OUTPUT_RE = /_No output captured\._/i
80
-
81
- // ─── Empty-Response Classification ────────────────────────────────────
82
-
83
- /**
84
- * Classify whether a response is empty, empty-with-tokens, or has content.
85
- * Matches the dispatch-guard classification (empty-mode1, empty-mode2).
86
- *
87
- * @param text The response text (may be null/undefined).
88
- * @param tokens Optional token counts for mode2 detection.
89
- */
90
- export function classifyEmptyResponse(
91
- text: string | null | undefined,
92
- tokens?: { tokensInput?: number; tokensOutput?: number },
93
- ): EmptyClassification {
94
- const hasContent = typeof text === 'string' && text.trim() !== ''
95
- if (hasContent) {
96
- return { mode: 'content', hasContent: true, hasTokens: hasTokens(tokens) }
97
- }
98
- const hasT = hasTokens(tokens)
99
- return {
100
- mode: hasT ? 'empty-mode2' : 'empty-mode1',
101
- hasContent: false,
102
- hasTokens: hasT,
103
- }
104
- }
105
-
106
- /** Whether token counts indicate the model actually ran. */
107
- function hasTokens(tokens?: { tokensInput?: number; tokensOutput?: number }): boolean {
108
- if (tokens === undefined) return false
109
- return (tokens.tokensInput ?? 0) > 0 || (tokens.tokensOutput ?? 0) > 0
110
- }
111
-
112
- // ─── Stuck-Agent Detection ────────────────────────────────────────────
113
-
114
- /**
115
- * Classify a report as success, budget-exhausted, or empty.
116
- * Detects max-steps patterns in the report text.
117
- *
118
- * @param report The delegation report markdown.
119
- */
120
- export function classifyStuckAgent(report: string): StuckClassification {
121
- const trimmed = report.trim()
122
-
123
- // Empty output
124
- if (NO_OUTPUT_RE.test(trimmed) || trimmed === '') {
125
- return { status: 'empty' }
126
- }
127
-
128
- // Budget-exhausted: extract content before the marker as partialResult
129
- if (BUDGET_EXHAUSTED_RE.test(trimmed)) {
130
- const outputSection = extractOutputSection(trimmed)
131
- const partialResult =
132
- outputSection !== undefined && !NO_OUTPUT_RE.test(outputSection) ? outputSection : undefined
133
-
134
- return {
135
- status: 'budget-exhausted',
136
- partialResult,
137
- recommendation:
138
- 'Agent hit step budget; partial result available. ' +
139
- 'Consider resuming with task_id or dispatching with larger budget.',
140
- }
141
- }
142
-
143
- return { status: 'success' }
144
- }
145
-
146
- /** Extract the text after "## Output" in a delegation report, or undefined. */
147
- function extractOutputSection(report: string): string | undefined {
148
- const marker = '## Output'
149
- const idx = report.indexOf(marker)
150
- if (idx === -1) return undefined
151
- const afterMarker = report.slice(idx + marker.length)
152
- // Skip the newline after "## Output"
153
- const content = afterMarker.replace(/^\n+/, '')
154
- // Stop at the next section (## or **Error**)
155
- const endIdx = content.search(/\n(?:##|\*\*Error\*\*|\[TIMEOUT)/)
156
- return endIdx === -1 ? content.trim() : content.slice(0, endIdx).trim()
157
- }
158
-
159
- // ─── Timeout Classification ───────────────────────────────────────────
160
-
161
- /**
162
- * Classify a timeout based on available output and retry history.
163
- *
164
- * - timeout-empty: no report, no messages, not yet retried → retryable
165
- * - timeout-with-partial: has report or messages → return partial
166
- * - timeout-exhausted: already retried → escalate
167
- *
168
- * @param input Report availability, message presence, and retry count.
169
- */
170
- export function classifyTimeout(input: TimeoutClassifyInput): DelegationResult {
171
- const { report, hasMessages, retryCount } = input
172
- const hasPartial =
173
- (report !== undefined && report.trim() !== '' && !NO_OUTPUT_RE.test(report)) || hasMessages
174
-
175
- // Already retried → exhausted, no more retries
176
- if (retryCount >= 1) {
177
- return {
178
- status: 'timeout',
179
- content: report ?? '',
180
- retryCount,
181
- subType: 'timeout-exhausted',
182
- partialResult: hasPartial ? (report ?? '') : undefined,
183
- recommendation:
184
- 'Agent timed out after retry. Options: ' +
185
- '(a) try different agent, (b) simplify scope, (c) manual intervention.',
186
- }
187
- }
188
-
189
- // Has partial output → return it
190
- if (hasPartial) {
191
- return {
192
- status: 'timeout',
193
- content: report ?? '',
194
- retryCount,
195
- subType: 'timeout-with-partial',
196
- partialResult: report ?? '',
197
- recommendation:
198
- 'Agent timed out but partial work exists. ' + 'Partial result is available above.',
199
- }
200
- }
201
-
202
- // No output at all → retryable
203
- return {
204
- status: 'timeout',
205
- content: report ?? '',
206
- retryCount,
207
- subType: 'timeout-empty',
208
- recommendation: 'Agent timed out with no output. Will retry once with rephrased prompt.',
209
- }
210
- }
211
-
212
- // ─── Result Helpers ───────────────────────────────────────────────────
213
-
214
- /**
215
- * Whether a delegation result is retryable (empty + retryCount < 1,
216
- * or timeout-empty + retryCount < 1).
217
- */
218
- export function isRetryableResult(result: DelegationResult): boolean {
219
- if (result.retryCount >= 1) return false
220
- if (result.status === 'empty') return true
221
- if (result.status === 'timeout' && result.subType === 'timeout-empty') return true
222
- return false
223
- }
224
-
225
- /**
226
- * Format a DelegationResult into a human-readable string for the orchestrator.
227
- * Success results are returned as-is (the raw content). Non-success results
228
- * get a structured status message.
229
- */
230
- export function formatDelegationResult(result: DelegationResult): string {
231
- if (result.status === 'success') {
232
- return result.content
233
- }
234
-
235
- const parts: string[] = []
236
-
237
- // Status header
238
- switch (result.status) {
239
- case 'empty': {
240
- const modeLabel =
241
- result.classification === 'empty-mode2'
242
- ? 'empty-mode2 (reasoning tokens present but no text output)'
243
- : 'empty-mode1 (no tokens generated)'
244
- parts.push(`[EMPTY RESULT — ${modeLabel}]`)
245
- if (result.retryCount === 0) {
246
- parts.push(
247
- 'The agent returned an empty response. ' +
248
- 'A retry with a rephrased prompt has been triggered automatically.',
249
- )
250
- } else {
251
- parts.push(
252
- 'The agent returned an empty response even after retry. ' +
253
- 'Options: (a) try different agent, (b) simplify scope, (c) manual intervention.',
254
- )
255
- }
256
- break
257
- }
258
- case 'budget-exhausted':
259
- parts.push('[BUDGET EXHAUSTED]')
260
- if (result.partialResult) {
261
- parts.push('Partial result:')
262
- parts.push(result.partialResult)
263
- }
264
- parts.push(result.recommendation ?? '')
265
- break
266
- case 'timeout': {
267
- const subLabel =
268
- result.subType === 'timeout-with-partial'
269
- ? 'with partial output'
270
- : result.subType === 'timeout-exhausted'
271
- ? 'exhausted after retry'
272
- : 'no output'
273
- parts.push(`[TIMEOUT — ${subLabel}]`)
274
- if (result.partialResult) {
275
- parts.push('Partial result:')
276
- parts.push(result.partialResult)
277
- }
278
- parts.push(result.recommendation ?? '')
279
- break
280
- }
281
- case 'error':
282
- parts.push('[ERROR]')
283
- parts.push(result.content)
284
- break
285
- case 'startup_failed':
286
- parts.push('[STARTUP FAILED]')
287
- parts.push(result.content)
288
- parts.push(
289
- result.recommendation ??
290
- 'Retry once or inspect the child session before dispatching again.',
291
- )
292
- break
293
- case 'bootstrap_unknown':
294
- parts.push('[BOOTSTRAP UNKNOWN]')
295
- parts.push(result.content)
296
- parts.push(
297
- result.recommendation ?? 'The host did not expose enough session state to verify startup.',
298
- )
299
- break
300
- }
301
-
302
- // Structured fields for non-success results
303
- parts.push(`status: ${result.status}`)
304
- parts.push(`retryCount: ${result.retryCount}`)
305
-
306
- return parts.filter((p) => p !== '').join('\n')
307
- }
@@ -1,374 +0,0 @@
1
- /**
2
- * Delegation finalize — PULL output from a child session and persist the
3
- * delegation report (markdown) + the terminal board transition.
4
- *
5
- * Completion is NOT pushed: the event hook observes `session.idle` on the
6
- * child session, then calls `finalizeDelegation()` which pulls the child's
7
- * messages via `client.session.messages()`, writes the report atomically
8
- * (tmp + rename) to `.pantheon/delegations/<rootSessionID>/<alias>.md`, and
9
- * moves the job to a terminal board state (persist-before-notify: the report
10
- * file exists before `board.updateStatus` resolves waiters).
11
- *
12
- * Pure TypeScript — zero runtime dependencies beyond Node.js builtins and the
13
- * BackgroundJobBoard. The client is a STRUCTURAL interface (fake-client
14
- * friendly) so this module is testable standalone, without the opencode SDK.
15
- *
16
- * @module delegation-finalize
17
- */
18
-
19
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
20
- import { join } from 'node:path'
21
-
22
- import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
23
- import type { PermissionTaskConfig } from './permission-globs.ts'
24
- import type { StepCapTracker } from './step-cap.ts'
25
-
26
- // ─── Types ─────────────────────────────────────────────────────────────
27
-
28
- /** A minimal structural view of one message bundle from session.messages(). */
29
- export interface DelegationMessageBundle {
30
- info?: { role?: string; finish?: string; error?: unknown }
31
- parts?: Array<{
32
- type?: string
33
- text?: string
34
- /** Tool name on `tool` parts — used by the activity sampler. */
35
- tool?: string
36
- /** Tool input on `tool` parts (opencode SDK ToolPart.metadata). */
37
- metadata?: { input?: unknown }
38
- }>
39
- }
40
-
41
- /** Minimal structural view of the opencode SDK session client we use. */
42
- export interface DelegationClientSession {
43
- create(input: {
44
- body: { parentID: string; title?: string; model?: { id: string; providerID: string } }
45
- }): Promise<{ id: string }>
46
- promptAsync(input: {
47
- path: { id: string }
48
- body: {
49
- agent: string
50
- model?: { id: string; providerID: string }
51
- parts: Array<{ type: 'text'; text: string }>
52
- }
53
- /** Request cancellation for hosts that support AbortSignal. */
54
- signal?: AbortSignal
55
- }): Promise<unknown>
56
- messages?(input: { path: { id: string } }): Promise<Array<DelegationMessageBundle>>
57
- /** Optional session metadata endpoint (older hosts do not expose it). */
58
- get?(input: { path: { id: string } }): Promise<unknown>
59
- }
60
-
61
- /** The client surface used by the delegation core. */
62
- export interface DelegationClient {
63
- session: DelegationClientSession
64
- }
65
-
66
- /** Tunables for the delegation core (all optional — see DELEGATION_DEFAULTS). */
67
- export interface DelegationOptions {
68
- /** Job timeout before the delegation is finalized as error/timedOut. */
69
- timeoutMs?: number
70
- /** Caller-side cap for pantheon_delegation_read's waitForTerminal. */
71
- readTimeoutMs?: number
72
- /** Base directory for delegation reports (default `.pantheon/delegations`). */
73
- outputDir?: string
74
- /** Explicit root-session allowlist for the depth guard. */
75
- rootSessions?: ReadonlySet<string>
76
- /** Custom depth-guard predicate — overrides rootSessions and the default. */
77
- isRootSession?: (sessionID: string) => boolean
78
- /** SDK-backed child predicate; authoritative when supplied by the plugin. */
79
- isChildSession?: (sessionID: string) => boolean
80
- /**
81
- * Enforce the runtime B2 delegation matrix; createDelegationTools defaults
82
- * this to true. Set false only for an explicitly legacy host that cannot
83
- * provide ToolContext.agent.
84
- */
85
- enforceRuntimeMatrix?: boolean
86
- /** Record a child returned by the SDK session.create call. */
87
- registerChildSession?: (sessionID: string, parentID: string) => void
88
- /**
89
- * Agents whose delegated child sessions are registered as read-only
90
- * (Phase 4 enforcement). Mirrors routing.yml
91
- * background_delegation.read_only_agents. Matching is case-insensitive.
92
- */
93
- readOnlyAgents?: ReadonlySet<string>
94
- /**
95
- * Per-agent model overrides for delegated child sessions, keyed by agent
96
- * name (lowercase, e.g. `apollo`). Values are opencode model IDs in
97
- * `provider/model` form (e.g. `opencode/deepseek-v4-flash-free`), sourced
98
- * from routing.yml agent entries. Higher priority than the active preset.
99
- */
100
- agentModels?: Readonly<Record<string, string>>
101
- /**
102
- * Fase B3: Per-exception delegation budgets. Keys use
103
- * `<parent-agent>-><target-agent>` (for example `athena->apollo`). The
104
- * count is tracked per parent session for this process. It is intentionally
105
- * not persisted across restart because this layer has no reliable store.
106
- * An absent key has no budget limit.
107
- */
108
- delegationBudgets?: ReadonlyMap<string, number>
109
- /**
110
- * Fase B3: Wall-clock timeout (ms) for individual delegation sessions.
111
- * The plugin resolves `PANTHEON_DELEGATION_TIMEOUT_MS` into this option;
112
- * direct callers may provide it here instead.
113
- */
114
- wallClockTimeoutMs?: number
115
- /**
116
- * Env override for the active-preset resolution (see resolveActivePreset).
117
- * Defaults to process.env. Primarily for tests.
118
- */
119
- presetEnv?: Record<string, string | undefined>
120
- /** Warning sink for the no-model fallback (defaults to console). */
121
- logger?: { warn?: (msg: string) => void }
122
- /**
123
- * R4: per-agent step-cap tracker (routing.yml `agents.<name>.max_steps`).
124
- * When an agent reaches max_steps the delegation is forced to
125
- * summarize-and-stop: skipped with a capped summary when already at the
126
- * cap, or the prompt is appended with a stop instruction when the cap is
127
- * hit by this dispatch.
128
- */
129
- stepCapTracker?: StepCapTracker
130
- /**
131
- * O5: permission.task glob rules controlling which subagents may be
132
- * invoked. Denied agents are removed from the delegate tool description
133
- * entirely (not just blocked at call time).
134
- */
135
- permissionTask?: PermissionTaskConfig
136
- /**
137
- * O5: candidate agent names for the delegate tool description. Defaults
138
- * to the canonical 14-agent roster.
139
- */
140
- agentNames?: readonly string[]
141
- /** Bounded post-prompt bootstrap watchdog window. */
142
- bootstrapTimeoutMs?: number
143
- /** Timeout for the promptAsync request itself (including AbortSignal). */
144
- promptTimeoutMs?: number
145
- /** Poll interval used by the bootstrap watchdog. */
146
- bootstrapPollIntervalMs?: number
147
- /** Injectable clock/sleeper for deterministic watchdog tests. */
148
- now?: () => number
149
- sleep?: (ms: number) => Promise<void>
150
- }
151
-
152
- /** Dependencies threaded to the finalize path. */
153
- export interface DelegationDeps {
154
- board: BackgroundJobBoard
155
- client: DelegationClient
156
- options: DelegationOptions
157
- }
158
-
159
- /** Terminal transition requested by the finalize path. */
160
- export interface FinalizeInput {
161
- state: 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
162
- error?: string
163
- timedOut?: boolean
164
- }
165
-
166
- /** Defaults matching routing.yml background_delegation (timeout_ms 900000). */
167
- export const DELEGATION_DEFAULTS = {
168
- timeoutMs: 900_000,
169
- readTimeoutMs: 900_000,
170
- outputDir: '.pantheon/delegations',
171
- } as const
172
-
173
- // ─── Output pulling ────────────────────────────────────────────────────
174
-
175
- interface PulledOutput {
176
- text: string
177
- /** True only when the child produced an assistant message or tool activity. */
178
- hasWork: boolean
179
- }
180
-
181
- /** Pull visible text and structurally identify real child work. */
182
- async function pullOutput(client: DelegationClient, childSessionID: string): Promise<PulledOutput> {
183
- if (typeof client.session.messages !== 'function')
184
- return { text: '(child session messages API unavailable)', hasWork: false }
185
- try {
186
- const bundles = await client.session.messages({ path: { id: childSessionID } })
187
- const lines: string[] = []
188
- let hasWork = false
189
- for (const bundle of bundles) {
190
- const hasToolPart = (bundle.parts ?? []).some((part) => part.type === 'tool')
191
- const hasTextPart = (bundle.parts ?? []).some(
192
- (part) => part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '',
193
- )
194
- if (
195
- bundle.info?.role === 'assistant' &&
196
- bundle.info.error === undefined &&
197
- (hasToolPart || hasTextPart)
198
- ) {
199
- hasWork = true
200
- }
201
- for (const part of bundle.parts ?? []) {
202
- if (
203
- part.type === 'tool' &&
204
- bundle.info?.role === 'assistant' &&
205
- bundle.info.error === undefined
206
- )
207
- hasWork = true
208
- if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
209
- lines.push(part.text)
210
- }
211
- }
212
- }
213
- return { text: lines.join('\n\n'), hasWork }
214
- } catch (err: unknown) {
215
- const reason = err instanceof Error ? err.message : String(err)
216
- return { text: `(failed to pull child session output: ${reason})`, hasWork: false }
217
- }
218
- }
219
-
220
- // ─── Markdown rendering ────────────────────────────────────────────────
221
-
222
- /** Render the delegation report body. Public for tests/Phase 3 reuse. */
223
- export function renderDelegationMarkdown(
224
- job: BackgroundJobRecord,
225
- output: string,
226
- opts: FinalizeInput & { at?: number },
227
- ): string {
228
- const at = opts.at ?? Date.now()
229
- const lines = [
230
- `# Delegation Report — ${job.alias}`,
231
- '',
232
- `- **Task ID**: \`${job.taskID}\``,
233
- `- **Agent**: ${job.agent}`,
234
- `- **Description**: ${job.description}`,
235
- `- **State**: ${opts.state}`,
236
- `- **Timed out**: ${opts.timedOut === true ? 'true' : 'false'}`,
237
- `- **Started**: ${new Date(job.launchedAt).toISOString()}`,
238
- `- **Finalized**: ${new Date(at).toISOString()}`,
239
- '',
240
- '## Output',
241
- '',
242
- output.trim() !== '' ? output : '_No output captured._',
243
- ]
244
- if (opts.error !== undefined) {
245
- lines.push('', `**Error**: ${opts.error}`)
246
- }
247
- if (opts.timedOut === true) {
248
- lines.push('', '[TIMEOUT REACHED]')
249
- }
250
- return `${lines.join('\n')}\n`
251
- }
252
-
253
- /** One-line result summary from the pulled output (for the board record). */
254
- function summarizeOutput(output: string): string {
255
- const firstLine = output
256
- .split('\n')
257
- .map((l) => l.trim())
258
- .find((l) => l !== '')
259
- if (firstLine === undefined) return 'No output captured'
260
- return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine
261
- }
262
-
263
- // ─── Report persistence ────────────────────────────────────────────────
264
-
265
- /**
266
- * Reject session IDs that could escape the delegation output directory.
267
- * `parentSessionID` is embedded in the report path
268
- * `<outputDir>/<parentSessionID>/<alias>.md` — `path.join` does NOT strip
269
- * `..`, so a traversing ID would write outside the sandbox. Called BEFORE any
270
- * path construction (F1 — path traversal hardening).
271
- *
272
- * @throws {Error} When the ID contains `..`, `/`, or `\`.
273
- */
274
- export function assertSafeParentSessionID(parentSessionID: string): void {
275
- if (
276
- parentSessionID.includes('..') ||
277
- parentSessionID.includes('/') ||
278
- parentSessionID.includes('\\')
279
- ) {
280
- throw new Error(`Invalid parentSessionID: ${parentSessionID}`)
281
- }
282
- }
283
-
284
- /** Atomic write (tmp + rename) of the report under <outputDir>/<parent>/<alias>.md. */
285
- async function writeDelegationReport(
286
- outputDir: string,
287
- job: BackgroundJobRecord,
288
- md: string,
289
- ): Promise<string> {
290
- assertSafeParentSessionID(job.parentSessionID)
291
- const dir = join(outputDir, job.parentSessionID)
292
- const filePath = join(dir, `${job.alias}.md`)
293
- const tmpPath = join(dir, `${job.alias}.md.tmp`)
294
- await mkdir(dir, { recursive: true })
295
- await writeFile(tmpPath, md, 'utf-8')
296
- await rename(tmpPath, filePath)
297
- return filePath
298
- }
299
-
300
- /** Read a previously written report; undefined when the file does not exist. */
301
- export async function readDelegationReport(
302
- outputDir: string,
303
- job: BackgroundJobRecord,
304
- ): Promise<string | undefined> {
305
- assertSafeParentSessionID(job.parentSessionID)
306
- const filePath = join(outputDir, job.parentSessionID, `${job.alias}.md`)
307
- try {
308
- return await readFile(filePath, 'utf-8')
309
- } catch (err: unknown) {
310
- if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
311
- return undefined
312
- }
313
- throw err
314
- }
315
- }
316
-
317
- // ─── Finalize ──────────────────────────────────────────────────────────
318
-
319
- /**
320
- * Finalize a delegation for a child session: pull output, write the report
321
- * atomically, then move the board job to the requested terminal state.
322
- *
323
- * The board transition is guarded: only `running` → terminal, or an
324
- * idempotent same-terminal re-notify, are applied. A finalize that races a
325
- * timeout (already `error`) never overwrites the terminal state — the report
326
- * file is still (re)written, so the marker reflects the latest observation.
327
- *
328
- * @returns The updated job record, or undefined when the session is unknown.
329
- */
330
- export async function finalizeDelegation(
331
- deps: DelegationDeps,
332
- childSessionID: string,
333
- opts: FinalizeInput,
334
- ): Promise<BackgroundJobRecord | undefined> {
335
- const job = deps.board.get(childSessionID)
336
- if (!job) return undefined
337
-
338
- const pulled = await pullOutput(deps.client, childSessionID)
339
- const output = pulled.text
340
- const effectiveOpts: FinalizeInput =
341
- opts.state === 'completed' && !pulled.hasWork
342
- ? {
343
- state: 'error',
344
- error: opts.error ?? 'Child session produced no assistant or tool output',
345
- ...(opts.timedOut !== undefined ? { timedOut: opts.timedOut } : {}),
346
- }
347
- : opts
348
- const outputDir = deps.options.outputDir ?? DELEGATION_DEFAULTS.outputDir
349
- await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, effectiveOpts))
350
-
351
- const status: {
352
- taskID: string
353
- state: 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
354
- error?: string
355
- timedOut?: boolean
356
- resultSummary?: string
357
- } = {
358
- taskID: childSessionID,
359
- state: effectiveOpts.state,
360
- resultSummary: summarizeOutput(output),
361
- }
362
- if (effectiveOpts.error !== undefined) status.error = effectiveOpts.error
363
- if (effectiveOpts.timedOut !== undefined) status.timedOut = effectiveOpts.timedOut
364
-
365
- const current = deps.board.get(childSessionID)
366
- if (current !== undefined) {
367
- if (current.state === 'running' || current.state === effectiveOpts.state) {
368
- await deps.board.updateStatus(status)
369
- }
370
- // Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
371
- // report above still captures this finalize; the board state stands.
372
- }
373
- return deps.board.get(childSessionID) ?? job
374
- }