pantheon-opencode 1.3.5 → 1.3.6

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