@vantran-se/openchamber-web 1.22.1 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +2 -3
  2. package/bin/cli.js +11 -1
  3. package/bin/cli.test.js +92 -0
  4. package/bin/lib/cli-startup.js +49 -0
  5. package/bin/lib/commands-startup.js +41 -6
  6. package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
  7. package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
  8. package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
  9. package/dist/assets/FilesView-BdTOpew9.js +17 -0
  10. package/dist/assets/GitView-Dqf9vHJh.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
  12. package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
  13. package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
  14. package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
  15. package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
  17. package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
  18. package/dist/assets/PlanView-BoJxdRZe.js +5 -0
  19. package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
  21. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  22. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
  23. package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
  25. package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
  26. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
  27. package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
  28. package/dist/assets/de-C8jV7J9y.js +10 -0
  29. package/dist/assets/es-BI6J1M5R.js +16 -0
  30. package/dist/assets/fr-D6oDFt7E.js +14 -0
  31. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  32. package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
  33. package/dist/assets/index-BqhiCmYS.js +39 -0
  34. package/dist/assets/index-FqSOBz0J.css +1 -0
  35. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
  36. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
  37. package/dist/assets/ja-C_4i5EQu.js +16 -0
  38. package/dist/assets/ko-BFbOTpbq.js +16 -0
  39. package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
  40. package/dist/assets/main-CJmb2dF8.js +930 -0
  41. package/dist/assets/markdownCore-DVuZpnli.js +5 -0
  42. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
  43. package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
  44. package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
  45. package/dist/assets/pl-COFvT56l.js +16 -0
  46. package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
  48. package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
  50. package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
  51. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
  52. package/dist/assets/surface-BvRzS58U.js +1 -0
  53. package/dist/assets/tr-B8Q0Go2l.js +7 -0
  54. package/dist/assets/uk-Dk4a-Fn-.js +16 -0
  55. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
  56. package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
  57. package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
  58. package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
  59. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
  60. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
  61. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  62. package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
  63. package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
  64. package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
  65. package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
  66. package/dist/index.html +5 -43
  67. package/dist/mini-chat.html +4 -4
  68. package/dist/mobile.html +4 -4
  69. package/package.json +8 -5
  70. package/server/index.d.ts +15 -0
  71. package/server/index.js +82 -13
  72. package/server/lib/agent-tool/runtime.js +2 -19
  73. package/server/lib/changelog/update-notes.js +82 -0
  74. package/server/lib/changelog/update-notes.test.js +59 -0
  75. package/server/lib/data-dir-migration.js +40 -0
  76. package/server/lib/data-dir-migration.test.js +59 -0
  77. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  78. package/server/lib/dev-tunnel/runtime.js +4 -4
  79. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  80. package/server/lib/event-stream/runtime.test.js +12 -12
  81. package/server/lib/fs/DOCUMENTATION.md +5 -0
  82. package/server/lib/fs/routes.js +63 -21
  83. package/server/lib/fs/routes.test.js +187 -1
  84. package/server/lib/git/DOCUMENTATION.md +32 -6
  85. package/server/lib/git/routes.js +40 -4
  86. package/server/lib/git/service.js +291 -215
  87. package/server/lib/git/service.test.js +593 -25
  88. package/server/lib/inherited-env.js +16 -9
  89. package/server/lib/inherited-env.test.js +8 -9
  90. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  91. package/server/lib/mcp-reconnect/runtime.js +122 -0
  92. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  93. package/server/lib/message-queue/DOCUMENTATION.md +161 -0
  94. package/server/lib/message-queue/runtime.js +870 -0
  95. package/server/lib/message-queue/runtime.test.js +482 -0
  96. package/server/lib/network-defaults.js +23 -0
  97. package/server/lib/network-defaults.test.js +52 -0
  98. package/server/lib/openchamber-sessions/routes.js +102 -0
  99. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  100. package/server/lib/opencode/DOCUMENTATION.md +30 -6
  101. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  102. package/server/lib/opencode/config-paths.test.js +116 -0
  103. package/server/lib/opencode/core-routes.js +1 -0
  104. package/server/lib/opencode/feature-routes-runtime.js +7 -0
  105. package/server/lib/opencode/input-history-scope.js +14 -0
  106. package/server/lib/opencode/managed-plugin-config.js +35 -0
  107. package/server/lib/opencode/openchamber-routes.js +64 -4
  108. package/server/lib/opencode/openchamber-routes.test.js +130 -3
  109. package/server/lib/opencode/plugins.js +2 -2
  110. package/server/lib/opencode/providers.js +44 -2
  111. package/server/lib/opencode/providers.test.js +131 -4
  112. package/server/lib/opencode/routes.js +9 -7
  113. package/server/lib/opencode/server-startup-runtime.js +1 -1
  114. package/server/lib/opencode/settings-files.js +214 -0
  115. package/server/lib/opencode/settings-helpers.js +113 -41
  116. package/server/lib/opencode/settings-helpers.test.js +342 -0
  117. package/server/lib/opencode/settings-registry.json +747 -0
  118. package/server/lib/opencode/settings-runtime.js +141 -20
  119. package/server/lib/opencode/settings-runtime.test.js +191 -4
  120. package/server/lib/opencode/shared.js +4 -1
  121. package/server/lib/opencode/shutdown-runtime.js +2 -0
  122. package/server/lib/opencode/skill-routes.js +2 -1
  123. package/server/lib/opencode/snippets.js +1 -2
  124. package/server/lib/package-manager.js +4 -29
  125. package/server/lib/package-manager.test.js +2 -2
  126. package/server/lib/project-context/DOCUMENTATION.md +25 -1
  127. package/server/lib/project-context/routes.http.test.js +18 -0
  128. package/server/lib/project-context/routes.js +15 -0
  129. package/server/lib/project-context/runtime.js +248 -23
  130. package/server/lib/project-context/runtime.test.js +106 -3
  131. package/server/lib/projects/DOCUMENTATION.md +76 -0
  132. package/server/lib/projects/project-config.js +142 -9
  133. package/server/lib/projects/project-id.js +17 -0
  134. package/server/lib/projects/project-setup.js +409 -0
  135. package/server/lib/projects/project-setup.test.js +557 -0
  136. package/server/lib/projects/routes.js +59 -0
  137. package/server/lib/quota/DOCUMENTATION.md +40 -2
  138. package/server/lib/quota/credentials/providers.js +4 -0
  139. package/server/lib/quota/credentials/store.js +1 -1
  140. package/server/lib/quota/credentials/store.test.js +4 -4
  141. package/server/lib/quota/index.js +1 -0
  142. package/server/lib/quota/providers/cline-pass.js +125 -0
  143. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  144. package/server/lib/quota/providers/exe-dev.js +75 -0
  145. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  146. package/server/lib/quota/providers/hyper.js +118 -0
  147. package/server/lib/quota/providers/hyper.test.js +126 -0
  148. package/server/lib/quota/providers/index.js +22 -0
  149. package/server/lib/quota/providers/neuralwatt.js +3 -6
  150. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  151. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  152. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  153. package/server/lib/quota/providers/opencode-go.js +2 -1
  154. package/server/lib/quota/providers/opencode-go.test.js +31 -2
  155. package/server/lib/quota/providers/openrouter.js +119 -24
  156. package/server/lib/quota/providers/openrouter.test.js +330 -0
  157. package/server/lib/quota/routes.js +2 -0
  158. package/server/lib/quota/utils/auth.js +1 -1
  159. package/server/lib/relay/DOCUMENTATION.md +56 -1
  160. package/server/lib/relay/cross-compat.test.js +26 -0
  161. package/server/lib/relay/downstream-scheduler.js +154 -0
  162. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  163. package/server/lib/relay/e2ee.js +10 -8
  164. package/server/lib/relay/flow-control.test.js +177 -0
  165. package/server/lib/relay/host-client.js +37 -8
  166. package/server/lib/relay/host-client.test.js +9 -1
  167. package/server/lib/relay/tunnel-codec.js +9 -0
  168. package/server/lib/relay/tunnel-host.js +69 -18
  169. package/server/lib/relay/tunnel-host.test.js +9 -1
  170. package/server/lib/security/request-security.js +21 -6
  171. package/server/lib/security/request-security.test.js +42 -8
  172. package/server/lib/session-assist/runtime.js +7 -10
  173. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  174. package/server/lib/session-goal/create.js +3 -1
  175. package/server/lib/session-goal/runtime.js +81 -20
  176. package/server/lib/session-goal/runtime.test.js +426 -0
  177. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  178. package/server/lib/skills-catalog/install.js +3 -2
  179. package/server/lib/small-model/DOCUMENTATION.md +7 -2
  180. package/server/lib/small-model/call.js +17 -6
  181. package/server/lib/small-model/call.test.js +52 -0
  182. package/server/lib/small-model/index.js +7 -11
  183. package/server/lib/small-model/routes.js +2 -1
  184. package/server/lib/small-model/runtime-providers.js +15 -7
  185. package/server/lib/small-model/runtime-providers.test.js +7 -1
  186. package/server/lib/system-prompt/runtime.js +2 -21
  187. package/server/lib/terminal/DOCUMENTATION.md +14 -10
  188. package/server/lib/terminal/runtime.js +190 -31
  189. package/server/lib/terminal/runtime.test.js +571 -37
  190. package/server/lib/terminal/shells.js +17 -0
  191. package/server/lib/terminal/shells.test.js +32 -1
  192. package/server/lib/ui-auth/ui-auth.js +4 -1
  193. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  194. package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
  195. package/server/lib/walkthrough/index.js +1 -0
  196. package/server/lib/walkthrough/model-settings.js +8 -12
  197. package/server/lib/walkthrough/prompt.js +3 -1
  198. package/server/lib/walkthrough/routes.test.js +26 -5
  199. package/server/lib/walkthrough/sources.js +23 -2
  200. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  201. package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
  202. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  203. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  204. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  205. package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
  206. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  207. package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
  208. package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
  209. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  210. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  211. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  212. package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
  213. package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
  214. package/dist/assets/de-DqVn5vme.js +0 -9
  215. package/dist/assets/es-hj43Z53i.js +0 -15
  216. package/dist/assets/fr-BmkZt2mF.js +0 -13
  217. package/dist/assets/index-L_ncV-6H.css +0 -1
  218. package/dist/assets/index-qES19dlJ.js +0 -7
  219. package/dist/assets/ja-DioMIEGF.js +0 -15
  220. package/dist/assets/ko-BqPgaQJb.js +0 -15
  221. package/dist/assets/main-CYgSu6dx.js +0 -928
  222. package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
  223. package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
  224. package/dist/assets/pl-slULBF4R.js +0 -15
  225. package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
  226. package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
  227. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  228. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  229. package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
  230. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  231. package/dist/assets/uk-BkkfxbOs.js +0 -15
  232. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  233. package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
  234. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  235. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  236. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
  237. package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
  238. package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
@@ -0,0 +1,76 @@
1
+ # Projects
2
+
3
+ ## Purpose
4
+
5
+ Server-owned storage for a project's per-user config file,
6
+ `~/.config/openchamber/projects/<projectId>.json`. The file holds two
7
+ families of keys with different writers, and this module is the only place
8
+ that writes it:
9
+
10
+ | Keys | Owner | Reached through |
11
+ |---|---|---|
12
+ | `version`, `scheduledTasks` | `project-config.js` (scheduled-task runtime) | `/api/projects/:projectId/scheduled-tasks/*` |
13
+ | `setup-worktree`, `setup-worktree-wait`, `projectActions`, `projectActionsPrimaryId`, `draftStarters`, `projectPath` | `project-setup.js` via `readProjectSetup` / `updateProjectSetup` on the same runtime | `GET/PUT /api/projects/:projectId/config` (`routes.js`) |
14
+
15
+ Notes, todos, and plans moved out of this file to `packages/web/server/lib/project-context`.
16
+
17
+ A second, optional source is the team's shared file, `<repo>/.openchamber/project.json`
18
+ (`version: 1`; `setupWorktree`, `setupWorktreeWait`, `projectActions`, `draftStarters`,
19
+ `plansDir`). The server reads it from the checkout the project id names
20
+ (`projectPathFromId`). `GET /api/projects/:projectId/config`
21
+ returns one merged view: what runs at the top level, plus `shared` and `personal`
22
+ blocks so a page can edit the personal file without copying a teammate's entry into it.
23
+
24
+ | Field | Merge rule |
25
+ |---|---|
26
+ | `setupWorktree` | shared first, then personal; personal `setupWorktreeMode: "replace"` uses the personal list only |
27
+ | `setupWorktreeWait` | personal when the personal file sets it, else shared, else `false` |
28
+ | `projectActions` | union by `id`; a personal action replaces the shared one with the same id; ids in personal `hiddenSharedActionIds` are dropped; every entry carries `source` |
29
+ | `projectActionsPrimaryId` | personal only |
30
+ | `draftStarters` | union by `type:name`, shared first, every entry carries `source` |
31
+ | `plansDir` | shared only |
32
+
33
+ A shared file that exists but cannot be parsed (or names a `plansDir` outside the
34
+ repo) is `shared.status: "invalid"` with a `reason`; the personal setup is still
35
+ served. It is never treated as "no shared setup".
36
+
37
+ ### Writing the shared file
38
+
39
+ `PUT /api/projects/:projectId/config/shared` (`updateSharedProjectSetup`) is
40
+ the only writer. The patch replaces the keys it names over the current file
41
+ (a broken file counts as empty, so a write repairs it); the result is written
42
+ pretty-printed with `version` first and only the keys that carry something
43
+ (`serializeSharedProjectConfig`), because the file is committed and reviewed.
44
+ A result with nothing in it removes the file and the `.openchamber` folder
45
+ when that leaves it empty, so unsharing the last item leaves no trace. The
46
+ write refuses a checkout that does not exist and a `plansDir` outside the
47
+ repo. The writer has seen what it shared, so its personal trust record is set
48
+ to the new hash; teammates still get the prompt. The shared UI composes
49
+ "share" and "make personal" as a shared write followed by a personal write.
50
+
51
+ ### Trust
52
+
53
+ Shared setup commands and shared actions run on the machine of whoever pulls
54
+ the repo, so they run only after the user has seen them. The view carries
55
+ `trust: { hash, trusted }`: `hash` is `sharedTrustHashOf(shared)`, a SHA-256
56
+ over the executable parts (`setupWorktree` and each action's `id`, `command`,
57
+ `runIn`, actions sorted by id; names and icons do not count), or `null` when
58
+ nothing executes. `trusted` is true when nothing executes or the personal
59
+ file's `sharedTrust.hash` equals the current hash, so a pull that changes a
60
+ command brings the prompt back. The client records an answer with a PUT of
61
+ `sharedTrustHash` (`null` forgets it). The prompt itself lives in the shared
62
+ UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
63
+
64
+ ## Modules
65
+
66
+ - `project-id.js` — `createProjectIdFromPath` / `projectPathFromId`: the path-derived id (`path_<base64url>`) that names the file, and the checkout path back from it. The shared UI derives the same id (`packages/ui/src/lib/projectId.ts`); both sides must agree.
67
+ - `project-config.js` — `createProjectConfigRuntime`: raw read, atomic write, the cross-process file lock (Electron and a CLI `serve` can share one projects dir), scheduled-task normalization, and the project-setup read/update.
68
+ - `project-setup.js` — sanitizers, the shared-file parser (`parseSharedProjectConfig`, `normalizePlansDir`), the merge (`mergeProjectSetup`), and the personal view for the setup keys. Mirrored in the VS Code extension host (`packages/vscode/src/project-setup.ts`), which owns the same file when the webview has no OpenChamber server; keep the two in sync.
69
+ - `routes.js` — the setup routes. `/api/projects` is on the JSON-body allowlist in `opencode/core-routes.js`.
70
+
71
+ ## Invariants
72
+
73
+ - **Every write is a locked read-modify-write of the whole document.** Keys the writer does not own, and keys from newer builds, come back out unchanged. A setup update and a scheduled-task update never clobber each other.
74
+ - **A wrongly shaped key is a 400, not a silent drop.** `projectSetupPatchToStored` throws; the file is untouched. Values inside a well-shaped key are sanitized (trimmed, capped, deduplicated) rather than rejected.
75
+ - **The client never composes the path.** `packages/ui/src/lib/openchamberConfig.ts` speaks only HTTP; the same code serves web, desktop, VS Code, and the phone, including a phone on a remote instance.
76
+ - **`OPENCHAMBER_DATA_DIR` moves this directory too.** Every OpenChamber folder hangs off the one root; a custom root gets `projects/`, `themes/`, and `speech-models/` copied in from `~/.config/openchamber` once at startup (copied, not moved: a second instance beside the default one must not strip it) (`lib/data-dir-migration.js`). A scratch server started with its own data dir therefore never touches the real project configs.
@@ -1,6 +1,21 @@
1
1
  import { DateTime, IANAZone } from 'luxon';
2
2
  import parser from 'cron-parser';
3
3
 
4
+ import { projectPathFromId } from './project-id.js';
5
+ import {
6
+ DEFAULT_PLANS_DIR,
7
+ EMPTY_SHARED_PROJECT_CONFIG,
8
+ SHARED_CONFIG_RELATIVE_PATH,
9
+ applySharedProjectSetupPatch,
10
+ isSharedProjectConfigEmpty,
11
+ mergeProjectSetup,
12
+ parseSharedProjectConfig,
13
+ projectSetupPatchToStored,
14
+ projectSetupViewOf,
15
+ serializeSharedProjectConfig,
16
+ sharedTrustHashOf,
17
+ } from './project-setup.js';
18
+
4
19
  const PROJECT_CONFIG_VERSION = 1;
5
20
  export const MAX_TASK_NAME_LENGTH = 80;
6
21
  const MAX_TASK_PROMPT_LENGTH = 20_000;
@@ -548,21 +563,16 @@ export const createProjectConfigRuntime = (deps) => {
548
563
  })
549
564
  );
550
565
 
551
- const writeProjectConfigToDisk = async (projectID, config) => {
566
+ // Atomic whole-document write; callers hand in the merged document so the
567
+ // keys they do not own survive untouched.
568
+ const writeRawProjectConfigToDisk = async (projectID, document) => {
552
569
  const filePath = resolveProjectConfigPath(projectID);
553
570
  const parentDirectory = path.dirname(filePath);
554
571
  const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
555
572
 
556
- const existing = await readRawProjectConfigFromDisk(projectID);
557
- const merged = {
558
- ...existing,
559
- version: PROJECT_CONFIG_VERSION,
560
- scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
561
- };
562
-
563
573
  await fsPromises.mkdir(parentDirectory, { recursive: true });
564
574
  try {
565
- await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
575
+ await fsPromises.writeFile(temporaryPath, JSON.stringify(document, null, 2), 'utf8');
566
576
  await fsPromises.rename(temporaryPath, filePath);
567
577
  } catch (error) {
568
578
  await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
@@ -570,6 +580,15 @@ export const createProjectConfigRuntime = (deps) => {
570
580
  }
571
581
  };
572
582
 
583
+ const writeProjectConfigToDisk = async (projectID, config) => {
584
+ const existing = await readRawProjectConfigFromDisk(projectID);
585
+ await writeRawProjectConfigToDisk(projectID, {
586
+ ...existing,
587
+ version: PROJECT_CONFIG_VERSION,
588
+ scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
589
+ });
590
+ };
591
+
573
592
  const withProjectWriteLock = async (projectID, mutate) => {
574
593
  const key = sanitizeProjectID(projectID);
575
594
  const previous = writeLocks.get(key) || Promise.resolve();
@@ -905,7 +924,121 @@ export const createProjectConfigRuntime = (deps) => {
905
924
  });
906
925
  };
907
926
 
927
+ // The client-owned part of the file (worktree setup, project actions, draft
928
+ // starters); see `project-setup.js`. Reads are lock-free like task lists;
929
+ // an update merges the sanitized patch over the raw document under the same
930
+ // cross-process lock the task writers use, so neither side clobbers the other.
931
+ // The shared file lives in the project's checkout. The checkout path comes
932
+ // from the id itself (`path_<base64url>`), with the personal file's
933
+ // `projectPath` as the fallback for ids of another form. A missing file is
934
+ // the normal case; an unreadable or unparsable one is reported as invalid,
935
+ // never as "no shared setup".
936
+ const projectPathOf = (projectID, personalRaw) => (
937
+ projectPathFromId(projectID) || (typeof personalRaw.projectPath === 'string' ? personalRaw.projectPath.trim() : '')
938
+ );
939
+ const sharedConfigPathOf = (projectPath) => path.join(projectPath, ...SHARED_CONFIG_RELATIVE_PATH.split('/'));
940
+
941
+ const readSharedProjectConfig = async (projectID, personalRaw) => {
942
+ const projectPath = projectPathOf(projectID, personalRaw);
943
+ if (!projectPath) return { status: 'missing' };
944
+ let raw;
945
+ try {
946
+ raw = await fsPromises.readFile(sharedConfigPathOf(projectPath), 'utf8');
947
+ } catch (error) {
948
+ if (error && typeof error === 'object' && error.code === 'ENOENT') return { status: 'missing' };
949
+ return { status: 'invalid', reason: error instanceof Error ? error.message : String(error) };
950
+ }
951
+ return parseSharedProjectConfig(raw);
952
+ };
953
+
954
+ const mergedProjectSetupOf = async (projectID, personalRaw) => (
955
+ mergeProjectSetup(projectSetupViewOf(personalRaw), await readSharedProjectConfig(projectID, personalRaw))
956
+ );
957
+
958
+ const readProjectSetup = async (projectID) => mergedProjectSetupOf(projectID, await readRawProjectConfigFromDisk(projectID));
959
+
960
+ const updateProjectSetup = async (projectID, patch) => {
961
+ const stored = projectSetupPatchToStored(patch);
962
+ return withProjectWriteLock(projectID, async () => {
963
+ const existing = await readRawProjectConfigFromDisk(projectID);
964
+ const merged = { ...existing, ...stored };
965
+ for (const [key, value] of Object.entries(stored)) {
966
+ if (value === undefined) delete merged[key];
967
+ }
968
+ await writeRawProjectConfigToDisk(projectID, merged);
969
+ return mergedProjectSetupOf(projectID, merged);
970
+ });
971
+ };
972
+
973
+ /**
974
+ * Change the team's shared file in the checkout: the patch replaces the
975
+ * keys it names over the current file (a broken file counts as empty, so
976
+ * a write repairs it). A result with nothing in it removes the file (and
977
+ * the `.openchamber` folder when that leaves it empty), so unsharing the
978
+ * last item leaves no trace. The writer has seen the commands it just
979
+ * shared, so the personal trust record is set to the new hash on this
980
+ * instance; teammates still get the prompt.
981
+ */
982
+ const updateSharedProjectSetup = async (projectID, patch) => (
983
+ withProjectWriteLock(projectID, async () => {
984
+ const personalRaw = await readRawProjectConfigFromDisk(projectID);
985
+ const projectPath = projectPathOf(projectID, personalRaw);
986
+ if (!projectPath) throw new Error('project checkout not found');
987
+ try {
988
+ if (!(await fsPromises.stat(projectPath)).isDirectory()) throw new Error('project checkout not found');
989
+ } catch {
990
+ throw new Error('project checkout not found');
991
+ }
992
+ const currentRead = await readSharedProjectConfig(projectID, personalRaw);
993
+ const current = currentRead.status === 'ok' ? currentRead.config : EMPTY_SHARED_PROJECT_CONFIG;
994
+ const next = applySharedProjectSetupPatch(current, patch);
995
+
996
+ const filePath = sharedConfigPathOf(projectPath);
997
+ if (isSharedProjectConfigEmpty(next)) {
998
+ await fsPromises.rm(filePath, { force: true });
999
+ await fsPromises.rmdir(path.dirname(filePath)).catch(() => {});
1000
+ } else {
1001
+ await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
1002
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
1003
+ try {
1004
+ await fsPromises.writeFile(temporaryPath, serializeSharedProjectConfig(next), 'utf8');
1005
+ await fsPromises.rename(temporaryPath, filePath);
1006
+ } catch (error) {
1007
+ await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
1008
+ throw error;
1009
+ }
1010
+ }
1011
+
1012
+ const hash = sharedTrustHashOf(next);
1013
+ const personalNext = { ...personalRaw };
1014
+ if (hash) personalNext.sharedTrust = { hash, trustedAt: Date.now() };
1015
+ else delete personalNext.sharedTrust;
1016
+ await writeRawProjectConfigToDisk(projectID, personalNext);
1017
+ return mergedProjectSetupOf(projectID, personalNext);
1018
+ })
1019
+ );
1020
+
1021
+ /**
1022
+ * The absolute repository plans folder of a project: `plansDir` from the
1023
+ * shared file when set, else the default `.openchamber/plans`. Setting
1024
+ * `plansDir` replaces the default outright (nothing is read from it any
1025
+ * more); moving files between the two is the user's job. Null only when the
1026
+ * checkout cannot be located.
1027
+ */
1028
+ const resolveSharedPlansDir = async (projectID) => {
1029
+ const personalRaw = await readRawProjectConfigFromDisk(projectID);
1030
+ const projectPath = projectPathOf(projectID, personalRaw);
1031
+ if (!projectPath) return null;
1032
+ const shared = await readSharedProjectConfig(projectID, personalRaw);
1033
+ const relative = shared.status === 'ok' && shared.config.plansDir ? shared.config.plansDir : DEFAULT_PLANS_DIR;
1034
+ return path.join(projectPath, ...relative.split('/'));
1035
+ };
1036
+
908
1037
  return {
1038
+ readProjectSetup,
1039
+ updateProjectSetup,
1040
+ updateSharedProjectSetup,
1041
+ resolveSharedPlansDir,
909
1042
  listScheduledTasks,
910
1043
  upsertScheduledTask,
911
1044
  deleteScheduledTask,
@@ -11,3 +11,20 @@ export const createProjectIdFromPath = (projectPath) => {
11
11
 
12
12
  return `path_${Buffer.from(normalized, 'utf8').toString('base64url')}`;
13
13
  };
14
+
15
+ /**
16
+ * The path a `path_<base64url>` id was made from, or `''` when the id is not
17
+ * of that form. The projects dir names files by this id, so the server can
18
+ * find the project's checkout (and the shared config inside it) from the id
19
+ * alone.
20
+ */
21
+ export const projectPathFromId = (projectId) => {
22
+ if (typeof projectId !== 'string' || !projectId.startsWith('path_')) return '';
23
+ const encoded = projectId.slice('path_'.length);
24
+ if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
25
+ try {
26
+ return Buffer.from(encoded, 'base64url').toString('utf8');
27
+ } catch {
28
+ return '';
29
+ }
30
+ };