pantheon-opencode 1.3.5-beta.60.efa533f → 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 (75) hide show
  1. package/README.md +38 -6
  2. package/package.json +4 -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 +58 -15
  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 +8 -2
  19. package/scripts/install/shared.mjs +8 -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/apollo.md +3 -0
  32. package/src/agents/demeter.md +1 -1
  33. package/src/agents/gaia.md +3 -0
  34. package/src/agents/hephaestus.md +1 -1
  35. package/src/agents/hermes.md +1 -1
  36. package/src/agents/themis.md +4 -0
  37. package/src/agents/zeus.md +5 -1
  38. package/src/mcp/code_mode_server.py +304 -18
  39. package/src/mcp/eval_store.py +233 -0
  40. package/src/mcp/mcp_persistence_server.py +23 -22
  41. package/src/mcp/mcp_resources_server.py +58 -0
  42. package/src/mcp/memory_mcp_server.py +80 -3
  43. package/src/pantheon/auto-wake.ts +2 -4
  44. package/src/pantheon/background-job-board.ts +17 -4
  45. package/src/pantheon/comment-checker.ts +48 -0
  46. package/src/pantheon/delegation-classifier.ts +307 -0
  47. package/src/pantheon/delegation-enforce.ts +67 -3
  48. package/src/pantheon/delegation-finalize.ts +44 -4
  49. package/src/pantheon/delegation.ts +413 -30
  50. package/src/pantheon/dispatch-guard.ts +2 -0
  51. package/src/pantheon/file-persistence.ts +7 -3
  52. package/src/pantheon/goal-loop.ts +13 -2
  53. package/src/pantheon/permission-eval.ts +284 -0
  54. package/src/pantheon/permission-globs.ts +86 -0
  55. package/src/pantheon/presets.d.mts +41 -0
  56. package/src/pantheon/presets.mjs +135 -0
  57. package/src/pantheon/retry-policy.ts +212 -0
  58. package/src/pantheon/session-guard.ts +48 -0
  59. package/src/pantheon/step-cap.ts +110 -0
  60. package/src/pantheon/todo-enforcer.ts +31 -5
  61. package/src/pantheon/todo-preserve.ts +7 -1
  62. package/src/pantheon/vision.ts +7 -5
  63. package/src/pantheon/zeus-delegate-with-retry.ts +401 -0
  64. package/src/plugin.ts +72 -13
  65. package/src/plugins/hook-runner.ts +1 -1
  66. package/src/plugins/pantheon-hooks.ts +1 -1
  67. package/src/plugins/tui/dist/tui.d.ts +1 -1
  68. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  69. package/src/plugins/tui/dist/tui.js +36 -14
  70. package/src/plugins/tui/dist/tui.tsx +71 -16
  71. package/src/plugins/tui/package.json +1 -1
  72. package/src/plugins/tui/src/index.tsx +71 -16
  73. package/src/plugins/tui/tsdown.config.ts +1 -1
  74. package/src/routing.yml +47 -0
  75. package/src/agents/README.md +0 -26
@@ -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`
@@ -365,15 +583,18 @@ export interface ChildSessionModelRef {
365
583
  }
366
584
 
367
585
  /**
368
- * Known-good fallback model (P1, 2026-08-11 — release 1.3.4): when an
586
+ * Known-good fallback model (P1, 2026-08-11 — release 1.3.4, updated
587
+ * 2026-08-21 for sandbox without PANTHEON_OPENCODE_API_KEY): when an
369
588
  * AUTO-RESOLVED model (routing.yml agent entry / active preset) points at a
370
589
  * provider whose API key is not configured, the child is dispatched on the
371
- * native `opencode` provider instead — verified to work in the field without
372
- * an external provider key. Validated with the same providerKeyConfigured
373
- * gate as the resolved model; an explicit caller-supplied `model` always wins
374
- * over this fallback.
590
+ * `opencode-go` provider instead — `opencode-go/deepseek-v4-flash`
591
+ * via https://opencode.ai/zen/go/v1 works without an external API key
592
+ * (Go subscription / opencode auth, sandbox default). Validated with the
593
+ * same providerKeyConfigured gate as the resolved model, with an
594
+ * opencode-go exception for the sandbox; an explicit caller-supplied
595
+ * `model` always wins over this fallback.
375
596
  */
376
- const FALLBACK_MODEL = 'opencode/deepseek-v4-flash-free'
597
+ const FALLBACK_MODEL = 'opencode-go/deepseek-v4-flash'
377
598
 
378
599
  /**
379
600
  * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
@@ -484,7 +705,11 @@ function resolveUsableChildModel(
484
705
 
485
706
  // Auto-resolved (agentModels / preset) → try the known-good fallback.
486
707
  const fallback = splitModelRef(FALLBACK_MODEL)
487
- if (fallback !== undefined && missingProviderKeyEnv(fallback.providerID, { env }) === undefined) {
708
+ if (
709
+ fallback !== undefined &&
710
+ (fallback.providerID === 'opencode-go' ||
711
+ missingProviderKeyEnv(fallback.providerID, { env }) === undefined)
712
+ ) {
488
713
  warn(
489
714
  `[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
490
715
  `(unset) — falling back to ${FALLBACK_MODEL}`,
@@ -579,10 +804,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
579
804
  }
580
805
 
581
806
  const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
582
- description:
583
- 'Dispatch a background agent as a child session and register it on the job board. ' +
584
- 'Returns the readable alias (e.g. "apo-1"); read the result with ' +
585
- 'pantheon_delegation_read. Only root sessions may delegate.',
807
+ description: buildDelegateDescription(options),
586
808
  args: delegateArgs,
587
809
  execute: async (args, ctx) => {
588
810
  if (!isRootSession(ctx.sessionID)) {
@@ -597,13 +819,34 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
597
819
  )
598
820
  }
599
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
+
600
840
  // Fase B3: only the two read-only exceptions are budgeted. The official
601
841
  // SDK provides the current agent on ToolContext. If a structural/older
602
842
  // host omits it, do NOT infer identity from prompt, target, or process
603
843
  // state: the budget is explicitly skipped and the delegation continues.
604
844
  const parentAgent = normalizeToolAgent(ctx.agent)
605
845
  const targetAgent = args.agent.toLowerCase()
606
- if (options.enforceRuntimeMatrix === true && !isDelegationAllowed(parentAgent, targetAgent)) {
846
+ if (
847
+ options.enforceRuntimeMatrix === true &&
848
+ !isDelegationAllowed(parentAgent, targetAgent, options.permissionTask)
849
+ ) {
607
850
  const reason =
608
851
  parentAgent === undefined
609
852
  ? 'ToolContext.agent is unavailable'
@@ -653,7 +896,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
653
896
  // preset) is used when its provider key is configured; an explicit
654
897
  // caller model is always respected (warned); an auto-resolved model
655
898
  // whose provider key is missing falls back to the known-good
656
- // opencode/deepseek-v4-flash-free; if nothing usable remains the
899
+ // opencode-go/deepseek-v4-flash; if nothing usable remains the
657
900
  // failure is returned as TEXT below — no session, no board job.
658
901
  const resolved = resolveUsableChildModel(options, args.agent, args.model)
659
902
  if (resolved.error !== undefined) {
@@ -667,7 +910,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
667
910
  created = await client.session.create({
668
911
  body: {
669
912
  parentID: ctx.sessionID,
670
- title: args.description ?? args.prompt.slice(0, 80),
913
+ title: args.description ?? effectivePrompt.slice(0, 80),
671
914
  ...(childModel !== undefined ? { model: childModel } : {}),
672
915
  },
673
916
  })
@@ -699,8 +942,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
699
942
  taskID: childSessionID,
700
943
  parentSessionID: ctx.sessionID,
701
944
  agent: args.agent,
702
- description: args.description ?? args.prompt,
703
- objective: args.prompt,
945
+ description: args.description ?? effectivePrompt,
946
+ objective: effectivePrompt,
704
947
  })
705
948
 
706
949
  // Timeout manager: cleared on finalize, unref'd so the timer never
@@ -716,19 +959,139 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
716
959
  timer.unref()
717
960
  timers.set(childSessionID, timer)
718
961
 
719
- // Fire-and-forget — completion is observed via session.idle on the
720
- // child (spike: noReply does NOT deliver anything to the parent).
721
- void client.session
722
- .promptAsync({
723
- path: { id: childSessionID },
724
- body: { agent: args.agent, parts: [{ type: 'text', text: args.prompt }] },
725
- })
726
- .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
+ }
727
999
 
728
- return (
729
- `Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
730
- `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,
731
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)
732
1095
  },
733
1096
  }
734
1097
 
@@ -765,10 +1128,30 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
765
1128
  return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
766
1129
  }
767
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
+
768
1152
  await board.markReconciled(job.taskID)
769
- // Fail-open: if session.messages never succeeded, keep the report as-is.
770
- if (!collector.sampled && collector.lines.length === 0) return md
771
- return `${md.replace(/\n+$/, '')}\n\n${formatActivitySection(collector.lines)}`
1153
+ const formatted = formatDelegationResult(delegationResult)
1154
+ return `${formatted}${activitySuffix}`
772
1155
  },
773
1156
  }
774
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)
@@ -20,6 +20,7 @@ import type { BackgroundJobBoard } from './background-job-board.ts'
20
20
  import type { ToolContextLike } from './delegation.ts'
21
21
  import { GoalStore } from './goal-store.ts'
22
22
  import { createPantheonLogger } from './logger.ts'
23
+ import { safeSessionPath } from './session-guard.ts'
23
24
  import type { TodoEnforcerMessage } from './todo-enforcer.ts'
24
25
 
25
26
  // Silence-by-default TUI policy (pantheon-hooks L42-58): warn → hooks.log,
@@ -188,8 +189,13 @@ export class GoalLoop {
188
189
  this.lastContinuationAt.set(sessionID, now)
189
190
  const inherited = await this.inheritAgentOrModel(sessionID)
190
191
  const prompt = GOAL_CONTINUATION_PROMPT.replace('{objective}', goal.objective)
192
+ const promptPath = safeSessionPath(sessionID)
193
+ if (!promptPath) {
194
+ this.warn(`goal continuation skipped: invalid sessionID "${sessionID}"`)
195
+ return
196
+ }
191
197
  await this.client.session.promptAsync({
192
- path: { id: sessionID },
198
+ path: promptPath.path,
193
199
  body: {
194
200
  ...(inherited.agent !== undefined ? { agent: inherited.agent } : {}),
195
201
  ...(inherited.model !== undefined ? { model: inherited.model } : {}),
@@ -206,7 +212,12 @@ export class GoalLoop {
206
212
  * continuation runs in the same context as the session's last turn.
207
213
  */
208
214
  private async inheritAgentOrModel(sessionID: string): Promise<InheritedContext> {
209
- const messages = await this.client.session.messages({ path: { id: sessionID } })
215
+ const path = safeSessionPath(sessionID)
216
+ if (!path) {
217
+ this.warn(`inherit skipped: invalid sessionID "${sessionID}"`)
218
+ return {}
219
+ }
220
+ const messages = await this.client.session.messages(path)
210
221
  for (const msg of [...messages].reverse()) {
211
222
  const info = msg.info
212
223
  if (info === undefined || info.role !== 'assistant') continue