pantheon-opencode 1.3.5 → 1.3.7

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 (71) hide show
  1. package/README.md +33 -1
  2. package/package.json +3 -3
  3. package/scripts/_pantheon_paths.py +0 -1
  4. package/scripts/build-agents-md.mjs +4 -2
  5. package/scripts/code_mode_server.py +307 -21
  6. package/scripts/doctor.mjs +26 -13
  7. package/scripts/hooks/format-multi-language.sh +6 -3
  8. package/scripts/hooks/log-session-start.sh +6 -0
  9. package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
  10. package/scripts/hooks/validate-post-conditions.sh +1 -1
  11. package/scripts/hooks/validate-tool-safety.sh +5 -2
  12. package/scripts/install/agents-md.mjs +3 -1
  13. package/scripts/install/cli-ui.mjs +24 -4
  14. package/scripts/install/interactive.mjs +18 -7
  15. package/scripts/install/migrate-config.mjs +13 -19
  16. package/scripts/install/migrate.mjs +8 -5
  17. package/scripts/install/model-picker.mjs +4 -1
  18. package/scripts/install/opencode.mjs +2 -0
  19. package/scripts/install/shared.mjs +6 -4
  20. package/scripts/install/state.mjs +9 -6
  21. package/scripts/mcp_persistence_server.py +23 -22
  22. package/scripts/mcp_resources_server.py +60 -1
  23. package/scripts/memory_mcp_server.py +80 -4
  24. package/scripts/postinstall.mjs +27 -25
  25. package/scripts/prune.mjs +1 -1
  26. package/scripts/redaction-gate.mjs +3 -1
  27. package/scripts/sync-tui.mjs +148 -0
  28. package/scripts/uninstall.mjs +0 -2
  29. package/scripts/validation-policy.mjs +6 -1
  30. package/scripts/version-check.mjs +9 -4
  31. package/src/agents/aphrodite.md +1 -1
  32. package/src/agents/demeter.md +1 -1
  33. package/src/agents/hephaestus.md +1 -1
  34. package/src/agents/hermes.md +1 -1
  35. package/src/agents/themis.md +4 -0
  36. package/src/mcp/code_mode_server.py +304 -18
  37. package/src/mcp/eval_store.py +233 -0
  38. package/src/mcp/mcp_persistence_server.py +23 -22
  39. package/src/mcp/mcp_resources_server.py +58 -0
  40. package/src/mcp/memory_mcp_server.py +80 -3
  41. package/src/pantheon/auto-wake.ts +2 -4
  42. package/src/pantheon/background-job-board.ts +17 -4
  43. package/src/pantheon/comment-checker.ts +48 -0
  44. package/src/pantheon/delegation-classifier.ts +307 -0
  45. package/src/pantheon/delegation-enforce.ts +21 -21
  46. package/src/pantheon/delegation-finalize.ts +44 -4
  47. package/src/pantheon/delegation-notify.ts +97 -0
  48. package/src/pantheon/delegation.ts +413 -23
  49. package/src/pantheon/dispatch-guard.ts +2 -0
  50. package/src/pantheon/file-persistence.ts +7 -3
  51. package/src/pantheon/permission-eval.ts +284 -0
  52. package/src/pantheon/permission-globs.ts +86 -0
  53. package/src/pantheon/presets.d.mts +41 -0
  54. package/src/pantheon/presets.mjs +134 -0
  55. package/src/pantheon/retry-policy.ts +212 -0
  56. package/src/pantheon/session-guard.ts +1 -3
  57. package/src/pantheon/step-cap.ts +110 -0
  58. package/src/pantheon/tui-version-check.ts +72 -0
  59. package/src/pantheon/vision.ts +7 -5
  60. package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
  61. package/src/plugin.ts +57 -3
  62. package/src/plugins/hook-runner.ts +1 -1
  63. package/src/plugins/pantheon-hooks.ts +1 -1
  64. package/src/plugins/tui/dist/tui.d.ts +1 -1
  65. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  66. package/src/plugins/tui/dist/tui.js +19 -14
  67. package/src/plugins/tui/dist/tui.tsx +52 -18
  68. package/src/plugins/tui/package.json +1 -1
  69. package/src/plugins/tui/src/index.tsx +106 -24
  70. package/src/plugins/tui/tsdown.config.ts +1 -1
  71. package/src/routing.yml +42 -0
@@ -38,6 +38,11 @@
38
38
  import { z } from 'zod'
39
39
 
40
40
  import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
41
+ import {
42
+ classifyStuckAgent,
43
+ type DelegationResult,
44
+ formatDelegationResult,
45
+ } from './delegation-classifier.ts'
41
46
  import {
42
47
  isDelegationAllowed,
43
48
  normalizeDelegationAgent,
@@ -53,8 +58,15 @@ import {
53
58
  finalizeDelegation as finalizeDelegationReport,
54
59
  readDelegationReport,
55
60
  } from './delegation-finalize.ts'
61
+ import {
62
+ type IdleChildScanDeps,
63
+ finalizeIdleChildrenWithoutMd,
64
+ startIdleChildScan,
65
+ } from './delegation-notify.ts'
56
66
  import { createPantheonLogger } from './logger.ts'
67
+ import { buildAgentListDescription } from './permission-globs.ts'
57
68
  import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
69
+ import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
58
70
 
59
71
  export type {
60
72
  DelegationClient,
@@ -64,12 +76,19 @@ export type {
64
76
  FinalizeInput,
65
77
  } from './delegation-finalize.ts'
66
78
  export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
79
+ export type { IdleChildScanDeps } from './delegation-notify.ts'
80
+ export { finalizeIdleChildrenWithoutMd, startIdleChildScan } from './delegation-notify.ts'
67
81
 
68
82
  // Fase B3: these are runtime controls, not routing/frontmatter fields. The
69
83
  // plugin reads them from the environment, while tests and embedders can pass
70
84
  // the same values through DelegationOptions.
71
85
  export const B3_DEFAULT_EXCEPTION_BUDGET = 5
72
86
 
87
+ /** Maximum time spent proving that an accepted prompt actually booted. */
88
+ export const BOOTSTRAP_TIMEOUT_MS = 5_000
89
+ /** Default cadence for the bounded bootstrap probe. */
90
+ export const BOOTSTRAP_POLL_INTERVAL_MS = 250
91
+
73
92
  function readIntegerEnv(
74
93
  env: Record<string, string | undefined>,
75
94
  name: string,
@@ -82,6 +101,175 @@ function readIntegerEnv(
82
101
  return Number.isSafeInteger(value) && value >= minimum ? value : fallback
83
102
  }
84
103
 
104
+ type BootstrapOutcome =
105
+ | { status: 'started'; elapsedMs: number; reason: string }
106
+ | { status: 'startup_failed' | 'bootstrap_unknown'; elapsedMs: number; reason: string }
107
+
108
+ type PromptOutcome =
109
+ | { status: 'accepted'; value: unknown }
110
+ | { status: 'rejected'; reason: string }
111
+ | { status: 'timeout'; reason: string }
112
+
113
+ /**
114
+ * Bound the SDK request itself, not just the board watchdog. The AbortSignal
115
+ * is passed to hosts that support it; the race also protects older hosts that
116
+ * ignore the signal and never settle their promise.
117
+ */
118
+ async function promptWithTimeout(
119
+ client: DelegationClient,
120
+ input: Parameters<DelegationClient['session']['promptAsync']>[0],
121
+ timeoutMs: number,
122
+ ): Promise<PromptOutcome> {
123
+ const controller = new AbortController()
124
+ let timer: NodeJS.Timeout | undefined
125
+ const operation = client.session.promptAsync({ ...input, signal: controller.signal }).then(
126
+ (value) => ({ status: 'accepted' as const, value }),
127
+ (error: unknown) => ({
128
+ status: 'rejected' as const,
129
+ reason: error instanceof Error ? error.message : String(error),
130
+ }),
131
+ )
132
+ try {
133
+ return await Promise.race([
134
+ operation,
135
+ new Promise<PromptOutcome>((resolve) => {
136
+ timer = setTimeout(() => {
137
+ controller.abort()
138
+ resolve({ status: 'timeout', reason: `promptAsync timed out after ${timeoutMs}ms` })
139
+ }, timeoutMs)
140
+ timer.unref()
141
+ }),
142
+ ])
143
+ } finally {
144
+ if (timer !== undefined) clearTimeout(timer)
145
+ }
146
+ }
147
+
148
+ const RUNNING_SESSION_STATES = new Set(['busy', 'running', 'processing', 'retry', 'pending'])
149
+
150
+ async function boundedProbe<T>(operation: Promise<T>, timeoutMs: number): Promise<T | undefined> {
151
+ if (timeoutMs <= 0) return undefined
152
+ let timer: NodeJS.Timeout | undefined
153
+ try {
154
+ return await Promise.race([
155
+ operation,
156
+ new Promise<undefined>((resolve) => {
157
+ timer = setTimeout(() => resolve(undefined), timeoutMs)
158
+ timer.unref()
159
+ }),
160
+ ])
161
+ } finally {
162
+ if (timer !== undefined) clearTimeout(timer)
163
+ }
164
+ }
165
+
166
+ function isRunningSession(value: unknown): boolean {
167
+ if (typeof value !== 'object' || value === null) return false
168
+ const record = value as Record<string, unknown>
169
+ const state = record.status ?? record.state
170
+ return typeof state === 'string' && RUNNING_SESSION_STATES.has(state.toLowerCase())
171
+ }
172
+
173
+ function hasDurableMessage(value: unknown): boolean {
174
+ if (!Array.isArray(value)) return false
175
+ return value.some((bundle) => {
176
+ if (typeof bundle !== 'object' || bundle === null) return false
177
+ const info = (bundle as { info?: { role?: unknown } }).info
178
+ return info?.role === 'user' || info?.role === 'assistant'
179
+ })
180
+ }
181
+
182
+ async function bootstrapChild(
183
+ client: DelegationClient,
184
+ childSessionID: string,
185
+ accepted: unknown,
186
+ options: DelegationOptions,
187
+ ): Promise<BootstrapOutcome> {
188
+ const now = options.now ?? Date.now
189
+ const sleep =
190
+ options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
191
+ const timeoutMs = options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS
192
+ const intervalMs = options.bootstrapPollIntervalMs ?? BOOTSTRAP_POLL_INTERVAL_MS
193
+ const startedAt = now()
194
+ const hasMessagesAPI = typeof client.session.messages === 'function'
195
+ const hasStatusAPI = typeof client.session.get === 'function'
196
+ const acceptedRunning = isRunningSession(accepted)
197
+
198
+ if (!hasMessagesAPI && !hasStatusAPI) {
199
+ return acceptedRunning
200
+ ? {
201
+ status: 'started',
202
+ elapsedMs: now() - startedAt,
203
+ reason: 'prompt accepted by legacy host',
204
+ }
205
+ : {
206
+ status: 'bootstrap_unknown',
207
+ elapsedMs: now() - startedAt,
208
+ reason:
209
+ 'host exposes neither session.messages nor session.get and prompt had no running evidence',
210
+ }
211
+ }
212
+
213
+ let sawProbe = false
214
+ while (now() - startedAt < timeoutMs) {
215
+ let durable = false
216
+ let running = acceptedRunning
217
+ const remainingMs = timeoutMs - (now() - startedAt)
218
+ if (hasMessagesAPI) {
219
+ try {
220
+ const messages = await boundedProbe(
221
+ client.session.messages!({ path: { id: childSessionID } }),
222
+ remainingMs,
223
+ )
224
+ if (messages !== undefined) {
225
+ sawProbe = true
226
+ durable = hasDurableMessage(messages)
227
+ }
228
+ } catch {
229
+ // A transient endpoint failure is diagnosed after the bounded window.
230
+ }
231
+ }
232
+ if (hasStatusAPI) {
233
+ try {
234
+ const status = await boundedProbe(
235
+ client.session.get!({ path: { id: childSessionID } }),
236
+ timeoutMs - (now() - startedAt),
237
+ )
238
+ if (status !== undefined) {
239
+ running = running || isRunningSession(status)
240
+ sawProbe = true
241
+ }
242
+ } catch {
243
+ // Fail closed only after the bounded window; never create an unbounded poll.
244
+ }
245
+ }
246
+ if (durable)
247
+ return {
248
+ status: 'started',
249
+ elapsedMs: now() - startedAt,
250
+ reason: 'durable child message observed',
251
+ }
252
+ if (running)
253
+ return {
254
+ status: 'started',
255
+ elapsedMs: now() - startedAt,
256
+ reason: 'child session reports running/processing',
257
+ }
258
+ await sleep(Math.min(intervalMs, Math.max(0, timeoutMs - (now() - startedAt))))
259
+ }
260
+ return sawProbe
261
+ ? {
262
+ status: 'startup_failed',
263
+ elapsedMs: now() - startedAt,
264
+ reason: `no durable message or running state after ${timeoutMs}ms`,
265
+ }
266
+ : {
267
+ status: 'bootstrap_unknown',
268
+ elapsedMs: now() - startedAt,
269
+ reason: `bootstrap probes were unavailable after ${timeoutMs}ms`,
270
+ }
271
+ }
272
+
85
273
  /** Resolve the two supported read-only delegation exception budgets. */
86
274
  export function resolveDelegationBudgets(
87
275
  env: Record<string, string | undefined> = process.env,
@@ -145,6 +333,8 @@ export interface DelegationToolset {
145
333
  childSessionID: string,
146
334
  opts: FinalizeInput,
147
335
  ) => Promise<BackgroundJobRecord | undefined>
336
+ /** Proactive finalize for idle children without MD reports (Fix 2). */
337
+ finalizeIdleChildrenWithoutMd: typeof finalizeIdleChildrenWithoutMd
148
338
  }
149
339
 
150
340
  /** Input for createDelegationTools(). */
@@ -177,6 +367,24 @@ const listArgs = {} satisfies z.ZodRawShape
177
367
 
178
368
  // ─── State labels ──────────────────────────────────────────────────────
179
369
 
370
+ /** Canonical 14-agent roster (O5 tool-description default). */
371
+ export const CANONICAL_AGENTS: readonly string[] = [
372
+ 'zeus',
373
+ 'athena',
374
+ 'apollo',
375
+ 'hermes',
376
+ 'aphrodite',
377
+ 'demeter',
378
+ 'themis',
379
+ 'prometheus',
380
+ 'hephaestus',
381
+ 'nyx',
382
+ 'gaia',
383
+ 'iris',
384
+ 'mnemosyne',
385
+ 'talos',
386
+ ]
387
+
180
388
  function stateLabel(state: BackgroundJobRecord['state']): string {
181
389
  switch (state) {
182
390
  case 'running':
@@ -185,6 +393,10 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
185
393
  return 'OK'
186
394
  case 'error':
187
395
  return 'ERR'
396
+ case 'startup_failed':
397
+ return 'STARTUP FAILED'
398
+ case 'startup_unknown':
399
+ return 'STARTUP UNKNOWN'
188
400
  case 'cancelled':
189
401
  return 'CANCELLED'
190
402
  case 'reconciled':
@@ -195,6 +407,21 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
195
407
  /** Normalize the SDK-provided caller agent without inventing an identity. */
196
408
  const normalizeToolAgent = normalizeDelegationAgent
197
409
 
410
+ /**
411
+ * O5: the delegate tool description. When permission.task rules are
412
+ * configured, denied agents are REMOVED from the description entirely (not
413
+ * just blocked at call time) — the caller never sees them as invocable.
414
+ */
415
+ function buildDelegateDescription(options: DelegationOptions): string {
416
+ const base =
417
+ 'Dispatch a background agent as a child session and register it on the job board. ' +
418
+ 'Returns the readable alias (e.g. "apo-1"); read the result with ' +
419
+ 'pantheon_delegation_read. Only root sessions may delegate.'
420
+ if (options.permissionTask === undefined) return base
421
+ const roster = options.agentNames ?? CANONICAL_AGENTS
422
+ return `${base} Allowed subagents: ${buildAgentListDescription(options.permissionTask, roster)}.`
423
+ }
424
+
198
425
  // ─── Agent activity sampling ────────────────────────────────────────────
199
426
  //
200
427
  // Visibility for `pantheon_delegation_read` / `pantheon_delegation_list`
@@ -487,7 +714,11 @@ function resolveUsableChildModel(
487
714
 
488
715
  // Auto-resolved (agentModels / preset) → try the known-good fallback.
489
716
  const fallback = splitModelRef(FALLBACK_MODEL)
490
- if (fallback !== undefined && (fallback.providerID === 'opencode-go' || missingProviderKeyEnv(fallback.providerID, { env }) === undefined)) {
717
+ if (
718
+ fallback !== undefined &&
719
+ (fallback.providerID === 'opencode-go' ||
720
+ missingProviderKeyEnv(fallback.providerID, { env }) === undefined)
721
+ ) {
491
722
  warn(
492
723
  `[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
493
724
  `(unset) — falling back to ${FALLBACK_MODEL}`,
@@ -582,10 +813,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
582
813
  }
583
814
 
584
815
  const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
585
- description:
586
- 'Dispatch a background agent as a child session and register it on the job board. ' +
587
- 'Returns the readable alias (e.g. "apo-1"); read the result with ' +
588
- 'pantheon_delegation_read. Only root sessions may delegate.',
816
+ description: buildDelegateDescription(options),
589
817
  args: delegateArgs,
590
818
  execute: async (args, ctx) => {
591
819
  if (!isRootSession(ctx.sessionID)) {
@@ -600,13 +828,34 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
600
828
  )
601
829
  }
602
830
 
831
+ // R4: per-agent step caps. An agent already at max_steps is forced to
832
+ // summarize-and-stop — the delegation is skipped with a capped summary
833
+ // (no session, no board job, no budget consumed). Otherwise record the
834
+ // step; when THIS step hits the cap, append the stop instruction to
835
+ // the prompt so the agent summarizes and stops instead of continuing.
836
+ let effectivePrompt = args.prompt
837
+ const stepCap = options.stepCapTracker
838
+ if (stepCap !== undefined) {
839
+ const maxSteps = stepCap.maxStepsFor(args.agent)
840
+ if (stepCap.isCapped(args.agent)) {
841
+ return cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS)
842
+ }
843
+ const rec = stepCap.recordStep(args.agent)
844
+ if (rec.capped && rec.maxSteps !== undefined) {
845
+ effectivePrompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
846
+ }
847
+ }
848
+
603
849
  // Fase B3: only the two read-only exceptions are budgeted. The official
604
850
  // SDK provides the current agent on ToolContext. If a structural/older
605
851
  // host omits it, do NOT infer identity from prompt, target, or process
606
852
  // state: the budget is explicitly skipped and the delegation continues.
607
853
  const parentAgent = normalizeToolAgent(ctx.agent)
608
854
  const targetAgent = args.agent.toLowerCase()
609
- if (options.enforceRuntimeMatrix === true && !isDelegationAllowed(parentAgent, targetAgent)) {
855
+ if (
856
+ options.enforceRuntimeMatrix === true &&
857
+ !isDelegationAllowed(parentAgent, targetAgent, options.permissionTask)
858
+ ) {
610
859
  const reason =
611
860
  parentAgent === undefined
612
861
  ? 'ToolContext.agent is unavailable'
@@ -670,7 +919,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
670
919
  created = await client.session.create({
671
920
  body: {
672
921
  parentID: ctx.sessionID,
673
- title: args.description ?? args.prompt.slice(0, 80),
922
+ title: args.description ?? effectivePrompt.slice(0, 80),
674
923
  ...(childModel !== undefined ? { model: childModel } : {}),
675
924
  },
676
925
  })
@@ -702,8 +951,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
702
951
  taskID: childSessionID,
703
952
  parentSessionID: ctx.sessionID,
704
953
  agent: args.agent,
705
- description: args.description ?? args.prompt,
706
- objective: args.prompt,
954
+ description: args.description ?? effectivePrompt,
955
+ objective: effectivePrompt,
707
956
  })
708
957
 
709
958
  // Timeout manager: cleared on finalize, unref'd so the timer never
@@ -719,19 +968,139 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
719
968
  timer.unref()
720
969
  timers.set(childSessionID, timer)
721
970
 
722
- // Fire-and-forget — completion is observed via session.idle on the
723
- // child (spike: noReply does NOT deliver anything to the parent).
724
- void client.session
725
- .promptAsync({
726
- path: { id: childSessionID },
727
- body: { agent: args.agent, parts: [{ type: 'text', text: args.prompt }] },
728
- })
729
- .catch((err: unknown) => log.error('[pantheon-delegate] promptAsync failed:', err))
971
+ const promptBody = {
972
+ agent: args.agent,
973
+ ...(childModel !== undefined ? { model: childModel } : {}),
974
+ parts: [{ type: 'text' as const, text: effectivePrompt }],
975
+ }
976
+ const promptOutcome = await promptWithTimeout(
977
+ client,
978
+ { path: { id: childSessionID }, body: promptBody },
979
+ options.promptTimeoutMs ?? options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS,
980
+ )
981
+ let bootstrap: BootstrapOutcome
982
+ let safeRetry = false
983
+ if (promptOutcome.status === 'rejected') {
984
+ // A synchronous rejection is the only safe automatic retry signal: the
985
+ // host explicitly told us that it did not accept the request.
986
+ bootstrap = {
987
+ status: 'startup_failed',
988
+ elapsedMs: 0,
989
+ reason: `promptAsync rejected: ${promptOutcome.reason}`,
990
+ }
991
+ safeRetry = true
992
+ } else if (promptOutcome.status === 'timeout') {
993
+ // A timeout is ambiguous: the server may have accepted the prompt even
994
+ // though the client never received its response. Never resend it.
995
+ bootstrap = { status: 'startup_failed', elapsedMs: 0, reason: promptOutcome.reason }
996
+ } else {
997
+ bootstrap = await bootstrapChild(client, childSessionID, promptOutcome.value, options)
998
+ }
999
+ log.info(
1000
+ `[pantheon-delegate] bootstrap child=${childSessionID} elapsed=${bootstrap.elapsedMs}ms reason=${bootstrap.reason}`,
1001
+ )
1002
+ if (bootstrap.status === 'started') {
1003
+ return (
1004
+ `Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
1005
+ `Read the result with pantheon_delegation_read({ id: "${job.alias}" }).`
1006
+ )
1007
+ }
730
1008
 
731
- return (
732
- `Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
733
- `Read the result with pantheon_delegation_read({ id: "${job.alias}" }).`
1009
+ // Every startup diagnosis is terminal for this child. Only a prompt
1010
+ // rejection (known not accepted) permits one fresh-session retry;
1011
+ // accepted-empty, unavailable APIs, and request timeouts do not.
1012
+ const boardStartupState =
1013
+ bootstrap.status === 'bootstrap_unknown' ? 'startup_unknown' : 'startup_failed'
1014
+ await finalize(childSessionID, {
1015
+ state: boardStartupState,
1016
+ error: `${bootstrap.status}: ${bootstrap.reason}`,
1017
+ })
1018
+ if (!safeRetry) {
1019
+ const finalResult: DelegationResult = {
1020
+ status: bootstrap.status,
1021
+ content: `Child session ${childSessionID} did not start (${bootstrap.reason}).`,
1022
+ retryCount: 0,
1023
+ recommendation:
1024
+ bootstrap.status === 'bootstrap_unknown'
1025
+ ? 'Inspect the child session in the board/TUI and retry manually only after confirming the prompt was not accepted.'
1026
+ : 'Inspect the child session/host promptAsync implementation; no automatic retry was attempted because acceptance was ambiguous.',
1027
+ }
1028
+ return formatDelegationResult(finalResult)
1029
+ }
1030
+
1031
+ let retry: { id: string }
1032
+ try {
1033
+ retry = await client.session.create({
1034
+ body: {
1035
+ parentID: ctx.sessionID,
1036
+ title: args.description ?? effectivePrompt.slice(0, 80),
1037
+ ...(childModel !== undefined ? { model: childModel } : {}),
1038
+ },
1039
+ })
1040
+ } catch (err: unknown) {
1041
+ const reason = err instanceof Error ? err.message : String(err)
1042
+ return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; retry creation failed: ${reason}`
1043
+ }
1044
+ const retryID = retry.id
1045
+ knownChildren.add(retryID)
1046
+ options.registerChildSession?.(retryID, ctx.sessionID)
1047
+ const retryJob = await board.registerLaunch({
1048
+ taskID: retryID,
1049
+ parentSessionID: ctx.sessionID,
1050
+ agent: args.agent,
1051
+ description: args.description ?? effectivePrompt,
1052
+ objective: effectivePrompt,
1053
+ })
1054
+ const retryTimer = setTimeout(() => {
1055
+ timers.delete(retryID)
1056
+ void finalize(retryID, {
1057
+ state: 'error',
1058
+ error: `Delegation [${retryJob.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
1059
+ timedOut: true,
1060
+ }).catch((err: unknown) =>
1061
+ log.error('[pantheon-delegate] retry timeout finalize failed:', err),
1062
+ )
1063
+ }, timeoutMs)
1064
+ retryTimer.unref()
1065
+ timers.set(retryID, retryTimer)
1066
+ const retryPrompt = await promptWithTimeout(
1067
+ client,
1068
+ { path: { id: retryID }, body: promptBody },
1069
+ options.promptTimeoutMs ?? options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS,
1070
+ )
1071
+ let retryBootstrap: BootstrapOutcome
1072
+ if (retryPrompt.status === 'rejected') {
1073
+ retryBootstrap = {
1074
+ status: 'startup_failed',
1075
+ elapsedMs: 0,
1076
+ reason: `retry promptAsync rejected: ${retryPrompt.reason}`,
1077
+ }
1078
+ } else if (retryPrompt.status === 'timeout') {
1079
+ retryBootstrap = { status: 'startup_failed', elapsedMs: 0, reason: retryPrompt.reason }
1080
+ } else {
1081
+ retryBootstrap = await bootstrapChild(client, retryID, retryPrompt.value, options)
1082
+ }
1083
+ log.info(
1084
+ `[pantheon-delegate] bootstrap retry child=${retryID} elapsed=${retryBootstrap.elapsedMs}ms reason=${retryBootstrap.reason}`,
734
1085
  )
1086
+ if (retryBootstrap.status === 'started') {
1087
+ return (
1088
+ `Delegated to ${args.agent}: [${retryJob.alias}] (task ${retryID}); retry=1.\n` +
1089
+ `Read the result with pantheon_delegation_read({ id: "${retryJob.alias}" }).`
1090
+ )
1091
+ }
1092
+ await finalize(retryID, {
1093
+ state: retryBootstrap.status === 'bootstrap_unknown' ? 'startup_unknown' : 'startup_failed',
1094
+ error: `startup retry exhausted: ${retryBootstrap.status}: ${retryBootstrap.reason}`,
1095
+ })
1096
+ const finalResult: DelegationResult = {
1097
+ status: retryBootstrap.status,
1098
+ content: `Child session ${retryID} did not start (${retryBootstrap.reason}).`,
1099
+ retryCount: 1,
1100
+ recommendation:
1101
+ 'Inspect the child session/host promptAsync implementation; no further automatic retries will be attempted.',
1102
+ }
1103
+ return formatDelegationResult(finalResult)
735
1104
  },
736
1105
  }
737
1106
 
@@ -768,10 +1137,30 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
768
1137
  return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
769
1138
  }
770
1139
 
1140
+ // Classify the result for stuck-agent detection
1141
+ const classification = classifyStuckAgent(md)
1142
+ const hasActivity = collector.sampled || collector.lines.length > 0
1143
+ const activitySuffix = hasActivity ? `\n\n${formatActivitySection(collector.lines)}` : ''
1144
+
1145
+ if (classification.status === 'success') {
1146
+ await board.markReconciled(job.taskID)
1147
+ // Backward compatible: success reports returned as-is
1148
+ if (!hasActivity) return md
1149
+ return `${md.replace(/\n+$/, '')}${activitySuffix}`
1150
+ }
1151
+
1152
+ // Non-success: build structured DelegationResult and format
1153
+ const delegationResult: DelegationResult = {
1154
+ status: classification.status === 'empty' ? 'empty' : classification.status,
1155
+ content: md,
1156
+ retryCount: 0,
1157
+ partialResult: classification.partialResult,
1158
+ recommendation: classification.recommendation,
1159
+ }
1160
+
771
1161
  await board.markReconciled(job.taskID)
772
- // Fail-open: if session.messages never succeeded, keep the report as-is.
773
- if (!collector.sampled && collector.lines.length === 0) return md
774
- return `${md.replace(/\n+$/, '')}\n\n${formatActivitySection(collector.lines)}`
1162
+ const formatted = formatDelegationResult(delegationResult)
1163
+ return `${formatted}${activitySuffix}`
775
1164
  },
776
1165
  }
777
1166
 
@@ -802,5 +1191,6 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
802
1191
  pantheon_delegation_read,
803
1192
  pantheon_delegation_list,
804
1193
  finalizeDelegation: finalize,
1194
+ finalizeIdleChildrenWithoutMd,
805
1195
  }
806
1196
  }
@@ -34,6 +34,8 @@ export interface DispatchResultLike {
34
34
  tokensInput?: number
35
35
  /** Output tokens (present when the model actually reasoned). */
36
36
  tokensOutput?: number
37
+ /** R1: the failure that produced this result (classified by retry-policy). */
38
+ error?: unknown
37
39
  }
38
40
 
39
41
  /** Classification of a dispatch result. */
@@ -58,7 +58,11 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
58
58
  const content = await readFile(this.statePath, 'utf-8')
59
59
  return JSON.parse(content) as BackgroundJobRecord[]
60
60
  } catch (err: unknown) {
61
- if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
61
+ if (
62
+ err instanceof Error &&
63
+ 'code' in err &&
64
+ (err as NodeJS.ErrnoException).code === 'ENOENT'
65
+ ) {
62
66
  return null
63
67
  }
64
68
  throw err
@@ -75,7 +79,7 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
75
79
 
76
80
  async saveJob(record: BackgroundJobRecord): Promise<void> {
77
81
  const records = (await this.readState()) ?? []
78
- const idx = records.findIndex(r => r.taskID === record.taskID)
82
+ const idx = records.findIndex((r) => r.taskID === record.taskID)
79
83
  if (idx >= 0) {
80
84
  records[idx] = record
81
85
  } else {
@@ -91,7 +95,7 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
91
95
  async deleteJob(taskID: string): Promise<void> {
92
96
  const records = await this.readState()
93
97
  if (!records) return
94
- const idx = records.findIndex(r => r.taskID === taskID)
98
+ const idx = records.findIndex((r) => r.taskID === taskID)
95
99
  if (idx < 0) return
96
100
  records.splice(idx, 1)
97
101
  await this.writeState(records)