pantheon-opencode 1.5.0-beta.14 → 1.5.0-beta.15

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 (42) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/plugin.json +1 -1
  4. package/pyproject.toml +2 -5
  5. package/src/agents/zeus.md +4 -26
  6. package/src/pantheon/background-job-board.ts +4 -4
  7. package/src/pantheon/delegate-manager.ts +95 -10
  8. package/src/pantheon/delegation-classifier.ts +3 -4
  9. package/src/pantheon/delegation-finalize.ts +69 -7
  10. package/src/pantheon/delegation.ts +16 -4
  11. package/src/pantheon/presets.d.mts +0 -20
  12. package/src/pantheon/presets.mjs +0 -64
  13. package/src/pantheon/toon-codec.ts +1 -1
  14. package/src/plugin-v2.ts +4 -6
  15. package/src/plugin.ts +6 -6
  16. package/src/plugins/pantheon-hooks.ts +1 -1
  17. package/src/plugins/tui/dist/tui.d.ts +43 -36
  18. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  19. package/src/plugins/tui/dist/tui.js +102 -38
  20. package/src/plugins/tui/package-lock.json +2 -2
  21. package/src/plugins/tui/package.json +1 -1
  22. package/src/plugins/tui/src/index.tsx +168 -67
  23. package/src/routing.yml +0 -16
  24. package/scripts/hash_verify.py +0 -192
  25. package/scripts/test-context-compression.sh +0 -169
  26. package/src/pantheon/auto-wake.ts +0 -85
  27. package/src/pantheon/chat-reminders.ts +0 -9
  28. package/src/pantheon/comment-checker.ts +0 -48
  29. package/src/pantheon/compaction-assert.ts +0 -160
  30. package/src/pantheon/compaction-handoff.ts +0 -304
  31. package/src/pantheon/cost-tracker.ts +0 -108
  32. package/src/pantheon/dispatch-guard.ts +0 -109
  33. package/src/pantheon/permission-eval.ts +0 -284
  34. package/src/pantheon/preemptive-compact.ts +0 -76
  35. package/src/pantheon/provider-capabilities.ts +0 -221
  36. package/src/pantheon/provider-stream-tracker.ts +0 -182
  37. package/src/pantheon/retry-policy.ts +0 -212
  38. package/src/pantheon/token-opt.ts +0 -689
  39. package/src/pantheon/v2-events.ts +0 -148
  40. package/src/pantheon/v2-hooks.ts +0 -287
  41. package/src/pantheon/v2-tool-definitions.ts +0 -331
  42. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
package/CHANGELOG.md CHANGED
@@ -18,6 +18,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
18
18
 
19
19
  ## ✅ Closed Issues
20
20
 
21
+ ## [v1.5.0-beta.15] - 2026-09-15
22
+
23
+ <!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
24
+ move this section to a versioned entry and reset the template below. -->
25
+
26
+ ## 🆕 What's New
27
+
28
+ - **Delegation `read` devolve o relatório completo:** o retorno de `read` passa a
29
+ ler o relatório integral do disco (com cap configurável e marker explícito de
30
+ truncamento) em vez do recibo resumido — o conteúdo é o mesmo tanto no caminho
31
+ nativo quanto no legado, com paridade garantida entre eles.
32
+ - **`sanitizeReceiptText` UTF-8-safe:** a sanitização de recibos deixou de corromper
33
+ caracteres multibyte; acentuação e demais codepoints UTF-8 são preservados no
34
+ caminho nativo e no legado.
35
+ - **TUI — cor de status na row inteira:** cada linha do painel Delegations passa a
36
+ ser pintada pelo estado — vermelho = falha, verde = concluído, amarelo = em
37
+ andamento — com o glyph mantido como canal redundante para acessibilidade.
38
+
39
+ ## 🐞 Fixed
40
+
41
+ - **TUI — painel Delegations só da sessão ativa:** o painel deixa de renderizar
42
+ rows cross-session/órfãs que causavam "Session not found" ao clicar; a lista
43
+ agora cobre apenas a sessão ativa, mantendo a retenção de concluídas (2 min)
44
+ e falhas (10 min).
45
+ - **Guards de estado e navegação órfã:** transições prematuras para `done` são
46
+ bloqueadas e a navegação defensiva ignora alvos órfãos, evitando seleção
47
+ inválida no painel.
48
+ - **Isolamento do board nos testes:** testes que importam o plugin real agora
49
+ isolam o BackgroundJobBoard, eliminando o fantasma "preserve this running
50
+ delegation"; o board real foi limpo.
51
+ - **Lean no core:** 17 módulos mortos de `src/pantheon/` removidos, ~38% dos
52
+ arquivos de teste removidos/consolidados e docs/scripts órfãos limpos, com
53
+ testes mínimos readicionados para os módulos vivos (tool-ceiling, native-probe).
54
+ - **Flake de TTL determinístico:** `test_ttl_expiry_real_time` deixou de competir
55
+ com a truncagem de microssegundos do SQLite — o teste agora espera o início de
56
+ um segundo limpo antes do store e usa margem sobre o TTL, eliminando o flake
57
+ dependente de carga (test-only).
58
+
21
59
  ## [v1.5.0-beta.14] - 2026-09-14
22
60
 
23
61
  <!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.5.0-beta.14",
3
+ "version": "1.5.0-beta.15",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon",
3
3
  "publisher": "ils15",
4
- "version": "1.5.0-beta.14",
4
+ "version": "1.5.0-beta.15",
5
5
  "displayName": "Pantheon",
6
6
  "description": "Conductor-Delegate orchestration platform with 14 specialized agents, 21 skills, and OpenCode support.",
7
7
  "agents": "./src/agents",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
4
4
 
5
5
  [project]
6
6
  name = "pantheon"
7
- version = "1.5.0-beta.14"
7
+ version = "1.5.0-beta.15"
8
8
  description = "Multi-agent orchestration framework"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = []
@@ -37,10 +37,8 @@ ignore = ["E501"]
37
37
  "scripts/scrub-secrets.py" = ["N999"]
38
38
  # These modules defer optional/heavy imports until the corresponding command is used.
39
39
  "scripts/vector_memory/*.py" = ["PLC0415"]
40
- "tests/test_code_mode_args.py" = ["PLC0415"]
41
40
  "tests/test_code_mode_sandbox.py" = ["PLC0415", "N806"]
42
41
  "tests/test_code_mode_server.py" = ["PLC0415"]
43
- "tests/test_eval_store.py" = ["PLC0415", "PLR2004"]
44
42
  "tests/test_memory_mcp_server.py" = ["PLC0415", "PLR2004"]
45
43
  # Existing public APIs and test fixtures are intentionally broad/simple; refactoring
46
44
  # them would be behavior-affecting rather than a mechanical lint correction.
@@ -54,8 +52,7 @@ ignore = ["E501"]
54
52
  "scripts/vector_memory/query.py" = ["PLR0913"]
55
53
  "src/mcp/eval_store.py" = ["PLR2004"]
56
54
  "tests/test_code_mode_frontmatter.py" = ["PLR2004"]
57
- "tests/test_eval_judge.py" = ["PLR2004"]
58
- "tests/test_eval_static.py" = ["PLR2004"]
55
+ "tests/test_eval.py" = ["PLR2004"]
59
56
  "tests/test_mcp_persistence_server.py" = ["PLR2004"]
60
57
 
61
58
  [tool.mypy]
@@ -301,32 +301,10 @@ vão a idle com todos incompletos. Guards (todos no `src/pantheon/todo-enforcer.
301
301
 
302
302
  ## Wave 4 (PR #46): Empty-Result Retry + /cost + Themis Tier
303
303
 
304
- ### Empty-Result Retry (dispatch-guard — MANUAL orchestration)
305
- OpenCode 1.18.x NAO permite interceptar a conclusao de `task()` via hooks,
306
- entao o `src/pantheon/dispatch-guard.ts` e uma lib pura usada POR VOCE na
307
- orquestracao (NAO esta wired no plugin):
308
-
309
- ```
310
- import { createDispatchGuard } from '.../src/pantheon/dispatch-guard.ts' # via code-mode ou subagente
311
-
312
- guard = createDispatchGuard({ retryOnEmpty: true, logger: { warn: console.warn } })
313
-
314
- # classificar resultado de task_status/wait:
315
- # 'content' → tem texto, pronto
316
- # 'empty-mode1' → SEM texto E SEM tokens (nada voltou) → RETRY 1x
317
- # 'empty-mode2' → SEM texto, MAS tokens (raciocinou, perdeu a parte de texto;
318
- # assinatura da falha themis Wave-2) → RETRY 1x
319
-
320
- out = await guard.maybeRetry(result, async () => { ...task() de novo... })
321
- # out.retried=true se redisparou; CAP DURO de 1 retry — nunca 2x.
322
- # Se o retry voltar vazio, `out.retried=false` — NAO tente de novo: escale.
323
- ```
324
- Regra: retry 1x APENAS em `empty-mode1`/`empty-mode2`. Resultado com conteudo
325
- nunca redispara. Apos 1 retry vazio → escalate (mesma regra do TODO Enforcer).
326
-
327
- > **Waves DEVEM usar `zeusDelegateWithRetry`** (`src/pantheon/zeus-delegate-with-retry.ts`) — encapsula `delegate → waitForTerminal → classify → retry 1x → escalate` com cap duro 1; se ainda vazio throw `ZeusEscalationError` (escale: tente outro agente, simplifique, manual).
328
- > Uso alto-nível: `import { zeusDelegateWithRetry } from './pantheon/zeus-delegate-with-retry.ts'` → `await zeusDelegateWithRetry({ board, client, sessionID, agent, prompt })`.
329
- > Uso baixo-nível: `createZeusRetryHelper().executeWithRetry(first, () => secondRead())` — classifica empty e decide retry/escalate (mesmo cap 1).
304
+ ### Empty-Result Retry (runtime — automatic)
305
+ Empty-response detection (`empty-mode1` / `empty-mode2`) and stuck-agent
306
+ classification are wired at runtime in `delegation-classifier.ts` (consumed by
307
+ `delegation.ts`). No manual retry helper is required.
330
308
 
331
309
  ### /cost — pantheon_cost tool (WIRED no plugin)
332
310
  `pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY usando o backend
@@ -2,7 +2,7 @@
2
2
  * Background Job Board — state machine for tracking background agent jobs.
3
3
  *
4
4
  * Tracks jobs through a state machine (running → completed/error/cancelled → reconciled),
5
- * supports alias generation per session, persistence via adapter, auto-wake signal files,
5
+ * supports alias generation per session, persistence via adapter, wake signal files,
6
6
  * and terminal state listeners (persist-before-notify write-ahead-log pattern).
7
7
  *
8
8
  * Pure TypeScript — zero external dependencies beyond Node.js builtins (fs/path).
@@ -123,7 +123,7 @@ export interface BoardOptions {
123
123
  /** Maximum completed jobs kept per agent for reuse (default: 3). */
124
124
  maxReusablePerAgent?: number
125
125
  /**
126
- * Directory for auto-wake signal files.
126
+ * Directory for wake signal files.
127
127
  * When set, a `.signal.json` file is atomically written on every terminal transition.
128
128
  * When null or undefined, no signal files are written.
129
129
  */
@@ -175,7 +175,7 @@ function getAgentPrefix(agent: string): string {
175
175
  * - Strict state machine with validated transitions
176
176
  * - Per-session, per-agent alias generation (apo-1, her-2, …)
177
177
  * - Optional persistence via injected PersistenceAdapter
178
- * - Optional auto-wake signal files on terminal transitions
178
+ * - Optional wake signal files on terminal transitions
179
179
  * - Terminal state listeners with persist-before-notify guarantee
180
180
  * - expire/prune helpers for session lifecycle management
181
181
  */
@@ -652,7 +652,7 @@ export class BackgroundJobBoard {
652
652
  }
653
653
 
654
654
  /**
655
- * Delete the auto-wake signal file for an alias (`<alias>.signal.json`).
655
+ * Delete the wake signal file for an alias (`<alias>.signal.json`).
656
656
  * No-op (false) when no signalDir is configured or the file is absent.
657
657
  * The delegate manager calls this on the spot right after auto-reconcile.
658
658
  */
@@ -23,13 +23,16 @@
23
23
  * @module delegate-manager
24
24
  */
25
25
 
26
+ import { join } from 'node:path'
26
27
  import { z } from 'zod'
27
28
  import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
28
29
  import type { DelegationToolset } from './delegation.ts'
29
30
  import {
31
+ capReportOutput,
30
32
  DELEGATION_DEFAULTS,
31
33
  type DelegationClient,
32
34
  readDelegationReport,
35
+ resolveReadMaxChars,
33
36
  } from './delegation-finalize.ts'
34
37
  import { createNativeProbe, type NativeProbeFn, type ProbeClient } from './native-probe.ts'
35
38
  import { classifyNativeResult, type NativeTaskStatus } from './native-task-status.ts'
@@ -66,10 +69,14 @@ export type NativeTaskFn = (input: NativeTaskInput) => Promise<NativeTaskResult>
66
69
  /** Minimal launch receipt: id, agent, state, one short line. */
67
70
  export interface ManagerReceipt {
68
71
  id: string
72
+ /** Board alias of the job (e.g. `apo-1`) — report files are keyed by it. */
73
+ alias: string
69
74
  agent: string
70
75
  state: 'reconciled' | 'error'
71
76
  status: NativeTaskStatus
72
77
  line: string
78
+ /** Verified child output (when the task completed with content). */
79
+ output?: string
73
80
  }
74
81
 
75
82
  export interface LaunchArgs {
@@ -109,6 +116,15 @@ export interface DelegateManagerOptions {
109
116
  nativeProbe?: NativeProbeFn
110
117
  /** V1 client used by the lazy capability probe. */
111
118
  probeClient?: ProbeClient
119
+ /** Base directory for delegation reports (default `.pantheon/delegations`). */
120
+ outputDir?: string
121
+ /**
122
+ * Inline cap (chars) for report content returned to the caller. Oversized
123
+ * reports keep head+tail with an explicit `[TRUNCATED …]` marker pointing
124
+ * at the full report file. Default: PANTHEON_DELEGATION_READ_MAX_CHARS
125
+ * or 50_000.
126
+ */
127
+ readMaxChars?: number
112
128
  }
113
129
 
114
130
  export interface DelegateManager {
@@ -139,7 +155,9 @@ export function resolveDelegateMode(
139
155
 
140
156
  const LINE_MAX = 160
141
157
 
142
- /** Remove terminal escapes and control characters from untrusted child output. */
158
+ /** Remove terminal escapes and control characters from untrusted child output.
159
+ * UTF-8 text (accents, emoji) is PRESERVED — only ANSI sequences, C0/C1
160
+ * controls and DEL are stripped; receipt/report lines stay human-readable. */
143
161
  export function sanitizeReceiptText(text: string): string {
144
162
  let clean = ''
145
163
  let skippingAnsi = false
@@ -154,7 +172,8 @@ export function sanitizeReceiptText(text: string): string {
154
172
  skippingAnsi = true
155
173
  continue
156
174
  }
157
- if (char === '\n' || char === '\t' || (code >= 0x20 && code <= 0x7e)) clean += char
175
+ const isControl = code < 0x20 || (code >= 0x7f && code <= 0x9f)
176
+ if (!isControl || char === '\n' || char === '\t') clean += char
158
177
  }
159
178
  return clean
160
179
  }
@@ -219,7 +238,13 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
219
238
  stepCap,
220
239
  nativeProbe,
221
240
  probeClient,
241
+ outputDir = DELEGATION_DEFAULTS.outputDir,
242
+ readMaxChars,
222
243
  } = options
244
+ const inlineMaxChars = readMaxChars ?? resolveReadMaxChars(env)
245
+
246
+ /** Report file path for a job alias (the durable full report). */
247
+ const reportPathFor = (alias: string): string => join(outputDir, parentSessionID, `${alias}.md`)
223
248
 
224
249
  // Strict native mode: foregroundFallback and models are overridden.
225
250
  // The user-facing options are ignored when PANTHEON_DELEGATE_MODE=native.
@@ -310,7 +335,14 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
310
335
  LINE_MAX,
311
336
  )
312
337
  : boardLine(settled ?? job)
313
- return { id: taskID, agent: job.agent, state: failed ? 'error' : 'reconciled', status, line }
338
+ return {
339
+ id: taskID,
340
+ alias,
341
+ agent: job.agent,
342
+ state: failed ? 'error' : 'reconciled',
343
+ status,
344
+ line,
345
+ }
314
346
  }
315
347
 
316
348
  /** Apply a terminal transition best-effort: a racing hook may have gotten there first. */
@@ -354,7 +386,11 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
354
386
  await applyTerminal(taskID, 'completed', {
355
387
  resultSummary: firstLine(outcome.result.content, 500),
356
388
  })
357
- return await settle(taskID, record.alias)
389
+ const settled = await settle(taskID, record.alias)
390
+ // The task() call already blocked until completion — return the verified
391
+ // output in the receipt (the parent reads the full report via the alias
392
+ // when needed). The tool layer caps it inline.
393
+ return { ...settled, output: outcome.result.content }
358
394
  } finally {
359
395
  pendingByAgent.set(key, Math.max(0, pending(args.agent) - 1))
360
396
  }
@@ -403,7 +439,17 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
403
439
  },
404
440
  ok ? undefined : (outcome as { ok: false; error?: string }).error,
405
441
  )
406
- return { id, agent: args.agent, state: ok ? 'reconciled' : 'error', status, line }
442
+ return {
443
+ id,
444
+ alias: id,
445
+ agent: args.agent,
446
+ state: ok ? 'reconciled' : 'error',
447
+ status,
448
+ line,
449
+ ...(ok
450
+ ? { output: (outcome as { ok: true; result: NativeTaskResult }).result.content ?? '' }
451
+ : {}),
452
+ }
407
453
  }
408
454
 
409
455
  return {
@@ -416,8 +462,10 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
416
462
  const maxSteps = stepCap.maxStepsFor(args.agent)
417
463
  if (stepCap.isCapped(args.agent)) {
418
464
  const line = cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS).slice(0, LINE_MAX)
465
+ const cappedId = `cap-${Date.now()}`
419
466
  return {
420
- id: `cap-${Date.now()}`,
467
+ id: cappedId,
468
+ alias: cappedId,
421
469
  agent: args.agent,
422
470
  state: 'reconciled',
423
471
  status: 'ESCALATE',
@@ -432,8 +480,10 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
432
480
  if (isNativeMode) {
433
481
  const unavailableStatus = await ensureNativeAvailable()
434
482
  if (unavailableStatus !== undefined) {
483
+ const probeId = `probe-${parentSessionID}`
435
484
  return {
436
- id: `probe-${parentSessionID}`,
485
+ id: probeId,
486
+ alias: probeId,
437
487
  agent: args.agent,
438
488
  state: 'error',
439
489
  status: unavailableStatus,
@@ -464,6 +514,13 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
464
514
  )
465
515
  if (terminal.state !== 'reconciled') await board.markReconciled(terminal.taskID)
466
516
  await board.deleteSignal(terminal.alias)
517
+ // Full report first (same contract as the legacy engine): the md file
518
+ // written by finalizeDelegation carries the complete child output. The
519
+ // one-line resultSummary is only a fallback when no report was written.
520
+ const md = await readDelegationReport(outputDir, terminal)
521
+ if (md !== undefined) {
522
+ return capReportOutput(md, inlineMaxChars, reportPathFor(terminal.alias))
523
+ }
467
524
  if (terminal.state === 'error' && !terminal.resultSummary) {
468
525
  return `# Delegation Report — ${terminal.alias}\n\nERROR: ${sanitizeReceiptText(terminal.lastStatusError ?? 'failed')}\n`
469
526
  }
@@ -640,6 +697,7 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
640
697
  ? {}
641
698
  : { promptTimeoutMs: managerOptions.timeoutMs }),
642
699
  })
700
+ const inlineMaxChars = managerOptions.readMaxChars ?? resolveReadMaxChars(env)
643
701
  const managers = new Map<string, DelegateManager>()
644
702
  const managerFor = (parentSessionID: string): DelegateManager => {
645
703
  const existing = managers.get(parentSessionID)
@@ -652,6 +710,7 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
652
710
  parentSessionID,
653
711
  env,
654
712
  ...(stepCap === undefined ? {} : { stepCap }),
713
+ ...(wiring.outputDir === undefined ? {} : { outputDir: wiring.outputDir }),
655
714
  nativeProbe,
656
715
  probeClient: client,
657
716
  })
@@ -702,7 +761,21 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
702
761
  ...(args.model === undefined ? {} : { model: args.model }),
703
762
  taskID: childID,
704
763
  })
705
- return `status: ${receipt.status}\n${receipt.line}`
764
+ // The launch blocked until the child finished — return the verified
765
+ // output inline (capped head+tail with a pointer to the full report),
766
+ // not just a status line. Foreground receipts carry no report file,
767
+ // so the marker points at pantheon_delegation_read instead.
768
+ const header = `status: ${receipt.status}\n${receipt.line}`
769
+ if (receipt.output === undefined || receipt.output.trim() === '') return header
770
+ const reportPath =
771
+ receipt.alias === receipt.id
772
+ ? undefined
773
+ : join(
774
+ wiring.outputDir ?? DELEGATION_DEFAULTS.outputDir,
775
+ ctx.sessionID,
776
+ `${receipt.alias}.md`,
777
+ )
778
+ return `${header}\n\n${capReportOutput(receipt.output, inlineMaxChars, reportPath)}`
706
779
  },
707
780
  },
708
781
  pantheon_delegation_read: {
@@ -739,6 +812,10 @@ export interface PluginNativeDelegationWiring {
739
812
  agentModels: Record<string, string | undefined>
740
813
  stepCap: StepCapTracker
741
814
  wallClockTimeoutMs?: number
815
+ /** Base directory for delegation reports (default `.pantheon/delegations`). */
816
+ outputDir?: string
817
+ /** Inline cap (chars) for delegation report content (default env or 50_000). */
818
+ readMaxChars?: number
742
819
  finalizeDelegation: DelegationToolset['finalizeDelegation']
743
820
  }
744
821
  export type PluginNativeDelegation = NativeDelegateToolset & {
@@ -751,15 +828,23 @@ export function buildPluginNativeDelegation(
751
828
  ...createNativeDelegateTools({
752
829
  board: wiring.board,
753
830
  client: wiring.client,
831
+ ...(wiring.outputDir === undefined ? {} : { outputDir: wiring.outputDir }),
754
832
  isRootSession: wiring.isRootSession,
755
833
  registerChildSession: wiring.registerChildSession,
756
834
  registerReadOnlySession: wiring.registerReadOnlySession,
757
835
  isReadOnlyAgent: (agent) => wiring.readOnlyAgents.has(agent.toLowerCase()),
758
836
  agentModel: (agent) => wiring.agentModels[agent.toLowerCase()],
759
837
  stepCap: wiring.stepCap,
760
- ...(wiring.wallClockTimeoutMs === undefined
838
+ ...(wiring.wallClockTimeoutMs === undefined && wiring.readMaxChars === undefined
761
839
  ? {}
762
- : { managerOptions: { timeoutMs: wiring.wallClockTimeoutMs } }),
840
+ : {
841
+ managerOptions: {
842
+ ...(wiring.wallClockTimeoutMs === undefined
843
+ ? {}
844
+ : { timeoutMs: wiring.wallClockTimeoutMs }),
845
+ ...(wiring.readMaxChars === undefined ? {} : { readMaxChars: wiring.readMaxChars }),
846
+ },
847
+ }),
763
848
  }),
764
849
  finalizeDelegation: wiring.finalizeDelegation,
765
850
  }
@@ -8,8 +8,7 @@
8
8
  * - `timeout` (timeout-with-partial / timeout-empty / timeout-exhausted)
9
9
  * - `error` — provider or infrastructure failure
10
10
  *
11
- * Integrates with the existing `dispatch-guard.ts` classification for
12
- * empty-response detection and provides a structured `DelegationResult`
11
+ * Provides empty-response detection and a structured `DelegationResult`
13
12
  * that replaces raw-text returns.
14
13
  *
15
14
  * @module delegation-classifier
@@ -17,7 +16,7 @@
17
16
 
18
17
  // ─── Types ─────────────────────────────────────────────────────────────
19
18
 
20
- /** Empty-response mode classification (matches dispatch-guard). */
19
+ /** Empty-response mode classification (empty-mode1 / empty-mode2). */
21
20
  export type EmptyMode = 'empty-mode1' | 'empty-mode2'
22
21
 
23
22
  /** Timeout sub-type based on partial output and retry history. */
@@ -82,7 +81,7 @@ const NO_OUTPUT_RE = /_No output captured\._/i
82
81
 
83
82
  /**
84
83
  * Classify whether a response is empty, empty-with-tokens, or has content.
85
- * Matches the dispatch-guard classification (empty-mode1, empty-mode2).
84
+ * Classifies empty-mode1 (no text/tokens) vs empty-mode2 (tokens, no text).
86
85
  *
87
86
  * @param text The response text (may be null/undefined).
88
87
  * @param tokens Optional token counts for mode2 detection.
@@ -157,6 +157,13 @@ export interface DelegationOptions {
157
157
  /** Injectable clock/sleeper for deterministic watchdog tests. */
158
158
  now?: () => number
159
159
  sleep?: (ms: number) => Promise<void>
160
+ /**
161
+ * Inline cap (chars) for report content returned by pantheon_delegation_read.
162
+ * Oversized reports keep head+tail with an explicit `[TRUNCATED …]` marker
163
+ * pointing at the full report file. Default: PANTHEON_DELEGATION_READ_MAX_CHARS
164
+ * or 50_000.
165
+ */
166
+ readMaxChars?: number
160
167
  /**
161
168
  * Kill-switch: when false, all delegation tools (delegate/read/list) throw
162
169
  * immediately. Mirrors `PANTHEON_DELEGATION=off` from the native manager.
@@ -276,6 +283,62 @@ function summarizeOutput(output: string): string {
276
283
  return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine
277
284
  }
278
285
 
286
+ // ─── Report inline cap ─────────────────────────────────────────────────
287
+ //
288
+ // Delegation reports are returned INLINE to the orchestrator (tool result).
289
+ // A very large child output must be capped BEFORE it floods the parent
290
+ // context — but a hard mid-content cut loses the report header AND the
291
+ // conclusion. The cap keeps head + tail with an explicit marker pointing at
292
+ // the full report (the durable `.pantheon/delegations/<parent>/<alias>.md`
293
+ // file or the read tool), mirroring how comparable agent hosts surface
294
+ // oversized tool output.
295
+
296
+ /** Default inline cap for delegation report content (chars). */
297
+ export const DELEGATION_READ_MAX_CHARS = 50_000
298
+ /** Lower bound for PANTHEON_DELEGATION_READ_MAX_CHARS. */
299
+ export const DELEGATION_READ_MAX_CHARS_MIN = 1_000
300
+
301
+ /** Resolve the inline report cap from the environment (default 50k, min 1k). */
302
+ export function resolveReadMaxChars(env: Record<string, string | undefined> = process.env): number {
303
+ const raw = env.PANTHEON_DELEGATION_READ_MAX_CHARS?.trim()
304
+ if (raw === undefined || raw === '') return DELEGATION_READ_MAX_CHARS
305
+ const value = Number(raw)
306
+ return Number.isSafeInteger(value) && value >= DELEGATION_READ_MAX_CHARS_MIN
307
+ ? value
308
+ : DELEGATION_READ_MAX_CHARS
309
+ }
310
+
311
+ /**
312
+ * Cap report markdown for inline return: ≤ `maxChars` passes through
313
+ * untouched; anything larger keeps the head (60% of the budget) + tail (25%)
314
+ * with an explicit `[TRUNCATED …]` marker between them. `reportPath` names
315
+ * the durable full-report file in the marker; without it the marker points
316
+ * at `pantheon_delegation_read`. Pure — no I/O.
317
+ */
318
+ export function capReportOutput(md: string, maxChars: number, reportPath?: string): string {
319
+ if (md.length <= maxChars) return md
320
+ if (maxChars < 200) {
321
+ // Budget too small for a head+tail split — keep what fits after the marker.
322
+ const marker = capReportMarker(md.length, 0, reportPath)
323
+ return `${md.slice(0, Math.max(0, maxChars - marker.length - 1))}\n${marker}`
324
+ }
325
+ const headLen = Math.floor(maxChars * 0.6)
326
+ const tailLen = Math.floor(maxChars * 0.25)
327
+ return `${md.slice(0, headLen)}\n${capReportMarker(md.length, headLen + tailLen, reportPath)}\n${md.slice(md.length - tailLen)}`
328
+ }
329
+
330
+ function capReportMarker(
331
+ totalChars: number,
332
+ keptChars: number,
333
+ reportPath: string | undefined,
334
+ ): string {
335
+ const where =
336
+ reportPath !== undefined
337
+ ? `full report: ${reportPath}`
338
+ : 'read the full report with pantheon_delegation_read'
339
+ return `[TRUNCATED: ${totalChars - keptChars} of ${totalChars} chars hidden — ${where}]`
340
+ }
341
+
279
342
  // ─── Report persistence ────────────────────────────────────────────────
280
343
 
281
344
  /**
@@ -387,13 +450,12 @@ export async function finalizeDelegation(
387
450
  // report above still captures this finalize; the board state stands.
388
451
  }
389
452
 
390
- // P1-1: Delete the auto-wake signal file to prevent leaks. The board
391
- // writes .signal.json on every terminal transition, but the legacy path
392
- // never called deleteSignal — only the native delegate-manager did.
393
- // The consumer (auto-wake.ts consumeWakeSignals) is intentionally NOT
394
- // wired in the event hook: finalizeDelegation already handles the
395
- // terminal transition, so consuming signals would be redundant and risks
396
- // double-processing. deleteSignal on finalize is sufficient.
453
+ // P1-1: Delete the wake signal file to prevent leaks. The board writes
454
+ // .signal.json on every terminal transition, but the legacy path never
455
+ // called deleteSignal — only the native delegate-manager did.
456
+ // finalizeDelegation already handles the terminal transition, so
457
+ // consuming signals would be redundant; deleteSignal on finalize is
458
+ // sufficient.
397
459
  await deps.board.deleteSignal(job.alias)
398
460
 
399
461
  // P1-4: Prune old completed jobs and enforce the hard entry cap. The
@@ -35,6 +35,7 @@
35
35
  * @module delegation
36
36
  */
37
37
 
38
+ import { join } from 'node:path'
38
39
  import { z } from 'zod'
39
40
 
40
41
  import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
@@ -49,6 +50,7 @@ import {
49
50
  readOnlyRegistry,
50
51
  } from './delegation-enforce.ts'
51
52
  import {
53
+ capReportOutput,
52
54
  DELEGATION_DEFAULTS,
53
55
  type DelegationClient,
54
56
  type DelegationDeps,
@@ -57,6 +59,7 @@ import {
57
59
  type FinalizeInput,
58
60
  finalizeDelegation as finalizeDelegationReport,
59
61
  readDelegationReport,
62
+ resolveReadMaxChars,
60
63
  } from './delegation-finalize.ts'
61
64
  import { finalizeIdleChildrenWithoutMd } from './delegation-notify.ts'
62
65
  import { createPantheonLogger } from './logger.ts'
@@ -814,6 +817,10 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
814
817
 
815
818
  const knownChildren = new Set<string>()
816
819
  const timers = new Map<string, NodeJS.Timeout>()
820
+ // Inline cap for report content returned by pantheon_delegation_read —
821
+ // oversized reports keep head+tail with an explicit marker pointing at the
822
+ // full report file (never a silent mid-content cut).
823
+ const inlineMaxChars = options.readMaxChars ?? resolveReadMaxChars()
817
824
  // Fase B3: per-parent-session delegation budget tracking, scoped to this
818
825
  // factory/process instance. Keyed by `${parentSessionID}:${parentAgent}->${targetAgent}`;
819
826
  // it is not restart-persistent because the factory has no reliable store.
@@ -1276,6 +1283,11 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1276
1283
  if (md === undefined) {
1277
1284
  return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
1278
1285
  }
1286
+ // Classify on the FULL report (markers may sit at either end), but
1287
+ // return the inline-capped markdown so a huge child output cannot
1288
+ // flood the parent context — the marker points at the full file.
1289
+ const reportPath = join(outputDir, terminal.parentSessionID, `${terminal.alias}.md`)
1290
+ const cappedMd = capReportOutput(md, inlineMaxChars, reportPath)
1279
1291
 
1280
1292
  // Classify the result for stuck-agent detection
1281
1293
  const classification = classifyStuckAgent(md)
@@ -1284,15 +1296,15 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1284
1296
 
1285
1297
  if (classification.status === 'success') {
1286
1298
  await board.markReconciled(job.taskID)
1287
- // Backward compatible: success reports returned as-is
1288
- if (!hasActivity) return md
1289
- return `${md.replace(/\n+$/, '')}${activitySuffix}`
1299
+ // Backward compatible: success reports returned as-is (capped)
1300
+ if (!hasActivity) return cappedMd
1301
+ return `${cappedMd.replace(/\n+$/, '')}${activitySuffix}`
1290
1302
  }
1291
1303
 
1292
1304
  // Non-success: build structured DelegationResult and format
1293
1305
  const delegationResult: DelegationResult = {
1294
1306
  status: classification.status === 'empty' ? 'empty' : classification.status,
1295
- content: md,
1307
+ content: cappedMd,
1296
1308
  retryCount: 0,
1297
1309
  partialResult: classification.partialResult,
1298
1310
  recommendation: classification.recommendation,
@@ -104,26 +104,6 @@ export interface RoutingRetryPolicyOptions {
104
104
  logger?: { warn?: (msg: string) => void }
105
105
  }
106
106
 
107
- /**
108
- * R1: per-error-type retry policy from routing.yml `retry_policy`
109
- * (auth/rate_limit/timeout/other → max retries). Fail-open → null.
110
- */
111
- export declare function loadRoutingRetryPolicy(
112
- options?: RoutingRetryPolicyOptions,
113
- ): Record<string, number> | null
114
-
115
- export interface RoutingCooldownResult {
116
- allowed_fails: number
117
- cooldown_time_seconds: number
118
- }
119
-
120
- /**
121
- * R1: provider cooldown config from routing.yml `cooldown`. Fail-open → null.
122
- */
123
- export declare function loadRoutingCooldown(
124
- options?: RoutingRetryPolicyOptions,
125
- ): RoutingCooldownResult | null
126
-
127
107
  /**
128
108
  * R4: per-agent step caps from routing.yml `agents.<name>.max_steps`.
129
109
  * Fail-open → {} (no agent capped).