@cortexkit/opencode-magic-context 0.22.4 → 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 (186) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/magic-context-prompt.d.ts +1 -1
  3. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  4. package/dist/agents/permissions.d.ts +4 -4
  5. package/dist/agents/permissions.d.ts.map +1 -1
  6. package/dist/config/index.d.ts.map +1 -1
  7. package/dist/config/project-security.d.ts +30 -0
  8. package/dist/config/project-security.d.ts.map +1 -0
  9. package/dist/config/prune-config-leaf.d.ts +27 -0
  10. package/dist/config/prune-config-leaf.d.ts.map +1 -0
  11. package/dist/config/schema/magic-context.d.ts +0 -13
  12. package/dist/config/schema/magic-context.d.ts.map +1 -1
  13. package/dist/config/variable.d.ts.map +1 -1
  14. package/dist/features/magic-context/compartment-storage.d.ts +9 -6
  15. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/scheduler.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
  19. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  20. package/dist/features/magic-context/key-files/read-stats.d.ts +0 -2
  21. package/dist/features/magic-context/key-files/read-stats.d.ts.map +1 -1
  22. package/dist/features/magic-context/memory/constants.d.ts +7 -0
  23. package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
  24. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  25. package/dist/features/magic-context/memory/embedding-ssrf.d.ts +29 -0
  26. package/dist/features/magic-context/memory/embedding-ssrf.d.ts.map +1 -0
  27. package/dist/features/magic-context/memory/project-identity.d.ts +10 -0
  28. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  29. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  30. package/dist/features/magic-context/project-docs-hash.d.ts.map +1 -1
  31. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  32. package/dist/features/magic-context/range-parser.d.ts +6 -0
  33. package/dist/features/magic-context/range-parser.d.ts.map +1 -1
  34. package/dist/features/magic-context/storage-db.d.ts +1 -1
  35. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  36. package/dist/features/magic-context/storage-meta-persisted.d.ts +124 -16
  37. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  38. package/dist/features/magic-context/storage-meta-shared.d.ts +9 -1
  39. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  40. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  41. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  42. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  43. package/dist/features/magic-context/storage-tags.d.ts +118 -1
  44. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  45. package/dist/features/magic-context/storage.d.ts +3 -3
  46. package/dist/features/magic-context/storage.d.ts.map +1 -1
  47. package/dist/features/magic-context/tagger.d.ts +12 -2
  48. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  49. package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -1
  50. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  51. package/dist/features/magic-context/types.d.ts +8 -0
  52. package/dist/features/magic-context/types.d.ts.map +1 -1
  53. package/dist/hooks/auto-update-checker/cache.d.ts.map +1 -1
  54. package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -1
  55. package/dist/hooks/auto-update-checker/semver.d.ts +17 -0
  56. package/dist/hooks/auto-update-checker/semver.d.ts.map +1 -0
  57. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  58. package/dist/hooks/magic-context/channel2-delivery.d.ts +22 -0
  59. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -0
  60. package/dist/hooks/magic-context/command-handler.d.ts +1 -7
  61. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  62. package/dist/hooks/magic-context/compaction-marker-manager.d.ts +1 -1
  63. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  64. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  66. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/compartment-runner-types.d.ts +5 -0
  68. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/compartment-runner-validation.d.ts +25 -0
  70. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  72. package/dist/hooks/magic-context/compartment-trigger.d.ts +47 -2
  73. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  74. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +117 -0
  75. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -0
  76. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/drop-stale-reduce-calls.d.ts +36 -1
  78. package/dist/hooks/magic-context/drop-stale-reduce-calls.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/emergency-drop.d.ts +86 -0
  80. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -0
  81. package/dist/hooks/magic-context/event-handler.d.ts +6 -4
  82. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  83. package/dist/hooks/magic-context/execute-flush.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/execute-status.d.ts +1 -1
  85. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +10 -3
  87. package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/hook-handlers.d.ts +3 -9
  89. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/hook.d.ts +3 -5
  91. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  92. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/note-visibility.d.ts +1 -1
  94. package/dist/hooks/magic-context/protected-tail-boundary.d.ts +132 -0
  95. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -0
  96. package/dist/hooks/magic-context/read-session-chunk.d.ts +55 -0
  97. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  99. package/dist/hooks/magic-context/read-session-raw.d.ts +91 -0
  100. package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts +70 -0
  102. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -0
  103. package/dist/hooks/magic-context/reference-retrieval.d.ts.map +1 -1
  104. package/dist/hooks/magic-context/send-session-notification.d.ts +2 -1
  105. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  106. package/dist/hooks/magic-context/system-prompt-hash.d.ts +0 -1
  107. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  108. package/dist/hooks/magic-context/tag-messages.d.ts +3 -0
  109. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  110. package/dist/hooks/magic-context/todo-view.d.ts +1 -1
  111. package/dist/hooks/magic-context/tool-drop-target.d.ts +9 -0
  112. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +15 -0
  114. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  115. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +7 -10
  116. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  117. package/dist/hooks/magic-context/transform.d.ts +14 -9
  118. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  119. package/dist/hooks/magic-context/upgrade-reminder.d.ts +2 -1
  120. package/dist/hooks/magic-context/upgrade-reminder.d.ts.map +1 -1
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +5682 -1281
  123. package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
  124. package/dist/plugin/embedding-bootstrap-helpers.d.ts.map +1 -1
  125. package/dist/plugin/hooks/create-session-hooks.d.ts +1 -1
  126. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  127. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  128. package/dist/plugin/tool-registry.d.ts.map +1 -1
  129. package/dist/shared/announcement.d.ts +4 -6
  130. package/dist/shared/announcement.d.ts.map +1 -1
  131. package/dist/shared/live-server-client.d.ts +50 -0
  132. package/dist/shared/live-server-client.d.ts.map +1 -0
  133. package/dist/shared/prompt-context.d.ts +31 -0
  134. package/dist/shared/prompt-context.d.ts.map +1 -0
  135. package/dist/shared/rpc-server.d.ts.map +1 -1
  136. package/dist/shared/rpc-types.d.ts +0 -3
  137. package/dist/shared/rpc-types.d.ts.map +1 -1
  138. package/dist/shared/safe-notification-target.d.ts +23 -0
  139. package/dist/shared/safe-notification-target.d.ts.map +1 -0
  140. package/dist/shared/tag-transcript.d.ts.map +1 -1
  141. package/dist/shared/transcript-opencode.d.ts.map +1 -1
  142. package/dist/shared/transcript.d.ts +15 -1
  143. package/dist/shared/transcript.d.ts.map +1 -1
  144. package/dist/tools/ctx-expand/constants.d.ts +1 -1
  145. package/dist/tools/ctx-expand/constants.d.ts.map +1 -1
  146. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  147. package/dist/tools/ctx-memory/constants.d.ts +1 -1
  148. package/dist/tools/ctx-memory/constants.d.ts.map +1 -1
  149. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  150. package/dist/tools/ctx-memory/types.d.ts +7 -3
  151. package/dist/tools/ctx-memory/types.d.ts.map +1 -1
  152. package/dist/tools/ctx-note/constants.d.ts +1 -1
  153. package/dist/tools/ctx-note/constants.d.ts.map +1 -1
  154. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  155. package/dist/tools/ctx-note/types.d.ts +4 -0
  156. package/dist/tools/ctx-note/types.d.ts.map +1 -1
  157. package/dist/tools/ctx-search/constants.d.ts +1 -1
  158. package/dist/tools/ctx-search/constants.d.ts.map +1 -1
  159. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  160. package/dist/tui/data/context-db.d.ts.map +1 -1
  161. package/package.json +2 -1
  162. package/src/shared/announcement.test.ts +18 -0
  163. package/src/shared/announcement.ts +35 -20
  164. package/src/shared/live-server-client.ts +152 -0
  165. package/src/shared/prompt-context.ts +135 -0
  166. package/src/shared/rpc-server.ts +18 -2
  167. package/src/shared/rpc-types.ts +0 -3
  168. package/src/shared/safe-notification-target.test.ts +97 -0
  169. package/src/shared/safe-notification-target.ts +102 -0
  170. package/src/shared/tag-transcript.test.ts +34 -8
  171. package/src/shared/tag-transcript.ts +110 -8
  172. package/src/shared/transcript-opencode.ts +15 -5
  173. package/src/shared/transcript.ts +20 -2
  174. package/src/tui/data/context-db.ts +0 -3
  175. package/src/tui/index.tsx +11 -10
  176. package/src/tui/slots/sidebar-content.tsx +1 -26
  177. package/dist/hooks/magic-context/apply-context-nudge.d.ts +0 -5
  178. package/dist/hooks/magic-context/apply-context-nudge.d.ts.map +0 -1
  179. package/dist/hooks/magic-context/nudge-bands.d.ts +0 -6
  180. package/dist/hooks/magic-context/nudge-bands.d.ts.map +0 -1
  181. package/dist/hooks/magic-context/nudge-injection.d.ts +0 -7
  182. package/dist/hooks/magic-context/nudge-injection.d.ts.map +0 -1
  183. package/dist/hooks/magic-context/nudge-placement-store.d.ts +0 -15
  184. package/dist/hooks/magic-context/nudge-placement-store.d.ts.map +0 -1
  185. package/dist/hooks/magic-context/nudger.d.ts +0 -21
  186. package/dist/hooks/magic-context/nudger.d.ts.map +0 -1
@@ -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
+ });
@@ -0,0 +1,102 @@
1
+ import { log } from "./logger";
2
+
3
+ /**
4
+ * Guard for ignored-message notification posting: only post into sessions
5
+ * that already carry a REAL title.
6
+ *
7
+ * Why: OpenCode's title generation (SessionPrompt.ensureTitle) silently and
8
+ * PERMANENTLY skips a session once it contains more than one non-synthetic
9
+ * user message. Our notification posts (config warnings, conflict warnings,
10
+ * schema-fence warnings, startup announcements) are `ignored: true` — hidden
11
+ * from the LLM — but NOT `synthetic: true`, so the title gate counts them as
12
+ * real user messages. A notification landing in a fresh session before the
13
+ * user's first prompt therefore suppressed that session's title forever
14
+ * (issue #129; only repros where the new session is the project's only one,
15
+ * e.g. fresh non-git directories).
16
+ *
17
+ * We deliberately do NOT mark our posts `synthetic: true` instead: the
18
+ * Desktop renderer (UserMessageDisplay) picks the first NON-synthetic text
19
+ * part, so synthetic would blank the message on Desktop — the only surface
20
+ * these posts exist for.
21
+ *
22
+ * ensureTitle short-circuits on `!isDefaultTitle(session.title)` before the
23
+ * message count, so posting into an already-titled session can never affect
24
+ * titling. Callers must NOT mark a notification as delivered when this guard
25
+ * returns "skip", so the next startup retries.
26
+ */
27
+
28
+ /**
29
+ * Mirrors OpenCode's Session.isDefaultTitle (session.ts): a default title is
30
+ * `New session - <ISO>` or `Child session - <ISO>`.
31
+ */
32
+ const DEFAULT_TITLE_RE =
33
+ /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
34
+
35
+ export function isDefaultSessionTitle(title: string): boolean {
36
+ return DEFAULT_TITLE_RE.test(title);
37
+ }
38
+
39
+ /**
40
+ * Read a session's current title via the SDK client. Returns null when the
41
+ * title cannot be determined (missing API, transport error, unexpected
42
+ * shape) — callers treat that as "fail open" and post, preserving delivery
43
+ * on clients/tests that don't expose session.get.
44
+ */
45
+ async function readSessionTitle(client: unknown, sessionId: string): Promise<string | null> {
46
+ try {
47
+ const c = client as {
48
+ session?: { get?: (input: unknown) => unknown };
49
+ };
50
+ if (typeof c.session?.get !== "function") return null;
51
+ const raw = await Promise.resolve(c.session.get({ path: { id: sessionId } }));
52
+ // SDK response shapes vary across versions: `{ data: { title } }` or
53
+ // the session object directly.
54
+ const obj = raw as { data?: { title?: unknown }; title?: unknown } | null;
55
+ const title = obj && typeof obj === "object" ? (obj.data?.title ?? obj.title) : undefined;
56
+ return typeof title === "string" ? title : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export interface SafeTargetOptions {
63
+ /** Total title checks before giving up (default 4). */
64
+ attempts?: number;
65
+ /** Delay between checks in ms (default 15s). */
66
+ delayMs?: number;
67
+ }
68
+
69
+ /**
70
+ * Resolve whether `sessionId` is safe to receive an ignored-message post.
71
+ *
72
+ * - "safe": the session has a real (non-default) title, or the title is
73
+ * unreadable (fail-open).
74
+ * - "skip": the session still has OpenCode's default title after all
75
+ * attempts — posting now could permanently suppress its title generation.
76
+ * The caller must leave its delivered/seen marker unset so a later
77
+ * startup retries.
78
+ *
79
+ * The retry window exists for the common startup case: plugin init fires a
80
+ * few seconds after launch, the user prompts shortly after, and the title
81
+ * lands within seconds of that first prompt.
82
+ */
83
+ export async function waitForSafeNotificationTarget(
84
+ client: unknown,
85
+ sessionId: string,
86
+ options?: SafeTargetOptions,
87
+ ): Promise<"safe" | "skip"> {
88
+ const attempts = Math.max(1, options?.attempts ?? 4);
89
+ const delayMs = options?.delayMs ?? 15_000;
90
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
91
+ const title = await readSessionTitle(client, sessionId);
92
+ if (title === null) return "safe";
93
+ if (!isDefaultSessionTitle(title)) return "safe";
94
+ if (attempt < attempts - 1) {
95
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
96
+ }
97
+ }
98
+ log(
99
+ `[magic-context] notification skipped: session ${sessionId} still has its default title (would suppress title generation); will retry on a later startup`,
100
+ );
101
+ return "skip";
102
+ }
@@ -159,7 +159,12 @@ describe("tagTranscript tool aggregation", () => {
159
159
  commit() {},
160
160
  };
161
161
 
162
- const { targets } = tagTranscript("session-1", transcript, tagger, {} as ContextDatabase);
162
+ const { targets } = tagTranscript(
163
+ "session-1",
164
+ transcript,
165
+ tagger,
166
+ new FakeDb() as unknown as ContextDatabase,
167
+ );
163
168
 
164
169
  const firstTag = tagger.getToolTag("session-1", "read:32", "assistant-1");
165
170
  const secondTag = tagger.getToolTag("session-1", "read:32", "assistant-2");
@@ -185,7 +190,12 @@ describe("tagTranscript tool aggregation", () => {
185
190
  commit() {},
186
191
  };
187
192
 
188
- const { targets } = tagTranscript("session-1", transcript, tagger, {} as ContextDatabase);
193
+ const { targets } = tagTranscript(
194
+ "session-1",
195
+ transcript,
196
+ tagger,
197
+ new FakeDb() as unknown as ContextDatabase,
198
+ );
189
199
  const tag = tagger.getToolTag("session-1", "read:99", "assistant-1");
190
200
 
191
201
  let result: "truncated" | "absent" | undefined;
@@ -210,7 +220,12 @@ describe("tagTranscript tool aggregation", () => {
210
220
  commit() {},
211
221
  };
212
222
 
213
- const { targets } = tagTranscript("session-1", transcript, tagger, {} as ContextDatabase);
223
+ const { targets } = tagTranscript(
224
+ "session-1",
225
+ transcript,
226
+ tagger,
227
+ new FakeDb() as unknown as ContextDatabase,
228
+ );
214
229
  const tag = tagger.getToolTag("session-1", "read:multi", "assistant-1");
215
230
 
216
231
  expect(targets.size).toBe(1);
@@ -235,7 +250,12 @@ describe("tagTranscript tool aggregation", () => {
235
250
  commit() {},
236
251
  };
237
252
 
238
- const { targets } = tagTranscript("session-1", transcript, tagger, {} as ContextDatabase);
253
+ const { targets } = tagTranscript(
254
+ "session-1",
255
+ transcript,
256
+ tagger,
257
+ new FakeDb() as unknown as ContextDatabase,
258
+ );
239
259
 
240
260
  const olderTag = tagger.getToolTag("session-1", "read:reused", "assistant-old");
241
261
  const nearestTag = tagger.getToolTag("session-1", "read:reused", "assistant-near");
@@ -272,9 +292,15 @@ describe("tagTranscript tool aggregation", () => {
272
292
 
273
293
  const tag = tagger.getToolTag("session-1", "read:image", "assistant-1");
274
294
  expect(tag).toBeDefined();
275
- expect(tagger.byteSizes.get(tag ?? -1)).toBe(2);
276
- expect(db.byteSizeUpdates).toHaveLength(1);
277
- expect(db.byteSizeUpdates[0]?.tagNumber).toBe(tag);
278
- expect(db.byteSizeUpdates[0]?.byteSize).toBeGreaterThan(512);
295
+ // byte_size is OUTPUT-only: the tag reserves at 0 on the tool_use
296
+ // occurrence (args live in inputByteSize), then updates to the real
297
+ // result payload size when the tool_result (incl. the non-text image
298
+ // block) is seen — proving non-text content is byte-accounted.
299
+ expect(tagger.byteSizes.get(tag ?? -1)).toBe(0);
300
+ // Two tool_result blocks (caption text + image) under one callId; the
301
+ // tag byte_size climbs to the LARGEST output block (the image > 512B).
302
+ const updatesForTag = db.byteSizeUpdates.filter((u) => u.tagNumber === tag);
303
+ expect(updatesForTag.length).toBeGreaterThanOrEqual(1);
304
+ expect(Math.max(...updatesForTag.map((u) => u.byteSize))).toBeGreaterThan(512);
279
305
  });
280
306
  });