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.
@@ -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) {
@@ -1,79 +1,9 @@
1
1
  /**
2
- * Chat Reminders (release-134 Phase 4) — the SHARED <system-reminder> buffer.
2
+ * Retired transcript-notification module.
3
3
  *
4
- * WHY a shared module (not owned by pantheon-hooks.ts):
5
- * 1. opencode's legacy plugin loader (verified in the 1.18.16 source,
6
- * packages/opencode/src/plugin/index.ts getLegacyPlugins) does
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) — re-inject fresh state after
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 `session.compacted` event hook calls `reassertAfterCompaction`,
11
- * which builds a COMPACT fresh-state block (running + unread delegations from
12
- * the board, active goals from the GoalStore — same gating as Phase 2) and
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, goals list, enqueue) is wrapped —
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, no reminder). The block is capped at
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 and the
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 compacted session whose next message receives the reminder. */
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: build the compact block and
139
- * enqueue it as a chat reminder (delivered on the session's next message).
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 true when a reminder was enqueued; false when there was nothing to
142
- * assert (silent skip) or a source failed (warned). NEVER throws — the event
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(deps: CompactionAssertDeps): Promise<boolean> {
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 false
152
- const enqueue = deps.enqueue ?? enqueueChatReminder
153
- enqueue(`${REASSERT_HEADER}\n${lines.join('\n')}`)
154
- return true
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 false
158
+ return undefined
159
159
  }
160
160
  }