@wolfx/opencode-magic-context 0.22.3 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/dist/agents/magic-context-prompt.d.ts +1 -1
  2. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  3. package/dist/agents/permissions.d.ts +4 -4
  4. package/dist/agents/permissions.d.ts.map +1 -1
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/project-security.d.ts +30 -0
  7. package/dist/config/project-security.d.ts.map +1 -0
  8. package/dist/config/prune-config-leaf.d.ts +27 -0
  9. package/dist/config/prune-config-leaf.d.ts.map +1 -0
  10. package/dist/config/schema/magic-context.d.ts +7 -13
  11. package/dist/config/schema/magic-context.d.ts.map +1 -1
  12. package/dist/config/variable.d.ts.map +1 -1
  13. package/dist/features/magic-context/compartment-storage.d.ts +9 -6
  14. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/scheduler.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
  18. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  19. package/dist/features/magic-context/key-files/aft-availability.d.ts.map +1 -1
  20. package/dist/features/magic-context/key-files/identify-key-files.d.ts.map +1 -1
  21. package/dist/features/magic-context/key-files/read-stats.d.ts +0 -2
  22. package/dist/features/magic-context/key-files/read-stats.d.ts.map +1 -1
  23. package/dist/features/magic-context/memory/constants.d.ts +7 -0
  24. package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
  25. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  26. package/dist/features/magic-context/memory/embedding-ssrf.d.ts +29 -0
  27. package/dist/features/magic-context/memory/embedding-ssrf.d.ts.map +1 -0
  28. package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -1
  29. package/dist/features/magic-context/memory/project-identity.d.ts +10 -0
  30. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  31. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  32. package/dist/features/magic-context/project-docs-hash.d.ts.map +1 -1
  33. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  34. package/dist/features/magic-context/range-parser.d.ts +6 -0
  35. package/dist/features/magic-context/range-parser.d.ts.map +1 -1
  36. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  37. package/dist/features/magic-context/storage-db.d.ts +1 -1
  38. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  39. package/dist/features/magic-context/storage-meta-persisted.d.ts +124 -16
  40. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  41. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  42. package/dist/features/magic-context/storage-meta-shared.d.ts +15 -1
  43. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  44. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  45. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  46. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  47. package/dist/features/magic-context/storage-tags.d.ts +118 -1
  48. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  49. package/dist/features/magic-context/storage.d.ts +3 -3
  50. package/dist/features/magic-context/storage.d.ts.map +1 -1
  51. package/dist/features/magic-context/tagger.d.ts +12 -2
  52. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  53. package/dist/features/magic-context/tool-definition-tokens.d.ts +21 -0
  54. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  55. package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -1
  56. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  57. package/dist/features/magic-context/types.d.ts +12 -0
  58. package/dist/features/magic-context/types.d.ts.map +1 -1
  59. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/channel2-delivery.d.ts +22 -0
  62. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -0
  63. package/dist/hooks/magic-context/command-handler.d.ts +1 -7
  64. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/compaction-marker-manager.d.ts +1 -1
  66. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  68. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  70. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/compartment-runner-types.d.ts +5 -0
  72. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/compartment-runner-validation.d.ts +25 -0
  74. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  76. package/dist/hooks/magic-context/compartment-trigger.d.ts +47 -2
  77. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  78. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +117 -0
  79. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -0
  80. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/drop-stale-reduce-calls.d.ts +36 -1
  82. package/dist/hooks/magic-context/drop-stale-reduce-calls.d.ts.map +1 -1
  83. package/dist/hooks/magic-context/emergency-drop.d.ts +86 -0
  84. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -0
  85. package/dist/hooks/magic-context/event-handler.d.ts +6 -4
  86. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/execute-flush.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/execute-status.d.ts +1 -1
  89. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +10 -3
  91. package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
  92. package/dist/hooks/magic-context/hook-handlers.d.ts +3 -9
  93. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  94. package/dist/hooks/magic-context/hook.d.ts +3 -5
  95. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/inject-compartments.d.ts +41 -0
  97. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/note-visibility.d.ts +1 -1
  99. package/dist/hooks/magic-context/protected-tail-boundary.d.ts +132 -0
  100. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -0
  101. package/dist/hooks/magic-context/read-session-chunk.d.ts +55 -0
  102. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  104. package/dist/hooks/magic-context/read-session-raw.d.ts +91 -0
  105. package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
  106. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts +70 -0
  107. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -0
  108. package/dist/hooks/magic-context/reference-retrieval.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/send-session-notification.d.ts +2 -1
  110. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/system-prompt-hash.d.ts +0 -1
  112. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/tag-messages.d.ts +3 -0
  114. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  115. package/dist/hooks/magic-context/todo-view.d.ts +1 -1
  116. package/dist/hooks/magic-context/tool-drop-target.d.ts +9 -0
  117. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  118. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +15 -0
  119. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  120. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +9 -11
  121. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  122. package/dist/hooks/magic-context/transform.d.ts +22 -9
  123. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  124. package/dist/hooks/magic-context/upgrade-reminder.d.ts +2 -1
  125. package/dist/hooks/magic-context/upgrade-reminder.d.ts.map +1 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +5915 -1336
  128. package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
  129. package/dist/plugin/embedding-bootstrap-helpers.d.ts.map +1 -1
  130. package/dist/plugin/hooks/create-session-hooks.d.ts +1 -1
  131. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  132. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  133. package/dist/plugin/tool-registry.d.ts.map +1 -1
  134. package/dist/shared/announcement.d.ts +4 -6
  135. package/dist/shared/announcement.d.ts.map +1 -1
  136. package/dist/shared/keep-subagents.d.ts +7 -0
  137. package/dist/shared/keep-subagents.d.ts.map +1 -0
  138. package/dist/shared/live-server-client.d.ts +50 -0
  139. package/dist/shared/live-server-client.d.ts.map +1 -0
  140. package/dist/shared/prompt-context.d.ts +31 -0
  141. package/dist/shared/prompt-context.d.ts.map +1 -0
  142. package/dist/shared/rpc-server.d.ts.map +1 -1
  143. package/dist/shared/rpc-types.d.ts +0 -3
  144. package/dist/shared/rpc-types.d.ts.map +1 -1
  145. package/dist/shared/safe-notification-target.d.ts +23 -0
  146. package/dist/shared/safe-notification-target.d.ts.map +1 -0
  147. package/dist/shared/tag-transcript.d.ts.map +1 -1
  148. package/dist/shared/transcript-opencode.d.ts.map +1 -1
  149. package/dist/shared/transcript.d.ts +15 -1
  150. package/dist/shared/transcript.d.ts.map +1 -1
  151. package/dist/tools/ctx-expand/constants.d.ts +1 -1
  152. package/dist/tools/ctx-expand/constants.d.ts.map +1 -1
  153. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  154. package/dist/tools/ctx-memory/constants.d.ts +1 -1
  155. package/dist/tools/ctx-memory/constants.d.ts.map +1 -1
  156. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  157. package/dist/tools/ctx-memory/types.d.ts +7 -3
  158. package/dist/tools/ctx-memory/types.d.ts.map +1 -1
  159. package/dist/tools/ctx-note/constants.d.ts +1 -1
  160. package/dist/tools/ctx-note/constants.d.ts.map +1 -1
  161. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  162. package/dist/tools/ctx-note/types.d.ts +4 -0
  163. package/dist/tools/ctx-note/types.d.ts.map +1 -1
  164. package/dist/tools/ctx-search/constants.d.ts +1 -1
  165. package/dist/tools/ctx-search/constants.d.ts.map +1 -1
  166. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  167. package/dist/tui/data/context-db.d.ts.map +1 -1
  168. package/package.json +3 -1
  169. package/src/shared/announcement.test.ts +18 -0
  170. package/src/shared/announcement.ts +35 -20
  171. package/src/shared/keep-subagents.test.ts +39 -0
  172. package/src/shared/keep-subagents.ts +33 -0
  173. package/src/shared/live-server-client.ts +152 -0
  174. package/src/shared/prompt-context.ts +135 -0
  175. package/src/shared/rpc-server.ts +18 -2
  176. package/src/shared/rpc-types.ts +0 -3
  177. package/src/shared/safe-notification-target.test.ts +97 -0
  178. package/src/shared/safe-notification-target.ts +102 -0
  179. package/src/shared/tag-transcript.test.ts +34 -8
  180. package/src/shared/tag-transcript.ts +110 -8
  181. package/src/shared/transcript-opencode.ts +15 -5
  182. package/src/shared/transcript.ts +20 -2
  183. package/src/tui/data/context-db.ts +0 -3
  184. package/src/tui/index.tsx +11 -10
  185. package/src/tui/slots/sidebar-content.tsx +1 -26
  186. package/dist/hooks/magic-context/apply-context-nudge.d.ts +0 -5
  187. package/dist/hooks/magic-context/apply-context-nudge.d.ts.map +0 -1
  188. package/dist/hooks/magic-context/nudge-bands.d.ts +0 -6
  189. package/dist/hooks/magic-context/nudge-bands.d.ts.map +0 -1
  190. package/dist/hooks/magic-context/nudge-injection.d.ts +0 -7
  191. package/dist/hooks/magic-context/nudge-injection.d.ts.map +0 -1
  192. package/dist/hooks/magic-context/nudge-placement-store.d.ts +0 -15
  193. package/dist/hooks/magic-context/nudge-placement-store.d.ts.map +0 -1
  194. package/dist/hooks/magic-context/nudger.d.ts +0 -21
  195. package/dist/hooks/magic-context/nudger.d.ts.map +0 -1
@@ -23,18 +23,18 @@ import { getMagicContextStorageDir } from "./data-path";
23
23
  * Bump only when there are user-visible changes worth a startup dialog.
24
24
  * Does NOT need to match the published package version.
25
25
  */
26
- export const ANNOUNCEMENT_VERSION = "0.22.0";
26
+ export const ANNOUNCEMENT_VERSION = "0.23.0";
27
27
 
28
28
  /**
29
29
  * Short, user-facing bullet strings. Keep each line ~80 chars or shorter so the
30
30
  * TUI dialog renders cleanly without horizontal scroll on a typical terminal.
31
31
  */
32
32
  export const ANNOUNCEMENT_FEATURES: ReadonlyArray<string> = [
33
- "NOW ON BY DEFAULT — Temporal awareness: the agent sees elapsed-time markers (e.g. +2h 15m) between messages and dated compartments, so it knows how long ago things happened. Opt out with temporal_awareness: false.",
34
- "NOW ON BY DEFAULT Auto-search hints: each turn a background ctx_search whispers a compact 'vague recall' when something relevant exists in your memories, past conversation, or git history. No full content injected. Opt out with memory.auto_search.enabled: false.",
35
- "Experimental features graduated to stable config: temporal_awareness and caveman_text_compression are now top-level keys; auto_search and git_commit_indexing moved under memory.* . Run `doctor` to migrate old experimental.* settings (your opt-ins/opt-outs are preserved).",
36
- "git_commit_indexing (make project history semantically searchable) stays opt-in enable with memory.git_commit_indexing.enabled: true.",
37
- "Audit hardening across both harnesses: memory config-bypass fix, supersede-delta cache-stability fixes, and dashboard correctness fixes.",
33
+ "Smarter context nudges: gentle <system-reminder> notes on tool outputs replace the old chat nudges quieter, cache-safe, and they now also help subagents manage their own context.",
34
+ "The agent can now maintain its own project memories: update, archive (batch), and merge the memories it sees not just write new ones. Memory categories are schema-enforced.",
35
+ "Big-session performance: historian trigger and token math moved off the database hot path multi-second stalls on large sessions are gone (measured 250ms 2.4ms per pass).",
36
+ "History compaction unstuck for sparse sessions: the protected-tail boundary is now size-based, so sessions with few user turns compact reliably instead of growing forever (issue #132).",
37
+ "Fixed: session titles no longer fail to generate in fresh directories (issue #129), plus 20+ correctness fixes from three audit rounds.",
38
38
  ];
39
39
 
40
40
  /**
@@ -52,23 +52,33 @@ function getStateFilePath(): string {
52
52
  return path.join(getMagicContextStorageDir(), STATE_FILENAME);
53
53
  }
54
54
 
55
- /**
56
- * Read the most recently dismissed announcement version, or `""` if none.
57
- *
58
- * Best-effort: any read failure returns `""` (which forces the announcement to
59
- * re-show). The cost of a spurious second dialog is much smaller than the cost
60
- * of suppressing a real announcement due to a transient FS error.
61
- */
62
- export function readLastAnnouncedVersion(): string {
55
+ type AnnouncementStateRead =
56
+ | { status: "missing" }
57
+ | { status: "valid"; version: string }
58
+ | { status: "error" };
59
+
60
+ function readAnnouncementState(): AnnouncementStateRead {
63
61
  try {
64
62
  const file = getStateFilePath();
65
- if (!fs.existsSync(file)) return "";
66
- return fs.readFileSync(file, "utf-8").trim();
63
+ if (!fs.existsSync(file)) return { status: "missing" };
64
+ const version = fs.readFileSync(file, "utf-8").trim();
65
+ if (!version) return { status: "error" };
66
+ return { status: "valid", version };
67
67
  } catch {
68
- return "";
68
+ return { status: "error" };
69
69
  }
70
70
  }
71
71
 
72
+ /**
73
+ * Read the most recently dismissed announcement version, or `""` if none can be
74
+ * returned. Callers that need to distinguish first-run from read/corruption
75
+ * failures should use the internal tri-state path in `shouldShowAnnouncement`.
76
+ */
77
+ export function readLastAnnouncedVersion(): string {
78
+ const state = readAnnouncementState();
79
+ return state.status === "valid" ? state.version : "";
80
+ }
81
+
72
82
  /**
73
83
  * Persist `version` as the most recently dismissed announcement. Best-effort:
74
84
  * write failures are swallowed so dialog-confirm flows never throw on storage
@@ -108,12 +118,17 @@ export function markAnnouncementSeen(version: string): void {
108
118
  */
109
119
  export function shouldShowAnnouncement(): boolean {
110
120
  if (!ANNOUNCEMENT_VERSION || ANNOUNCEMENT_FEATURES.length === 0) return false;
111
- const lastVersion = readLastAnnouncedVersion();
112
- if (!lastVersion) {
121
+ const state = readAnnouncementState();
122
+ if (state.status === "missing") {
113
123
  // No prior state: fresh install or wiped sandbox. Seed to current and
114
124
  // skip the announcement so we never pester first-run / ephemeral envs.
115
125
  markAnnouncementSeen(ANNOUNCEMENT_VERSION);
116
126
  return false;
117
127
  }
118
- return lastVersion !== ANNOUNCEMENT_VERSION;
128
+ if (state.status === "error") {
129
+ // A corrupt or temporarily unreadable existing state file is not first-run.
130
+ // Do not advance the version; a later successful boot can still show it.
131
+ return false;
132
+ }
133
+ return state.version !== ANNOUNCEMENT_VERSION;
119
134
  }
@@ -0,0 +1,39 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ _resetKeepSubagentsForTesting,
4
+ setKeepSubagents,
5
+ shouldKeepSubagents,
6
+ } from "./keep-subagents";
7
+
8
+ afterEach(() => {
9
+ _resetKeepSubagentsForTesting();
10
+ });
11
+
12
+ describe("keep-subagents flag", () => {
13
+ it("#given default #then subagent sessions are NOT kept (deleted on success)", () => {
14
+ expect(shouldKeepSubagents()).toBe(false);
15
+ });
16
+
17
+ it("#given setKeepSubagents(true) #then sessions are kept", () => {
18
+ setKeepSubagents(true);
19
+ expect(shouldKeepSubagents()).toBe(true);
20
+ });
21
+
22
+ it("#given setKeepSubagents(false) #then sessions are not kept", () => {
23
+ setKeepSubagents(true);
24
+ setKeepSubagents(false);
25
+ expect(shouldKeepSubagents()).toBe(false);
26
+ });
27
+
28
+ it("#given a non-true value #then coerces to false (only strict true keeps)", () => {
29
+ // boot wiring passes `config.keep_subagents === true`, but guard anyway.
30
+ setKeepSubagents(undefined as unknown as boolean);
31
+ expect(shouldKeepSubagents()).toBe(false);
32
+ });
33
+
34
+ it("#given reset helper #then returns to default false", () => {
35
+ setKeepSubagents(true);
36
+ _resetKeepSubagentsForTesting();
37
+ expect(shouldKeepSubagents()).toBe(false);
38
+ });
39
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Debug / data-collection switch: when enabled, Magic Context does NOT delete
3
+ * the child sessions it spawns for its own subagents (historian, dreamer,
4
+ * sidekick, memory-migration, key-files, user-memory review, recomp).
5
+ *
6
+ * By default these child sessions are deleted on success (only FAILED ones are
7
+ * kept for debugging). With `keep_subagents: true` ALL of them are retained, so
8
+ * their full transcript — prompt, tool calls, token usage, model output — stays
9
+ * inspectable in OpenCode's session store / the dashboard. Intended for
10
+ * short-term data collection (e.g. profiling what the dreamer actually does)
11
+ * before the dreamer v2 overhaul, NOT for steady-state use — kept sessions
12
+ * accumulate in the host's session DB until manually cleared.
13
+ *
14
+ * Process-global, set once at boot from config (mirrors `harness.ts`). A
15
+ * config change requires a restart to take effect. NEVER thread this through
16
+ * per-call args — it's a coarse, boot-time debug toggle.
17
+ */
18
+ let keepSubagents = false;
19
+
20
+ /** Set at plugin boot from `keep_subagents` config. */
21
+ export function setKeepSubagents(value: boolean): void {
22
+ keepSubagents = value === true;
23
+ }
24
+
25
+ /** True when subagent child sessions should be retained (not deleted). */
26
+ export function shouldKeepSubagents(): boolean {
27
+ return keepSubagents;
28
+ }
29
+
30
+ /** Test-only reset. Do NOT call from production paths. */
31
+ export function _resetKeepSubagentsForTesting(): void {
32
+ keepSubagents = false;
33
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Live-server client for the Channel 2 ctx_reduce ceiling nudge (a synthetic
3
+ * user `<system-reminder>` delivered via `promptAsync`).
4
+ *
5
+ * WHY a separate client instead of the plugin-provided `input.client`:
6
+ * OpenCode's plugin `input.client` routes through `Server.Default().app.fetch`,
7
+ * which uses a SEPARATE Effect `memoMap` from the live HTTP listener the UI
8
+ * uses. `SessionRunState` lives per-memoMap, so a plugin-origin `promptAsync`
9
+ * observes an "idle" runner while the live turn is still running, `ensureRunning`
10
+ * fails to coalesce, and OpenCode persists duplicate assistant children
11
+ * (upstream bug anomalyco/opencode#28202). Building a `createOpencodeClient`
12
+ * aimed at `input.serverUrl` via `globalThis.fetch` enters the SAME live
13
+ * listener, so `ensureRunning` sees the real run and coalesces — the synthetic
14
+ * message lands at the tail after the current assistant step.
15
+ *
16
+ * The live listener is only reachable on OpenCode Desktop (Electron+Node) and
17
+ * TUI launched with `--port 0`; plain TUI binds an internal listener that 404s
18
+ * `/session/*`. We probe once at init and cache per `serverUrl`. When
19
+ * unreachable, Channel 2 is DISABLED (Channel 1 + 85% force-materialization
20
+ * remain the backstop) — MC deliberately does NOT fall back to the in-process
21
+ * client because that would knowingly trigger #28202.
22
+ */
23
+
24
+ import { createOpencodeClient } from "@opencode-ai/sdk";
25
+
26
+ export type LiveServerClient = ReturnType<typeof createOpencodeClient>;
27
+
28
+ const clientCache = new Map<string, LiveServerClient>();
29
+
30
+ function cacheKey(serverUrl: string, directory: string): string {
31
+ return `${serverUrl}|${directory}`;
32
+ }
33
+
34
+ function normalizeServerUrl(serverUrl: string): string {
35
+ try {
36
+ return new URL(serverUrl).toString();
37
+ } catch {
38
+ return serverUrl;
39
+ }
40
+ }
41
+
42
+ /** Basic-auth header OpenCode expects when `OPENCODE_SERVER_PASSWORD` is set. */
43
+ function serverAuthHeaders(): Record<string, string> | undefined {
44
+ const password = process.env.OPENCODE_SERVER_PASSWORD;
45
+ if (!password) return undefined;
46
+ const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
47
+ return {
48
+ Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Cached `createOpencodeClient` aimed at the live HTTP listener for the given
54
+ * `(serverUrl, directory)`. One client is reused across deliveries.
55
+ */
56
+ export function getLiveServerClient(serverUrl: string, directory: string): LiveServerClient {
57
+ const key = cacheKey(serverUrl, directory);
58
+ const cached = clientCache.get(key);
59
+ if (cached) return cached;
60
+ const client = createOpencodeClient({
61
+ baseUrl: serverUrl,
62
+ directory,
63
+ headers: serverAuthHeaders(),
64
+ fetch: globalThis.fetch,
65
+ });
66
+ clientCache.set(key, client);
67
+ return client;
68
+ }
69
+
70
+ // Per-serverUrl wake decision + probe TTL. One plugin process can host multiple
71
+ // OpenCode windows with different listener URLs, so the decision must be keyed.
72
+ interface ProbeDecision {
73
+ reachable: boolean;
74
+ probedAt: number;
75
+ }
76
+ const wakeDecisionByServerUrl = new Map<string, ProbeDecision>();
77
+
78
+ // Re-probe window: a transient 404/timeout shouldn't permanently disable
79
+ // Channel 2 for the whole session lifetime (per council-r3).
80
+ const PROBE_TTL_MS = 10 * 60_000;
81
+
82
+ /**
83
+ * Probe whether `serverUrl` serves OpenCode's HTTP API within `timeoutMs`.
84
+ * `true` only when `/session` proves the API is usable: any 2xx, or 401/403
85
+ * (auth-protected listener still exists). `false` for 404 (plain TUI internal
86
+ * listener), 5xx, connection refused, DNS failure, timeout, or malformed URL.
87
+ * Records the result + timestamp in the per-serverUrl cache.
88
+ */
89
+ export async function probeServerReachable(
90
+ serverUrl: string | undefined,
91
+ timeoutMs = 1500,
92
+ ): Promise<boolean> {
93
+ if (!serverUrl) return false;
94
+ const normalized = normalizeServerUrl(serverUrl);
95
+ const controller = new AbortController();
96
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
97
+ let reachable = false;
98
+ try {
99
+ const probeUrl = new URL("/session", serverUrl).toString();
100
+ const res = await globalThis.fetch(probeUrl, {
101
+ method: "GET",
102
+ headers: serverAuthHeaders(),
103
+ signal: controller.signal,
104
+ });
105
+ reachable = res.ok || res.status === 401 || res.status === 403;
106
+ } catch {
107
+ reachable = false;
108
+ } finally {
109
+ clearTimeout(timer);
110
+ wakeDecisionByServerUrl.set(normalized, { reachable, probedAt: Date.now() });
111
+ }
112
+ return reachable;
113
+ }
114
+
115
+ /** Record a probe result directly (test helper / explicit override). */
116
+ export function setLiveServerWakeAvailable(
117
+ serverUrl: string | undefined,
118
+ available: boolean,
119
+ ): void {
120
+ if (!serverUrl) return;
121
+ wakeDecisionByServerUrl.set(normalizeServerUrl(serverUrl), {
122
+ reachable: available,
123
+ probedAt: Date.now(),
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Should Channel 2 deliver through the live-server client for `serverUrl`?
129
+ * Returns false when never probed or the last probe failed. A stale decision
130
+ * (older than the TTL) returns false so the caller re-probes before delivering.
131
+ */
132
+ export function useLiveServerWake(serverUrl?: string): boolean {
133
+ if (!serverUrl) return false;
134
+ const decision = wakeDecisionByServerUrl.get(normalizeServerUrl(serverUrl));
135
+ if (!decision) return false;
136
+ if (Date.now() - decision.probedAt > PROBE_TTL_MS) return false;
137
+ return decision.reachable;
138
+ }
139
+
140
+ /** True when a usable (non-stale) probe decision exists, regardless of outcome. */
141
+ export function hasFreshProbe(serverUrl?: string): boolean {
142
+ if (!serverUrl) return false;
143
+ const decision = wakeDecisionByServerUrl.get(normalizeServerUrl(serverUrl));
144
+ if (!decision) return false;
145
+ return Date.now() - decision.probedAt <= PROBE_TTL_MS;
146
+ }
147
+
148
+ /** Test helper — reset both caches between cases. */
149
+ export function __resetLiveServerClientForTests(): void {
150
+ clientCache.clear();
151
+ wakeDecisionByServerUrl.clear();
152
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Resolve the newest effective prompt context (agent + model + variant) for a
3
+ * session by reading recent messages from the OpenCode HTTP API.
4
+ *
5
+ * WHY: a Channel 2 ceiling nudge sends a synthetic user message via
6
+ * `promptAsync` with `noReply:false` (it DOES trigger an assistant turn).
7
+ * OpenCode's `createUserMessage` resolves variant relative to the chosen
8
+ * agent; passing model alone makes OpenCode pick the default agent whose model
9
+ * check then fails, bypassing the active variant and busting the provider
10
+ * prefix cache the prior turn warmed. So we pass agent + model + variant
11
+ * explicitly, mirroring the resolution AFT/opencode-xtra use for their wake
12
+ * notifications.
13
+ *
14
+ * Walk newest→oldest and merge field-by-field so the newest context-bearing
15
+ * message wins while older messages only fill fields it did not provide. Read
16
+ * BOTH the flat shape (`info.providerID`) used by AssistantMessage and the
17
+ * nested shape (`info.model.providerID`) used by UserMessage.
18
+ *
19
+ * Bounded via `query.limit` — the legacy `/session/{id}/message` endpoint
20
+ * hydrates the ENTIRE session without it (30k-45k messages on large sessions).
21
+ */
22
+
23
+ export interface ResolvedPromptContext {
24
+ agent?: string;
25
+ model?: { providerID: string; modelID: string };
26
+ variant?: string;
27
+ }
28
+
29
+ interface RawInfo {
30
+ role?: string;
31
+ agent?: string;
32
+ variant?: string;
33
+ providerID?: string;
34
+ modelID?: string;
35
+ model?: { providerID?: string; modelID?: string; variant?: string };
36
+ }
37
+
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === "object" && value !== null;
40
+ }
41
+
42
+ function extractMessages(response: unknown): unknown[] {
43
+ if (Array.isArray(response)) return response;
44
+ if (isRecord(response) && Array.isArray(response.data)) return response.data;
45
+ return [];
46
+ }
47
+
48
+ function extractFromMessage(message: unknown): ResolvedPromptContext | null {
49
+ if (!isRecord(message) || !isRecord(message.info)) return null;
50
+ const info = message.info as RawInfo;
51
+ const modelInfo = isRecord(info.model) ? info.model : undefined;
52
+
53
+ const agent = typeof info.agent === "string" ? info.agent : undefined;
54
+ const providerID =
55
+ typeof modelInfo?.providerID === "string"
56
+ ? modelInfo.providerID
57
+ : typeof info.providerID === "string"
58
+ ? info.providerID
59
+ : undefined;
60
+ const modelID =
61
+ typeof modelInfo?.modelID === "string"
62
+ ? modelInfo.modelID
63
+ : typeof info.modelID === "string"
64
+ ? info.modelID
65
+ : undefined;
66
+ const variant =
67
+ typeof modelInfo?.variant === "string"
68
+ ? modelInfo.variant
69
+ : typeof info.variant === "string"
70
+ ? info.variant
71
+ : undefined;
72
+
73
+ if (!agent && (!providerID || !modelID) && !variant) return null;
74
+ const out: ResolvedPromptContext = {};
75
+ if (agent) out.agent = agent;
76
+ if (providerID && modelID) out.model = { providerID, modelID };
77
+ if (variant) out.variant = variant;
78
+ return out;
79
+ }
80
+
81
+ function mergeContexts(
82
+ base: ResolvedPromptContext,
83
+ patch: ResolvedPromptContext,
84
+ ): ResolvedPromptContext {
85
+ return {
86
+ agent: base.agent ?? patch.agent,
87
+ model: base.model ?? patch.model,
88
+ variant: base.variant ?? patch.variant,
89
+ };
90
+ }
91
+
92
+ function isComplete(ctx: ResolvedPromptContext): boolean {
93
+ return Boolean(ctx.agent && ctx.model && ctx.variant);
94
+ }
95
+
96
+ const PROMPT_CONTEXT_MESSAGE_LIMIT = 50;
97
+
98
+ export async function resolvePromptContext(
99
+ client: unknown,
100
+ sessionId: string,
101
+ ): Promise<ResolvedPromptContext | null> {
102
+ if (!client || !sessionId) return null;
103
+ const c = client as {
104
+ session?: {
105
+ messages?: (input: {
106
+ path: { id: string };
107
+ query?: { limit?: number };
108
+ }) => Promise<{ data?: unknown[] } | unknown[]>;
109
+ };
110
+ };
111
+ if (typeof c.session?.messages !== "function") return null;
112
+
113
+ let messages: unknown[] = [];
114
+ try {
115
+ const response = await c.session.messages({
116
+ path: { id: sessionId },
117
+ query: { limit: PROMPT_CONTEXT_MESSAGE_LIMIT },
118
+ });
119
+ messages = extractMessages(response);
120
+ } catch {
121
+ return null;
122
+ }
123
+ if (messages.length === 0) return null;
124
+
125
+ let result: ResolvedPromptContext = {};
126
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
127
+ const ctx = extractFromMessage(messages[i]);
128
+ if (!ctx) continue;
129
+ result = mergeContexts(result, ctx);
130
+ if (isComplete(result)) return result;
131
+ }
132
+
133
+ if (!result.agent && !result.model && !result.variant) return null;
134
+ return result;
135
+ }
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import {
3
3
  mkdirSync,
4
4
  readdirSync,
@@ -14,6 +14,19 @@ import { isPidAlive, parseRpcPortFile, rpcPortDir, rpcPortFilePath } from "./rpc
14
14
 
15
15
  type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
16
16
 
17
+ /**
18
+ * Constant-time bearer-token comparison. `timingSafeEqual` throws on
19
+ * length-mismatched buffers, so guard on length first (the length itself is not
20
+ * secret — the token bytes are). Avoids leaking the token via response-timing on
21
+ * the loopback auth check.
22
+ */
23
+ function tokensMatch(presented: string, expected: string): boolean {
24
+ const a = Buffer.from(presented, "utf8");
25
+ const b = Buffer.from(expected, "utf8");
26
+ if (a.length !== b.length) return false;
27
+ return timingSafeEqual(a, b);
28
+ }
29
+
17
30
  export class MagicContextRpcServer {
18
31
  private server: Server | null = null;
19
32
  private port = 0;
@@ -149,9 +162,12 @@ export class MagicContextRpcServer {
149
162
  // Require the per-process bearer token on every side-effecting call.
150
163
  // The legitimate TUI client reads it from the same port file it used to
151
164
  // discover the port; a process that only guessed the port cannot.
165
+ // Constant-time compare so a local attacker can't byte-probe the token
166
+ // via response-timing (length-guard first, since timingSafeEqual throws
167
+ // on length mismatch).
152
168
  const auth = req.headers.authorization;
153
169
  const presented = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
154
- if (presented !== this.token) {
170
+ if (!tokensMatch(presented, this.token)) {
155
171
  res.writeHead(401, { "Content-Type": "application/json" });
156
172
  res.end(JSON.stringify({ error: "Unauthorized" }));
157
173
  req.resume();
@@ -97,7 +97,6 @@ export interface StatusDetail extends SidebarSnapshot {
97
97
  activeBytes: number;
98
98
  lastResponseTime: number;
99
99
  lastNudgeTokens: number;
100
- lastNudgeBand: string;
101
100
  lastTransformError: string | null;
102
101
  isSubagent: boolean;
103
102
  pendingOps: Array<{ tagId: number; operation: string }>;
@@ -118,9 +117,7 @@ export interface StatusDetail extends SidebarSnapshot {
118
117
  */
119
118
  executeThresholdTokens?: number;
120
119
  protectedTagCount: number;
121
- nudgeInterval: number;
122
120
  historyBudgetPercentage: number;
123
- nextNudgeAfter: number;
124
121
  historyBlockTokens: number;
125
122
  compressionBudget: number | null;
126
123
  compressionUsage: string | null;
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { isDefaultSessionTitle, waitForSafeNotificationTarget } from "./safe-notification-target";
3
+
4
+ function clientWithTitle(title: string | undefined, calls?: { count: number }) {
5
+ return {
6
+ session: {
7
+ get: async (_input: unknown) => {
8
+ if (calls) calls.count += 1;
9
+ return { data: { title } };
10
+ },
11
+ },
12
+ };
13
+ }
14
+
15
+ describe("isDefaultSessionTitle", () => {
16
+ it("matches OpenCode default titles for parent and child sessions", () => {
17
+ expect(isDefaultSessionTitle("New session - 2026-06-10T15:33:11.538Z")).toBe(true);
18
+ expect(isDefaultSessionTitle("Child session - 2026-01-02T03:04:05.678Z")).toBe(true);
19
+ });
20
+
21
+ it("does not match real titles", () => {
22
+ expect(isDefaultSessionTitle("Quick test")).toBe(false);
23
+ expect(isDefaultSessionTitle("New session - notes")).toBe(false);
24
+ // Prefix alone isn't enough — the timestamp must match exactly,
25
+ // mirroring OpenCode's Session.isDefaultTitle.
26
+ expect(isDefaultSessionTitle("New session - 2026-06-10")).toBe(false);
27
+ });
28
+ });
29
+
30
+ describe("waitForSafeNotificationTarget", () => {
31
+ it("returns safe immediately for a titled session", async () => {
32
+ const calls = { count: 0 };
33
+ const result = await waitForSafeNotificationTarget(
34
+ clientWithTitle("Fix tagger collision", calls),
35
+ "ses-titled",
36
+ { attempts: 4, delayMs: 1 },
37
+ );
38
+ expect(result).toBe("safe");
39
+ expect(calls.count).toBe(1);
40
+ });
41
+
42
+ it("returns skip after exhausting attempts on a default-titled session", async () => {
43
+ const calls = { count: 0 };
44
+ const result = await waitForSafeNotificationTarget(
45
+ clientWithTitle("New session - 2026-06-10T15:33:11.538Z", calls),
46
+ "ses-fresh",
47
+ { attempts: 3, delayMs: 1 },
48
+ );
49
+ expect(result).toBe("skip");
50
+ expect(calls.count).toBe(3);
51
+ });
52
+
53
+ it("returns safe once the title flips to a real one mid-retry", async () => {
54
+ let call = 0;
55
+ const client = {
56
+ session: {
57
+ get: async () => {
58
+ call += 1;
59
+ return {
60
+ data: {
61
+ title: call < 2 ? "New session - 2026-06-10T15:33:11.538Z" : "Greeting",
62
+ },
63
+ };
64
+ },
65
+ },
66
+ };
67
+ const result = await waitForSafeNotificationTarget(client, "ses-flip", {
68
+ attempts: 4,
69
+ delayMs: 1,
70
+ });
71
+ expect(result).toBe("safe");
72
+ expect(call).toBe(2);
73
+ });
74
+
75
+ it("fails open when the client cannot report a title", async () => {
76
+ expect(
77
+ await waitForSafeNotificationTarget({}, "ses-no-api", { attempts: 2, delayMs: 1 }),
78
+ ).toBe("safe");
79
+ const throwing = {
80
+ session: {
81
+ get: async () => {
82
+ throw new Error("transport down");
83
+ },
84
+ },
85
+ };
86
+ expect(
87
+ await waitForSafeNotificationTarget(throwing, "ses-throw", { attempts: 2, delayMs: 1 }),
88
+ ).toBe("safe");
89
+ // Direct-shape response (no `.data` wrapper) is also recognized.
90
+ const direct = {
91
+ session: { get: async () => ({ title: "Real title" }) },
92
+ };
93
+ expect(
94
+ await waitForSafeNotificationTarget(direct, "ses-direct", { attempts: 2, delayMs: 1 }),
95
+ ).toBe("safe");
96
+ });
97
+ });