@neuralnomads/codenomad-dev 0.20.0-dev-20260909-855d2ee7 → 0.20.0-dev-20260911-ea37f97b

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 (177) hide show
  1. package/dist/api-types.js.map +1 -1
  2. package/dist/index.js +42 -31
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.test.js +9 -1
  5. package/dist/index.test.js.map +1 -1
  6. package/dist/opencode/pruning-installation.js +65 -0
  7. package/dist/opencode/pruning-installation.js.map +1 -0
  8. package/dist/opencode/pruning-installation.test.js +101 -0
  9. package/dist/opencode/pruning-installation.test.js.map +1 -0
  10. package/dist/opencode/pruning-lifecycle.js +66 -0
  11. package/dist/opencode/pruning-lifecycle.js.map +1 -0
  12. package/dist/opencode/session-pruning/claim-fence.js +33 -0
  13. package/dist/opencode/session-pruning/claim-fence.js.map +1 -0
  14. package/dist/opencode/session-pruning/claim-fence.test.js +104 -0
  15. package/dist/opencode/session-pruning/claim-fence.test.js.map +1 -0
  16. package/dist/opencode/session-pruning/contract.js +40 -0
  17. package/dist/opencode/session-pruning/contract.js.map +1 -0
  18. package/dist/opencode/session-pruning/database-path.js +17 -0
  19. package/dist/opencode/session-pruning/database-path.js.map +1 -0
  20. package/dist/opencode/session-pruning/database-path.test.js +22 -0
  21. package/dist/opencode/session-pruning/database-path.test.js.map +1 -0
  22. package/dist/opencode/session-pruning/desktop-plugin.js +14 -0
  23. package/dist/opencode/session-pruning/desktop-plugin.js.map +1 -0
  24. package/dist/opencode/session-pruning/index.js +2 -0
  25. package/dist/opencode/session-pruning/index.js.map +1 -0
  26. package/dist/opencode/session-pruning/isolated-store.js +18 -0
  27. package/dist/opencode/session-pruning/isolated-store.js.map +1 -0
  28. package/dist/opencode/session-pruning/planner.js +36 -0
  29. package/dist/opencode/session-pruning/planner.js.map +1 -0
  30. package/dist/opencode/session-pruning/plugin.js +45 -0
  31. package/dist/opencode/session-pruning/plugin.js.map +1 -0
  32. package/dist/opencode/session-pruning/presence.js +42 -0
  33. package/dist/opencode/session-pruning/presence.js.map +1 -0
  34. package/dist/opencode/session-pruning/preview-store.js +21 -0
  35. package/dist/opencode/session-pruning/preview-store.js.map +1 -0
  36. package/dist/opencode/session-pruning/pruning.test.js +172 -0
  37. package/dist/opencode/session-pruning/pruning.test.js.map +1 -0
  38. package/dist/opencode/session-pruning/revision.js +18 -0
  39. package/dist/opencode/session-pruning/revision.js.map +1 -0
  40. package/dist/opencode/session-pruning/service.js +56 -0
  41. package/dist/opencode/session-pruning/service.js.map +1 -0
  42. package/dist/opencode/session-pruning/service.test.js +74 -0
  43. package/dist/opencode/session-pruning/service.test.js.map +1 -0
  44. package/dist/opencode/session-pruning/storage-path.js +6 -0
  45. package/dist/opencode/session-pruning/storage-path.js.map +1 -0
  46. package/dist/opencode/session-pruning/transaction.js +53 -0
  47. package/dist/opencode/session-pruning/transaction.js.map +1 -0
  48. package/dist/opencode/session-pruning/tui-reload.js +84 -0
  49. package/dist/opencode/session-pruning/tui-reload.js.map +1 -0
  50. package/dist/opencode/session-pruning/tui-reload.native.js +269 -0
  51. package/dist/opencode/session-pruning/tui-reload.native.js.map +1 -0
  52. package/dist/opencode/session-pruning/tui-reload.test.js +71 -0
  53. package/dist/opencode/session-pruning/tui-reload.test.js.map +1 -0
  54. package/dist/opencode/session-pruning/tui.js +12 -0
  55. package/dist/opencode/session-pruning/tui.js.map +1 -0
  56. package/dist/plugins/session-pruning/plugin.mjs +26559 -0
  57. package/dist/server/__tests__/instance-proxy.test.js +10 -5
  58. package/dist/server/__tests__/instance-proxy.test.js.map +1 -1
  59. package/dist/server/__tests__/listener-base-url.test.js +26 -1
  60. package/dist/server/__tests__/listener-base-url.test.js.map +1 -1
  61. package/dist/server/__tests__/network-addresses.test.js +40 -0
  62. package/dist/server/__tests__/network-addresses.test.js.map +1 -1
  63. package/dist/server/__tests__/network-host.test.js +34 -0
  64. package/dist/server/__tests__/network-host.test.js.map +1 -0
  65. package/dist/server/__tests__/tls-ipv6.test.js +54 -0
  66. package/dist/server/__tests__/tls-ipv6.test.js.map +1 -0
  67. package/dist/server/http-server.js +8 -6
  68. package/dist/server/http-server.js.map +1 -1
  69. package/dist/server/listener-base-url.js +12 -3
  70. package/dist/server/listener-base-url.js.map +1 -1
  71. package/dist/server/network-addresses.js +20 -42
  72. package/dist/server/network-addresses.js.map +1 -1
  73. package/dist/server/network-host.js +62 -0
  74. package/dist/server/network-host.js.map +1 -0
  75. package/dist/server/routes/meta.js +2 -4
  76. package/dist/server/routes/meta.js.map +1 -1
  77. package/dist/server/routes/session-pruning.js +45 -0
  78. package/dist/server/routes/session-pruning.js.map +1 -0
  79. package/dist/server/routes/session-pruning.test.js +98 -0
  80. package/dist/server/routes/session-pruning.test.js.map +1 -0
  81. package/dist/server/tls.js +52 -32
  82. package/dist/server/tls.js.map +1 -1
  83. package/dist/workspaces/manager.js +1 -0
  84. package/dist/workspaces/manager.js.map +1 -1
  85. package/package.json +6 -3
  86. package/public/assets/App-0-FcHzPS.js +44 -0
  87. package/public/assets/App-0-FcHzPS.js.map +1 -0
  88. package/public/assets/FilesTab-CRpGDs1B.js +3 -0
  89. package/public/assets/{FilesTab-z7UrjcAQ.js.map → FilesTab-CRpGDs1B.js.map} +1 -1
  90. package/public/assets/{GitChangesTab-C04bYKhs.js → GitChangesTab-DxFebTiq.js} +3 -3
  91. package/public/assets/{GitChangesTab-C04bYKhs.js.map → GitChangesTab-DxFebTiq.js.map} +1 -1
  92. package/public/assets/{SplitFilePanel-B29MqCuU.js → SplitFilePanel-DgTMrrO-.js} +2 -2
  93. package/public/assets/{SplitFilePanel-B29MqCuU.js.map → SplitFilePanel-DgTMrrO-.js.map} +1 -1
  94. package/public/assets/{StatusTab-wUKfwnFf.js → StatusTab-LKUA9cbH.js} +2 -2
  95. package/public/assets/{StatusTab-wUKfwnFf.js.map → StatusTab-LKUA9cbH.js.map} +1 -1
  96. package/public/assets/{align-justify-glU678D9.js → align-justify-v_yPXSys.js} +2 -2
  97. package/public/assets/{align-justify-glU678D9.js.map → align-justify-v_yPXSys.js.map} +1 -1
  98. package/public/assets/{bundle-full-BrsCElYv.js → bundle-full-HzL_Jtqk.js} +2 -2
  99. package/public/assets/{bundle-full-BrsCElYv.js.map → bundle-full-HzL_Jtqk.js.map} +1 -1
  100. package/public/assets/{client-state-j9Iu7F-E.js → client-state-CF-PJ0GG.js} +2 -2
  101. package/public/assets/{client-state-j9Iu7F-E.js.map → client-state-CF-PJ0GG.js.map} +1 -1
  102. package/public/assets/{diff-viewer-B0VS1voD.js → diff-viewer-BFDQBDVy.js} +2 -2
  103. package/public/assets/{diff-viewer-B0VS1voD.js.map → diff-viewer-BFDQBDVy.js.map} +1 -1
  104. package/public/assets/index-8iPlm2r0.js +2 -0
  105. package/public/assets/index-8iPlm2r0.js.map +1 -0
  106. package/public/assets/index-B185mC7m.js +2 -0
  107. package/public/assets/index-B185mC7m.js.map +1 -0
  108. package/public/assets/index-Bbj1xF8i.js +2 -0
  109. package/public/assets/index-Bbj1xF8i.js.map +1 -0
  110. package/public/assets/index-BsmFN3k1.js +2 -0
  111. package/public/assets/index-BsmFN3k1.js.map +1 -0
  112. package/public/assets/index-Bz_OQJYA.css +1 -0
  113. package/public/assets/index-C52CfqK3.js +2 -0
  114. package/public/assets/index-C52CfqK3.js.map +1 -0
  115. package/public/assets/index-CHYmKl10.js +2 -0
  116. package/public/assets/index-CHYmKl10.js.map +1 -0
  117. package/public/assets/index-CTKYD_VJ.js +2 -0
  118. package/public/assets/index-CTKYD_VJ.js.map +1 -0
  119. package/public/assets/index-D2UHSOG5.js +2 -0
  120. package/public/assets/{index-ClaHfu-1.js.map → index-D2UHSOG5.js.map} +1 -1
  121. package/public/assets/index-DDoB_Z36.js +2 -0
  122. package/public/assets/index-DDoB_Z36.js.map +1 -0
  123. package/public/assets/{index-BzAEHHap.js → index-DcwAtZw-.js} +3 -3
  124. package/public/assets/index-DcwAtZw-.js.map +1 -0
  125. package/public/assets/index-DxUD3RYr.js +2 -0
  126. package/public/assets/index-DxUD3RYr.js.map +1 -0
  127. package/public/assets/{index-B6bX3AMT.js → index-RK9gf0uk.js} +2 -2
  128. package/public/assets/{index-B6bX3AMT.js.map → index-RK9gf0uk.js.map} +1 -1
  129. package/public/assets/{loading-CYiiwb08.js → loading-BSB82snY.js} +2 -2
  130. package/public/assets/{loading-CYiiwb08.js.map → loading-BSB82snY.js.map} +1 -1
  131. package/public/assets/main-CZztwrfv.js +48 -0
  132. package/public/assets/main-CZztwrfv.js.map +1 -0
  133. package/public/assets/{markdown-DWvSdCZz.js → markdown-DtidoL2Z.js} +4 -4
  134. package/public/assets/{markdown-DWvSdCZz.js.map → markdown-DtidoL2Z.js.map} +1 -1
  135. package/public/assets/{monaco-viewer-Cc9bWg7I.js → monaco-viewer-CpxDfB-6.js} +2 -2
  136. package/public/assets/monaco-viewer-CpxDfB-6.js.map +1 -0
  137. package/public/assets/{startup-state-settings-card-2KHpBL8U.js → startup-state-settings-card-BY741Rt0.js} +2 -2
  138. package/public/assets/{startup-state-settings-card-2KHpBL8U.js.map → startup-state-settings-card-BY741Rt0.js.map} +1 -1
  139. package/public/assets/{terminal-square-lzHuj_of.js → terminal-square-BdC_jwm3.js} +2 -2
  140. package/public/assets/{terminal-square-lzHuj_of.js.map → terminal-square-BdC_jwm3.js.map} +1 -1
  141. package/public/assets/{tool-call-DjkszeT4.js → tool-call-DbtCsJ4x.js} +10 -10
  142. package/public/assets/{tool-call-DjkszeT4.js.map → tool-call-DbtCsJ4x.js.map} +1 -1
  143. package/public/assets/{unified-picker-5JmIM6a0.js → unified-picker-C0eo1chJ.js} +2 -2
  144. package/public/assets/{unified-picker-5JmIM6a0.js.map → unified-picker-C0eo1chJ.js.map} +1 -1
  145. package/public/assets/{wrap-text-N0Cav2Vp.js → wrap-text-BuMtdvMx.js} +2 -2
  146. package/public/assets/{wrap-text-N0Cav2Vp.js.map → wrap-text-BuMtdvMx.js.map} +1 -1
  147. package/public/index.html +4 -4
  148. package/public/loading.html +4 -4
  149. package/public/sw.js +1 -1
  150. package/public/sw.js.map +1 -1
  151. package/public/assets/App-Nlt9zV-v.js +0 -43
  152. package/public/assets/App-Nlt9zV-v.js.map +0 -1
  153. package/public/assets/FilesTab-z7UrjcAQ.js +0 -3
  154. package/public/assets/index-BIRLnDWY.js +0 -2
  155. package/public/assets/index-BIRLnDWY.js.map +0 -1
  156. package/public/assets/index-BuScpzpP.js +0 -2
  157. package/public/assets/index-BuScpzpP.js.map +0 -1
  158. package/public/assets/index-BzAEHHap.js.map +0 -1
  159. package/public/assets/index-C-DZt-oz.css +0 -1
  160. package/public/assets/index-C9RXVQl9.js +0 -2
  161. package/public/assets/index-C9RXVQl9.js.map +0 -1
  162. package/public/assets/index-ClaHfu-1.js +0 -2
  163. package/public/assets/index-DBx5htn3.js +0 -2
  164. package/public/assets/index-DBx5htn3.js.map +0 -1
  165. package/public/assets/index-DHHMf3u1.js +0 -2
  166. package/public/assets/index-DHHMf3u1.js.map +0 -1
  167. package/public/assets/index-DN4Kstsd.js +0 -2
  168. package/public/assets/index-DN4Kstsd.js.map +0 -1
  169. package/public/assets/index-DbMyNDyo.js +0 -2
  170. package/public/assets/index-DbMyNDyo.js.map +0 -1
  171. package/public/assets/index-rcsmuqww.js +0 -2
  172. package/public/assets/index-rcsmuqww.js.map +0 -1
  173. package/public/assets/index-uz2QFRlk.js +0 -2
  174. package/public/assets/index-uz2QFRlk.js.map +0 -1
  175. package/public/assets/main-OmXs7qtG.js +0 -48
  176. package/public/assets/main-OmXs7qtG.js.map +0 -1
  177. package/public/assets/monaco-viewer-Cc9bWg7I.js.map +0 -1
@@ -0,0 +1,65 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile, utimes } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { PRESENCE_INTERVAL_MS } from "./session-pruning/presence";
6
+ const marker = "// Managed by CodeNomad: session pruning lifecycle v1\n";
7
+ export async function installPruningPresence(bundle, paths) {
8
+ const hash = createHash("sha256").update(bundle).digest("hex");
9
+ const directory = path.join(paths.data, "session-pruning");
10
+ const nativeDirectory = paths.nativeData ? path.posix.join(paths.nativeData, "session-pruning") : directory;
11
+ const leases = path.join(directory, "presence");
12
+ const nativeLeases = paths.nativeData ? path.posix.join(nativeDirectory, "presence") : leases;
13
+ const plugin = path.join(directory, `${hash}.mjs`);
14
+ const nativeUrl = new URL("file:///");
15
+ nativeUrl.pathname = `${nativeDirectory}/${hash}.mjs`;
16
+ const pluginUrl = paths.nativeData ? nativeUrl.href : pathToFileURL(plugin).href;
17
+ const entry = path.join(paths.config, "plugins", "codenomad-session-pruning.ts");
18
+ const source = `${marker}import { desktopPlugin } from ${JSON.stringify(pluginUrl)}\nexport default desktopPlugin(${JSON.stringify(nativeLeases)})\n`;
19
+ await mkdir(leases, { recursive: true });
20
+ await mkdir(path.dirname(entry), { recursive: true });
21
+ const existing = await readFile(entry, "utf8").catch((error) => {
22
+ if (error.code !== "ENOENT")
23
+ throw error;
24
+ return undefined;
25
+ });
26
+ if (existing && !existing.startsWith(marker))
27
+ throw new Error(`Existing plugin entry is not managed by CodeNomad: ${entry}`);
28
+ // Immutable, content-addressed payloads keep running instances independent of upgrades.
29
+ await writeFile(plugin, bundle, { flag: "wx" }).catch((error) => {
30
+ if (error.code !== "EEXIST")
31
+ throw error;
32
+ });
33
+ const lease = path.join(leases, `${randomUUID()}.lease`);
34
+ await writeFile(lease, "");
35
+ try {
36
+ if (existing !== source) {
37
+ const temporary = `${entry}.${randomUUID()}.tmp`;
38
+ try {
39
+ await writeFile(temporary, source);
40
+ await rename(temporary, entry);
41
+ }
42
+ finally {
43
+ await rm(temporary, { force: true });
44
+ }
45
+ }
46
+ }
47
+ catch (error) {
48
+ await rm(lease, { force: true });
49
+ throw error;
50
+ }
51
+ let pending = Promise.resolve();
52
+ const timer = setInterval(() => {
53
+ pending = pending.then(async () => {
54
+ const now = new Date();
55
+ await utimes(lease, now, now);
56
+ }).catch(() => { });
57
+ }, PRESENCE_INTERVAL_MS);
58
+ timer.unref();
59
+ return async () => {
60
+ clearInterval(timer);
61
+ await pending;
62
+ await rm(lease, { force: true });
63
+ };
64
+ }
65
+ //# sourceMappingURL=pruning-installation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pruning-installation.js","sourceRoot":"","sources":["../../src/opencode/pruning-installation.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash, randomUUID } from \"node:crypto\"\nimport { mkdir, readFile, rename, rm, writeFile, utimes } from \"node:fs/promises\"\nimport path from \"node:path\"\nimport { pathToFileURL } from \"node:url\"\nimport { PRESENCE_INTERVAL_MS } from \"./session-pruning/presence\"\n\nexport interface PruningPaths {\n config: string\n data: string\n // WSL uses Linux paths in the entry, UNC paths only for host filesystem I/O.\n nativeData?: string\n}\nconst marker = \"// Managed by CodeNomad: session pruning lifecycle v1\\n\"\n\nexport async function installPruningPresence(bundle: Uint8Array, paths: PruningPaths) {\n const hash = createHash(\"sha256\").update(bundle).digest(\"hex\")\n const directory = path.join(paths.data, \"session-pruning\")\n const nativeDirectory = paths.nativeData ? path.posix.join(paths.nativeData, \"session-pruning\") : directory\n const leases = path.join(directory, \"presence\")\n const nativeLeases = paths.nativeData ? path.posix.join(nativeDirectory, \"presence\") : leases\n const plugin = path.join(directory, `${hash}.mjs`)\n const nativeUrl = new URL(\"file:///\")\n nativeUrl.pathname = `${nativeDirectory}/${hash}.mjs`\n const pluginUrl = paths.nativeData ? nativeUrl.href : pathToFileURL(plugin).href\n const entry = path.join(paths.config, \"plugins\", \"codenomad-session-pruning.ts\")\n const source = `${marker}import { desktopPlugin } from ${JSON.stringify(pluginUrl)}\\nexport default desktopPlugin(${JSON.stringify(nativeLeases)})\\n`\n await mkdir(leases, { recursive: true })\n await mkdir(path.dirname(entry), { recursive: true })\n const existing = await readFile(entry, \"utf8\").catch((error: NodeJS.ErrnoException) => {\n if (error.code !== \"ENOENT\") throw error\n return undefined\n })\n if (existing && !existing.startsWith(marker)) throw new Error(`Existing plugin entry is not managed by CodeNomad: ${entry}`)\n // Immutable, content-addressed payloads keep running instances independent of upgrades.\n await writeFile(plugin, bundle, { flag: \"wx\" }).catch((error: NodeJS.ErrnoException) => {\n if (error.code !== \"EEXIST\") throw error\n })\n const lease = path.join(leases, `${randomUUID()}.lease`)\n await writeFile(lease, \"\")\n try {\n if (existing !== source) {\n const temporary = `${entry}.${randomUUID()}.tmp`\n try {\n await writeFile(temporary, source)\n await rename(temporary, entry)\n } finally { await rm(temporary, { force: true }) }\n }\n } catch (error) {\n await rm(lease, { force: true })\n throw error\n }\n let pending = Promise.resolve()\n const timer = setInterval(() => {\n pending = pending.then(async () => {\n const now = new Date()\n await utimes(lease, now, now)\n }).catch(() => {})\n }, PRESENCE_INTERVAL_MS)\n timer.unref()\n return async () => {\n clearInterval(timer)\n await pending\n await rm(lease, { force: true })\n }\n}\n"]}
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { mkdtemp, mkdir, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { installPruningPresence } from "./pruning-installation";
7
+ import { followPresence, hasPresence, PRESENCE_EXPIRY_MS, PRESENCE_INTERVAL_MS } from "./session-pruning/presence";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ test("bundled installation supports two backends, clean close, crash expiry and reopening", async () => {
10
+ const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-pruning-presence-"));
11
+ const paths = { config: path.join(root, "config"), data: path.join(root, "data") };
12
+ const leases = path.join(paths.data, "session-pruning", "presence");
13
+ let first;
14
+ let second;
15
+ let stop;
16
+ let registrations = 0;
17
+ let disposals = 0;
18
+ const check = async (predicate) => {
19
+ for (let i = 0; i < 100 && !predicate(); i++)
20
+ await delay(100);
21
+ assert(predicate());
22
+ };
23
+ try {
24
+ stop = await followPresence(leases, async () => { registrations++; return () => { disposals++; }; });
25
+ assert.equal(registrations, 0);
26
+ const bundle = Buffer.from("export const desktopPlugin = () => ({id: 'fixture'})");
27
+ first = await installPruningPresence(bundle, paths);
28
+ await check(() => registrations === 1);
29
+ const entry = path.join(paths.config, "plugins", "codenomad-session-pruning.ts");
30
+ const source = await readFile(entry, "utf8");
31
+ assert(!source.includes("node_modules"));
32
+ second = await installPruningPresence(bundle, paths);
33
+ assert.equal(await readFile(entry, "utf8"), source);
34
+ await first();
35
+ first = undefined;
36
+ await delay(PRESENCE_INTERVAL_MS + 100);
37
+ assert.equal(disposals, 0);
38
+ await second();
39
+ second = undefined;
40
+ await check(() => disposals === 1);
41
+ const stale = path.join(leases, "dead.lease");
42
+ await writeFile(stale, "");
43
+ const beforeCrash = new Date(Date.now() - PRESENCE_EXPIRY_MS - 100);
44
+ await utimes(stale, beforeCrash, beforeCrash);
45
+ assert.equal(await hasPresence(leases), false);
46
+ first = await installPruningPresence(bundle, paths);
47
+ await check(() => registrations === 2);
48
+ await stop();
49
+ stop = undefined;
50
+ assert.equal(disposals, 2);
51
+ assert.equal((await readdir(path.join(paths.data, "session-pruning"))).filter(name => name.endsWith(".mjs")).length, 1);
52
+ }
53
+ finally {
54
+ await stop?.();
55
+ await first?.();
56
+ await second?.();
57
+ await rm(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+ test("unloading while RPC registration is pending disposes it exactly once", async () => {
61
+ const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-pruning-unload-"));
62
+ let finish;
63
+ let stopped = 0;
64
+ let started = false;
65
+ const pending = new Promise(resolve => { finish = resolve; });
66
+ const cleanup = await followPresence(root, async () => {
67
+ started = true;
68
+ await pending;
69
+ return () => { stopped++; };
70
+ });
71
+ try {
72
+ await writeFile(path.join(root, "abcd.lease"), "");
73
+ for (let i = 0; i < 100 && !started; i++)
74
+ await delay(50);
75
+ assert(started);
76
+ const closing = cleanup();
77
+ finish();
78
+ await closing;
79
+ assert.equal(stopped, 1);
80
+ }
81
+ finally {
82
+ finish();
83
+ await cleanup();
84
+ await rm(root, { recursive: true, force: true });
85
+ }
86
+ });
87
+ test("installation preserves a user-authored plugin entry", async () => {
88
+ const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-pruning-existing-"));
89
+ const plugins = path.join(root, "plugins");
90
+ try {
91
+ await mkdir(plugins);
92
+ const entry = path.join(plugins, "codenomad-session-pruning.ts");
93
+ await writeFile(entry, "// user work");
94
+ await assert.rejects(installPruningPresence(Buffer.from("fixture"), { config: root, data: path.join(root, "data") }), /not managed/);
95
+ assert.equal(await readFile(entry, "utf8"), "// user work");
96
+ }
97
+ finally {
98
+ await rm(root, { recursive: true, force: true });
99
+ }
100
+ });
101
+ //# sourceMappingURL=pruning-installation.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pruning-installation.test.js","sourceRoot":"","sources":["../../src/opencode/pruning-installation.test.ts"],"names":[],"mappings":"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","sourcesContent":["import assert from \"node:assert/strict\"\nimport { test } from \"node:test\"\nimport { mkdtemp, mkdir, readFile, readdir, rm, utimes, writeFile } from \"node:fs/promises\"\nimport os from \"node:os\"\nimport path from \"node:path\"\nimport { installPruningPresence } from \"./pruning-installation\"\nimport { followPresence, hasPresence, PRESENCE_EXPIRY_MS, PRESENCE_INTERVAL_MS } from \"./session-pruning/presence\"\nimport { setTimeout as delay } from \"node:timers/promises\"\n\ntest(\"bundled installation supports two backends, clean close, crash expiry and reopening\", async () => {\n const root = await mkdtemp(path.join(os.tmpdir(), \"codenomad-pruning-presence-\"))\n const paths = { config: path.join(root, \"config\"), data: path.join(root, \"data\") }\n const leases = path.join(paths.data, \"session-pruning\", \"presence\")\n let first: (() => Promise<void>) | undefined\n let second: (() => Promise<void>) | undefined\n let stop: (() => Promise<void>) | undefined\n let registrations = 0\n let disposals = 0\n const check = async (predicate: () => boolean) => {\n for (let i = 0; i < 100 && !predicate(); i++) await delay(100)\n assert(predicate())\n }\n try {\n stop = await followPresence(leases, async () => { registrations++; return () => { disposals++ } })\n assert.equal(registrations, 0)\n const bundle = Buffer.from(\"export const desktopPlugin = () => ({id: 'fixture'})\")\n first = await installPruningPresence(bundle, paths)\n await check(() => registrations === 1)\n const entry = path.join(paths.config, \"plugins\", \"codenomad-session-pruning.ts\")\n const source = await readFile(entry, \"utf8\")\n assert(!source.includes(\"node_modules\"))\n second = await installPruningPresence(bundle, paths)\n assert.equal(await readFile(entry, \"utf8\"), source)\n await first(); first = undefined\n await delay(PRESENCE_INTERVAL_MS + 100)\n assert.equal(disposals, 0)\n await second(); second = undefined\n await check(() => disposals === 1)\n const stale = path.join(leases, \"dead.lease\")\n await writeFile(stale, \"\")\n const beforeCrash = new Date(Date.now() - PRESENCE_EXPIRY_MS - 100)\n await utimes(stale, beforeCrash, beforeCrash)\n assert.equal(await hasPresence(leases), false)\n first = await installPruningPresence(bundle, paths)\n await check(() => registrations === 2)\n await stop(); stop = undefined\n assert.equal(disposals, 2)\n assert.equal((await readdir(path.join(paths.data, \"session-pruning\"))).filter(name => name.endsWith(\".mjs\")).length, 1)\n } finally {\n await stop?.(); await first?.(); await second?.()\n await rm(root, { recursive: true, force: true })\n }\n})\n\ntest(\"unloading while RPC registration is pending disposes it exactly once\", async () => {\n const root = await mkdtemp(path.join(os.tmpdir(), \"codenomad-pruning-unload-\"))\n let finish!: () => void\n let stopped = 0\n let started = false\n const pending = new Promise<void>(resolve => { finish = resolve })\n const cleanup = await followPresence(root, async () => {\n started = true\n await pending\n return () => { stopped++ }\n })\n try {\n await writeFile(path.join(root, \"abcd.lease\"), \"\")\n for (let i = 0; i < 100 && !started; i++) await delay(50)\n assert(started)\n const closing = cleanup()\n finish()\n await closing\n assert.equal(stopped, 1)\n } finally { finish(); await cleanup(); await rm(root, { recursive: true, force: true }) }\n})\n\ntest(\"installation preserves a user-authored plugin entry\", async () => {\n const root = await mkdtemp(path.join(os.tmpdir(), \"codenomad-pruning-existing-\"))\n const plugins = path.join(root, \"plugins\")\n try {\n await mkdir(plugins)\n const entry = path.join(plugins, \"codenomad-session-pruning.ts\")\n await writeFile(entry, \"// user work\")\n await assert.rejects(installPruningPresence(Buffer.from(\"fixture\"), { config: root, data: path.join(root, \"data\") }), /not managed/)\n assert.equal(await readFile(entry, \"utf8\"), \"// user work\")\n } finally { await rm(root, { recursive: true, force: true }) }\n})\n"]}
@@ -0,0 +1,66 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { installPruningPresence } from "./pruning-installation";
7
+ const execute = promisify(execFile);
8
+ function hostPaths(env) {
9
+ const home = os.homedir();
10
+ return {
11
+ config: env.OPENCODE_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "opencode"),
12
+ data: path.join(env.XDG_DATA_HOME || path.join(home, ".local", "share"), "codenomad"),
13
+ };
14
+ }
15
+ async function wslPaths(distro, env) {
16
+ const keys = ["OPENCODE_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME"];
17
+ const { stdout } = await execute("wsl.exe", ["--distribution", distro, "--exec", "env",
18
+ ...keys.filter(key => env[key] !== undefined).map(key => `${key}=${env[key]}`),
19
+ "sh", "-c", 'printf "%s\\n%s\\n" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}" "${XDG_DATA_HOME:-$HOME/.local/share}/codenomad"',
20
+ ], { windowsHide: true, timeout: 15_000, maxBuffer: 64 * 1024 });
21
+ const [config, data] = stdout.trim().split(/\r?\n/);
22
+ if (!config?.startsWith("/") || !data?.startsWith("/"))
23
+ throw new Error("Could not resolve WSL plugin directories");
24
+ const unc = (directory) => `\\\\wsl.localhost\\${distro}${directory.replaceAll("/", "\\")}`;
25
+ return { config: unc(config), data: unc(data), nativeData: data };
26
+ }
27
+ // One backend owns one lease per daemon namespace, shared by all its windows.
28
+ // Other profiles/backends own independent leases; none owns or stops OpenCode.
29
+ export class PruningLifecycle {
30
+ constructor() {
31
+ this.installations = new Map();
32
+ this.stopped = false;
33
+ }
34
+ async start(launch, environment = {}) {
35
+ if (this.stopped)
36
+ return;
37
+ const paths = launch?.kind === "wsl" ? await wslPaths(launch.distro, environment) : hostPaths({ ...process.env, ...environment });
38
+ if (this.stopped)
39
+ return;
40
+ const key = paths.config;
41
+ if (!this.installations.has(key)) {
42
+ const installation = (async () => {
43
+ const bundle = await readFile(new URL("../plugins/session-pruning/plugin.mjs", import.meta.url))
44
+ .catch((error) => {
45
+ if (error.code !== "ENOENT")
46
+ throw error;
47
+ return readFile(new URL("../../dist/plugins/session-pruning/plugin.mjs", import.meta.url));
48
+ });
49
+ return installPruningPresence(bundle, paths);
50
+ })();
51
+ this.installations.set(key, installation);
52
+ void installation.catch(() => { if (this.installations.get(key) === installation)
53
+ this.installations.delete(key); });
54
+ }
55
+ await this.installations.get(key);
56
+ }
57
+ async stop() {
58
+ this.stopped = true;
59
+ await Promise.all([...this.installations.values()].map(async (installation) => {
60
+ const dispose = await installation.catch(() => undefined);
61
+ await dispose?.();
62
+ }));
63
+ this.installations.clear();
64
+ }
65
+ }
66
+ //# sourceMappingURL=pruning-lifecycle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pruning-lifecycle.js","sourceRoot":"","sources":["../../src/opencode/pruning-lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC7C,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAC3C,OAAO,EAAE,MAAM,SAAS,CAAA;AACxB,OAAO,IAAI,MAAM,WAAW,CAAA;AAC5B,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAErC,OAAO,EAAE,sBAAsB,EAAqB,MAAM,wBAAwB,CAAA;AAElF,MAAM,OAAO,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAA;AAEnC,SAAS,SAAS,CAAC,GAAsB;IACvC,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,EAAE,CAAA;IACzB,OAAO;QACL,MAAM,EAAE,GAAG,CAAC,mBAAmB,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,eAAe,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,EAAE,UAAU,CAAC;QAC3G,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,aAAa,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,WAAW,CAAC;KACtF,CAAA;AACH,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,MAAc,EAAE,GAAsB;IAC5D,MAAM,IAAI,GAAG,CAAC,qBAAqB,EAAE,iBAAiB,EAAE,eAAe,CAAC,CAAA;IACxE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,OAAO,CAAC,SAAS,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK;QACpF,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,SAAS,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;QAC9E,IAAI,EAAE,IAAI,EAAE,2IAA2I;KACxJ,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,GAAG,IAAI,EAAE,CAAC,CAAA;IAChE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;IACnD,IAAI,CAAC,MAAM,EAAE,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,GAAG,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAA;IACnH,MAAM,GAAG,GAAG,CAAC,SAAiB,EAAE,EAAE,CAAC,sBAAsB,MAAM,GAAG,SAAS,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,CAAA;IACnG,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,CAAA;AACnE,CAAC;AAED,8EAA8E;AAC9E,+EAA+E;AAC/E,MAAM,OAAO,gBAAgB;IAA7B;QACmB,kBAAa,GAAG,IAAI,GAAG,EAAwC,CAAA;QACxE,YAAO,GAAG,KAAK,CAAA;IA8BzB,CAAC;IA5BC,KAAK,CAAC,KAAK,CAAC,MAA0B,EAAE,cAAiC,EAAE;QACzE,IAAI,IAAI,CAAC,OAAO;YAAE,OAAM;QACxB,MAAM,KAAK,GAAG,MAAM,EAAE,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,MAAM,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,GAAG,WAAW,EAAE,CAAC,CAAA;QACjI,IAAI,IAAI,CAAC,OAAO;YAAE,OAAM;QACxB,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAA;QACxB,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YACjC,MAAM,YAAY,GAAG,CAAC,KAAK,IAAI,EAAE;gBAC/B,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,GAAG,CAAC,uCAAuC,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;qBAC7F,KAAK,CAAC,CAAC,KAA4B,EAAE,EAAE;oBACtC,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ;wBAAE,MAAM,KAAK,CAAA;oBACxC,OAAO,QAAQ,CAAC,IAAI,GAAG,CAAC,+CAA+C,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;gBAC5F,CAAC,CAAC,CAAA;gBACJ,OAAO,sBAAsB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;YAC9C,CAAC,CAAC,EAAE,CAAA;YACJ,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,GAAG,EAAE,YAAY,CAAC,CAAA;YACzC,KAAK,YAAY,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,YAAY;gBAAE,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA,CAAC,CAAC,CAAC,CAAA;QACrH,CAAC;QACD,MAAM,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IACnC,CAAC;IAED,KAAK,CAAC,IAAI;QACR,IAAI,CAAC,OAAO,GAAG,IAAI,CAAA;QACnB,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,aAAa,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,KAAK,EAAC,YAAY,EAAC,EAAE;YAC1E,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;YACzD,MAAM,OAAO,EAAE,EAAE,CAAA;QACnB,CAAC,CAAC,CAAC,CAAA;QACH,IAAI,CAAC,aAAa,CAAC,KAAK,EAAE,CAAA;IAC5B,CAAC;CACF","sourcesContent":["import { execFile } from \"node:child_process\"\nimport { readFile } from \"node:fs/promises\"\nimport os from \"node:os\"\nimport path from \"node:path\"\nimport { promisify } from \"node:util\"\nimport type { ServiceLaunchSpec } from \"../workspaces/spawn\"\nimport { installPruningPresence, type PruningPaths } from \"./pruning-installation\"\n\nconst execute = promisify(execFile)\n\nfunction hostPaths(env: NodeJS.ProcessEnv): PruningPaths {\n const home = os.homedir()\n return {\n config: env.OPENCODE_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(home, \".config\"), \"opencode\"),\n data: path.join(env.XDG_DATA_HOME || path.join(home, \".local\", \"share\"), \"codenomad\"),\n }\n}\n\nasync function wslPaths(distro: string, env: NodeJS.ProcessEnv): Promise<PruningPaths> {\n const keys = [\"OPENCODE_CONFIG_DIR\", \"XDG_CONFIG_HOME\", \"XDG_DATA_HOME\"]\n const { stdout } = await execute(\"wsl.exe\", [\"--distribution\", distro, \"--exec\", \"env\",\n ...keys.filter(key => env[key] !== undefined).map(key => `${key}=${env[key]}`),\n \"sh\", \"-c\", 'printf \"%s\\\\n%s\\\\n\" \"${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}\" \"${XDG_DATA_HOME:-$HOME/.local/share}/codenomad\"',\n ], { windowsHide: true, timeout: 15_000, maxBuffer: 64 * 1024 })\n const [config, data] = stdout.trim().split(/\\r?\\n/)\n if (!config?.startsWith(\"/\") || !data?.startsWith(\"/\")) throw new Error(\"Could not resolve WSL plugin directories\")\n const unc = (directory: string) => `\\\\\\\\wsl.localhost\\\\${distro}${directory.replaceAll(\"/\", \"\\\\\")}`\n return { config: unc(config), data: unc(data), nativeData: data }\n}\n\n// One backend owns one lease per daemon namespace, shared by all its windows.\n// Other profiles/backends own independent leases; none owns or stops OpenCode.\nexport class PruningLifecycle {\n private readonly installations = new Map<string, Promise<() => Promise<void>>>()\n private stopped = false\n\n async start(launch?: ServiceLaunchSpec, environment: NodeJS.ProcessEnv = {}): Promise<void> {\n if (this.stopped) return\n const paths = launch?.kind === \"wsl\" ? await wslPaths(launch.distro, environment) : hostPaths({ ...process.env, ...environment })\n if (this.stopped) return\n const key = paths.config\n if (!this.installations.has(key)) {\n const installation = (async () => {\n const bundle = await readFile(new URL(\"../plugins/session-pruning/plugin.mjs\", import.meta.url))\n .catch((error: NodeJS.ErrnoException) => {\n if (error.code !== \"ENOENT\") throw error\n return readFile(new URL(\"../../dist/plugins/session-pruning/plugin.mjs\", import.meta.url))\n })\n return installPruningPresence(bundle, paths)\n })()\n this.installations.set(key, installation)\n void installation.catch(() => { if (this.installations.get(key) === installation) this.installations.delete(key) })\n }\n await this.installations.get(key)\n }\n\n async stop(): Promise<void> {\n this.stopped = true\n await Promise.all([...this.installations.values()].map(async installation => {\n const dispose = await installation.catch(() => undefined)\n await dispose?.()\n }))\n this.installations.clear()\n }\n}\n"]}
@@ -0,0 +1,33 @@
1
+ import { storageDirectory } from "./storage-path";
2
+ // This is an internal storage contract, NOT a public OpenCode lock API.
3
+ // Validate the actual storage/claim state; runtime version labels are not gates.
4
+ export const PRUNING_PLUGIN_ID = "codenomad-session-pruning";
5
+ export const storageKey = (key) => `plugin:${Array.from(PRUNING_PLUGIN_ID)
6
+ .map(char => char.charCodeAt(0).toString(16).padStart(4, "0")).join("")}:${key}`;
7
+ export function validateClaimFence(db, sessionID, identity) {
8
+ if (!db.isTransaction)
9
+ throw new Error("Claim fence requires an acquired write transaction");
10
+ if (db.prepare("PRAGMA database_list").all().length !== 1)
11
+ return "unsupported_storage";
12
+ if (db.prepare("SELECT 1 FROM sqlite_schema WHERE type='trigger' LIMIT 1").get())
13
+ return "unsupported_storage";
14
+ const marker = db.prepare("SELECT value FROM kv WHERE key=?").get(identity.key);
15
+ if (marker?.value !== JSON.stringify(identity.nonce))
16
+ return "unsupported_storage";
17
+ const session = db.prepare("SELECT directory,project_id,workspace_id,time_suspended,time_compacting,revert FROM session_v2 WHERE id=?").get(sessionID);
18
+ if (!session || session.directory !== storageDirectory(identity.directory)
19
+ || session.project_id !== identity.projectID || session.workspace_id !== (identity.workspaceID ?? null))
20
+ return "not_deletable";
21
+ // Native execution must COMMIT its claim before it can enter the runner.
22
+ // BEGIN IMMEDIATE excludes that commit until our synchronous transaction ends.
23
+ // If it committed first, we observe its non-null claim and refuse the write.
24
+ // Never set/release the claim ourselves: it also drives crash recovery.
25
+ if (session.time_suspended !== null || session.time_compacting !== null || session.revert !== null)
26
+ return "maintenance_required";
27
+ const sequence = db.prepare("SELECT owner_id FROM event_sequence WHERE aggregate_id=?").get(sessionID);
28
+ if (!sequence || sequence.owner_id !== null
29
+ || db.prepare("SELECT 1 FROM event WHERE aggregate_id=? LIMIT 1").get(sessionID))
30
+ return "unsupported_storage";
31
+ return undefined;
32
+ }
33
+ //# sourceMappingURL=claim-fence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claim-fence.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/claim-fence.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEjD,wEAAwE;AACxE,iFAAiF;AACjF,MAAM,CAAC,MAAM,iBAAiB,GAAG,2BAA2B,CAAA;AAC5D,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,GAAW,EAAE,EAAE,CAAC,UAAU,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC;KAC/E,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,GAAG,EAAE,CAAA;AAYlF,MAAM,UAAU,kBAAkB,CAAC,EAAgB,EAAE,SAAiB,EAAE,QAAyB;IAC/F,IAAI,CAAC,EAAE,CAAC,aAAa;QAAE,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAA;IAC5F,IAAI,EAAE,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC,GAAG,EAAE,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,qBAA8B,CAAA;IAChG,IAAI,EAAE,CAAC,OAAO,CAAC,0DAA0D,CAAC,CAAC,GAAG,EAAE;QAAE,OAAO,qBAA8B,CAAA;IACvH,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CAAC,kCAAkC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA;IAC/E,IAAI,MAAM,EAAE,KAAK,KAAK,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,qBAA8B,CAAA;IAC3F,MAAM,OAAO,GAAG,EAAE,CAAC,OAAO,CAAC,2GAA2G,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;IACtJ,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,SAAS,KAAK,gBAAgB,CAAC,QAAQ,CAAC,SAAS,CAAC;WACrE,OAAO,CAAC,UAAU,KAAK,QAAQ,CAAC,SAAS,IAAI,OAAO,CAAC,YAAY,KAAK,CAAC,QAAQ,CAAC,WAAW,IAAI,IAAI,CAAC;QAAE,OAAO,eAAwB,CAAA;IAC1I,yEAAyE;IACzE,+EAA+E;IAC/E,6EAA6E;IAC7E,wEAAwE;IACxE,IAAI,OAAO,CAAC,cAAc,KAAK,IAAI,IAAI,OAAO,CAAC,eAAe,KAAK,IAAI,IAAI,OAAO,CAAC,MAAM,KAAK,IAAI;QAAE,OAAO,sBAA+B,CAAA;IAC1I,MAAM,QAAQ,GAAG,EAAE,CAAC,OAAO,CAAC,0DAA0D,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;IACtG,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,QAAQ,KAAK,IAAI;WACtC,EAAE,CAAC,OAAO,CAAC,kDAAkD,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC;QAAE,OAAO,qBAA8B,CAAA;IACzH,OAAO,SAAS,CAAA;AAClB,CAAC","sourcesContent":["import type { DatabaseSync } from \"node:sqlite\"\nimport { storageDirectory } from \"./storage-path\"\n\n// This is an internal storage contract, NOT a public OpenCode lock API.\n// Validate the actual storage/claim state; runtime version labels are not gates.\nexport const PRUNING_PLUGIN_ID = \"codenomad-session-pruning\"\nexport const storageKey = (key: string) => `plugin:${Array.from(PRUNING_PLUGIN_ID)\n .map(char => char.charCodeAt(0).toString(16).padStart(4, \"0\")).join(\"\")}:${key}`\n\nexport interface StorageIdentity {\n // A fresh ctx.storage challenge proves this connection reaches the daemon's\n // current DB, not a same-session snapshot or an accidentally configured file.\n key: string\n nonce: string\n directory: string\n projectID: string\n workspaceID?: string\n}\n\nexport function validateClaimFence(db: DatabaseSync, sessionID: string, identity: StorageIdentity) {\n if (!db.isTransaction) throw new Error(\"Claim fence requires an acquired write transaction\")\n if (db.prepare(\"PRAGMA database_list\").all().length !== 1) return \"unsupported_storage\" as const\n if (db.prepare(\"SELECT 1 FROM sqlite_schema WHERE type='trigger' LIMIT 1\").get()) return \"unsupported_storage\" as const\n const marker = db.prepare(\"SELECT value FROM kv WHERE key=?\").get(identity.key)\n if (marker?.value !== JSON.stringify(identity.nonce)) return \"unsupported_storage\" as const\n const session = db.prepare(\"SELECT directory,project_id,workspace_id,time_suspended,time_compacting,revert FROM session_v2 WHERE id=?\").get(sessionID)\n if (!session || session.directory !== storageDirectory(identity.directory)\n || session.project_id !== identity.projectID || session.workspace_id !== (identity.workspaceID ?? null)) return \"not_deletable\" as const\n // Native execution must COMMIT its claim before it can enter the runner.\n // BEGIN IMMEDIATE excludes that commit until our synchronous transaction ends.\n // If it committed first, we observe its non-null claim and refuse the write.\n // Never set/release the claim ourselves: it also drives crash recovery.\n if (session.time_suspended !== null || session.time_compacting !== null || session.revert !== null) return \"maintenance_required\" as const\n const sequence = db.prepare(\"SELECT owner_id FROM event_sequence WHERE aggregate_id=?\").get(sessionID)\n if (!sequence || sequence.owner_id !== null\n || db.prepare(\"SELECT 1 FROM event WHERE aggregate_id=? LIMIT 1\").get(sessionID)) return \"unsupported_storage\" as const\n return undefined\n}\n"]}
@@ -0,0 +1,104 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { validateClaimFence } from "./claim-fence";
5
+ import { pruneTransaction } from "./transaction";
6
+ import { revision } from "./planner";
7
+ import { storageDirectory } from "./storage-path";
8
+ const identity = { directory: "/work", projectID: "p", key: "binding", nonce: "fresh" };
9
+ const content = [{ type: "reasoning", text: "remove" }, { type: "text", text: "retain" }];
10
+ const input = { sessionID: "s", messageID: "m", revision: revision(content), indexes: [0] };
11
+ function fixture() {
12
+ const db = new DatabaseSync(":memory:");
13
+ db.exec(`CREATE TABLE session_v2(id TEXT PRIMARY KEY,directory TEXT,project_id TEXT,workspace_id TEXT,time_suspended INTEGER,time_compacting INTEGER,revert TEXT);
14
+ INSERT INTO session_v2 VALUES('s','/work','p',NULL,NULL,NULL,NULL);
15
+ CREATE TABLE event_sequence(aggregate_id TEXT PRIMARY KEY,owner_id TEXT);
16
+ INSERT INTO event_sequence VALUES('s',NULL);
17
+ CREATE TABLE event(aggregate_id TEXT);
18
+ CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER,time_updated INTEGER);
19
+ INSERT INTO kv VALUES('binding','"fresh"',1,1);
20
+ CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,data TEXT);`);
21
+ db.prepare("INSERT INTO session_message VALUES('m','s','assistant',?)").run(JSON.stringify({ time: { completed: 1 }, content }));
22
+ return db;
23
+ }
24
+ const run = (db, binding = identity) => pruneTransaction(db, input, () => validateClaimFence(db, input.sessionID, binding), "receipt/");
25
+ test("fence requires ownership of the SQLite transaction", () => {
26
+ const db = fixture();
27
+ try {
28
+ assert.throws(() => validateClaimFence(db, "s", identity), /write transaction/);
29
+ }
30
+ finally {
31
+ db.close();
32
+ }
33
+ });
34
+ for (const [label, sql] of [
35
+ ["active execution", "UPDATE session_v2 SET time_suspended=1"],
36
+ ["compaction", "UPDATE session_v2 SET time_compacting=1"],
37
+ ["staged revert", "UPDATE session_v2 SET revert='{}'"],
38
+ ["moved session", "UPDATE session_v2 SET directory='/other'"],
39
+ ["changed project", "UPDATE session_v2 SET project_id='other'"],
40
+ ["changed workspace", "UPDATE session_v2 SET workspace_id='other'"],
41
+ ["wrong database challenge", "UPDATE kv SET value='\"snapshot\"'"],
42
+ ["retained event", "INSERT INTO event VALUES('s')"],
43
+ ["remote aggregate owner", "UPDATE event_sequence SET owner_id='remote'"],
44
+ ["unknown trigger", "CREATE TRIGGER writes AFTER UPDATE ON session_message BEGIN SELECT 1; END"],
45
+ ])
46
+ test(`rejects ${label} without a content write or receipt`, () => {
47
+ const db = fixture();
48
+ try {
49
+ db.exec(sql);
50
+ const before = db.prepare("SELECT data FROM session_message").get().data;
51
+ assert.equal(run(db).status, "blocked");
52
+ assert.equal(db.prepare("SELECT data FROM session_message").get().data, before);
53
+ assert.equal(db.prepare("SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'").get().n, 0);
54
+ assert.equal(db.isTransaction, false);
55
+ }
56
+ finally {
57
+ db.close();
58
+ }
59
+ });
60
+ test("missing native claim storage rolls back without a content write or receipt", () => {
61
+ const db = fixture();
62
+ try {
63
+ db.exec("ALTER TABLE session_v2 RENAME COLUMN time_suspended TO missing_claim");
64
+ assert.equal(run(db).status, "blocked");
65
+ assert.deepEqual(JSON.parse(db.prepare("SELECT data FROM session_message").get().data).content, content);
66
+ assert.equal(db.prepare("SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'").get().n, 0);
67
+ assert.equal(db.isTransaction, false);
68
+ }
69
+ finally {
70
+ db.close();
71
+ }
72
+ });
73
+ test("commit and receipt are atomic, and repeated delivery never deletes a second block", () => {
74
+ const db = fixture();
75
+ try {
76
+ const result = run(db);
77
+ assert.equal(result.status, "pruned");
78
+ assert.deepEqual(run(db), result);
79
+ assert.equal(db.prepare("SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'").get().n, 1);
80
+ assert.deepEqual(JSON.parse(db.prepare("SELECT data FROM session_message").get().data).content, [content[1]]);
81
+ assert.equal(db.isTransaction, false);
82
+ }
83
+ finally {
84
+ db.close();
85
+ }
86
+ });
87
+ test("a failed receipt insert rolls back the content change", () => {
88
+ const db = fixture();
89
+ try {
90
+ // Inject a storage failure without a trigger, which the fence rightly rejects.
91
+ db.exec("DROP TABLE kv; CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER CHECK(time_created < 2),time_updated INTEGER); INSERT INTO kv VALUES('binding','\"fresh\"',1,1)");
92
+ assert.throws(() => run(db));
93
+ assert.deepEqual(JSON.parse(db.prepare("SELECT data FROM session_message").get().data).content, content);
94
+ assert.equal(db.isTransaction, false);
95
+ }
96
+ finally {
97
+ db.close();
98
+ }
99
+ });
100
+ test("Windows directory encoding agrees with native SQLite storage", () => {
101
+ const directory = "C:\\work\\project";
102
+ assert.equal(storageDirectory(directory), process.platform === "win32" ? "C:/work/project" : directory);
103
+ });
104
+ //# sourceMappingURL=claim-fence.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claim-fence.test.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/claim-fence.test.ts"],"names":[],"mappings":"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","sourcesContent":["import assert from \"node:assert/strict\"\nimport { test } from \"node:test\"\nimport { DatabaseSync } from \"node:sqlite\"\nimport { validateClaimFence, type StorageIdentity } from \"./claim-fence\"\nimport { pruneTransaction } from \"./transaction\"\nimport { revision } from \"./planner\"\nimport { storageDirectory } from \"./storage-path\"\n\nconst identity: StorageIdentity = { directory: \"/work\", projectID: \"p\", key: \"binding\", nonce: \"fresh\" }\nconst content = [{ type: \"reasoning\", text: \"remove\" }, { type: \"text\", text: \"retain\" }]\nconst input = { sessionID: \"s\", messageID: \"m\", revision: revision(content), indexes: [0] }\nfunction fixture() {\n const db = new DatabaseSync(\":memory:\")\n db.exec(`CREATE TABLE session_v2(id TEXT PRIMARY KEY,directory TEXT,project_id TEXT,workspace_id TEXT,time_suspended INTEGER,time_compacting INTEGER,revert TEXT);\n INSERT INTO session_v2 VALUES('s','/work','p',NULL,NULL,NULL,NULL);\n CREATE TABLE event_sequence(aggregate_id TEXT PRIMARY KEY,owner_id TEXT);\n INSERT INTO event_sequence VALUES('s',NULL);\n CREATE TABLE event(aggregate_id TEXT);\n CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER,time_updated INTEGER);\n INSERT INTO kv VALUES('binding','\"fresh\"',1,1);\n CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,data TEXT);`)\n db.prepare(\"INSERT INTO session_message VALUES('m','s','assistant',?)\").run(JSON.stringify({ time: { completed: 1 }, content }))\n return db\n}\nconst run = (db: DatabaseSync, binding = identity) => pruneTransaction(db, input, () => validateClaimFence(db, input.sessionID, binding), \"receipt/\")\n\ntest(\"fence requires ownership of the SQLite transaction\", () => {\n const db = fixture()\n try { assert.throws(() => validateClaimFence(db, \"s\", identity), /write transaction/) }\n finally { db.close() }\n})\n\nfor (const [label, sql] of [\n [\"active execution\", \"UPDATE session_v2 SET time_suspended=1\"],\n [\"compaction\", \"UPDATE session_v2 SET time_compacting=1\"],\n [\"staged revert\", \"UPDATE session_v2 SET revert='{}'\"],\n [\"moved session\", \"UPDATE session_v2 SET directory='/other'\"],\n [\"changed project\", \"UPDATE session_v2 SET project_id='other'\"],\n [\"changed workspace\", \"UPDATE session_v2 SET workspace_id='other'\"],\n [\"wrong database challenge\", \"UPDATE kv SET value='\\\"snapshot\\\"'\"],\n [\"retained event\", \"INSERT INTO event VALUES('s')\"],\n [\"remote aggregate owner\", \"UPDATE event_sequence SET owner_id='remote'\"],\n [\"unknown trigger\", \"CREATE TRIGGER writes AFTER UPDATE ON session_message BEGIN SELECT 1; END\"],\n] as const) test(`rejects ${label} without a content write or receipt`, () => {\n const db = fixture()\n try {\n db.exec(sql)\n const before = db.prepare(\"SELECT data FROM session_message\").get()!.data\n assert.equal(run(db).status, \"blocked\")\n assert.equal(db.prepare(\"SELECT data FROM session_message\").get()!.data, before)\n assert.equal(db.prepare(\"SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'\").get()!.n, 0)\n assert.equal(db.isTransaction, false)\n } finally { db.close() }\n})\n\ntest(\"missing native claim storage rolls back without a content write or receipt\", () => {\n const db = fixture()\n try {\n db.exec(\"ALTER TABLE session_v2 RENAME COLUMN time_suspended TO missing_claim\")\n assert.equal(run(db).status, \"blocked\")\n assert.deepEqual(JSON.parse(db.prepare(\"SELECT data FROM session_message\").get()!.data as string).content, content)\n assert.equal(db.prepare(\"SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'\").get()!.n, 0)\n assert.equal(db.isTransaction, false)\n }\n finally { db.close() }\n})\n\ntest(\"commit and receipt are atomic, and repeated delivery never deletes a second block\", () => {\n const db = fixture()\n try {\n const result = run(db)\n assert.equal(result.status, \"pruned\")\n assert.deepEqual(run(db), result)\n assert.equal(db.prepare(\"SELECT count(*) AS n FROM kv WHERE key LIKE 'receipt/%'\").get()!.n, 1)\n assert.deepEqual(JSON.parse(db.prepare(\"SELECT data FROM session_message\").get()!.data as string).content, [content[1]])\n assert.equal(db.isTransaction, false)\n } finally { db.close() }\n})\n\ntest(\"a failed receipt insert rolls back the content change\", () => {\n const db = fixture()\n try {\n // Inject a storage failure without a trigger, which the fence rightly rejects.\n db.exec(\"DROP TABLE kv; CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER CHECK(time_created < 2),time_updated INTEGER); INSERT INTO kv VALUES('binding','\\\"fresh\\\"',1,1)\")\n assert.throws(() => run(db))\n assert.deepEqual(JSON.parse(db.prepare(\"SELECT data FROM session_message\").get()!.data as string).content, content)\n assert.equal(db.isTransaction, false)\n } finally { db.close() }\n})\n\ntest(\"Windows directory encoding agrees with native SQLite storage\", () => {\n const directory = \"C:\\\\work\\\\project\"\n assert.equal(storageDirectory(directory), process.platform === \"win32\" ? \"C:/work/project\" : directory)\n})\n"]}
@@ -0,0 +1,40 @@
1
+ import { z } from "zod";
2
+ export const PRUNING_RPC_ID = "codenomad.session-pruning";
3
+ export const PRUNING_EVENT = `rpc.${PRUNING_RPC_ID}.pruned`;
4
+ const id = z.string().min(1).max(256);
5
+ export const messageTargetSchema = z.object({ sessionID: id, messageID: id }).strict();
6
+ export const pruneRequestSchema = messageTargetSchema.extend({
7
+ revision: z.string().regex(/^[a-f0-9]{64}$/),
8
+ indexes: z.array(z.number().int().min(0).max(100_000)).min(1).max(100_001)
9
+ .refine((values) => new Set(values).size === values.length),
10
+ }).strict();
11
+ export const pruningBlockedSchema = z.object({
12
+ status: z.literal("blocked"),
13
+ reason: z.enum(["unavailable", "maintenance_required", "conflict", "not_deletable", "unsupported_storage"]),
14
+ }).strict();
15
+ export const pruneResultSchema = z.union([
16
+ z.object({
17
+ status: z.literal("pruned"), messageID: id,
18
+ revision: z.string().regex(/^[a-f0-9]{64}$/), removedCount: z.number().int().positive(),
19
+ }).strict(),
20
+ pruningBlockedSchema,
21
+ ]);
22
+ export const prunePreviewSchema = z.union([
23
+ z.object({
24
+ status: z.literal("preview"), revision: z.string().regex(/^[a-f0-9]{64}$/),
25
+ liveMutation: z.boolean(),
26
+ parts: z.array(z.object({ index: z.number().int().nonnegative(), type: z.enum(["tool", "reasoning"]), bytes: z.number().int().nonnegative() })),
27
+ }).strict(),
28
+ pruningBlockedSchema,
29
+ ]);
30
+ export const prunedEventSchema = messageTargetSchema.extend({ revision: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
31
+ // Portable Standard Schema contract: no Core imports or native event impersonation.
32
+ export const pruningRpcDefinition = {
33
+ id: PRUNING_RPC_ID,
34
+ methods: {
35
+ preview: { input: messageTargetSchema, output: prunePreviewSchema },
36
+ prune: { input: pruneRequestSchema, output: pruneResultSchema },
37
+ },
38
+ events: { pruned: { schema: prunedEventSchema } },
39
+ };
40
+ //# sourceMappingURL=contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/contract.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from \"zod\"\n\nexport const PRUNING_RPC_ID = \"codenomad.session-pruning\"\nexport const PRUNING_EVENT = `rpc.${PRUNING_RPC_ID}.pruned` as const\nconst id = z.string().min(1).max(256)\nexport const messageTargetSchema = z.object({ sessionID: id, messageID: id }).strict()\nexport const pruneRequestSchema = messageTargetSchema.extend({\n revision: z.string().regex(/^[a-f0-9]{64}$/),\n indexes: z.array(z.number().int().min(0).max(100_000)).min(1).max(100_001)\n .refine((values) => new Set(values).size === values.length),\n}).strict()\nexport type PruneRequest = z.infer<typeof pruneRequestSchema>\nexport const pruningBlockedSchema = z.object({\n status: z.literal(\"blocked\"),\n reason: z.enum([\"unavailable\", \"maintenance_required\", \"conflict\", \"not_deletable\", \"unsupported_storage\"]),\n}).strict()\nexport const pruneResultSchema = z.union([\n z.object({\n status: z.literal(\"pruned\"), messageID: id,\n revision: z.string().regex(/^[a-f0-9]{64}$/), removedCount: z.number().int().positive(),\n }).strict(),\n pruningBlockedSchema,\n])\nexport type PruneResult = z.infer<typeof pruneResultSchema>\nexport const prunePreviewSchema = z.union([\n z.object({\n status: z.literal(\"preview\"), revision: z.string().regex(/^[a-f0-9]{64}$/),\n liveMutation: z.boolean(),\n parts: z.array(z.object({ index: z.number().int().nonnegative(), type: z.enum([\"tool\", \"reasoning\"]), bytes: z.number().int().nonnegative() })),\n }).strict(),\n pruningBlockedSchema,\n])\nexport const prunedEventSchema = messageTargetSchema.extend({ revision: z.string().regex(/^[a-f0-9]{64}$/) }).strict()\n\n// Portable Standard Schema contract: no Core imports or native event impersonation.\nexport const pruningRpcDefinition = {\n id: PRUNING_RPC_ID,\n methods: {\n preview: { input: messageTargetSchema, output: prunePreviewSchema },\n prune: { input: pruneRequestSchema, output: pruneResultSchema },\n },\n events: { pruned: { schema: prunedEventSchema } },\n} as const\n"]}
@@ -0,0 +1,17 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ // Match OpenCode's daemon-side data-directory and channel filename conventions.
4
+ // The fresh storage challenge still verifies the chosen file before any write.
5
+ export function pruningDatabasePath(configured, channel, env = process.env, home = os.homedir()) {
6
+ if (configured !== undefined)
7
+ return configured;
8
+ const data = path.join(env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME)
9
+ ? env.XDG_DATA_HOME : path.join(home, ".local", "share"), "opencode");
10
+ if (env.OPENCODE_DB)
11
+ return env.OPENCODE_DB === ":memory:" || path.isAbsolute(env.OPENCODE_DB)
12
+ ? env.OPENCODE_DB : path.join(data, env.OPENCODE_DB);
13
+ const shared = ["latest", "beta", "prod"].includes(channel)
14
+ || env.OPENCODE_DISABLE_CHANNEL_DB === "1" || env.OPENCODE_DISABLE_CHANNEL_DB === "true";
15
+ return path.join(data, shared ? "opencode.db" : `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`);
16
+ }
17
+ //# sourceMappingURL=database-path.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database-path.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/database-path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAA;AACxB,OAAO,IAAI,MAAM,WAAW,CAAA;AAE5B,gFAAgF;AAChF,+EAA+E;AAC/E,MAAM,UAAU,mBAAmB,CACjC,UAAmB,EACnB,OAAe,EACf,MAAyB,OAAO,CAAC,GAAG,EACpC,OAAe,EAAE,CAAC,OAAO,EAAE;IAE3B,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,UAAU,CAAA;IAC/C,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,aAAa,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,aAAa,CAAC;QAC5E,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,UAAU,CAAC,CAAA;IACvE,IAAI,GAAG,CAAC,WAAW;QAAE,OAAO,GAAG,CAAC,WAAW,KAAK,UAAU,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,WAAW,CAAC;YAC5F,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,WAAW,CAAC,CAAA;IACtD,MAAM,MAAM,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC;WACtD,GAAG,CAAC,2BAA2B,KAAK,GAAG,IAAI,GAAG,CAAC,2BAA2B,KAAK,MAAM,CAAA;IAC1F,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,YAAY,OAAO,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,KAAK,CAAC,CAAA;AAC5G,CAAC","sourcesContent":["import os from \"node:os\"\nimport path from \"node:path\"\n\n// Match OpenCode's daemon-side data-directory and channel filename conventions.\n// The fresh storage challenge still verifies the chosen file before any write.\nexport function pruningDatabasePath(\n configured: unknown,\n channel: string,\n env: NodeJS.ProcessEnv = process.env,\n home: string = os.homedir(),\n): unknown {\n if (configured !== undefined) return configured\n const data = path.join(env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME)\n ? env.XDG_DATA_HOME : path.join(home, \".local\", \"share\"), \"opencode\")\n if (env.OPENCODE_DB) return env.OPENCODE_DB === \":memory:\" || path.isAbsolute(env.OPENCODE_DB)\n ? env.OPENCODE_DB : path.join(data, env.OPENCODE_DB)\n const shared = [\"latest\", \"beta\", \"prod\"].includes(channel)\n || env.OPENCODE_DISABLE_CHANNEL_DB === \"1\" || env.OPENCODE_DISABLE_CHANNEL_DB === \"true\"\n return path.join(data, shared ? \"opencode.db\" : `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, \"-\")}.db`)\n}\n"]}
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import path from "node:path";
4
+ import { pruningDatabasePath } from "./database-path";
5
+ test("local plugin resolves the daemon's normal database without per-project configuration", () => {
6
+ const home = path.resolve("synthetic-home");
7
+ const data = path.join(home, ".local", "share", "opencode");
8
+ for (const channel of ["beta", "latest", "prod"]) {
9
+ assert.equal(pruningDatabasePath(undefined, channel, {}, home), path.join(data, "opencode.db"));
10
+ }
11
+ assert.equal(pruningDatabasePath(undefined, "dev/feature", {}, home), path.join(data, "opencode-dev-feature.db"));
12
+ assert.equal(pruningDatabasePath(undefined, "dev", { OPENCODE_DISABLE_CHANNEL_DB: "true" }, home), path.join(data, "opencode.db"));
13
+ const xdg = path.join(home, "xdg");
14
+ assert.equal(pruningDatabasePath(undefined, "beta", { XDG_DATA_HOME: xdg }, home), path.join(xdg, "opencode", "opencode.db"));
15
+ assert.equal(pruningDatabasePath(undefined, "beta", { XDG_DATA_HOME: "relative" }, home), path.join(data, "opencode.db"));
16
+ assert.equal(pruningDatabasePath(undefined, "beta", { OPENCODE_DB: "custom.db" }, home), path.join(data, "custom.db"));
17
+ const explicit = path.join(home, "explicit.db");
18
+ assert.equal(pruningDatabasePath(undefined, "beta", { OPENCODE_DB: explicit }, home), explicit);
19
+ assert.equal(pruningDatabasePath(undefined, "beta", { OPENCODE_DB: ":memory:" }, home), ":memory:");
20
+ assert.equal(pruningDatabasePath(explicit, "beta", { OPENCODE_DB: "ignored.db" }, home), explicit);
21
+ });
22
+ //# sourceMappingURL=database-path.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database-path.test.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/database-path.test.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,oBAAoB,CAAA;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,IAAI,MAAM,WAAW,CAAA;AAC5B,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAErD,IAAI,CAAC,sFAAsF,EAAE,GAAG,EAAE;IAChG,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAA;IAC3C,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,UAAU,CAAC,CAAA;IAC3D,KAAK,MAAM,OAAO,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,CAAC;QACjD,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,OAAO,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAA;IACjG,CAAC;IACD,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,aAAa,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,yBAAyB,CAAC,CAAC,CAAA;IACjH,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,KAAK,EAAE,EAAE,2BAA2B,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAA;IAClI,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;IAClC,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,aAAa,EAAE,GAAG,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC,CAAA;IAC7H,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,aAAa,EAAE,UAAU,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAA;IACzH,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,CAAA;IACtH,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IAC/C,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,QAAQ,EAAE,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC,CAAA;IAC/F,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,EAAE,IAAI,CAAC,EAAE,UAAU,CAAC,CAAA;IACnG,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,YAAY,EAAE,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC,CAAA;AACpG,CAAC,CAAC,CAAA","sourcesContent":["import assert from \"node:assert/strict\"\nimport { test } from \"node:test\"\nimport path from \"node:path\"\nimport { pruningDatabasePath } from \"./database-path\"\n\ntest(\"local plugin resolves the daemon's normal database without per-project configuration\", () => {\n const home = path.resolve(\"synthetic-home\")\n const data = path.join(home, \".local\", \"share\", \"opencode\")\n for (const channel of [\"beta\", \"latest\", \"prod\"]) {\n assert.equal(pruningDatabasePath(undefined, channel, {}, home), path.join(data, \"opencode.db\"))\n }\n assert.equal(pruningDatabasePath(undefined, \"dev/feature\", {}, home), path.join(data, \"opencode-dev-feature.db\"))\n assert.equal(pruningDatabasePath(undefined, \"dev\", { OPENCODE_DISABLE_CHANNEL_DB: \"true\" }, home), path.join(data, \"opencode.db\"))\n const xdg = path.join(home, \"xdg\")\n assert.equal(pruningDatabasePath(undefined, \"beta\", { XDG_DATA_HOME: xdg }, home), path.join(xdg, \"opencode\", \"opencode.db\"))\n assert.equal(pruningDatabasePath(undefined, \"beta\", { XDG_DATA_HOME: \"relative\" }, home), path.join(data, \"opencode.db\"))\n assert.equal(pruningDatabasePath(undefined, \"beta\", { OPENCODE_DB: \"custom.db\" }, home), path.join(data, \"custom.db\"))\n const explicit = path.join(home, \"explicit.db\")\n assert.equal(pruningDatabasePath(undefined, \"beta\", { OPENCODE_DB: explicit }, home), explicit)\n assert.equal(pruningDatabasePath(undefined, \"beta\", { OPENCODE_DB: \":memory:\" }, home), \":memory:\")\n assert.equal(pruningDatabasePath(explicit, \"beta\", { OPENCODE_DB: \"ignored.db\" }, home), explicit)\n})\n"]}
@@ -0,0 +1,14 @@
1
+ import plugin from "./plugin";
2
+ import { followPresence } from "./presence";
3
+ // The bundled factory is wrapped by a tiny auto-discovered native entry.
4
+ // No tools, commands or model hooks are registered, even while CodeNomad is open.
5
+ export function desktopPlugin(presenceDirectory) {
6
+ return {
7
+ ...plugin,
8
+ setup: (ctx) => followPresence(presenceDirectory, async () => {
9
+ const cleanup = await plugin.setup(ctx);
10
+ return async () => { await cleanup?.(); };
11
+ }),
12
+ };
13
+ }
14
+ //# sourceMappingURL=desktop-plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"desktop-plugin.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/desktop-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAC7B,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAE3C,yEAAyE;AACzE,kFAAkF;AAClF,MAAM,UAAU,aAAa,CAAC,iBAAyB;IACrD,OAAO;QACL,GAAG,MAAM;QACT,KAAK,EAAE,CAAC,GAAuC,EAAE,EAAE,CAAC,cAAc,CAAC,iBAAiB,EAAE,KAAK,IAAI,EAAE;YAC/F,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;YACvC,OAAO,KAAK,IAAI,EAAE,GAAG,MAAM,OAAO,EAAE,EAAE,CAAA,CAAC,CAAC,CAAA;QAC1C,CAAC,CAAC;KACH,CAAA;AACH,CAAC","sourcesContent":["import plugin from \"./plugin\"\nimport { followPresence } from \"./presence\"\n\n// The bundled factory is wrapped by a tiny auto-discovered native entry.\n// No tools, commands or model hooks are registered, even while CodeNomad is open.\nexport function desktopPlugin(presenceDirectory: string) {\n return {\n ...plugin,\n setup: (ctx: Parameters<typeof plugin.setup>[0]) => followPresence(presenceDirectory, async () => {\n const cleanup = await plugin.setup(ctx)\n return async () => { await cleanup?.() }\n }),\n }\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export { default } from "./plugin";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/opencode/session-pruning/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAA","sourcesContent":["export { default } from \"./plugin\"\n"]}