pantheon-opencode 1.4.0 → 1.4.2
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.
- package/README.md +201 -95
- package/bin/pantheon-init.mjs +7 -0
- package/commands/pantheon-model.md +34 -0
- package/opencode.json +0 -2
- package/package.json +2 -2
- package/scripts/generate-preset-docs.mjs +239 -0
- package/scripts/install/model-picker.mjs +367 -2
- package/scripts/install/opencode.mjs +150 -137
- package/src/pantheon/chat-reminders.ts +4 -74
- package/src/pantheon/compaction-assert.ts +28 -28
- package/src/pantheon/delegation-finalize.ts +44 -11
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +45 -121
- package/src/pantheon/model-command.ts +858 -0
- package/src/pantheon/presets.d.mts +4 -3
- package/src/pantheon/presets.mjs +47 -54
- package/src/pantheon/zeus-delegate-with-retry.ts +9 -4
- package/src/plugin.ts +17 -29
- package/src/plugins/pantheon-hooks.ts +15 -158
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/dist/tui.tsx +13 -2
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +13 -2
- package/src/plugins/tui/tsdown.config.ts +11 -0
- package/src/routing.yml +71 -123
|
@@ -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 (
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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 (
|
|
688
|
+
if (config.default_agent === undefined && pantheonConfig.default_agent !== undefined) {
|
|
691
689
|
config.default_agent = pantheonConfig.default_agent
|
|
692
690
|
}
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
708
|
+
if (config.experimental === undefined) {
|
|
714
709
|
config.experimental = {}
|
|
715
710
|
}
|
|
716
|
-
if (
|
|
717
|
-
|
|
718
|
-
|
|
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 (
|
|
741
|
+
if (config.plugin === undefined) {
|
|
739
742
|
config.plugin = []
|
|
740
743
|
}
|
|
741
|
-
if (Array.isArray(
|
|
742
|
-
|
|
743
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
791
|
-
config.permission
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
'
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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 {
|
|
1017
|
-
await
|
|
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) {
|
|
@@ -1,79 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Retired transcript-notification module.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `Object.values(mod)` and treats EVERY export as a plugin factory —
|
|
8
|
-
* a non-function export throws `TypeError("Plugin export is not a
|
|
9
|
-
* function")` and a function export is invoked with a PluginInput.
|
|
10
|
-
* pantheon-hooks.ts therefore cannot EXPORT the enqueue API.
|
|
11
|
-
* 2. src/plugin.ts (a separate plugin file) must ENQUEUE into the SAME
|
|
12
|
-
* buffer the pantheon-hooks `chat.message` hook drains — a local copy
|
|
13
|
-
* would enqueue into a buffer that is never delivered.
|
|
14
|
-
* 3. opencode loads plugins via plain `await import(entry)` (PluginLoader),
|
|
15
|
-
* so both plugin files importing this module resolve to ONE module
|
|
16
|
-
* instance (Bun/Node module cache) → one shared buffer.
|
|
17
|
-
*
|
|
18
|
-
* release-134 Phase 4 uses this buffer for the post-compaction state
|
|
19
|
-
* re-assertion: `reassertAfterCompaction` (compaction-assert.ts) enqueues a
|
|
20
|
-
* fresh-state block here, and the existing chat.message delivery path
|
|
21
|
-
* (pantheon-hooks.ts) injects it as a <system-reminder> into the session's
|
|
22
|
-
* next message. Zero new delivery code — the P0 messageID guard already
|
|
23
|
-
* protects subagent promptAsync fires.
|
|
24
|
-
*
|
|
25
|
-
* Bounded: at most CHAT_REMINDER_MAX entries, each expiring
|
|
26
|
-
* CHAT_REMINDER_TTL_MS after enqueue. Full-buffer enqueues are SKIPPED,
|
|
27
|
-
* never unbounded. No background timers — expiry is only checked on enqueue
|
|
28
|
-
* and on delivery (drainFreshChatReminders).
|
|
4
|
+
* Delegation lifecycle state must not be queued for delivery into user
|
|
5
|
+
* messages. Visibility is provided by the board/list/read tools and TUI
|
|
6
|
+
* toasts; this intentionally exports no delivery API.
|
|
29
7
|
*
|
|
30
8
|
* @module chat-reminders
|
|
31
9
|
*/
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Hard cap on queued reminder entries (anti-spam philosophy, matching the
|
|
35
|
-
* "throttled signals are skipped, never backlogged" rule).
|
|
36
|
-
*/
|
|
37
|
-
export const CHAT_REMINDER_MAX = 10
|
|
38
|
-
|
|
39
|
-
/** How long a queued reminder stays deliverable (pruned after ~60s). */
|
|
40
|
-
export const CHAT_REMINDER_TTL_MS = 60_000
|
|
41
|
-
|
|
42
|
-
/** One queued reminder: the text plus its enqueue timestamp. */
|
|
43
|
-
export interface ChatReminderEntry {
|
|
44
|
-
text: string
|
|
45
|
-
at: number
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** The shared buffer — module-level state, consumed by the chat.message hook. */
|
|
49
|
-
export const pendingChatReminders: ChatReminderEntry[] = []
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Queue one chat-reminder entry, pruning expired entries first. Never throws.
|
|
53
|
-
* A full buffer skips the push (bounded, never backlogged).
|
|
54
|
-
*/
|
|
55
|
-
export function enqueueChatReminder(text: string): void {
|
|
56
|
-
const now = Date.now()
|
|
57
|
-
for (let i = pendingChatReminders.length - 1; i >= 0; i--) {
|
|
58
|
-
const r = pendingChatReminders[i]
|
|
59
|
-
if (r !== undefined && now - r.at > CHAT_REMINDER_TTL_MS) pendingChatReminders.splice(i, 1)
|
|
60
|
-
}
|
|
61
|
-
if (pendingChatReminders.length >= CHAT_REMINDER_MAX) return
|
|
62
|
-
pendingChatReminders.push({ text, at: now })
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Deliver (drain) every FRESH reminder as one joined block: expired entries
|
|
67
|
-
* are dropped, the buffer is cleared in all cases (no leak). Returns
|
|
68
|
-
* undefined when nothing fresh remains — the caller skips injection.
|
|
69
|
-
*
|
|
70
|
-
* This is the delivery helper the pantheon-hooks `chat.message` hook calls
|
|
71
|
-
* AFTER its P0 messageID guard (empty messageID → early return, buffer kept).
|
|
72
|
-
*/
|
|
73
|
-
export function drainFreshChatReminders(): string | undefined {
|
|
74
|
-
const now = Date.now()
|
|
75
|
-
const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
|
|
76
|
-
pendingChatReminders.length = 0
|
|
77
|
-
if (fresh.length === 0) return undefined
|
|
78
|
-
return fresh.map((r) => r.text).join('\n')
|
|
79
|
-
}
|
|
@@ -1,37 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compaction Re-Assert (release-134 Phase 4) —
|
|
3
|
-
* compaction.
|
|
2
|
+
* Compaction Re-Assert (release-134 Phase 4) — detect fresh state after
|
|
3
|
+
* compaction without writing to the chat transcript.
|
|
4
4
|
*
|
|
5
5
|
* WHY: after a session is compacted, the system state may have changed since
|
|
6
6
|
* the summary was built (new background delegations launched, results
|
|
7
7
|
* delivered, goals updated). The compacted session's context is frozen at
|
|
8
8
|
* summary time — the model would operate on stale state.
|
|
9
9
|
*
|
|
10
|
-
* HOW: the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* enqueues it into the SHARED chat-reminder buffer (chat-reminders.ts). The
|
|
14
|
-
* existing pantheon-hooks `chat.message` hook delivers it as a
|
|
15
|
-
* <system-reminder> into the session's next message — zero new delivery
|
|
16
|
-
* code, and the P0 messageID guard already protects subagent fires.
|
|
10
|
+
* HOW: the compaction lifecycle keeps the board and goal stores authoritative;
|
|
11
|
+
* the `experimental.session.compacting` hook builds the context that is
|
|
12
|
+
* consumed by the compaction itself. Chat is never used as a delivery channel.
|
|
17
13
|
*
|
|
18
14
|
* Deliberately NOT `session.promptAsync({noReply:true})`: the sprint 4+7
|
|
19
15
|
* spike refuted noReply as a push mechanism on this opencode build (nothing
|
|
20
16
|
* is delivered to the parent).
|
|
21
17
|
*
|
|
22
|
-
* FAIL-OPEN: every source (board list
|
|
18
|
+
* FAIL-OPEN: every source (board list and goals list) is wrapped —
|
|
23
19
|
* a failure logs a warn via the silence-by-default logger and the hook never
|
|
24
20
|
* throws. A session with NO fresh state (nothing running/unread/goals) is a
|
|
25
|
-
* SILENT skip (no warn
|
|
21
|
+
* SILENT skip (no warn). The block is capped at
|
|
26
22
|
* REASSERT_MAX_LINES item lines (1 header) so it stays compact.
|
|
27
23
|
*
|
|
28
|
-
* Pure TypeScript — zero runtime deps beyond the board/goal types
|
|
29
|
-
* shared reminder buffer.
|
|
24
|
+
* Pure TypeScript — zero runtime deps beyond the board/goal types.
|
|
30
25
|
*
|
|
31
26
|
* @module compaction-assert
|
|
32
27
|
*/
|
|
33
28
|
import type { BackgroundJobBoard } from './background-job-board.ts'
|
|
34
|
-
import { enqueueChatReminder } from './chat-reminders.ts'
|
|
35
29
|
import type { CompactionGoalSource } from './delegation-compaction.ts'
|
|
36
30
|
import { createPantheonLogger } from './logger.ts'
|
|
37
31
|
|
|
@@ -51,16 +45,16 @@ export const REASSERT_HEADER = 'State re-assertion after compaction — the summ
|
|
|
51
45
|
|
|
52
46
|
/** Dependencies threaded to reassertAfterCompaction (wired from plugin.ts). */
|
|
53
47
|
export interface CompactionAssertDeps {
|
|
54
|
-
/** The
|
|
48
|
+
/** The session whose compaction context is being prepared. */
|
|
55
49
|
sessionID: string
|
|
56
50
|
/** Board query surface — `list` is all the re-assertion needs. */
|
|
57
51
|
board: Pick<BackgroundJobBoard, 'list'>
|
|
58
52
|
/** Goal source for the goals block (optional — same gating as Phase 2). */
|
|
59
53
|
goals?: CompactionGoalSource
|
|
60
|
-
/** Injectable enqueue (testable). Defaults to the shared chat buffer. */
|
|
61
|
-
enqueue?: (text: string) => void
|
|
62
54
|
/** Injectable warn logger (testable). Defaults to silence-by-default. */
|
|
63
55
|
logger?: { warn: (message: string) => void }
|
|
56
|
+
/** Non-chat delivery sink (for example a pending `output.context` block). */
|
|
57
|
+
deliverContext?: (context: string[]) => void | Promise<void>
|
|
64
58
|
}
|
|
65
59
|
|
|
66
60
|
// ─── Formatting ────────────────────────────────────────────────────────
|
|
@@ -135,26 +129,32 @@ async function buildStateLines(
|
|
|
135
129
|
}
|
|
136
130
|
|
|
137
131
|
/**
|
|
138
|
-
* Re-assert fresh state for a compacted session
|
|
139
|
-
*
|
|
132
|
+
* Re-assert fresh state for a compacted session and return the context block
|
|
133
|
+
* to the non-chat caller. The caller can hand this array to OpenCode's
|
|
134
|
+
* `output.context` or persist it through the board; constructing it here and
|
|
135
|
+
* throwing it away would make the hook a no-op.
|
|
140
136
|
*
|
|
141
|
-
* Returns
|
|
142
|
-
* assert (silent skip) or a source failed (warned). NEVER throws —
|
|
143
|
-
* hook must never break the session.
|
|
137
|
+
* Returns the fresh-state context when found, or undefined when there was
|
|
138
|
+
* nothing to assert (silent skip) or a source failed (warned). NEVER throws —
|
|
139
|
+
* the event hook must never break the session.
|
|
144
140
|
*/
|
|
145
|
-
export async function reassertAfterCompaction(
|
|
141
|
+
export async function reassertAfterCompaction(
|
|
142
|
+
deps: CompactionAssertDeps,
|
|
143
|
+
): Promise<string[] | undefined> {
|
|
146
144
|
// Silence-by-default logger: file-only warn, console echo opt-in via
|
|
147
145
|
// PANTHEON_HOOKS_LOG=1 (TUI pollution policy, pantheon-hooks L42-58).
|
|
148
146
|
const warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
|
|
149
147
|
try {
|
|
150
148
|
const lines = await buildStateLines(deps, warn)
|
|
151
|
-
if (lines === undefined) return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
149
|
+
if (lines === undefined) return undefined
|
|
150
|
+
// This is a context return value, not a chat message. The caller owns the
|
|
151
|
+
// supported delivery path (output.context or persisted board state).
|
|
152
|
+
const context = [REASSERT_HEADER, ...lines]
|
|
153
|
+
await deps.deliverContext?.(context)
|
|
154
|
+
return context
|
|
155
155
|
} catch (err: unknown) {
|
|
156
156
|
const reason = err instanceof Error ? err.message : String(err)
|
|
157
157
|
warn(`compaction re-assert failed for session ${deps.sessionID}: ${reason}`)
|
|
158
|
-
return
|
|
158
|
+
return undefined
|
|
159
159
|
}
|
|
160
160
|
}
|