@vantran-se/openchamber-web 1.22.1 → 1.22.2

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 (163) hide show
  1. package/README.md +2 -3
  2. package/bin/cli.js +4 -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-CV1HNV5l.js} +1 -1
  7. package/dist/assets/{DiffView-Dh1ey2x9.js → DiffView-DkkSa30n.js} +3 -3
  8. package/dist/assets/DirtyBranchSwitchDialog-DW4WaXPc.js +1 -0
  9. package/dist/assets/FilesView-DDu8zDy5.js +17 -0
  10. package/dist/assets/GitView-1Ixo8z-M.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-DvCLL7dT.js} +1 -1
  12. package/dist/assets/LinearIssuesView-Bs1rZrF2.js +1 -0
  13. package/dist/assets/{MarkdownImageGallery-bA0PS-tn.js → MarkdownImageGallery-DyJUc6h9.js} +1 -1
  14. package/dist/assets/MarkdownRendererImpl-Bdm8vMkS.js +12 -0
  15. package/dist/assets/{OnboardingScreen-D3kYJIb6.js → OnboardingScreen-Dg66B6oB.js} +1 -1
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-CAduNjfZ.js} +1 -1
  17. package/dist/assets/{PierreDiffViewer-Ct2A13NO.js → PierreDiffViewer-CTIb1GYD.js} +2 -2
  18. package/dist/assets/PlanView-trDaszfq.js +5 -0
  19. package/dist/assets/SettingsView-DStMfBmy.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DQQFnrSZ.js} +1 -1
  21. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-SjY2XZlA.js} +6 -6
  22. package/dist/assets/ToolPartDiffPreview-DJdzUQPE.js +8 -0
  23. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-Iehm4iMH.js} +1 -1
  24. package/dist/assets/{WalkthroughView-AX-u2pFg.js → WalkthroughView-BALp2d9O.js} +2 -2
  25. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-BKZA43vN.js} +2 -2
  26. package/dist/assets/{contextPanelEmbeddedChat-C9EcRSDF.js → contextPanelEmbeddedChat-D2PM_pEv.js} +1 -1
  27. package/dist/assets/{de-DqVn5vme.js → de-DlSNLAcy.js} +6 -5
  28. package/dist/assets/{es-hj43Z53i.js → es-D1GbowZ3.js} +6 -5
  29. package/dist/assets/{fr-BmkZt2mF.js → fr-BPOLXIAY.js} +6 -5
  30. package/dist/assets/{index-DmhujXbT.js → index-Ce0LR32V.js} +1 -1
  31. package/dist/assets/index-D42fPvt4.css +1 -0
  32. package/dist/assets/index-DlFbCkEy.js +39 -0
  33. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-DaB2u_Kv.js} +1 -1
  34. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-yJZa2IAC.js} +1 -1
  35. package/dist/assets/{ja-DioMIEGF.js → ja-Dm2-m9io.js} +7 -6
  36. package/dist/assets/{ko-BqPgaQJb.js → ko-CIXlzQ5-.js} +7 -6
  37. package/dist/assets/main-B7qTsd_w.js +930 -0
  38. package/dist/assets/{main-B2a_JfKZ.js → main-CyQrD9dG.js} +2 -2
  39. package/dist/assets/{markdownCore-CWRCqSH2.js → markdownCore-CeEwGgEK.js} +2 -2
  40. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-7cPEx4sV.js} +2 -2
  41. package/dist/assets/{mobile-QNcFIVCW.js → mobile-QtzkPTM8.js} +2 -2
  42. package/dist/assets/{patchFileDiff-BpMQZnYu.js → patchFileDiff-No55fDiD.js} +1 -1
  43. package/dist/assets/pl-B2zmkOpw.js +16 -0
  44. package/dist/assets/{pt-BR-BqH8fuCH.js → pt-BR-C-JE5H6y.js} +6 -5
  45. package/dist/assets/{renderElectronMiniChatApp-D77bmLqo.js → renderElectronMiniChatApp-ePTkBJNS.js} +2 -2
  46. package/dist/assets/renderMobileApp-D9RjB9DX.js +25 -0
  47. package/dist/assets/runtimeEndpointReset-DfJIWwwo.js +17 -0
  48. package/dist/assets/{scheduledTasksApi-BSRCtP6T.js → scheduledTasksApi-DuhdyYEa.js} +1 -1
  49. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-EHEg58-n.js} +1 -1
  50. package/dist/assets/tr-MJmRIzEY.js +7 -0
  51. package/dist/assets/{uk-BkkfxbOs.js → uk-BmD7eM8k.js} +6 -5
  52. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-CuJPKtI1.js} +151 -180
  53. package/dist/assets/useInlineCommentController-DD7_7OP_.js +2 -0
  54. package/dist/assets/{useRootScrollLock-C0N9mCwz.js → useRootScrollLock-DRGAe8PN.js} +1 -1
  55. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-9ZzoH0o0.js} +2 -2
  56. package/dist/assets/vendor-zod-CK21ihK-.js +39 -0
  57. package/dist/assets/{web-components-BNMwa0qm.js → web-components-PttKBrhi.js} +1 -1
  58. package/dist/assets/{zh-CN-C6FMdVgv.js → zh-CN-JJHl4ehT.js} +6 -5
  59. package/dist/assets/{zh-TW-CdcMjDxv.js → zh-TW-DPc4DEEW.js} +6 -5
  60. package/dist/index.html +5 -5
  61. package/dist/mini-chat.html +4 -4
  62. package/dist/mobile.html +4 -4
  63. package/package.json +8 -4
  64. package/server/index.js +38 -5
  65. package/server/lib/agent-tool/runtime.js +2 -19
  66. package/server/lib/changelog/update-notes.js +82 -0
  67. package/server/lib/changelog/update-notes.test.js +59 -0
  68. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  69. package/server/lib/dev-tunnel/runtime.js +4 -4
  70. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  71. package/server/lib/fs/DOCUMENTATION.md +2 -0
  72. package/server/lib/fs/routes.js +32 -21
  73. package/server/lib/fs/routes.test.js +77 -0
  74. package/server/lib/git/DOCUMENTATION.md +3 -1
  75. package/server/lib/git/routes.js +16 -0
  76. package/server/lib/git/service.js +107 -17
  77. package/server/lib/git/service.test.js +171 -0
  78. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  79. package/server/lib/mcp-reconnect/runtime.js +122 -0
  80. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  81. package/server/lib/message-queue/DOCUMENTATION.md +158 -0
  82. package/server/lib/message-queue/runtime.js +870 -0
  83. package/server/lib/message-queue/runtime.test.js +482 -0
  84. package/server/lib/openchamber-sessions/routes.js +102 -0
  85. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  86. package/server/lib/opencode/DOCUMENTATION.md +13 -5
  87. package/server/lib/opencode/config-paths.test.js +116 -0
  88. package/server/lib/opencode/core-routes.js +1 -0
  89. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  90. package/server/lib/opencode/input-history-scope.js +14 -0
  91. package/server/lib/opencode/managed-plugin-config.js +35 -0
  92. package/server/lib/opencode/openchamber-routes.test.js +2 -2
  93. package/server/lib/opencode/plugins.js +2 -2
  94. package/server/lib/opencode/providers.js +44 -2
  95. package/server/lib/opencode/providers.test.js +131 -4
  96. package/server/lib/opencode/routes.js +4 -4
  97. package/server/lib/opencode/server-startup-runtime.js +1 -1
  98. package/server/lib/opencode/settings-helpers.js +28 -0
  99. package/server/lib/opencode/settings-helpers.test.js +177 -0
  100. package/server/lib/opencode/settings-runtime.js +2 -2
  101. package/server/lib/opencode/settings-runtime.test.js +29 -0
  102. package/server/lib/opencode/shared.js +4 -1
  103. package/server/lib/opencode/shutdown-runtime.js +2 -0
  104. package/server/lib/opencode/skill-routes.js +2 -1
  105. package/server/lib/opencode/snippets.js +1 -2
  106. package/server/lib/package-manager.js +4 -29
  107. package/server/lib/package-manager.test.js +2 -2
  108. package/server/lib/quota/DOCUMENTATION.md +2 -1
  109. package/server/lib/quota/credentials/providers.js +4 -0
  110. package/server/lib/quota/credentials/store.js +1 -1
  111. package/server/lib/quota/credentials/store.test.js +4 -4
  112. package/server/lib/quota/providers/exe-dev.js +75 -0
  113. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  114. package/server/lib/quota/providers/index.js +7 -0
  115. package/server/lib/quota/providers/opencode-go.js +1 -0
  116. package/server/lib/quota/providers/opencode-go.test.js +5 -1
  117. package/server/lib/quota/routes.js +2 -0
  118. package/server/lib/quota/utils/auth.js +1 -1
  119. package/server/lib/relay/DOCUMENTATION.md +1 -1
  120. package/server/lib/relay/host-client.test.js +9 -1
  121. package/server/lib/relay/tunnel-host.js +3 -1
  122. package/server/lib/relay/tunnel-host.test.js +9 -1
  123. package/server/lib/security/request-security.js +21 -6
  124. package/server/lib/security/request-security.test.js +42 -8
  125. package/server/lib/session-assist/runtime.js +1 -0
  126. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  127. package/server/lib/session-goal/create.js +3 -1
  128. package/server/lib/session-goal/runtime.js +77 -11
  129. package/server/lib/session-goal/runtime.test.js +426 -0
  130. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  131. package/server/lib/skills-catalog/install.js +3 -2
  132. package/server/lib/small-model/DOCUMENTATION.md +4 -0
  133. package/server/lib/small-model/call.js +8 -2
  134. package/server/lib/small-model/call.test.js +23 -0
  135. package/server/lib/small-model/index.js +2 -1
  136. package/server/lib/small-model/routes.js +2 -1
  137. package/server/lib/system-prompt/runtime.js +2 -21
  138. package/server/lib/terminal/DOCUMENTATION.md +11 -7
  139. package/server/lib/terminal/runtime.js +180 -26
  140. package/server/lib/terminal/runtime.test.js +553 -26
  141. package/server/lib/terminal/shells.js +17 -0
  142. package/server/lib/terminal/shells.test.js +32 -1
  143. package/server/lib/ui-auth/ui-auth.js +4 -1
  144. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  145. package/server/lib/walkthrough/index.js +1 -0
  146. package/server/lib/walkthrough/routes.test.js +26 -5
  147. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  148. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  149. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  150. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  151. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  152. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  153. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  154. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  155. package/dist/assets/index-L_ncV-6H.css +0 -1
  156. package/dist/assets/index-qES19dlJ.js +0 -7
  157. package/dist/assets/main-CYgSu6dx.js +0 -928
  158. package/dist/assets/pl-slULBF4R.js +0 -15
  159. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  160. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  161. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  162. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  163. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
@@ -196,7 +196,7 @@ const isPathWithinRoot = (resolvedPath, rootPath, path, os) => {
196
196
  return true;
197
197
  };
198
198
 
199
- const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
199
+ const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, managedRoots }) => {
200
200
  const normalized = normalizeDirectoryPath(targetPath);
201
201
  if (!normalized || typeof normalized !== 'string') {
202
202
  return { ok: false, error: 'Path is required' };
@@ -209,8 +209,12 @@ const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDi
209
209
  return { ok: true, base: resolvedBase, resolved };
210
210
  }
211
211
 
212
- if (isPathWithinRoot(resolved, openchamberUserConfigRoot, path, os)) {
213
- return { ok: true, base: path.resolve(openchamberUserConfigRoot), resolved };
212
+ // Managed roots (config root, relocated chats root) stay valid targets
213
+ // even outside the active workspace.
214
+ for (const root of managedRoots) {
215
+ if (isPathWithinRoot(resolved, root, path, os)) {
216
+ return { ok: true, base: path.resolve(root), resolved };
217
+ }
214
218
  }
215
219
 
216
220
  return { ok: false, error: 'Path is outside of active workspace' };
@@ -249,7 +253,7 @@ const resolveWorkspacePathFromWorktrees = async ({ targetPath, baseDirectory, pa
249
253
  return { ok: false, error: 'Path is outside of active workspace' };
250
254
  };
251
255
 
252
- const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
256
+ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, managedRoots }) => {
253
257
  const resolvedProject = await resolveProjectDirectory(req);
254
258
  if (!resolvedProject.directory) {
255
259
  return { ok: false, error: resolvedProject.error || 'Active workspace is required' };
@@ -261,7 +265,7 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
261
265
  path,
262
266
  os,
263
267
  normalizeDirectoryPath,
264
- openchamberUserConfigRoot,
268
+ managedRoots,
265
269
  });
266
270
  if (resolved.ok || resolved.error !== 'Path is outside of active workspace') {
267
271
  return resolved;
@@ -281,7 +285,7 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
281
285
  path,
282
286
  os,
283
287
  normalizeDirectoryPath,
284
- openchamberUserConfigRoot,
288
+ managedRoots,
285
289
  });
286
290
  if (lexical.ok) {
287
291
  return lexical;
@@ -412,7 +416,7 @@ const escapeCloneSshKeyPath = (sshKeyPath) => {
412
416
  return `'${normalized.replace(/'/g, "'\\''")}'`;
413
417
  };
414
418
 
415
- const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProjectDirectory, path, os, fsPromises, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
419
+ const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProjectDirectory, path, os, fsPromises, normalizeDirectoryPath, managedRoots }) => {
416
420
  if (req.query?.allowOutsideWorkspace === 'true') {
417
421
  const normalized = normalizeDirectoryPath(targetPath);
418
422
  if (!normalized || typeof normalized !== 'string') {
@@ -434,7 +438,7 @@ const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProje
434
438
  path,
435
439
  os,
436
440
  normalizeDirectoryPath,
437
- openchamberUserConfigRoot,
441
+ managedRoots,
438
442
  });
439
443
  };
440
444
 
@@ -520,7 +524,14 @@ export const registerFsRoutes = (app, dependencies) => {
520
524
  buildAugmentedPath,
521
525
  resolveGitBinaryForSpawn,
522
526
  openchamberUserConfigRoot,
527
+ managedChatsRoot,
523
528
  } = dependencies;
529
+ // Chat worktrees may live outside every project workspace; both managed
530
+ // roots stay valid filesystem targets.
531
+ const chatsRoot = typeof managedChatsRoot === 'string' && managedChatsRoot.trim()
532
+ ? path.resolve(managedChatsRoot.trim())
533
+ : path.join(openchamberUserConfigRoot, 'chats');
534
+ const managedRoots = [path.resolve(openchamberUserConfigRoot), chatsRoot];
524
535
  const realpathCache = createRealpathCache({
525
536
  realpath: fsPromises.realpath.bind(fsPromises),
526
537
  });
@@ -699,7 +710,7 @@ export const registerFsRoutes = (app, dependencies) => {
699
710
  if (!home || typeof home !== 'string' || home.length === 0) {
700
711
  return res.status(500).json({ error: 'Failed to resolve home directory' });
701
712
  }
702
- return res.json({ home });
713
+ return res.json({ home, chatsRoot });
703
714
  } catch (error) {
704
715
  console.error('Failed to resolve home directory:', error);
705
716
  return res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' });
@@ -725,7 +736,7 @@ export const registerFsRoutes = (app, dependencies) => {
725
736
  path,
726
737
  os,
727
738
  normalizeDirectoryPath,
728
- openchamberUserConfigRoot,
739
+ managedRoots,
729
740
  });
730
741
  if (!resolved.ok) {
731
742
  return res.status(400).json({ error: resolved.error });
@@ -870,7 +881,7 @@ export const registerFsRoutes = (app, dependencies) => {
870
881
  os,
871
882
  fsPromises,
872
883
  normalizeDirectoryPath,
873
- openchamberUserConfigRoot,
884
+ managedRoots,
874
885
  });
875
886
  if (!resolved.ok) {
876
887
  if (req.query?.allowOutsideWorkspace === 'true') {
@@ -920,7 +931,7 @@ export const registerFsRoutes = (app, dependencies) => {
920
931
  os,
921
932
  fsPromises,
922
933
  normalizeDirectoryPath,
923
- openchamberUserConfigRoot,
934
+ managedRoots,
924
935
  });
925
936
  if (!resolved.ok) {
926
937
  if (req.query?.allowOutsideWorkspace === 'true') {
@@ -984,7 +995,7 @@ export const registerFsRoutes = (app, dependencies) => {
984
995
  os,
985
996
  fsPromises,
986
997
  normalizeDirectoryPath,
987
- openchamberUserConfigRoot,
998
+ managedRoots,
988
999
  });
989
1000
  if (!resolved.ok) {
990
1001
  if (req.query?.allowOutsideWorkspace === 'true') {
@@ -1065,7 +1076,7 @@ export const registerFsRoutes = (app, dependencies) => {
1065
1076
  path,
1066
1077
  os,
1067
1078
  normalizeDirectoryPath,
1068
- openchamberUserConfigRoot,
1079
+ managedRoots,
1069
1080
  });
1070
1081
  if (!resolved.ok) {
1071
1082
  return res.status(400).json({ error: resolved.error });
@@ -1117,7 +1128,7 @@ export const registerFsRoutes = (app, dependencies) => {
1117
1128
  path,
1118
1129
  os,
1119
1130
  normalizeDirectoryPath,
1120
- openchamberUserConfigRoot,
1131
+ managedRoots,
1121
1132
  });
1122
1133
  if (!resolved.ok) {
1123
1134
  return res.status(400).json({ error: resolved.error });
@@ -1187,7 +1198,7 @@ export const registerFsRoutes = (app, dependencies) => {
1187
1198
  path,
1188
1199
  os,
1189
1200
  normalizeDirectoryPath,
1190
- openchamberUserConfigRoot,
1201
+ managedRoots,
1191
1202
  });
1192
1203
  if (!resolved.ok) {
1193
1204
  return res.status(400).json({ error: resolved.error });
@@ -1294,7 +1305,7 @@ export const registerFsRoutes = (app, dependencies) => {
1294
1305
  path,
1295
1306
  os,
1296
1307
  normalizeDirectoryPath,
1297
- openchamberUserConfigRoot,
1308
+ managedRoots,
1298
1309
  });
1299
1310
  if (!resolved.ok) {
1300
1311
  return res.status(400).json({ error: resolved.error });
@@ -1332,7 +1343,7 @@ export const registerFsRoutes = (app, dependencies) => {
1332
1343
  path,
1333
1344
  os,
1334
1345
  normalizeDirectoryPath,
1335
- openchamberUserConfigRoot,
1346
+ managedRoots,
1336
1347
  });
1337
1348
  if (!resolvedOld.ok) {
1338
1349
  return res.status(400).json({ error: resolvedOld.error });
@@ -1345,7 +1356,7 @@ export const registerFsRoutes = (app, dependencies) => {
1345
1356
  path,
1346
1357
  os,
1347
1358
  normalizeDirectoryPath,
1348
- openchamberUserConfigRoot,
1359
+ managedRoots,
1349
1360
  });
1350
1361
  if (!resolvedNew.ok) {
1351
1362
  return res.status(400).json({ error: resolvedNew.error });
@@ -1451,7 +1462,7 @@ export const registerFsRoutes = (app, dependencies) => {
1451
1462
  path,
1452
1463
  os,
1453
1464
  normalizeDirectoryPath,
1454
- openchamberUserConfigRoot,
1465
+ managedRoots,
1455
1466
  });
1456
1467
  if (!resolvedForWorkspace.ok) {
1457
1468
  console.warn(`Rejected /api/fs/exec outside workspace: ${resolvedForWorkspace.error}`);
@@ -1689,7 +1700,7 @@ export const registerFsRoutes = (app, dependencies) => {
1689
1700
  path,
1690
1701
  os,
1691
1702
  normalizeDirectoryPath,
1692
- openchamberUserConfigRoot,
1703
+ managedRoots,
1693
1704
  });
1694
1705
  if (!resolved.ok) {
1695
1706
  return res.status(400).json({ error: resolved.error });
@@ -1429,3 +1429,80 @@ describe('fs stat directory scope (issue 3019)', () => {
1429
1429
  expect(res.body.isFile).toBe(true);
1430
1430
  });
1431
1431
  });
1432
+
1433
+ describe('fs managed chats root', () => {
1434
+ const registerWithChatsRoot = ({ managedChatsRoot, fsPromises = {} } = {}) => {
1435
+ const { app, getRoute } = createRouteRegistry();
1436
+ registerFsRoutes(app, {
1437
+ os: { homedir: () => '/home/user' },
1438
+ path: path.posix,
1439
+ fsPromises: {
1440
+ realpath: async (targetPath) => targetPath,
1441
+ mkdir: async () => undefined,
1442
+ ...fsPromises,
1443
+ },
1444
+ spawn: vi.fn(),
1445
+ crypto: { randomUUID: () => 'job-0' },
1446
+ normalizeDirectoryPath: (p) => p,
1447
+ resolveProjectDirectory: async () => ({ directory: '/repo' }),
1448
+ buildAugmentedPath: () => '/usr/bin',
1449
+ resolveGitBinaryForSpawn: () => 'git',
1450
+ openchamberUserConfigRoot: '/home/user/.config/openchamber',
1451
+ managedChatsRoot,
1452
+ });
1453
+ return {
1454
+ home: getRoute('GET', '/api/fs/home'),
1455
+ mkdir: getRoute('POST', '/api/fs/mkdir'),
1456
+ };
1457
+ };
1458
+
1459
+ it('exposes the default chats root next to the home directory', async () => {
1460
+ const { home } = registerWithChatsRoot();
1461
+
1462
+ const res = createMockResponse();
1463
+ await home(undefined, res);
1464
+
1465
+ expect(res.statusCode).toBe(200);
1466
+ expect(res.body.home).toBe('/home/user');
1467
+ expect(res.body.chatsRoot).toBe('/home/user/.config/openchamber/chats');
1468
+ });
1469
+
1470
+ it('exposes a relocated chats root when OPENCHAMBER_CHATS_DIR is configured upstream', async () => {
1471
+ const { home } = registerWithChatsRoot({ managedChatsRoot: '/srv/openchamber-chats' });
1472
+
1473
+ const res = createMockResponse();
1474
+ await home(undefined, res);
1475
+
1476
+ expect(res.statusCode).toBe(200);
1477
+ expect(res.body.chatsRoot).toBe('/srv/openchamber-chats');
1478
+ });
1479
+
1480
+ it('allows mkdir inside the relocated chats root outside the active workspace', async () => {
1481
+ const mkdirCalls = [];
1482
+ const { mkdir } = registerWithChatsRoot({
1483
+ managedChatsRoot: '/srv/openchamber-chats',
1484
+ fsPromises: {
1485
+ mkdir: async (targetPath) => {
1486
+ mkdirCalls.push(targetPath);
1487
+ },
1488
+ },
1489
+ });
1490
+
1491
+ const res = createMockResponse();
1492
+ await mkdir({ body: { path: '/srv/openchamber-chats/2026-08-25/session-a' } }, res);
1493
+
1494
+ expect(res.statusCode).toBe(200);
1495
+ expect(res.body.success).toBe(true);
1496
+ expect(mkdirCalls).toEqual(['/srv/openchamber-chats/2026-08-25/session-a']);
1497
+ });
1498
+
1499
+ it('still rejects mkdir outside the workspace and all managed roots', async () => {
1500
+ const { mkdir } = registerWithChatsRoot({ managedChatsRoot: '/srv/openchamber-chats' });
1501
+
1502
+ const res = createMockResponse();
1503
+ await mkdir({ body: { path: '/etc/passwd-holder' } }, res);
1504
+
1505
+ expect(res.statusCode).toBe(400);
1506
+ expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
1507
+ });
1508
+ });
@@ -39,6 +39,7 @@ The following functions are exported and used by the web server:
39
39
 
40
40
  ### Branch Operations
41
41
  - `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
42
+ - `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream.
42
43
  - `createBranch(directory, branchName, options)`: Create and checkout a new branch.
43
44
  - `checkoutBranch(directory, branchName)`: Checkout an existing branch. A remote-tracking name (`origin/main`, or the `remotes/`-prefixed form) resolves to the local branch of that name, created with `--track` when it does not exist yet, because the branch selector offers remote branches as places to work rather than commits to inspect — a literal checkout of the remote ref would detach HEAD. A local branch whose own name looks like a remote ref wins over that resolution, and anything unresolvable is checked out as requested. The returned `branch` is the branch that was actually checked out, which callers should report instead of the requested name.
44
45
  - `deleteBranch(directory, branch, options)`: Delete a branch (supports force flag).
@@ -48,7 +49,7 @@ The following functions are exported and used by the web server:
48
49
  ### Worktree Operations
49
50
  - `getWorktrees(directory)`: List all git worktrees for a repository.
50
51
  - `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config).
51
- - `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap.
52
+ - `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). When the current tracked branch has no unpublished commits, the UI supplies its remote-tracking ref and this operation fetches that branch once before creating the worktree. A failed fetch falls back to the local branch and reports `sourceFetchFailed`; other remote start refs still require an existing local ref when their fetch fails. After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap.
52
53
  - `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
53
54
  - `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
54
55
 
@@ -142,6 +143,7 @@ The following functions are internal helpers used by exported functions:
142
143
  - `path`: Absolute path to worktree directory.
143
144
  - `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues.
144
145
  - `bootstrapStatus`: Background setup state. The legacy `status` remains `pending`, `ready`, or `failed`, while `phase` reports `directory-created`, `git-ready`, or `setup-ready`. Fast create starts at `pending`/`directory-created`; population and upstream Git completion advances to `pending`/`git-ready` before setup/start scripts; completed setup is `ready`/`setup-ready`. A missing in-memory state falls back to `ready`/`setup-ready`; clients continue to accept legacy status responses that omit `phase`.
146
+ - `sourceFetchFailed`: Present when the automatic source-branch fetch failed and creation fell back to the tracked local branch.
145
147
  - Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup.
146
148
  - Worktree removal waits for any active create/bootstrap task for that directory before deleting it, preventing a background Git or setup task from restoring removed state or racing filesystem cleanup.
147
149
  - Worktree bootstrap retries transient `index.lock` conflicts. If the lock remains byte-for-byte and metadata-identical across the retry window, it is treated as stale, removed, and population continues automatically; changing locks are left untouched and reported as failures.
@@ -873,6 +873,22 @@ export function registerGitRoutes(app) {
873
873
  }
874
874
  });
875
875
 
876
+ app.post('/api/git/branch-push-status', async (req, res) => {
877
+ const { getUnpushedBranchCounts } = await getGitLibraries();
878
+ try {
879
+ const directory = req.query.directory;
880
+ const branches = req.body?.branches;
881
+ if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
882
+ if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) {
883
+ return res.status(400).json({ error: 'branches must be an array of branch names' });
884
+ }
885
+ res.json(await getUnpushedBranchCounts(directory, branches));
886
+ } catch (error) {
887
+ console.error('Failed to get branch push status:', error);
888
+ res.status(500).json({ error: error.message || 'Failed to get branch push status' });
889
+ }
890
+ });
891
+
876
892
  app.post('/api/git/branches', async (req, res) => {
877
893
  const { createBranch } = await getGitLibraries();
878
894
  try {
@@ -684,6 +684,15 @@ const parseWorktreePorcelain = (raw) => {
684
684
  const branchRef = line.substring('branch '.length).trim();
685
685
  current.branchRef = branchRef;
686
686
  current.branch = cleanBranchName(branchRef);
687
+ continue;
688
+ }
689
+
690
+ // git marks a worktree whose directory is gone (deleted outside git) as
691
+ // prunable; it stays registered until `git worktree prune`. The sidebar
692
+ // needs that distinction: the directory is missing, but the sessions that
693
+ // lived there are not.
694
+ if (line === 'prunable' || line.startsWith('prunable ')) {
695
+ current.prunable = true;
687
696
  }
688
697
  }
689
698
 
@@ -3687,6 +3696,35 @@ export async function getBranches(directory) {
3687
3696
  }
3688
3697
  }
3689
3698
 
3699
+ /**
3700
+ * Counts locally unpushed commits for a small caller-supplied set of local
3701
+ * branches. This deliberately reads only local refs: the branch picker calls
3702
+ * it when opened, never polls, and never fetches a remote behind the user's
3703
+ * back. Unknown, remote, and upstream-less branches are omitted.
3704
+ */
3705
+ export async function getUnpushedBranchCounts(directory, branchNames) {
3706
+ const { git } = await createRepositoryGitContext(directory);
3707
+ const requested = [...new Set(Array.isArray(branchNames) ? branchNames : [])]
3708
+ .filter((name) => typeof name === 'string' && name.length > 0)
3709
+ .slice(0, 5);
3710
+ if (requested.length === 0) return { counts: {} };
3711
+
3712
+ const local = new Set((await git.branchLocal()).all);
3713
+ const counts = {};
3714
+ await Promise.all(requested.map(async (branch) => {
3715
+ if (!local.has(branch)) return;
3716
+ const upstream = await git.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`])
3717
+ .then((value) => value.trim())
3718
+ .catch(() => '');
3719
+ if (!upstream) return;
3720
+ const count = await git.raw(['rev-list', '--count', `${upstream}..${branch}`])
3721
+ .then((value) => Number.parseInt(value.trim(), 10))
3722
+ .catch(() => 0);
3723
+ if (Number.isFinite(count) && count > 0) counts[branch] = count;
3724
+ }));
3725
+ return { counts };
3726
+ }
3727
+
3690
3728
  async function getRemoteDefaultBranches(git) {
3691
3729
  let defaults = {};
3692
3730
 
@@ -4007,6 +4045,7 @@ export async function getWorktrees(directory) {
4007
4045
  name: path.basename(entry.worktree || ''),
4008
4046
  branch: entry.branch || '',
4009
4047
  path: entry.worktree,
4048
+ prunable: entry.prunable === true,
4010
4049
  }));
4011
4050
  } catch (error) {
4012
4051
  // Worktrees are an optional feature. When the caller passes a directory
@@ -4266,6 +4305,7 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
4266
4305
 
4267
4306
  const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
4268
4307
  if (parsedRemoteStartRef) {
4308
+ worktreeAddArgs.splice(2, 0, '--no-track');
4269
4309
  inferredUpstream = {
4270
4310
  remote: parsedRemoteStartRef.remote,
4271
4311
  branch: parsedRemoteStartRef.branch,
@@ -4273,24 +4313,17 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
4273
4313
  }
4274
4314
  }
4275
4315
 
4276
- if (ensureRemoteName && ensureRemoteUrl) {
4316
+ if (mode === 'existing' && ensureRemoteName && ensureRemoteUrl) {
4277
4317
  await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
4278
4318
  }
4279
4319
 
4280
- if (mode === 'new') {
4281
- const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
4282
- if (parsedRemoteStartRef) {
4283
- await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
4284
- }
4285
- }
4286
-
4287
4320
  await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
4288
4321
 
4289
4322
  const upstreamRemote = shouldSetUpstream
4290
- ? String(inferredUpstream?.remote || input?.upstreamRemote || '').trim()
4323
+ ? String(input?.upstreamRemote || inferredUpstream?.remote || '').trim()
4291
4324
  : '';
4292
4325
  const upstreamBranch = shouldSetUpstream
4293
- ? String(inferredUpstream?.branch || input?.upstreamBranch || '').trim()
4326
+ ? String(input?.upstreamBranch || inferredUpstream?.branch || '').trim()
4294
4327
  : '';
4295
4328
 
4296
4329
  const bootstrapStatus = setWorktreeBootstrapState(
@@ -4325,6 +4358,50 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
4325
4358
  };
4326
4359
  }
4327
4360
 
4361
+ const prepareWorktreeCreateSource = async (context, input = {}) => {
4362
+ if (input?.mode === 'existing') {
4363
+ return { input, sourceFetchFailed: false };
4364
+ }
4365
+
4366
+ const startRef = normalizeStartRef(input?.startRef);
4367
+ const remoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
4368
+ if (!remoteStartRef) {
4369
+ return { input, sourceFetchFailed: false };
4370
+ }
4371
+
4372
+ const status = await getStatus(context.primaryWorktree, { mode: 'light' }).catch(() => null);
4373
+ const trackingRef = status?.tracking
4374
+ ? await resolveRemoteBranchRef(context.primaryWorktree, status.tracking)
4375
+ : null;
4376
+ const canFallbackToLocal = Boolean(
4377
+ status?.current
4378
+ && status.ahead === 0
4379
+ && trackingRef?.fullRef === remoteStartRef.fullRef
4380
+ );
4381
+
4382
+ try {
4383
+ await fetchRemoteBranchRef(context.primaryWorktree, remoteStartRef.remote, remoteStartRef.branch);
4384
+ return { input, sourceFetchFailed: false };
4385
+ } catch (error) {
4386
+ if (canFallbackToLocal) {
4387
+ return {
4388
+ input: { ...input, startRef: status.current },
4389
+ sourceFetchFailed: true,
4390
+ };
4391
+ }
4392
+
4393
+ const refExists = await runGitCommand(
4394
+ context.primaryWorktree,
4395
+ ['show-ref', '--verify', '--quiet', remoteStartRef.fullRef]
4396
+ );
4397
+ if (!refExists.success) {
4398
+ throw error;
4399
+ }
4400
+ console.warn(`Worktree create: failed to refresh ${remoteStartRef.remote}/${remoteStartRef.branch}, proceeding with the existing remote-tracking ref`);
4401
+ return { input, sourceFetchFailed: false };
4402
+ }
4403
+ };
4404
+
4328
4405
  export async function createWorktree(directory, input = {}) {
4329
4406
  const mode = input?.mode === 'existing' ? 'existing' : 'new';
4330
4407
  const context = await resolveWorktreeProjectContext(directory);
@@ -4333,10 +4410,18 @@ export async function createWorktree(directory, input = {}) {
4333
4410
  await assertWorktreeCreatePreflight(directory, input);
4334
4411
  }
4335
4412
 
4413
+ const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
4414
+ const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
4415
+ if (ensureRemoteName && ensureRemoteUrl) {
4416
+ await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
4417
+ }
4418
+ const prepared = await prepareWorktreeCreateSource(context, input);
4419
+ const preparedInput = prepared.input;
4420
+
4336
4421
  await fsp.mkdir(context.worktreeRoot, { recursive: true });
4337
4422
 
4338
- const preferredName = String(input?.worktreeName || input?.name || '').trim();
4339
- const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
4423
+ const preferredName = String(preparedInput?.worktreeName || preparedInput?.name || '').trim();
4424
+ const preferredBranchName = cleanBranchName(String(preparedInput?.branchName || '').trim());
4340
4425
 
4341
4426
  const candidate = await resolveCandidateDirectory(
4342
4427
  context.worktreeRoot,
@@ -4345,7 +4430,7 @@ export async function createWorktree(directory, input = {}) {
4345
4430
  context.primaryWorktree
4346
4431
  );
4347
4432
 
4348
- if (input?.returnAfterDirectoryCreated === true) {
4433
+ if (preparedInput?.returnAfterDirectoryCreated === true) {
4349
4434
  await fsp.mkdir(candidate.directory, { recursive: false });
4350
4435
 
4351
4436
  const bootstrapStatus = setWorktreeBootstrapState(
@@ -4354,10 +4439,10 @@ export async function createWorktree(directory, input = {}) {
4354
4439
  WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
4355
4440
  );
4356
4441
  const localBranch = mode === 'existing'
4357
- ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
4442
+ ? cleanBranchName(String(preparedInput?.branchName || preparedInput?.existingBranch || candidate.branch || '').trim())
4358
4443
  : candidate.branch;
4359
4444
 
4360
- const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
4445
+ const task = attachGitWorktreeToCandidate(context, candidate, preparedInput).catch(async (error) => {
4361
4446
  setWorktreeBootstrapState(
4362
4447
  candidate.directory,
4363
4448
  WORKTREE_BOOTSTRAP_FAILED,
@@ -4369,7 +4454,7 @@ export async function createWorktree(directory, input = {}) {
4369
4454
  });
4370
4455
  trackWorktreeBootstrapTask(candidate.directory, task);
4371
4456
 
4372
- return {
4457
+ const result = {
4373
4458
  head: '',
4374
4459
  name: candidate.name,
4375
4460
  branch: localBranch,
@@ -4377,9 +4462,14 @@ export async function createWorktree(directory, input = {}) {
4377
4462
  directoryCreated: true,
4378
4463
  bootstrapStatus,
4379
4464
  };
4465
+ if (prepared.sourceFetchFailed) {
4466
+ result.sourceFetchFailed = true;
4467
+ }
4468
+ return result;
4380
4469
  }
4381
4470
 
4382
- return attachGitWorktreeToCandidate(context, candidate, input);
4471
+ const result = await attachGitWorktreeToCandidate(context, candidate, preparedInput);
4472
+ return prepared.sourceFetchFailed ? { ...result, sourceFetchFailed: true } : result;
4383
4473
  }
4384
4474
 
4385
4475
  export async function getWorktreeBootstrapStatus(directory) {