@cortexkit/opencode-magic-context 0.18.0 → 0.20.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 (120) hide show
  1. package/README.md +2 -2
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/features/magic-context/compaction-marker.d.ts +17 -0
  4. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  5. package/dist/features/magic-context/compartment-storage.d.ts +11 -0
  6. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  7. package/dist/features/magic-context/dreamer/lease.d.ts +1 -0
  8. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  11. package/dist/features/magic-context/key-files/aft-availability.d.ts +11 -0
  12. package/dist/features/magic-context/key-files/aft-availability.d.ts.map +1 -0
  13. package/dist/features/magic-context/key-files/identify-key-files.d.ts +45 -0
  14. package/dist/features/magic-context/key-files/identify-key-files.d.ts.map +1 -1
  15. package/dist/features/magic-context/key-files/project-key-files.d.ts +42 -0
  16. package/dist/features/magic-context/key-files/project-key-files.d.ts.map +1 -0
  17. package/dist/features/magic-context/key-files/read-history.d.ts +26 -0
  18. package/dist/features/magic-context/key-files/read-history.d.ts.map +1 -0
  19. package/dist/features/magic-context/memory/embedding-identity.d.ts +11 -0
  20. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -0
  21. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  22. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  23. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  24. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  25. package/dist/features/magic-context/overflow-detection.d.ts +1 -1
  26. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  27. package/dist/features/magic-context/storage-db.d.ts +1 -0
  28. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  29. package/dist/features/magic-context/storage-meta-persisted.d.ts +64 -0
  30. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  31. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  32. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  33. package/dist/features/magic-context/storage.d.ts +1 -1
  34. package/dist/features/magic-context/storage.d.ts.map +1 -1
  35. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  36. package/dist/hooks/auto-update-checker/cache.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/boundary-execution.d.ts +24 -0
  38. package/dist/hooks/magic-context/boundary-execution.d.ts.map +1 -0
  39. package/dist/hooks/magic-context/cache-busting-signals.d.ts +10 -0
  40. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -0
  41. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  42. package/dist/hooks/magic-context/compaction-marker-manager.d.ts +50 -0
  43. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  44. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +1 -1
  46. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/compartment-runner-types.d.ts +16 -7
  50. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/compartment-runner.d.ts +7 -2
  52. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  53. package/dist/hooks/magic-context/event-handler.d.ts +1 -0
  54. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/historian-state-file.d.ts +25 -11
  56. package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/hook-handlers.d.ts +11 -4
  58. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/inject-compartments.d.ts +2 -1
  61. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  62. package/dist/hooks/magic-context/key-files-block.d.ts +27 -0
  63. package/dist/hooks/magic-context/key-files-block.d.ts.map +1 -0
  64. package/dist/hooks/magic-context/live-session-state.d.ts +3 -1
  65. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  66. package/dist/hooks/magic-context/read-session-db.d.ts +2 -0
  67. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  68. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +10 -4
  70. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/transform-context-state.d.ts +5 -2
  72. package/dist/hooks/magic-context/transform-context-state.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +15 -1
  74. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/transform.d.ts +3 -0
  76. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  77. package/dist/index.js +2316 -1112
  78. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  79. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  80. package/dist/plugin/sidebar-snapshot-cache.d.ts.map +1 -1
  81. package/dist/shared/conflict-detector.d.ts +49 -0
  82. package/dist/shared/conflict-detector.d.ts.map +1 -1
  83. package/dist/shared/conflict-fixer.d.ts +1 -1
  84. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  85. package/dist/shared/data-path.d.ts +84 -0
  86. package/dist/shared/data-path.d.ts.map +1 -1
  87. package/dist/shared/logger.d.ts +6 -0
  88. package/dist/shared/logger.d.ts.map +1 -1
  89. package/dist/shared/rpc-client.d.ts +2 -1
  90. package/dist/shared/rpc-client.d.ts.map +1 -1
  91. package/dist/shared/rpc-notifications.d.ts +3 -2
  92. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  93. package/dist/shared/rpc-server.d.ts +3 -0
  94. package/dist/shared/rpc-server.d.ts.map +1 -1
  95. package/dist/shared/rpc-types.d.ts +1 -0
  96. package/dist/shared/rpc-types.d.ts.map +1 -1
  97. package/dist/shared/rpc-utils.d.ts +13 -2
  98. package/dist/shared/rpc-utils.d.ts.map +1 -1
  99. package/dist/shared/stable-json.d.ts +21 -0
  100. package/dist/shared/stable-json.d.ts.map +1 -0
  101. package/dist/shared/transcript.d.ts +2 -2
  102. package/dist/tui/data/context-db.d.ts.map +1 -1
  103. package/package.json +1 -1
  104. package/src/shared/conflict-detector.ts +4 -4
  105. package/src/shared/conflict-fixer.test.ts +124 -0
  106. package/src/shared/conflict-fixer.ts +34 -28
  107. package/src/shared/data-path.test.ts +38 -0
  108. package/src/shared/data-path.ts +99 -0
  109. package/src/shared/logger.ts +29 -3
  110. package/src/shared/rpc-client.test.ts +161 -0
  111. package/src/shared/rpc-client.ts +82 -22
  112. package/src/shared/rpc-notifications.test.ts +20 -0
  113. package/src/shared/rpc-notifications.ts +9 -6
  114. package/src/shared/rpc-server.ts +42 -4
  115. package/src/shared/rpc-types.ts +1 -0
  116. package/src/shared/rpc-utils.ts +59 -3
  117. package/src/shared/stable-json.test.ts +87 -0
  118. package/src/shared/stable-json.ts +37 -0
  119. package/src/shared/transcript.ts +2 -2
  120. package/src/tui/data/context-db.ts +20 -1
@@ -8,12 +8,14 @@
8
8
  */
9
9
 
10
10
  export interface RpcNotification {
11
+ id: number;
11
12
  type: string;
12
13
  payload: Record<string, unknown>;
13
14
  sessionId?: string;
14
15
  }
15
16
 
16
17
  let queue: RpcNotification[] = [];
18
+ let nextNotificationId = 1;
17
19
  // Timestamp of last drain — used to detect if TUI is actively polling.
18
20
  // The TUI polls every 500ms; we consider it connected if it polled within
19
21
  // the last 3 seconds (6× the poll interval, tolerates transient delays).
@@ -26,20 +28,21 @@ export function pushNotification(
26
28
  payload: Record<string, unknown>,
27
29
  sessionId?: string,
28
30
  ): void {
29
- queue.push({ type, payload, sessionId });
31
+ queue.push({ id: nextNotificationId++, type, payload, sessionId });
30
32
  // Cap queue size to prevent unbounded growth if TUI is not polling
31
33
  if (queue.length > 100) {
32
34
  queue = queue.slice(-50);
33
35
  }
34
36
  }
35
37
 
36
- /** Drain and return all pending notifications atomically.
38
+ /** Return pending notifications after acking the client's last received id.
37
39
  * Updates lastDrainAt so isTuiConnected() reflects recent activity. */
38
- export function drainNotifications(): RpcNotification[] {
40
+ export function drainNotifications(lastReceivedId = 0): RpcNotification[] {
39
41
  lastDrainAt = Date.now();
40
- const result = queue;
41
- queue = [];
42
- return result;
42
+ if (lastReceivedId > 0) {
43
+ queue = queue.filter((notification) => notification.id > lastReceivedId);
44
+ }
45
+ return [...queue];
43
46
  }
44
47
 
45
48
  /** Whether a TUI client is actively polling for notifications.
@@ -1,8 +1,15 @@
1
- import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
2
9
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
10
  import { dirname } from "node:path";
4
11
  import { log } from "./logger";
5
- import { rpcPortFilePath } from "./rpc-utils";
12
+ import { isPidAlive, parseRpcPortFile, rpcPortDir, rpcPortFilePath } from "./rpc-utils";
6
13
 
7
14
  type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
8
15
 
@@ -11,9 +18,12 @@ export class MagicContextRpcServer {
11
18
  private port = 0;
12
19
  private handlers = new Map<string, RpcHandler>();
13
20
  private portFilePath: string;
21
+ private portDir: string;
22
+ private startedAt = Date.now();
14
23
 
15
24
  constructor(storageDir: string, directory: string) {
16
25
  this.portFilePath = rpcPortFilePath(storageDir, directory);
26
+ this.portDir = rpcPortDir(storageDir, directory);
17
27
  }
18
28
 
19
29
  /** Register an RPC method handler. */
@@ -40,12 +50,24 @@ export class MagicContextRpcServer {
40
50
  this.port = addr.port;
41
51
  this.server = server;
42
52
 
43
- // Write port file atomically
53
+ // Write a per-process port file atomically. Multi-instance
54
+ // OpenCode is supported: TUI discovery scans all live pid files
55
+ // and picks the most recent instead of cross-wiring via one
56
+ // shared project file.
44
57
  try {
58
+ this.warnIfOtherLiveInstance();
45
59
  const dir = dirname(this.portFilePath);
46
60
  mkdirSync(dir, { recursive: true });
47
61
  const tmpPath = `${this.portFilePath}.tmp`;
48
- writeFileSync(tmpPath, String(this.port), "utf-8");
62
+ writeFileSync(
63
+ tmpPath,
64
+ JSON.stringify({
65
+ port: this.port,
66
+ pid: process.pid,
67
+ started_at: this.startedAt,
68
+ }),
69
+ "utf-8",
70
+ );
49
71
  renameSync(tmpPath, this.portFilePath);
50
72
  log(`[rpc] server listening on 127.0.0.1:${this.port}`);
51
73
  } catch (err) {
@@ -60,6 +82,22 @@ export class MagicContextRpcServer {
60
82
  });
61
83
  }
62
84
 
85
+ private warnIfOtherLiveInstance(): void {
86
+ try {
87
+ for (const entry of readdirSync(this.portDir)) {
88
+ if (!entry.startsWith("port-") || !entry.endsWith(".json")) continue;
89
+ const record = parseRpcPortFile(readFileSync(`${this.portDir}/${entry}`, "utf-8"));
90
+ if (!record || record.pid === process.pid || !isPidAlive(record.pid)) continue;
91
+ log(
92
+ `[rpc] another Magic Context RPC server is active for this project (pid ${record.pid}, port ${record.port}); starting separate instance on a new port`,
93
+ );
94
+ return;
95
+ }
96
+ } catch {
97
+ // No discovery directory yet, or unreadable stale file. Not fatal.
98
+ }
99
+ }
100
+
63
101
  /** Stop the server and clean up port file. */
64
102
  stop(): void {
65
103
  if (this.server) {
@@ -84,6 +84,7 @@ export interface StatusDetail extends SidebarSnapshot {
84
84
  }
85
85
 
86
86
  export interface RpcNotificationMessage {
87
+ id: number;
87
88
  type: string;
88
89
  payload: Record<string, unknown>;
89
90
  sessionId?: string;
@@ -1,6 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
 
4
+ export interface RpcPortFileRecord {
5
+ port: number;
6
+ pid: number;
7
+ started_at: number;
8
+ }
9
+
4
10
  /**
5
11
  * Stable hash for a project directory — scopes RPC port files per-project
6
12
  * so multiple OpenCode instances don't collide.
@@ -10,7 +16,57 @@ export function projectHash(directory: string): string {
10
16
  return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
11
17
  }
12
18
 
13
- /** Per-project RPC port file path. */
14
- export function rpcPortFilePath(storageDir: string, directory: string): string {
15
- return join(storageDir, "rpc", projectHash(directory), "port");
19
+ /** Directory containing per-process RPC discovery files for a project. */
20
+ export function rpcPortDir(storageDir: string, directory: string): string {
21
+ return join(storageDir, "rpc", projectHash(directory));
22
+ }
23
+
24
+ /** Per-process RPC port file path. */
25
+ export function rpcPortFilePath(storageDir: string, directory: string, pid = process.pid): string {
26
+ return join(rpcPortDir(storageDir, directory), `port-${pid}.json`);
27
+ }
28
+
29
+ /** Legacy single-port file used by v0.18.0 and earlier. */
30
+ export function legacyRpcPortFilePath(storageDir: string, directory: string): string {
31
+ return join(rpcPortDir(storageDir, directory), "port");
32
+ }
33
+
34
+ export function isPidAlive(pid: number): boolean {
35
+ if (!Number.isInteger(pid) || pid <= 0) return false;
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ } catch (err) {
40
+ return (err as NodeJS.ErrnoException).code === "EPERM";
41
+ }
42
+ }
43
+
44
+ export function parseRpcPortFile(content: string, fallbackPid = 0): RpcPortFileRecord | null {
45
+ const trimmed = content.trim();
46
+ if (!trimmed) return null;
47
+
48
+ if (trimmed.startsWith("{")) {
49
+ try {
50
+ const parsed = JSON.parse(trimmed) as Partial<RpcPortFileRecord>;
51
+ const port = Number(parsed.port);
52
+ const pid = Number(parsed.pid);
53
+ const startedAt = Number(parsed.started_at);
54
+ if (!isValidPort(port) || !Number.isInteger(pid) || pid <= 0) return null;
55
+ return {
56
+ port,
57
+ pid,
58
+ started_at: Number.isFinite(startedAt) ? startedAt : 0,
59
+ };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ const port = Number.parseInt(trimmed, 10);
66
+ if (!isValidPort(port)) return null;
67
+ return { port, pid: fallbackPid, started_at: 0 };
68
+ }
69
+
70
+ function isValidPort(port: number): boolean {
71
+ return Number.isInteger(port) && port > 0 && port <= 65535;
16
72
  }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { stableStringify } from "./stable-json";
3
+
4
+ describe("stableStringify", () => {
5
+ test("primitive values match JSON.stringify", () => {
6
+ expect(stableStringify("hello")).toBe('"hello"');
7
+ expect(stableStringify(42)).toBe("42");
8
+ expect(stableStringify(true)).toBe("true");
9
+ expect(stableStringify(false)).toBe("false");
10
+ expect(stableStringify(null)).toBe("null");
11
+ });
12
+
13
+ test("undefined renders as literal string", () => {
14
+ expect(stableStringify(undefined)).toBe("undefined");
15
+ });
16
+
17
+ test("object keys sort by code-point order, not locale", () => {
18
+ // 'Z' (0x5a) sorts before 'a' (0x61) by code-point.
19
+ // localeCompare would sort 'a' before 'Z' in many locales.
20
+ // We want code-point semantics.
21
+ const input = { Z: 1, a: 2 };
22
+ expect(stableStringify(input)).toBe('{"Z":1,"a":2}');
23
+ });
24
+
25
+ test("nested objects sort recursively", () => {
26
+ const input = { b: { y: 1, x: 2 }, a: { z: 3, w: 4 } };
27
+ expect(stableStringify(input)).toBe('{"a":{"w":4,"z":3},"b":{"x":2,"y":1}}');
28
+ });
29
+
30
+ test("arrays preserve order", () => {
31
+ const input = [3, 1, 2];
32
+ expect(stableStringify(input)).toBe("[3,1,2]");
33
+ });
34
+
35
+ test("arrays of objects sort keys per element", () => {
36
+ const input = [
37
+ { b: 1, a: 2 },
38
+ { d: 3, c: 4 },
39
+ ];
40
+ expect(stableStringify(input)).toBe('[{"a":2,"b":1},{"c":4,"d":3}]');
41
+ });
42
+
43
+ test("identical objects with different key insertion order produce same string", () => {
44
+ const a = { foo: 1, bar: 2 };
45
+ const b = { bar: 2, foo: 1 };
46
+ expect(stableStringify(a)).toBe(stableStringify(b));
47
+ });
48
+
49
+ test("circular references render as marker, do not throw", () => {
50
+ const a: Record<string, unknown> = { x: 1 };
51
+ a.self = a;
52
+ expect(stableStringify(a)).toBe('{"self":"[Circular]","x":1}');
53
+ });
54
+
55
+ test("mixed cycle through array does not crash", () => {
56
+ const arr: unknown[] = [];
57
+ arr.push(arr);
58
+ expect(stableStringify(arr)).toBe('["[Circular]"]');
59
+ });
60
+
61
+ test("empty object and array", () => {
62
+ expect(stableStringify({})).toBe("{}");
63
+ expect(stableStringify([])).toBe("[]");
64
+ });
65
+
66
+ test("special string characters JSON-escaped in keys", () => {
67
+ const input = { 'with "quotes"': 1 };
68
+ expect(stableStringify(input)).toBe('{"with \\"quotes\\"":1}');
69
+ });
70
+
71
+ test("Unicode key sort by code-point, not by collation", () => {
72
+ // 'ä' (U+00E4) sorts AFTER 'z' (U+007A) by code-point.
73
+ // localeCompare in many locales would put 'ä' near 'a'.
74
+ const input = { z: 1, ä: 2 };
75
+ const result = stableStringify(input);
76
+ expect(result).toBe('{"z":1,"ä":2}');
77
+ });
78
+
79
+ test("deterministic across multiple calls", () => {
80
+ const input = { c: 3, a: 1, b: 2 };
81
+ const first = stableStringify(input);
82
+ const second = stableStringify(input);
83
+ const third = stableStringify(input);
84
+ expect(first).toBe(second);
85
+ expect(second).toBe(third);
86
+ });
87
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Process-local deterministic JSON serialization for JSON-like plain
3
+ * objects. Keys are sorted by code-point order (NOT locale-sensitive).
4
+ *
5
+ * Contract:
6
+ * - Stable for plain objects, arrays, primitives, and `null`.
7
+ * - `undefined` serialized as the string "undefined".
8
+ * - Circular references serialized as the string `"[Circular]"`.
9
+ * - **NOT** a canonical cross-runtime / cross-locale JSON serializer.
10
+ * Two different runtimes that disagree on `JSON.stringify` of primitives
11
+ * (none known today) would produce different output.
12
+ *
13
+ * Used for:
14
+ * - `tool_definition_measurements` fingerprint hashing
15
+ * - `pending_compaction_marker_state` CAS comparison
16
+ *
17
+ * If a future use case needs true canonical JSON (e.g. cross-process
18
+ * signing), build a separate utility — do NOT widen this contract.
19
+ */
20
+ export function stableStringify(value: unknown, seen = new WeakSet<object>()): string {
21
+ if (value === undefined) return "undefined";
22
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? String(value);
23
+ if (seen.has(value)) return '"[Circular]"';
24
+ seen.add(value);
25
+ if (Array.isArray(value)) {
26
+ return `[${value.map((item) => stableStringify(item, seen)).join(",")}]`;
27
+ }
28
+ // Code-point sort (NOT localeCompare). Stable across runtimes/locales.
29
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => {
30
+ if (a < b) return -1;
31
+ if (a > b) return 1;
32
+ return 0;
33
+ });
34
+ return `{${entries
35
+ .map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child, seen)}`)
36
+ .join(",")}}`;
37
+ }
@@ -22,7 +22,7 @@
22
22
  * 3. Lets the shared transform code (tagging, stripping, drops)
23
23
  * operate on `TranscriptPart` interface instances without caring
24
24
  * whether they're wrapping `Part` from `@opencode-ai/sdk` or
25
- * `TextContent | ToolCall | ThinkingContent` from `@mariozechner/pi-ai`.
25
+ * `TextContent | ToolCall | ThinkingContent` from `@earendil-works/pi-ai`.
26
26
  *
27
27
  * What this interface deliberately does NOT do:
28
28
  *
@@ -181,7 +181,7 @@ export interface TranscriptMessage {
181
181
  * messages-transform.ts, Pi's context-event handler). The shared
182
182
  * transform code receives a Transcript and operates only through this
183
183
  * interface — it never imports from `@opencode-ai/sdk` or
184
- * `@mariozechner/pi-ai`.
184
+ * `@earendil-works/pi-ai`.
185
185
  */
186
186
  export interface Transcript {
187
187
  /** Ordered messages in the current pass. */
@@ -10,6 +10,7 @@ import type { RpcNotificationMessage, SidebarSnapshot, StatusDetail } from "../.
10
10
  export type { SidebarSnapshot, StatusDetail };
11
11
 
12
12
  let rpcClient: MagicContextRpcClient | null = null;
13
+ let lastReceivedNotificationId = 0;
13
14
 
14
15
  function getStorageDir(): string {
15
16
  // Plugin v0.16+ uses the shared cortexkit/magic-context path so OpenCode
@@ -30,6 +31,7 @@ export function initRpcClient(directory: string): void {
30
31
  export function closeRpc(): void {
31
32
  rpcClient?.reset();
32
33
  rpcClient = null;
34
+ lastReceivedNotificationId = 0;
33
35
  }
34
36
 
35
37
  const EMPTY_SNAPSHOT: SidebarSnapshot = {
@@ -103,9 +105,19 @@ function recallSidebarSnapshot(sessionId: string, fallback: SidebarSnapshot): Si
103
105
  stickySidebarCache.delete(sessionId);
104
106
  return fallback;
105
107
  }
108
+ if (!hasInFlightEvidence(fallback)) {
109
+ stickySidebarCache.delete(sessionId);
110
+ return fallback;
111
+ }
106
112
  return cached.snapshot;
107
113
  }
108
114
 
115
+ function hasInFlightEvidence(snapshot: SidebarSnapshot): boolean {
116
+ return (
117
+ snapshot.compartmentInProgress || snapshot.historianRunning || snapshot.pendingOpsCount > 0
118
+ );
119
+ }
120
+
109
121
  /** Fetch sidebar snapshot from the server via RPC. */
110
122
  export async function loadSidebarSnapshot(
111
123
  sessionId: string,
@@ -225,8 +237,15 @@ export async function consumeTuiMessages(): Promise<TuiMessage[]> {
225
237
  try {
226
238
  const result = await rpcClient.call<{ messages: RpcNotificationMessage[] }>(
227
239
  "pending-notifications",
240
+ { lastReceivedId: lastReceivedNotificationId },
228
241
  );
229
- return (result.messages ?? []).map((m) => ({
242
+ const messages = result.messages ?? [];
243
+ for (const message of messages) {
244
+ if (message.id > lastReceivedNotificationId) {
245
+ lastReceivedNotificationId = message.id;
246
+ }
247
+ }
248
+ return messages.map((m) => ({
230
249
  type: m.type,
231
250
  payload: m.payload,
232
251
  sessionId: m.sessionId,