pantheon-opencode 1.4.1 → 1.4.3

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.
@@ -43,6 +43,7 @@ import {
43
43
  syncDir,
44
44
  writeIfChanged,
45
45
  } from './shared.mjs'
46
+
46
47
  import { setupVenv, venvPythonPath } from './venv.mjs'
47
48
 
48
49
  const COMPONENT_NAMES = [
@@ -59,6 +60,16 @@ function deepClone(value) {
59
60
  return JSON.parse(JSON.stringify(value))
60
61
  }
61
62
 
63
+ function readJsonConfig(filePath) {
64
+ if (!existsSync(filePath)) return {}
65
+
66
+ try {
67
+ return JSON.parse(readFileSync(filePath, 'utf8'))
68
+ } catch (cause) {
69
+ throw new Error(`Invalid JSON in ${filePath}`, { cause })
70
+ }
71
+ }
72
+
62
73
  function mergeMissing(target, source) {
63
74
  if (!source || typeof source !== 'object' || Array.isArray(source)) return target
64
75
  if (!target || typeof target !== 'object' || Array.isArray(target)) return deepClone(source)
@@ -274,6 +285,11 @@ export async function installOpenCode(
274
285
  }
275
286
  }
276
287
 
288
+ const pantheonConfigPath = join(ROOT, 'opencode.json')
289
+ const targetConfigPath = join(target, 'opencode.json')
290
+ const config = readJsonConfig(targetConfigPath)
291
+ const pantheonConfig = readJsonConfig(pantheonConfigPath)
292
+
277
293
  // Determine layout based on install scope.
278
294
  // Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
279
295
  // agents/ skills/ commands/ plugins/
@@ -533,27 +549,6 @@ export async function installOpenCode(
533
549
  // Reads TARGET's existing config first, then merges Pantheon settings
534
550
  // on top. Preserves user's MCP, provider, plugin, compaction, theme.
535
551
  // -----------------------------------------------------------------------
536
- const pantheonConfigPath = join(ROOT, 'opencode.json')
537
- const targetConfigPath = join(target, 'opencode.json')
538
-
539
- let config = {}
540
- if (existsSync(targetConfigPath)) {
541
- try {
542
- config = JSON.parse(readFileSync(targetConfigPath, 'utf8'))
543
- } catch {
544
- config = {}
545
- }
546
- }
547
-
548
- let pantheonConfig = {}
549
- if (existsSync(pantheonConfigPath)) {
550
- try {
551
- pantheonConfig = JSON.parse(readFileSync(pantheonConfigPath, 'utf8'))
552
- } catch {
553
- pantheonConfig = {}
554
- }
555
- }
556
-
557
552
  // --------------------------------------------------------------------
558
553
  // A. Parse canonical agent config from agents/*.agent.md frontmatter
559
554
  // and merge into opencode.json config.
@@ -622,61 +617,64 @@ export async function installOpenCode(
622
617
  'disable_model_invocation',
623
618
  ]
624
619
 
625
- if (!config.agent) config.agent = {}
626
-
627
- for (const [agentName, agentCfg] of Object.entries(canonicalAgentConfig)) {
628
- if (!agentCfg || typeof agentCfg !== 'object') continue
620
+ if (config.agent === undefined) config.agent = {}
621
+
622
+ if (config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent)) {
623
+ for (const [agentName, agentCfg] of Object.entries(canonicalAgentConfig)) {
624
+ if (!agentCfg || typeof agentCfg !== 'object') continue
625
+
626
+ if (Object.hasOwn(config.agent, agentName)) {
627
+ // ── Agent exists in target config ──
628
+ // Update framework-managed fields from canonical source
629
+ // Preserve user-customized fields (model, provider, mcp, etc.)
630
+ const existing = config.agent[agentName]
631
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) continue
632
+ if (agentSources[agentName]) existing.source = agentSources[agentName]
633
+ for (const field of MANAGED_FIELDS) {
634
+ if (field in agentCfg) {
635
+ existing[field] = JSON.parse(JSON.stringify(agentCfg[field]))
636
+ }
637
+ }
638
+ // Remove stale fields that are no longer in canonical config
639
+ delete existing.model
640
+ delete existing.small_model
641
+ } else {
642
+ // ── New agent ──
643
+ const newAgent = {}
644
+ if (agentSources[agentName]) newAgent.source = agentSources[agentName]
645
+ if (agentCfg.description) newAgent.description = agentCfg.description
646
+
647
+ // Copy all framework-managed fields from canonical config
648
+ for (const field of MANAGED_FIELDS) {
649
+ if (field in agentCfg) {
650
+ newAgent[field] = JSON.parse(JSON.stringify(agentCfg[field]))
651
+ }
652
+ }
629
653
 
630
- if (config.agent[agentName]) {
631
- // ── Agent exists in target config ──
632
- // Update framework-managed fields from canonical source
633
- // Preserve user-customized fields (model, provider, mcp, etc.)
634
- const existing = config.agent[agentName]
635
- if (agentSources[agentName]) existing.source = agentSources[agentName]
636
- for (const field of MANAGED_FIELDS) {
637
- if (field in agentCfg) {
638
- existing[field] = JSON.parse(JSON.stringify(agentCfg[field]))
654
+ // Ensure bash permission is set from canonical (default to deny if missing)
655
+ if (newAgent.permission === undefined) {
656
+ newAgent.permission = {}
639
657
  }
640
- }
641
- // Remove stale fields that are no longer in canonical config
642
- delete existing.model
643
- delete existing.small_model
644
- } else {
645
- // ── New agent ──
646
- const newAgent = {}
647
- if (agentSources[agentName]) newAgent.source = agentSources[agentName]
648
- if (agentCfg.description) newAgent.description = agentCfg.description
649
-
650
- // Copy all framework-managed fields from canonical config
651
- for (const field of MANAGED_FIELDS) {
652
- if (field in agentCfg) {
653
- newAgent[field] = JSON.parse(JSON.stringify(agentCfg[field]))
658
+ if (newAgent.permission.bash === undefined && agentCfg.permission?.bash) {
659
+ newAgent.permission.bash = JSON.parse(JSON.stringify(agentCfg.permission.bash))
654
660
  }
655
- }
656
661
 
657
- // Ensure bash permission is set from canonical (default to deny if missing)
658
- if (!newAgent.permission) {
659
- newAgent.permission = {}
660
- }
661
- if (!newAgent.permission.bash && agentCfg.permission?.bash) {
662
- newAgent.permission.bash = JSON.parse(JSON.stringify(agentCfg.permission.bash))
662
+ config.agent[agentName] = newAgent
663
663
  }
664
-
665
- config.agent[agentName] = newAgent
666
664
  }
667
- }
668
665
 
669
- // Remove stale agents (exist in target config but not in canonical source)
670
- // Only removes agents whose source is managed (starts with our prefix)
671
- // so user-defined agents with different source paths are preserved.
672
- const canonicalNames = new Set(Object.keys(canonicalAgentConfig))
673
- for (const [agentName, agentCfg] of Object.entries(config.agent)) {
674
- const source = agentCfg?.source || ''
675
- if (source.startsWith(agentPrefix) && !canonicalNames.has(agentName)) {
676
- delete config.agent[agentName]
666
+ // Remove stale agents (exist in target config but not in canonical source)
667
+ // Only removes agents whose source is managed (starts with our prefix)
668
+ // so user-defined agents with different source paths are preserved.
669
+ const canonicalNames = new Set(Object.keys(canonicalAgentConfig))
670
+ for (const [agentName, agentCfg] of Object.entries(config.agent)) {
671
+ const source = agentCfg?.source || ''
672
+ if (source.startsWith(agentPrefix) && !canonicalNames.has(agentName)) {
673
+ delete config.agent[agentName]
674
+ }
677
675
  }
676
+ if (Object.keys(config.agent).length === 0) delete config.agent
678
677
  }
679
- if (Object.keys(config.agent).length === 0) delete config.agent
680
678
 
681
679
  // --------------------------------------------------------------------
682
680
  // B. Commands from .md frontmatter (commands.json removed)
@@ -687,19 +685,16 @@ export async function installOpenCode(
687
685
  // --------------------------------------------------------------------
688
686
  // B.5 Ensure critical top-level OpenCode config sections
689
687
  // --------------------------------------------------------------------
690
- if (!config.default_agent && pantheonConfig.default_agent) {
688
+ if (config.default_agent === undefined && pantheonConfig.default_agent !== undefined) {
691
689
  config.default_agent = pantheonConfig.default_agent
692
690
  }
693
- // Merge top-level model/small_model from the repo config when the target
694
- // config has no top-level `model` (fresh sandbox installs have none). The
695
- // repo's opencode.json carries the default pair
696
- // (opencode-go/deepseek-v4-flash); a user-set model is never overwritten.
697
- if (!config.model && pantheonConfig.model) {
698
- config.model = pantheonConfig.model
699
- if (!config.small_model && pantheonConfig.small_model) {
700
- config.small_model = pantheonConfig.small_model
701
- }
702
- }
691
+ // Apply only explicit top-level model overrides. Existing values remain
692
+ // untouched and an install without either flag leaves both fields absent.
693
+ const modelFlag = typeof opts.model === 'string' && opts.model !== '' ? opts.model : undefined
694
+ const smallModelFlag =
695
+ typeof opts.smallModel === 'string' && opts.smallModel !== '' ? opts.smallModel : undefined
696
+ if (modelFlag !== undefined) config.model = modelFlag
697
+ if (smallModelFlag !== undefined) config.small_model = smallModelFlag
703
698
  // Merge subagent_depth HERE (the config merge always runs, including on a
704
699
  // fresh install) instead of a file-existence-gated block, so run #1 already
705
700
  // produces the final config — run #2 must be a byte-identical no-op (#19).
@@ -710,16 +705,24 @@ export async function installOpenCode(
710
705
  // experimental (preserving the user's choice) and default fresh installs to
711
706
  // experimental.subagent_depth = 2. V1 reads experimental.subagent_depth
712
707
  // too, so the V1-shape config stays valid under both versions.
713
- if (config.experimental === undefined || typeof config.experimental !== 'object') {
708
+ if (config.experimental === undefined) {
714
709
  config.experimental = {}
715
710
  }
716
- if (config.subagent_depth !== undefined) {
717
- if (config.experimental.subagent_depth === undefined) {
718
- config.experimental.subagent_depth = config.subagent_depth
711
+ if (
712
+ typeof config.experimental === 'object' &&
713
+ config.experimental !== null &&
714
+ !Array.isArray(config.experimental)
715
+ ) {
716
+ if (config.subagent_depth !== undefined) {
717
+ if (config.experimental.subagent_depth === undefined) {
718
+ config.experimental.subagent_depth = config.subagent_depth
719
+ }
720
+ delete config.subagent_depth
721
+ } else if (config.experimental.subagent_depth === undefined) {
722
+ config.experimental.subagent_depth = 2
719
723
  }
724
+ } else if (config.subagent_depth !== undefined) {
720
725
  delete config.subagent_depth
721
- } else if (config.experimental.subagent_depth === undefined) {
722
- config.experimental.subagent_depth = 2
723
726
  }
724
727
 
725
728
  // --------------------------------------------------------------------
@@ -735,74 +738,82 @@ export async function installOpenCode(
735
738
  // The source opencode.json keeps its dev path for local development; the
736
739
  // transform happens at install/sync time only.
737
740
 
738
- if (!Array.isArray(config.plugin)) {
741
+ if (config.plugin === undefined) {
739
742
  config.plugin = []
740
743
  }
741
- if (Array.isArray(pantheonConfig.plugin)) {
742
- for (const plugin of pantheonConfig.plugin) {
743
- const resolved = resolveInstalledPlugin(plugin)
744
+ if (Array.isArray(config.plugin)) {
745
+ if (Array.isArray(pantheonConfig.plugin)) {
746
+ for (const plugin of pantheonConfig.plugin) {
747
+ const resolved = resolveInstalledPlugin(plugin)
748
+ const file = basename(resolved)
749
+ // Replace any pre-existing entry for the same plugin file (e.g. a stale
750
+ // dev-machine path from an earlier install) so upgrades stay hermetic.
751
+ config.plugin = config.plugin.filter((p) => basename(p) !== file)
752
+ config.plugin.push(resolved)
753
+ }
754
+ }
755
+
756
+ // The two Pantheon plugins MUST always be registered, on fresh installs AND
757
+ // upgrades. The packaged opencode.json carries no `plugin` key (dev-machine
758
+ // paths removed in 3e552cc), so config.plugin would otherwise only contain
759
+ // whatever the user had — a fresh install would register NOTHING and lose
760
+ // delegation tools, GoalLoop, cost command, hashline and vision. Register
761
+ // both unconditionally, replacing any stale entry for the same file
762
+ // (basename-dedup) so installs and upgrades are idempotent and hermetic
763
+ // while preserving any user plugins.
764
+ const ensurePantheonPlugin = (ref) => {
765
+ const resolved = resolveInstalledPlugin(ref)
744
766
  const file = basename(resolved)
745
- // Replace any pre-existing entry for the same plugin file (e.g. a stale
746
- // dev-machine path from an earlier install) so upgrades stay hermetic.
747
767
  config.plugin = config.plugin.filter((p) => basename(p) !== file)
748
768
  config.plugin.push(resolved)
749
769
  }
770
+ // Root-level delegation plugin (PR #45): tools, GoalLoop, cost, hashline.
771
+ ensurePantheonPlugin('src/plugin.ts')
772
+ // Runtime hooks plugin: chat hooks, hook-runner, TUI wiring.
773
+ ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
750
774
  }
751
775
 
752
- // The two Pantheon plugins MUST always be registered, on fresh installs AND
753
- // upgrades. The packaged opencode.json carries no `plugin` key (dev-machine
754
- // paths removed in 3e552cc), so config.plugin would otherwise only contain
755
- // whatever the user had — a fresh install would register NOTHING and lose
756
- // delegation tools, GoalLoop, cost command, hashline and vision. Register
757
- // both unconditionally, replacing any stale entry for the same file
758
- // (basename-dedup) so installs and upgrades are idempotent and hermetic
759
- // while preserving any user plugins.
760
- const ensurePantheonPlugin = (ref) => {
761
- const resolved = resolveInstalledPlugin(ref)
762
- const file = basename(resolved)
763
- config.plugin = config.plugin.filter((p) => basename(p) !== file)
764
- config.plugin.push(resolved)
765
- }
766
- // Root-level delegation plugin (PR #45): tools, GoalLoop, cost, hashline.
767
- ensurePantheonPlugin('src/plugin.ts')
768
- // Runtime hooks plugin: chat hooks, hook-runner, TUI wiring.
769
- ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
770
-
771
- if (!config.provider && pantheonConfig.provider) {
776
+ if (config.provider === undefined && pantheonConfig.provider !== undefined) {
772
777
  config.provider = deepClone(pantheonConfig.provider)
773
- } else if (config.provider && pantheonConfig.provider) {
778
+ } else if (config.provider !== undefined && pantheonConfig.provider !== undefined) {
774
779
  mergeMissing(config.provider, pantheonConfig.provider)
775
780
  }
776
781
 
777
- if (!config.compaction && pantheonConfig.compaction) {
782
+ if (config.compaction === undefined && pantheonConfig.compaction !== undefined) {
778
783
  config.compaction = deepClone(pantheonConfig.compaction)
779
- } else if (config.compaction && pantheonConfig.compaction) {
784
+ } else if (config.compaction !== undefined && pantheonConfig.compaction !== undefined) {
780
785
  mergeMissing(config.compaction, pantheonConfig.compaction)
781
786
  }
782
787
 
783
788
  // --------------------------------------------------------------------
784
789
  // C. Merge permissions
785
790
  // --------------------------------------------------------------------
786
- if (!config.permission) config.permission = {}
787
- if (pantheonConfig.permission) {
791
+ if (config.permission === undefined) config.permission = {}
792
+ if (pantheonConfig.permission !== undefined) {
788
793
  mergeMissing(config.permission, pantheonConfig.permission)
789
794
  }
790
- if (componentSet.has('skills')) {
791
- config.permission.skill = { '*': 'allow' }
792
- }
793
- if (!config.permission.bash) {
794
- config.permission.bash = {
795
- 'git *': 'allow',
796
- 'npm *': 'allow',
797
- 'npx *': 'allow',
798
- 'pytest *': 'allow',
799
- 'ruff *': 'allow',
800
- 'black *': 'allow',
801
- 'pip *': 'allow',
802
- 'docker *': 'allow',
803
- 'curl *': 'allow',
804
- 'gh *': 'allow',
805
- 'make *': 'allow',
795
+ if (
796
+ typeof config.permission === 'object' &&
797
+ config.permission !== null &&
798
+ !Array.isArray(config.permission)
799
+ ) {
800
+ if (componentSet.has('skills')) {
801
+ config.permission.skill = { '*': 'allow' }
802
+ }
803
+ if (config.permission.bash === undefined) {
804
+ config.permission.bash = {
805
+ 'git *': 'allow',
806
+ 'npm *': 'allow',
807
+ 'npx *': 'allow',
808
+ 'pytest *': 'allow',
809
+ 'ruff *': 'allow',
810
+ 'black *': 'allow',
811
+ 'pip *': 'allow',
812
+ 'docker *': 'allow',
813
+ 'curl *': 'allow',
814
+ 'gh *': 'allow',
815
+ 'make *': 'allow',
816
+ }
806
817
  }
807
818
  }
808
819
 
@@ -816,9 +827,9 @@ export async function installOpenCode(
816
827
  // glob is no longer needed — keeping it would duplicate content under V1
817
828
  // and is ignored under V2 anyway. Only ensure AGENTS.md is present.
818
829
  const pantheonInstructions = ['AGENTS.md']
819
- if (!config.instructions) {
830
+ if (config.instructions === undefined) {
820
831
  config.instructions = [...pantheonInstructions]
821
- } else {
832
+ } else if (Array.isArray(config.instructions)) {
822
833
  // Strip stale per-file instruction globs from PREVIOUS installs
823
834
  // (e.g. `instructions/*.instructions.md` / `src/instructions/*.md`).
824
835
  // The merge is additive-only otherwise, so without this filter existing
@@ -838,7 +849,7 @@ export async function installOpenCode(
838
849
  // --------------------------------------------------------------------
839
850
  // E. Ensure $schema
840
851
  // --------------------------------------------------------------------
841
- if (!config.$schema) {
852
+ if (config.$schema === undefined) {
842
853
  config.$schema = 'https://opencode.ai/config.json'
843
854
  }
844
855
 
@@ -1009,12 +1020,14 @@ export async function installOpenCode(
1009
1020
  }
1010
1021
 
1011
1022
  // -----------------------------------------------------------------------
1012
- // 5. Model preset selection (--components agents). Interactive picker runs
1023
+ // 5. Model preset selection (--components agents). Interactive wizard runs
1013
1024
  // unless autoYes (use defaults) or an explicit --preset was given.
1025
+ // Q1 default "herdar do chat" (inherit) writes no active-preset.json —
1026
+ // delegates inherit the parent chat model (native inheritance).
1014
1027
  // -----------------------------------------------------------------------
1015
1028
  if (interactive && !dryRun && !autoYes && !opts.preset) {
1016
- const { runModelPicker } = await import('./model-picker.mjs')
1017
- await runModelPicker({ presetDir: target, logger: console })
1029
+ const { runInitWizard } = await import('./model-picker.mjs')
1030
+ await runInitWizard({ presetDir: target, logger: console })
1018
1031
  }
1019
1032
 
1020
1033
  if (opts.preset && !dryRun) {
@@ -144,33 +144,23 @@ task_status(task_id="ses_xxx", wait=true)
144
144
  -> bloqueia ate completar: { state: "completed", task_result: "..." }
145
145
  ```
146
146
 
147
- ### Pantheon Delegation Tools (plugin) — 3-tool API
147
+ ### Pantheon Delegation — BackgroundJobBoard (plugin)
148
148
 
149
- O plugin Pantheon adiciona 3 tools de delegacao gerenciada pelo BackgroundJobBoard:
149
+ O plugin Pantheon mantém um **BackgroundJobBoard** persistente
150
+ (`.pantheon/board/state.json`) que rastreia trabalho em voo e o carrega para o
151
+ contexto de compactação. O toolset de delegação do plugin
152
+ (`pantheon_delegate`, `pantheon_delegation_read`, `pantheon_delegation_list`)
153
+ foi **removido em 1.4.3** — dispatch e coleta usam a API nativa
154
+ `task(background=true)` / `task_status(wait=true)`.
150
155
 
151
- ```
152
- pantheon_delegate({prompt, agent, description?, read_only?})
153
- -> cria sessao filha, registra no board, retorna alias: [apo-1]
154
- pantheon_delegation_read({id: "apo-1"})
155
- -> BLOQUEIA ate o job terminar, retorna o report markdown, marca reconciled
156
- -> durante a espera, mostra o que o agente esta fazendo: seccao final "## Agent Activity"
157
- (amostra as messages do child a cada ~2s; tool calls + args truncados / texto)
158
- pantheon_delegation_list()
159
- -> lista jobs da sessao, com [unread] para jobs terminados nao lidos
160
- -> jobs RUNNING mostram "last activity:" (ultima acao visivel do child)
161
- -> fail-open: sem suporte a messages, formato atual inalterado
162
- ```
163
-
164
- - **Read-only enforcement:** agentes read-only (apollo, gaia — `read_only_agents` no routing.yml) tem `edit`, `write`, `bash`, `task` NEGADOS dentro da sessao delegada (o guard de `tool.execute.before` THROW com mensagem acionavel). Use `read_only: true` no delegate para qualquer agente de investigacao.
165
- - **Depth-2 hard-enforced:** sessao read-only NAO cria subagentes (`task` bloqueado) — investigacao nunca vira arvore.
166
156
  - **Compactacao:** jobs em voo (running + unread terminal) sao injetados no contexto de compactacao via `experimental.session.compacting` — delegacao em andamento nao se perde ao compactar.
167
157
 
168
- ### DELEGATION_RULES (plugin path)
158
+ ### DELEGATION_RULES (native task path)
169
159
 
170
- 1. **NUNCA** polle `pantheon_delegation_list` para checar se um job terminou.
171
- 2. Voce NAO recebe notificacao injetada no chat — politica do usuario: ZERO `task-notification` no transcript. Visibilidade de conclusao: marcador `[unread]` em `pantheon_delegation_list`, `pantheon_delegation_read({id})`, toasts TUI (pantheon-hooks) e carry-forward de compactacao.
172
- 3. Use `pantheon_delegation_read({id})` APENAS para fan-in explicito / recuperacao de resultado sob demanda (bloqueia ate o fim).
173
- 4. `pantheon_delegation_list` e para diagnostico, nao para polling.
160
+ 1. **NUNCA** polle `task_status` para checar se um job terminou — use `wait=true` para fan-in explicito.
161
+ 2. Voce NAO recebe notificacao injetada no chat — politica do usuario: ZERO `task-notification` no transcript. Visibilidade de conclusao: marcador `[unread]` no board, toasts TUI (pantheon-hooks) e carry-forward de compactacao.
162
+ 3. Use `task_status({task_id, wait: true})` APENAS para fan-in explicito / recuperacao de resultado sob demanda (bloqueia ate o fim).
163
+ 4. `task_status` sem `wait` e para diagnostico, nao para polling.
174
164
 
175
165
  ### Background (sempre usar)
176
166
  - **Apollo, Hermes, Aphrodite, Demeter, Hephaestus, Prometheus**
@@ -324,9 +314,8 @@ out = await guard.maybeRetry(result, async () => { ...task() de novo... })
324
314
  Regra: retry 1x APENAS em `empty-mode1`/`empty-mode2`. Resultado com conteudo
325
315
  nunca redispara. Apos 1 retry vazio → escalate (mesma regra do TODO Enforcer).
326
316
 
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).
317
+ > **Waves DEVEM usar `dispatch-guard`** (`src/pantheon/dispatch-guard.ts`) — encapsula `task → classify → retry 1x → escalate` com cap duro 1; se ainda vazio throw `ZeusEscalationError` (escale: tente outro agente, simplifique, manual).
318
+ > Uso alto-nível: `import { createDispatchGuard } from './pantheon/dispatch-guard.ts'` → `await guard.maybeRetry(result, () => task(...))`.
330
319
 
331
320
  ### /cost — pantheon_cost tool (WIRED no plugin)
332
321
  `pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY (node:sqlite;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Cost Command (Wave 4, PR #46) — `pantheon_cost` structural tool: cost +
3
- * token visibility for delegation traffic, straight from opencode.db.
3
+ * token visibility by agent, straight from opencode.db.
4
4
  *
5
5
  * Reads the opencode session database READ-ONLY (sum cost + tokens by
6
6
  * agent over the last N days → markdown table). Zero new dependencies:
@@ -13,8 +13,8 @@
13
13
  * dbPath resolution: explicit option > env PANTHEON_COST_DB > the version-aware
14
14
  * default selected by PANTHEON_OPENCODE_VERSION (v1 or v2). An unset version
15
15
  * deliberately preserves V1 behavior. The tool is wired in
16
- * plugin.ts alongside the delegation toolset (usable by zeus and any agent
17
- * with tool access — no routing change needed).
16
+ * plugin.ts (usable by zeus and any agent with tool access — no routing change
17
+ * needed).
18
18
  *
19
19
  * @module cost-command
20
20
  */
@@ -25,7 +25,7 @@ import { join } from 'node:path'
25
25
  import { promisify } from 'node:util'
26
26
  import { z } from 'zod'
27
27
 
28
- import type { ToolContextLike } from './delegation.ts'
28
+ import type { ToolContextLike } from './tool-context.ts'
29
29
 
30
30
  const execFileAsync = promisify(execFile)
31
31
 
@@ -44,7 +44,7 @@ export interface CostCommandOptions {
44
44
 
45
45
  export type OpenCodeVersion = 'v1' | 'v2'
46
46
 
47
- /** One cost tool: description + zod args shape + execute (delegation.ts shape). */
47
+ /** One cost tool: description + zod args shape + execute. */
48
48
  export interface CostTool<Args extends z.ZodRawShape = typeof costArgs> {
49
49
  description: string
50
50
  args: Args
@@ -62,7 +62,7 @@ const costArgs = {
62
62
  .min(1)
63
63
  .max(365)
64
64
  .optional()
65
- .describe('Number of days of delegation history to include (default 7).'),
65
+ .describe('Number of days of cost history to include (default 7).'),
66
66
  } satisfies z.ZodRawShape
67
67
 
68
68
  /** Return the safe, isolated state DB name for an OpenCode version. */
@@ -239,7 +239,7 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
239
239
  return {
240
240
  pantheon_cost: {
241
241
  description:
242
- 'Report delegation cost + token usage by agent over the last N days, read from opencode.db (read-only, no writes).',
242
+ 'Report cost + token usage by agent over the last N days, read from opencode.db (read-only, no writes).',
243
243
  args: costArgs,
244
244
  execute,
245
245
  },
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Read-Only Enforcement (Phase 4) — deny mutating tools inside sessions that
3
- * were delegated as read-only (advisory `read_only: true` on
4
- * pantheon_delegate, or the agent ∈ `readOnlyAgents` from routing.yml).
3
+ * were delegated as read-only (advisory `read_only: true` on the native
4
+ * `task` delegate, or the agent ∈ `readOnlyAgents` from routing.yml).
5
5
  *
6
6
  * OpenCode hook: `tool.execute.before`. The hook shape is
7
7
  * `(input, output) => Promise<void>`; THROWING from the hook denies the tool
@@ -18,11 +18,11 @@
18
18
  * - `task` is blocked too, which hard-enforces depth-2: a read-only agent
19
19
  * cannot spawn its own subagents.
20
20
  *
21
- * Unknown sessions are allowed for ordinary tools. Delegation tools are
22
- * fail-closed when the runtime matrix is wired: missing agent identity or
21
+ * Unknown sessions are allowed for ordinary tools. The runtime matrix is
22
+ * fail-closed when wired: missing agent identity or
23
23
  * target metadata is denied rather than inferred.
24
24
  *
25
- * The registry is populated by delegation.ts at delegate time: the CHILD
25
+ * The registry is populated at delegate time: the CHILD
26
26
  * session is registered when the delegate call is read-only.
27
27
  *
28
28
  * @module delegation-enforce
@@ -147,7 +147,6 @@ export function isDelegationAllowed(
147
147
  function targetFromTool(tool: string, args: unknown): string | undefined {
148
148
  if (args === null || typeof args !== 'object') return undefined
149
149
  const record = args as Record<string, unknown>
150
- if (tool === 'pantheon_delegate') return normalizeDelegationAgent(record.agent)
151
150
  if (tool === 'task') {
152
151
  return normalizeDelegationAgent(record.subagent_type ?? record.subagentType)
153
152
  }
@@ -236,7 +235,7 @@ export function zeusReadGuard(tool: string, args: unknown, agent: string | undef
236
235
  // ─── Registry ──────────────────────────────────────────────────────────
237
236
 
238
237
  /**
239
- * Read-only session registry. `createDelegationTools` registers child
238
+ * Read-only session registry. The plugin's delegate wiring registers child
240
239
  * sessions here; the plugin's `tool.execute.before` guard consults it.
241
240
  * Process-wide singleton for plugin wiring + factory for test isolation.
242
241
  */
@@ -291,19 +290,19 @@ export function createEnforcementGuard(input: {
291
290
  options?: EnforcementGuardOptions
292
291
  }): ToolExecuteBeforeHandler {
293
292
  const blockedTools = input.options?.blockedTools ?? DEFAULT_BLOCKED_TOOLS
294
- const prefix = input.options?.messagePrefix ?? 'pantheon_delegate guard'
293
+ const prefix = input.options?.messagePrefix ?? 'delegation guard'
295
294
 
296
295
  return async (hook: ToolExecuteBeforeInput, output?: ToolExecuteBeforeOutput): Promise<void> => {
297
- const isDelegationTool = hook.tool === 'task' || hook.tool === 'pantheon_delegate'
296
+ const isDelegationTool = hook.tool === 'task'
298
297
  if (isDelegationTool && input.options?.isChildSession?.(hook.sessionID) === true) {
299
298
  throw new Error(
300
299
  `${prefix}: session ${hook.sessionID} is a child session — delegation tools are denied ` +
301
300
  'to prevent nested delegation',
302
301
  )
303
302
  }
304
- // pantheon_delegate has the authoritative ToolContext.agent at execution
305
- // time, so its matrix check lives in delegation.ts. The hook only has the
306
- // session-scoped agent learned from chat.params for native task().
303
+ // The native task() delegate has the authoritative ToolContext.agent at
304
+ // execution time, so its matrix check lives in dispatch-guard.ts. The hook
305
+ // only has the session-scoped agent learned from chat.params.
307
306
  if (
308
307
  hook.tool === 'task' &&
309
308
  input.options?.isRootSession !== undefined &&
@@ -336,7 +335,7 @@ export function createEnforcementGuard(input: {
336
335
  `This session was delegated for investigation only (read-only agent); it cannot ` +
337
336
  `modify files, run commands, or spawn subagents. ` +
338
337
  `If a write result is needed, dispatch a write-capable agent ` +
339
- `(e.g. hermes, aphrodite) with pantheon_delegate instead.`,
338
+ `(e.g. hermes, aphrodite) with the native task() tool instead.`,
340
339
  )
341
340
  }
342
341
  }
@@ -17,11 +17,11 @@
17
17
  import { z } from 'zod'
18
18
 
19
19
  import type { BackgroundJobBoard } from './background-job-board.ts'
20
- import type { ToolContextLike } from './delegation.ts'
21
20
  import { GoalStore } from './goal-store.ts'
22
21
  import { createPantheonLogger } from './logger.ts'
23
22
  import { safeSessionPath } from './session-guard.ts'
24
23
  import type { TodoEnforcerMessage } from './todo-enforcer.ts'
24
+ import type { ToolContextLike } from './tool-context.ts'
25
25
 
26
26
  // Silence-by-default TUI policy (pantheon-hooks L42-58): warn → hooks.log,
27
27
  // console echo opt-in via PANTHEON_HOOKS_LOG=1. `deps.logger` stays for tests.
@@ -103,7 +103,7 @@ const goalUpdateArgs = {
103
103
 
104
104
  const goalGetArgs = {} satisfies z.ZodRawShape
105
105
 
106
- /** One structural goal tool: description + zod args shape + execute (delegation.ts shape). */
106
+ /** One structural goal tool: description + zod args shape + execute. */
107
107
  export type GoalTool<Args extends z.ZodRawShape> = {
108
108
  description: string
109
109
  args: Args
@@ -15,7 +15,7 @@
15
15
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
16
16
  import { join } from 'node:path'
17
17
 
18
- import { assertSafeParentSessionID } from './delegation-finalize.ts'
18
+ import { assertSafeParentSessionID } from './tool-context.ts'
19
19
 
20
20
  export type GoalStatus = 'pending' | 'in_progress' | 'done'
21
21
 
@@ -2,8 +2,8 @@
2
2
  * Hashline edit tool (Wave 2, PR #46) — structural `hashline_edit` tool that
3
3
  * anchors edits to stable sha256 content tags instead of fragile line numbers.
4
4
  *
5
- * Tool shape matches the delegation tools (`{description, args, execute}`,
6
- * zod-validated — same import pattern as delegation.ts).
5
+ * Tool shape follows the plugin tool convention (`{description, args, execute}`,
6
+ * zod-validated) used across the Pantheon toolset.
7
7
  *
8
8
  * Safety model (council-approved):
9
9
  * 1. ALL refs are validated against the ORIGINAL file snapshot BEFORE any