@wolfx/opencode-magic-context 0.28.0 → 0.30.3

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 (108) 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/config/schema/magic-context.d.ts +11 -0
  4. package/dist/config/schema/magic-context.d.ts.map +1 -1
  5. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  6. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +0 -1
  7. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts +10 -0
  9. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -3
  11. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  12. package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
  13. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  14. package/dist/features/magic-context/dreamer/task-registry.d.ts +0 -1
  15. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
  16. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  17. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  18. package/dist/features/magic-context/recursive-text-splitter.d.ts +36 -0
  19. package/dist/features/magic-context/recursive-text-splitter.d.ts.map +1 -0
  20. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  21. package/dist/features/magic-context/storage-db.d.ts +2 -21
  22. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  23. package/dist/features/magic-context/storage-schema-helpers.d.ts +30 -0
  24. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -0
  25. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  26. package/dist/features/magic-context/types.d.ts +12 -1
  27. package/dist/features/magic-context/types.d.ts.map +1 -1
  28. package/dist/hooks/magic-context/apply-operations.d.ts +8 -1
  29. package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
  30. package/dist/hooks/magic-context/channel2-delivery.d.ts +9 -5
  31. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  32. package/dist/hooks/magic-context/edit-marker.d.ts +11 -0
  33. package/dist/hooks/magic-context/edit-marker.d.ts.map +1 -0
  34. package/dist/hooks/magic-context/event-handler.d.ts +1 -4
  35. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  36. package/dist/hooks/magic-context/hook.d.ts +1 -2
  37. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/supersession-reclaim.d.ts +34 -0
  40. package/dist/hooks/magic-context/supersession-reclaim.d.ts.map +1 -0
  41. package/dist/hooks/magic-context/system-prompt-hash.d.ts +5 -0
  42. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/tag-messages.d.ts +8 -0
  44. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/tool-drop-target.d.ts +2 -0
  46. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -0
  48. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/transform.d.ts +4 -0
  50. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3587 -5086
  53. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  54. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  55. package/dist/plugin/tool-registry.d.ts.map +1 -1
  56. package/dist/shared/announcement.d.ts +1 -1
  57. package/dist/shared/announcement.d.ts.map +1 -1
  58. package/dist/shared/commit-detection.d.ts +29 -0
  59. package/dist/shared/commit-detection.d.ts.map +1 -0
  60. package/dist/shared/data-path.d.ts.map +1 -1
  61. package/dist/shared/exit-abort-registry.d.ts +25 -0
  62. package/dist/shared/exit-abort-registry.d.ts.map +1 -0
  63. package/dist/shared/harness-provider-map.d.ts +30 -0
  64. package/dist/shared/harness-provider-map.d.ts.map +1 -0
  65. package/dist/shared/rpc-client.d.ts +8 -0
  66. package/dist/shared/rpc-client.d.ts.map +1 -1
  67. package/dist/shared/rpc-notifications.d.ts +28 -10
  68. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  69. package/dist/shared/rpc-server.d.ts +22 -3
  70. package/dist/shared/rpc-server.d.ts.map +1 -1
  71. package/dist/shared/tag-transcript.d.ts.map +1 -1
  72. package/dist/shared/transcript.d.ts +15 -0
  73. package/dist/shared/transcript.d.ts.map +1 -1
  74. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  75. package/dist/tui/badge-contrast.d.ts +37 -22
  76. package/dist/tui/badge-contrast.d.ts.map +1 -1
  77. package/dist/tui/data/context-db.d.ts +4 -14
  78. package/dist/tui/data/context-db.d.ts.map +1 -1
  79. package/dist/tui/data/notification-socket.d.ts +39 -0
  80. package/dist/tui/data/notification-socket.d.ts.map +1 -0
  81. package/package.json +78 -77
  82. package/src/shared/announcement.ts +2 -3
  83. package/src/shared/commit-detection.test.ts +63 -0
  84. package/src/shared/commit-detection.ts +53 -0
  85. package/src/shared/data-path.test.ts +28 -0
  86. package/src/shared/data-path.ts +5 -0
  87. package/src/shared/exit-abort-registry.test.ts +50 -0
  88. package/src/shared/exit-abort-registry.ts +46 -0
  89. package/src/shared/harness-provider-map.test.ts +63 -0
  90. package/src/shared/harness-provider-map.ts +56 -0
  91. package/src/shared/rpc-client.ts +14 -0
  92. package/src/shared/rpc-notifications.test.ts +68 -11
  93. package/src/shared/rpc-notifications.ts +75 -36
  94. package/src/shared/rpc-server.ts +249 -150
  95. package/src/shared/tag-transcript.ts +32 -0
  96. package/src/shared/transcript-opencode.ts +33 -0
  97. package/src/shared/transcript.ts +17 -0
  98. package/src/tui/badge-contrast.test.ts +39 -1
  99. package/src/tui/badge-contrast.ts +63 -25
  100. package/src/tui/data/context-db.ts +10 -64
  101. package/src/tui/data/notification-socket.ts +229 -0
  102. package/src/tui/index.tsx +68 -118
  103. package/src/tui/slots/sidebar-content.tsx +2 -2
  104. package/dist/hooks/is-anthropic-provider.d.ts +0 -2
  105. package/dist/hooks/is-anthropic-provider.d.ts.map +0 -1
  106. package/dist/shared/live-server-client.d.ts +0 -50
  107. package/dist/shared/live-server-client.d.ts.map +0 -1
  108. package/src/shared/live-server-client.ts +0 -152
package/package.json CHANGED
@@ -1,79 +1,80 @@
1
1
  {
2
- "name": "@wolfx/opencode-magic-context",
3
- "version": "0.28.0",
4
- "type": "module",
5
- "description": "OpenCode plugin for Magic Context — cross-session memory and context management",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "author": "ualtinok",
9
- "license": "MIT",
10
- "keywords": [
11
- "opencode",
12
- "plugin",
13
- "context",
14
- "memory",
15
- "ai",
16
- "llm",
17
- "prompt-caching",
18
- "session-history"
19
- ],
20
- "repository": {
21
- "type": "git",
22
- "url": "https://github.com/cortexkit/magic-context",
23
- "directory": "packages/plugin"
24
- },
25
- "files": [
26
- "dist",
27
- "src/tui",
28
- "src/shared",
29
- "README.md"
30
- ],
31
- "scripts": {
32
- "build": "bun build src/index.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external bun:sqlite --external node:sqlite && tsc --emitDeclarationOnly",
33
- "typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
34
- "test": "bun test",
35
- "lint": "biome check .",
36
- "lint:fix": "biome check --write .",
37
- "format": "biome format --write .",
38
- "format:check": "biome format .",
39
- "clean": "rm -rf dist",
40
- "prepublishOnly": "bun run build"
41
- },
42
- "dependencies": {
43
- "@jitl/quickjs-singlefile-cjs-release-asyncify": "0.32.0",
44
- "@opencode-ai/plugin": "^1.15.13",
45
- "@opencode-ai/sdk": "^1.15.13",
46
- "@opentui/core": "^0.4.2",
47
- "@opentui/solid": "^0.4.2",
48
- "ai-tokenizer": "^1.0.6",
49
- "comment-json": "^4.2.5",
50
- "quickjs-emscripten": "^0.32.0",
51
- "solid-js": "1.9.12",
52
- "zod": "^4.1.8"
53
- },
54
- "devDependencies": {
55
- "@biomejs/biome": "^2.4.7",
56
- "@types/better-sqlite3": "^7.6.13",
57
- "@types/bun": "^1.3.10",
58
- "@types/node": "^22.0.0",
59
- "bun-types": "^1.3.10",
60
- "typescript": "^5.8.0"
61
- },
62
- "exports": {
63
- ".": {
64
- "types": "./dist/index.d.ts",
65
- "import": "./dist/index.js"
66
- },
67
- "./tui": {
68
- "types": "./src/tui/index.tsx",
69
- "import": "./src/tui/index.tsx"
70
- }
71
- },
72
- "oc-plugin": [
73
- "server",
74
- "tui"
75
- ],
76
- "peerDependencies": {
77
- "@opencode-ai/plugin": ">=1.15.0"
78
- }
2
+ "name": "@wolfx/opencode-magic-context",
3
+ "version": "0.30.3",
4
+ "type": "module",
5
+ "description": "OpenCode plugin for Magic Context — cross-session memory and context management",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "author": "ualtinok",
9
+ "license": "MIT",
10
+ "keywords": [
11
+ "opencode",
12
+ "plugin",
13
+ "context",
14
+ "memory",
15
+ "ai",
16
+ "llm",
17
+ "prompt-caching",
18
+ "session-history"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/cortexkit/magic-context",
23
+ "directory": "packages/plugin"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src/tui",
28
+ "src/shared",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @huggingface/transformers --external onnxruntime-web --external bun:sqlite --external node:sqlite && tsc --emitDeclarationOnly",
33
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
34
+ "test": "bun test",
35
+ "lint": "biome check .",
36
+ "lint:fix": "biome check --write .",
37
+ "format": "biome format --write .",
38
+ "format:check": "biome format .",
39
+ "clean": "rm -rf dist",
40
+ "prepublishOnly": "bun run build"
41
+ },
42
+ "dependencies": {
43
+ "@huggingface/transformers": "^4.1.0",
44
+ "@jitl/quickjs-singlefile-cjs-release-asyncify": "0.32.0",
45
+ "@opencode-ai/plugin": "^1.17.11",
46
+ "@opencode-ai/sdk": "^1.17.11",
47
+ "@opentui/core": "^0.4.2",
48
+ "@opentui/solid": "^0.4.2",
49
+ "ai-tokenizer": "^1.0.6",
50
+ "comment-json": "^5.0.0",
51
+ "quickjs-emscripten": "^0.32.0",
52
+ "solid-js": "1.9.12",
53
+ "zod": "^4.1.8"
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "^2.5.1",
57
+ "@types/better-sqlite3": "^7.6.13",
58
+ "@types/bun": "^1.3.10",
59
+ "@types/node": "^22.20.0",
60
+ "bun-types": "^1.3.10",
61
+ "typescript": "^5.8.0"
62
+ },
63
+ "exports": {
64
+ ".": {
65
+ "types": "./dist/index.d.ts",
66
+ "import": "./dist/index.js"
67
+ },
68
+ "./tui": {
69
+ "types": "./src/tui/index.tsx",
70
+ "import": "./src/tui/index.tsx"
71
+ }
72
+ },
73
+ "oc-plugin": [
74
+ "server",
75
+ "tui"
76
+ ],
77
+ "peerDependencies": {
78
+ "@opencode-ai/plugin": ">=1.15.0"
79
+ }
79
80
  }
@@ -37,15 +37,14 @@ import { getMagicContextStorageDir } from "./data-path";
37
37
  * Bump only when there are user-visible changes worth a startup dialog.
38
38
  * Does NOT need to match the published package version.
39
39
  */
40
- export const ANNOUNCEMENT_VERSION = "0.28.0";
40
+ export const ANNOUNCEMENT_VERSION = "0.30.2";
41
41
 
42
42
  /**
43
43
  * Short, user-facing bullet strings. Keep each line ~80 chars or shorter so the
44
44
  * TUI dialog renders cleanly without horizontal scroll on a typical terminal.
45
45
  */
46
46
  export const ANNOUNCEMENT_FEATURES: ReadonlyArray<string> = [
47
- 'New \'language\' option: set a top-level 2-letter language code (e.g. "tr" or "es") and Magic Context writes its summaries, memories, and guidance in that language, while keeping all structure (tags, categories, code, paths) in English. User-level only, off by default.',
48
- "The ctx_reduce reminder now reflects how much tool output is actually reclaimable, instead of escalating to 'urgent' just because you're near compaction. It also no longer suggests dropping the agent's task list or tiny status outputs.",
47
+ "Fixed high idle CPU from the TUI sidebar (#200): it now uses a single persistent connection to the plugin instead of polling, so an idle session no longer burns CPU.",
49
48
  ];
50
49
 
51
50
  /**
@@ -0,0 +1,63 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ COMMIT_HASH_TEST_PATTERN,
6
+ COMMIT_VERB_PATTERN,
7
+ createCommitHashExtractPattern,
8
+ textMentionsRecentCommit,
9
+ } from "./commit-detection";
10
+
11
+ describe("textMentionsRecentCommit", () => {
12
+ it("fires on a hash + commit-action verb in the same text", () => {
13
+ expect(textMentionsRecentCommit("Committed abc1234 with the fix")).toBe(true);
14
+ expect(textMentionsRecentCommit("merged a1b2c3d into main")).toBe(true);
15
+ expect(textMentionsRecentCommit("rebased onto feedb4d cleanly")).toBe(true);
16
+ expect(textMentionsRecentCommit("cherry-picked deadbeef")).toBe(true);
17
+ });
18
+
19
+ it("does NOT fire on a hash alone, or the bare word 'hash'/'sha' + hex", () => {
20
+ expect(textMentionsRecentCommit("the value is abc1234")).toBe(false);
21
+ // 'hash'/'sha' are intentionally NOT commit-action verbs (parity contract).
22
+ expect(textMentionsRecentCommit("hash is abc1234567")).toBe(false);
23
+ expect(textMentionsRecentCommit("sha abc1234")).toBe(false);
24
+ });
25
+
26
+ it("does NOT fire on a verb alone (no hash)", () => {
27
+ expect(textMentionsRecentCommit("I will commit later")).toBe(false);
28
+ });
29
+
30
+ it("respects the 7-12 hex length bound", () => {
31
+ expect(textMentionsRecentCommit("committed abc12")).toBe(false); // too short
32
+ expect(textMentionsRecentCommit("committed abc1234567890abcdef")).toBe(false); // too long
33
+ });
34
+
35
+ it("does not match commit-ish words that are not commit actions", () => {
36
+ expect(COMMIT_VERB_PATTERN.test("commitment to abc1234")).toBe(false);
37
+ expect(COMMIT_VERB_PATTERN.test("a merger of abc1234")).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe("shared regex instances are stateless across calls", () => {
42
+ it("COMMIT_HASH_TEST_PATTERN.test is repeatable (non-global)", () => {
43
+ expect(COMMIT_HASH_TEST_PATTERN.test("abc1234")).toBe(true);
44
+ expect(COMMIT_HASH_TEST_PATTERN.test("abc1234")).toBe(true); // no lastIndex drift
45
+ });
46
+
47
+ it("createCommitHashExtractPattern returns a fresh global regex each call", () => {
48
+ const a = createCommitHashExtractPattern();
49
+ const b = createCommitHashExtractPattern();
50
+ expect(a).not.toBe(b);
51
+ expect(a.global).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("createCommitHashExtractPattern (historian extraction)", () => {
56
+ it("captures backtick-wrapped and bare hashes, deduped via matchAll", () => {
57
+ const text = "Committed `abc1234` and def5678, also abc1234 again";
58
+ const found = [...text.matchAll(createCommitHashExtractPattern())].map((m) =>
59
+ m[1]?.toLowerCase(),
60
+ );
61
+ expect(found).toEqual(["abc1234", "def5678", "abc1234"]);
62
+ });
63
+ });
@@ -0,0 +1,53 @@
1
+ // Canonical commit-detection patterns — the SINGLE source of truth shared by:
2
+ // - the historian commit-cluster trigger + summary hash extraction
3
+ // (read-session-formatting.ts),
4
+ // - the OpenCode note-nudge `commit_detected` boundary (tag-messages.ts),
5
+ // - the Pi note-nudge detector (detect-recent-commit.ts).
6
+ //
7
+ // These three previously each carried their own hash/verb regexes that had
8
+ // drifted (hash length 6 vs 7; verb sets {hash, sha} vs {merge, rebas}). Keeping
9
+ // them here stops that drift: change the patterns once and every site follows.
10
+ //
11
+ // All three sites look for a short git hash AND a commit-related word in the
12
+ // SAME assistant text part — the pairing is what keeps false positives low.
13
+
14
+ /** A short git hash: 7-12 hex chars (git's default abbreviated hash is 7).
15
+ * 6 was too loose — more random-hex false positives. */
16
+ const HASH_HEX = "[0-9a-f]{7,12}";
17
+
18
+ /**
19
+ * Boolean hash test. Non-global (stateless), so this single instance is safe to
20
+ * reuse with `.test()` across call sites — only `/g` regexes carry `lastIndex`.
21
+ */
22
+ export const COMMIT_HASH_TEST_PATTERN = new RegExp(`\\b${HASH_HEX}\\b`, "i");
23
+
24
+ /**
25
+ * Commit-ACTION verbs, with common inflections, each fully word-boundary-anchored
26
+ * (so they don't match e.g. "commitment"/"merger"). Non-global → safe to share.
27
+ *
28
+ * Scope decision: this is the commit-action set the OpenCode + Pi note-nudge
29
+ * detectors used and pin in tests ("commit/cherry-pick/merge/rebase"). It does
30
+ * NOT include the bare nouns "hash"/"sha" that the historian's old hint regex
31
+ * carried — a parity test asserts "hash <hex>" alone must NOT count as a commit,
32
+ * and those nouns only ever gated a cosmetic hash-strip in historian summaries
33
+ * (never a trigger), so unifying to the action set is behavior-preserving where
34
+ * it matters.
35
+ */
36
+ export const COMMIT_VERB_PATTERN =
37
+ /\b(?:commit(?:ted|ting|s)?|cherry-?pick(?:ed|ing|s)?|merge[ds]?|merging|rebas(?:e|ed|es|ing))\b/i;
38
+
39
+ /** True when a text part mentions a commit hash in a commit context. Used by the
40
+ * OpenCode + Pi note-nudge detectors. */
41
+ export function textMentionsRecentCommit(text: string): boolean {
42
+ return COMMIT_HASH_TEST_PATTERN.test(text) && COMMIT_VERB_PATTERN.test(text);
43
+ }
44
+
45
+ /**
46
+ * Fresh `/g` capturing, backtick-aware hash pattern for the historian's
47
+ * extract-and-strip path (matchAll + replace). Returned as a NEW instance per
48
+ * call: a `/g` regex carries `lastIndex`, so handing out a fresh one is
49
+ * bulletproof against accidental `.exec()` reuse across callers.
50
+ */
51
+ export function createCommitHashExtractPattern(): RegExp {
52
+ return new RegExp(`\`?\\b(${HASH_HEX})\\b\`?`, "gi");
53
+ }
@@ -7,6 +7,7 @@ import {
7
7
  getCacheDir,
8
8
  getDataDir,
9
9
  getLegacyOpenCodeMagicContextStorageDir,
10
+ getMagicContextLogPath,
10
11
  getMagicContextStorageDir,
11
12
  getOpenCodeCacheDir,
12
13
  getOpenCodeStorageDir,
@@ -18,6 +19,7 @@ const savedEnv = {
18
19
  XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
19
20
  XDG_DATA_HOME: process.env.XDG_DATA_HOME,
20
21
  LOCALAPPDATA: process.env.LOCALAPPDATA,
22
+ MAGIC_CONTEXT_LOG_PATH: process.env.MAGIC_CONTEXT_LOG_PATH,
21
23
  };
22
24
 
23
25
  describe("data-path", () => {
@@ -25,10 +27,12 @@ describe("data-path", () => {
25
27
  process.env.XDG_CACHE_HOME = undefined;
26
28
  process.env.XDG_DATA_HOME = undefined;
27
29
  process.env.LOCALAPPDATA = undefined;
30
+ process.env.MAGIC_CONTEXT_LOG_PATH = undefined;
28
31
  // Bun's env handling: explicit delete for unset
29
32
  delete process.env.XDG_CACHE_HOME;
30
33
  delete process.env.XDG_DATA_HOME;
31
34
  delete process.env.LOCALAPPDATA;
35
+ delete process.env.MAGIC_CONTEXT_LOG_PATH;
32
36
  });
33
37
 
34
38
  afterEach(() => {
@@ -37,6 +41,9 @@ describe("data-path", () => {
37
41
  if (savedEnv.XDG_DATA_HOME !== undefined)
38
42
  process.env.XDG_DATA_HOME = savedEnv.XDG_DATA_HOME;
39
43
  if (savedEnv.LOCALAPPDATA !== undefined) process.env.LOCALAPPDATA = savedEnv.LOCALAPPDATA;
44
+ if (savedEnv.MAGIC_CONTEXT_LOG_PATH !== undefined)
45
+ process.env.MAGIC_CONTEXT_LOG_PATH = savedEnv.MAGIC_CONTEXT_LOG_PATH;
46
+ else delete process.env.MAGIC_CONTEXT_LOG_PATH;
40
47
  });
41
48
 
42
49
  test("getCacheDir falls back to <homedir>/.cache when XDG_CACHE_HOME is unset (all platforms)", () => {
@@ -158,6 +165,27 @@ describe("data-path", () => {
158
165
  path.join("/some/project/", ".cortexkit", "magic-context"),
159
166
  );
160
167
  });
168
+
169
+ test("getMagicContextLogPath falls back to the harness temp dir when the env override is unset", () => {
170
+ expect(getMagicContextLogPath("opencode")).toBe(
171
+ path.join(os.tmpdir(), "opencode", "magic-context", "magic-context.log"),
172
+ );
173
+ expect(getMagicContextLogPath("pi")).toBe(
174
+ path.join(os.tmpdir(), "pi", "magic-context", "magic-context.log"),
175
+ );
176
+ });
177
+
178
+ test("getMagicContextLogPath honors MAGIC_CONTEXT_LOG_PATH", () => {
179
+ process.env.MAGIC_CONTEXT_LOG_PATH = "/tmp/custom/magic-context.log";
180
+ expect(getMagicContextLogPath("pi")).toBe("/tmp/custom/magic-context.log");
181
+ });
182
+
183
+ test("getMagicContextLogPath ignores a blank MAGIC_CONTEXT_LOG_PATH", () => {
184
+ process.env.MAGIC_CONTEXT_LOG_PATH = " ";
185
+ expect(getMagicContextLogPath("pi")).toBe(
186
+ path.join(os.tmpdir(), "pi", "magic-context", "magic-context.log"),
187
+ );
188
+ });
161
189
  });
162
190
 
163
191
  describe("ensureCortexKitArtifactGitignore", () => {
@@ -46,6 +46,11 @@ export function getMagicContextTempDir(harness: HarnessId = getHarness()): strin
46
46
  * reflected in the next flush.
47
47
  */
48
48
  export function getMagicContextLogPath(harness: HarnessId = getHarness()): string {
49
+ // An explicit override wins over the harness temp-dir default, so users on
50
+ // sandboxed/ephemeral setups (Docker, CI) can point the diagnostic log at a
51
+ // persistent or shared path. Blank/whitespace is treated as unset.
52
+ const envPath = process.env.MAGIC_CONTEXT_LOG_PATH?.trim();
53
+ if (envPath) return envPath;
49
54
  return path.join(getMagicContextTempDir(harness), "magic-context.log");
50
55
  }
51
56
 
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { registerExitAbort, unregisterExitAbort } from "./exit-abort-registry";
3
+
4
+ // Captured before any registration so we can isolate the ONE listener the
5
+ // registry installs process-wide (it intentionally never removes it, mirroring
6
+ // production, so the suite must not strip it either).
7
+ const baseline = process.listenerCount("exit");
8
+
9
+ /** The registry's single 'exit' listener (the first one added past baseline). */
10
+ function registryListener(): () => void {
11
+ return process.listeners("exit").slice(baseline)[0] as () => void;
12
+ }
13
+
14
+ describe("exit-abort-registry", () => {
15
+ it("adds exactly ONE process exit listener no matter how many controllers register", () => {
16
+ registerExitAbort(new AbortController());
17
+ registerExitAbort(new AbortController());
18
+ registerExitAbort(new AbortController());
19
+ expect(process.listenerCount("exit") - baseline).toBe(1);
20
+ });
21
+
22
+ it("aborts every registered controller when the exit listener fires", () => {
23
+ const a = new AbortController();
24
+ const b = new AbortController();
25
+ registerExitAbort(a);
26
+ registerExitAbort(b);
27
+
28
+ // Invoke the registry's listener directly (emitting 'exit' would end the
29
+ // test process).
30
+ registryListener()();
31
+
32
+ expect(a.signal.aborted).toBe(true);
33
+ expect(b.signal.aborted).toBe(true);
34
+ // Still exactly one listener after firing.
35
+ expect(process.listenerCount("exit") - baseline).toBe(1);
36
+ });
37
+
38
+ it("does not abort a controller that was unregistered before exit", () => {
39
+ const keep = new AbortController();
40
+ const drop = new AbortController();
41
+ registerExitAbort(keep);
42
+ registerExitAbort(drop);
43
+ unregisterExitAbort(drop);
44
+
45
+ registryListener()();
46
+
47
+ expect(keep.signal.aborted).toBe(true);
48
+ expect(drop.signal.aborted).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Process-global registry of AbortControllers to abort on process exit, backed
3
+ * by a SINGLE `process.once("exit")` listener no matter how many controllers
4
+ * register.
5
+ *
6
+ * Why this exists: the plugin factory runs once per plugin instance, and
7
+ * OpenCode Desktop loads many instances in one Node process (one per open
8
+ * project). Registering a `process.once("exit")` per instance added one listener
9
+ * each, so past Node's default 10-listener cap it logged a
10
+ * `MaxListenersExceededWarning` ("11 exit listeners added to [process]"). One
11
+ * module-global listener that fans out to every registered controller keeps the
12
+ * count at one.
13
+ */
14
+
15
+ const controllers = new Set<AbortController>();
16
+ let listenerRegistered = false;
17
+
18
+ function abortAll(): void {
19
+ for (const controller of controllers) {
20
+ try {
21
+ controller.abort();
22
+ } catch {
23
+ // best-effort: the process is exiting anyway
24
+ }
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Abort `controller` when the process exits. The underlying `process.once("exit")`
30
+ * listener is installed on the first call only; subsequent calls just add to the
31
+ * fan-out set.
32
+ */
33
+ export function registerExitAbort(controller: AbortController): void {
34
+ controllers.add(controller);
35
+ if (listenerRegistered) return;
36
+ listenerRegistered = true;
37
+ process.once("exit", abortAll);
38
+ }
39
+
40
+ /**
41
+ * Stop tracking `controller` (e.g. when its plugin instance is disposed) so the
42
+ * set doesn't grow without bound as Desktop opens and closes projects.
43
+ */
44
+ export function unregisterExitAbort(controller: AbortController): void {
45
+ controllers.delete(controller);
46
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { piModelRefToCanonical, resolveModelRefForPi } from "./harness-provider-map";
3
+
4
+ describe("harness-provider-map", () => {
5
+ describe("resolveModelRefForPi (canonical -> Pi, used when spawning)", () => {
6
+ it("maps the diverging auth-plugin providers, preserving the model id", () => {
7
+ expect(resolveModelRefForPi("openai/gpt-5.5")).toBe("openai-codex/gpt-5.5");
8
+ expect(resolveModelRefForPi("google/antigravity-gemini-3.5-flash")).toBe(
9
+ "google-antigravity/antigravity-gemini-3.5-flash",
10
+ );
11
+ });
12
+
13
+ it("leaves anthropic and every other provider unchanged", () => {
14
+ expect(resolveModelRefForPi("anthropic/claude-opus-4-8")).toBe(
15
+ "anthropic/claude-opus-4-8",
16
+ );
17
+ expect(resolveModelRefForPi("cerebras/gpt-oss-120b")).toBe("cerebras/gpt-oss-120b");
18
+ expect(resolveModelRefForPi("openrouter/openai/gpt-5.5")).toBe(
19
+ "openrouter/openai/gpt-5.5",
20
+ );
21
+ });
22
+
23
+ it("is idempotent: a config already in Pi form still resolves to Pi form", () => {
24
+ expect(resolveModelRefForPi("openai-codex/gpt-5.5")).toBe("openai-codex/gpt-5.5");
25
+ expect(resolveModelRefForPi("google-antigravity/antigravity-gemini-3.1-pro")).toBe(
26
+ "google-antigravity/antigravity-gemini-3.1-pro",
27
+ );
28
+ });
29
+
30
+ it("preserves model ids that themselves contain slashes", () => {
31
+ expect(resolveModelRefForPi("openai/some/nested/id")).toBe(
32
+ "openai-codex/some/nested/id",
33
+ );
34
+ });
35
+
36
+ it("passes through malformed refs (no slash, empty provider) unchanged", () => {
37
+ expect(resolveModelRefForPi("gpt-5.5")).toBe("gpt-5.5");
38
+ expect(resolveModelRefForPi("/gpt-5.5")).toBe("/gpt-5.5");
39
+ expect(resolveModelRefForPi("")).toBe("");
40
+ });
41
+ });
42
+
43
+ describe("piModelRefToCanonical (Pi -> canonical, used by Pi setup write)", () => {
44
+ it("normalizes Pi-native provider ids to the OpenCode form", () => {
45
+ expect(piModelRefToCanonical("openai-codex/gpt-5.5")).toBe("openai/gpt-5.5");
46
+ expect(piModelRefToCanonical("google-antigravity/antigravity-gemini-3.5-flash")).toBe(
47
+ "google/antigravity-gemini-3.5-flash",
48
+ );
49
+ });
50
+
51
+ it("leaves already-canonical and unmapped providers unchanged", () => {
52
+ expect(piModelRefToCanonical("anthropic/claude-opus-4-8")).toBe(
53
+ "anthropic/claude-opus-4-8",
54
+ );
55
+ expect(piModelRefToCanonical("openai/gpt-5.5")).toBe("openai/gpt-5.5");
56
+ });
57
+
58
+ it("round-trips with resolveModelRefForPi", () => {
59
+ const piForm = "openai-codex/gpt-5.5";
60
+ expect(resolveModelRefForPi(piModelRefToCanonical(piForm))).toBe(piForm);
61
+ });
62
+ });
63
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Provider-id translation between the canonical (OpenCode) form stored in the
3
+ * shared magic-context config and Pi's harness-native provider ids.
4
+ *
5
+ * OpenCode and Pi now share ONE config, but a few auth-plugin providers were
6
+ * named differently on each side. The model id AFTER the slash is identical;
7
+ * only the provider prefix differs:
8
+ *
9
+ * canonical (OpenCode) Pi
10
+ * -------------------- -------------------
11
+ * openai/<model> openai-codex/<model>
12
+ * google/<model> google-antigravity/<model>
13
+ * anthropic/<model> anthropic/<model> (same; every other provider too)
14
+ *
15
+ * Canonical = OpenCode: the config always stores the OpenCode form. Pi
16
+ * translates at its edges:
17
+ * - read: canonical -> Pi when spawning a configured model (subagent-runner).
18
+ * - write: Pi -> canonical in the Pi setup wizard, so a config written from
19
+ * the Pi side stays readable by OpenCode.
20
+ *
21
+ * OpenCode needs no translation (canonical IS the OpenCode form).
22
+ */
23
+
24
+ const CANONICAL_TO_PI_PROVIDER: Record<string, string> = {
25
+ openai: "openai-codex",
26
+ google: "google-antigravity",
27
+ };
28
+
29
+ const PI_TO_CANONICAL_PROVIDER: Record<string, string> = {
30
+ "openai-codex": "openai",
31
+ "google-antigravity": "google",
32
+ };
33
+
34
+ /** Remap only the provider prefix (text before the first "/"), preserving the
35
+ * model id verbatim. No "/", empty provider, or unmapped provider -> unchanged. */
36
+ function remapProviderPrefix(ref: string, map: Record<string, string>): string {
37
+ if (typeof ref !== "string") return ref;
38
+ const slash = ref.indexOf("/");
39
+ if (slash <= 0) return ref;
40
+ const provider = ref.slice(0, slash);
41
+ const mapped = map[provider];
42
+ return mapped ? `${mapped}${ref.slice(slash)}` : ref;
43
+ }
44
+
45
+ /** Pi-native `provider/model` -> canonical (OpenCode). Identity when unmapped.
46
+ * Used by the Pi setup wizard so configs it writes stay OpenCode-readable. */
47
+ export function piModelRefToCanonical(ref: string): string {
48
+ return remapProviderPrefix(ref, PI_TO_CANONICAL_PROVIDER);
49
+ }
50
+
51
+ /** Canonical (OpenCode) `provider/model` -> Pi-native, for spawning a model on
52
+ * Pi. Idempotent: normalizes any Pi-form prefix back to canonical first, so it
53
+ * is safe on a config that already holds Pi-form ids (hand-edited or pre-fix). */
54
+ export function resolveModelRefForPi(ref: string): string {
55
+ return remapProviderPrefix(piModelRefToCanonical(ref), CANONICAL_TO_PI_PROVIDER);
56
+ }
@@ -97,6 +97,20 @@ export class MagicContextRpcClient {
97
97
  }
98
98
  }
99
99
 
100
+ /** Resolve the live server's port + bearer token (for opening the WS push
101
+ * channel). Reuses the same health-checked port-file discovery as `call`,
102
+ * so the WS client and the HTTP client always agree on which server instance
103
+ * (and token) to use. Returns null when no live server is found. */
104
+ async resolveEndpoint(): Promise<{ port: number; token: string | null } | null> {
105
+ try {
106
+ const port = await this.resolvePort();
107
+ if (port === null) return null;
108
+ return { port, token: this.token };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
100
114
  private async resolvePort(): Promise<number | null> {
101
115
  if (this.port && this.healthChecked) {
102
116
  return this.port;