@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
@@ -12,6 +12,7 @@ import {
12
12
  createWorktree,
13
13
  getWorktreeBootstrapStatus,
14
14
  getBranches,
15
+ getUnpushedBranchCounts,
15
16
  getRangeDiff,
16
17
  getStatus,
17
18
  getWorktrees,
@@ -54,6 +55,14 @@ const runGit = (cwd, args) =>
54
55
  stdio: ['ignore', 'pipe', 'pipe'],
55
56
  });
56
57
 
58
+ const readBranchConfig = (cwd, branch, key) => {
59
+ try {
60
+ return runGit(cwd, ['config', '--get', `branch.${branch}.${key}`]).trim();
61
+ } catch {
62
+ return '';
63
+ }
64
+ };
65
+
57
66
  /**
58
67
  * A repository on `next` whose only remote publishes `defaultBranch` and has it
59
68
  * recorded as that remote's HEAD — the shape of every repository whose default
@@ -508,6 +517,24 @@ describe('getWorktrees', () => {
508
517
  expect(Array.isArray(result)).toBe(true);
509
518
  expect(warnSpy).not.toHaveBeenCalled();
510
519
  });
520
+ it('flags a worktree whose directory was deleted outside git as prunable', async () => {
521
+ const repo = createTempDir();
522
+ runGit(repo, ['init', '-b', 'main']);
523
+ runGit(repo, ['config', 'user.email', 'test@example.com']);
524
+ runGit(repo, ['config', 'user.name', 'Test User']);
525
+ runGit(repo, ['commit', '--allow-empty', '-m', 'init']);
526
+ const worktreePath = path.join(createTempDir(), 'feature');
527
+ runGit(repo, ['worktree', 'add', worktreePath, '-b', 'feature']);
528
+
529
+ const before = await getWorktrees(repo);
530
+ expect(before.find((entry) => entry.branch === 'feature')).toMatchObject({ prunable: false });
531
+
532
+ fs.rmSync(worktreePath, { recursive: true, force: true });
533
+
534
+ const after = await getWorktrees(repo);
535
+ expect(after.find((entry) => entry.branch === 'feature')).toMatchObject({ path: expect.any(String), prunable: true });
536
+ expect(after.find((entry) => entry.branch === 'main')).toMatchObject({ prunable: false });
537
+ });
511
538
  });
512
539
 
513
540
  // ---------------------------------------------------------------------------
@@ -854,6 +881,135 @@ describe('createWorktree', () => {
854
881
  }
855
882
  }
856
883
  });
884
+
885
+ it('does not auto-track the remote start ref when creating a new branch from it', async () => {
886
+ if (!canRunGit()) return;
887
+
888
+ const previousXdgDataHome = process.env.XDG_DATA_HOME;
889
+ const dataHome = createTempDir();
890
+ process.env.XDG_DATA_HOME = dataHome;
891
+
892
+ try {
893
+ const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' });
894
+
895
+ const created = await createWorktree(repository, {
896
+ mode: 'new',
897
+ branchName: 'openchamber/feature',
898
+ worktreeName: 'feature-wt',
899
+ startRef: 'remotes/origin/main',
900
+ setUpstream: true,
901
+ upstreamRemote: 'origin',
902
+ upstreamBranch: 'openchamber/feature',
903
+ });
904
+
905
+ expect(created.branch).toBe('openchamber/feature');
906
+
907
+ await expect.poll(
908
+ () => getWorktreeBootstrapStatus(created.path).then((status) => status.status === 'ready' || status.status === 'failed'),
909
+ { timeout: 5_000 }
910
+ ).toBe(true);
911
+
912
+ expect(readBranchConfig(created.path, 'openchamber/feature', 'remote')).toBe('');
913
+ expect(readBranchConfig(created.path, 'openchamber/feature', 'merge')).toBe('');
914
+ } finally {
915
+ if (previousXdgDataHome === undefined) {
916
+ delete process.env.XDG_DATA_HOME;
917
+ } else {
918
+ process.env.XDG_DATA_HOME = previousXdgDataHome;
919
+ }
920
+ }
921
+ }, 30_000);
922
+
923
+ it('falls back to the remote start ref for upstream tracking when no explicit keys are given', async () => {
924
+ if (!canRunGit()) return;
925
+
926
+ const previousXdgDataHome = process.env.XDG_DATA_HOME;
927
+ const dataHome = createTempDir();
928
+ process.env.XDG_DATA_HOME = dataHome;
929
+
930
+ try {
931
+ const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' });
932
+
933
+ const created = await createWorktree(repository, {
934
+ mode: 'new',
935
+ branchName: 'openchamber/fallback-wt',
936
+ worktreeName: 'fallback-wt',
937
+ startRef: 'remotes/origin/main',
938
+ setUpstream: true,
939
+ });
940
+
941
+ await expect.poll(
942
+ () => readBranchConfig(created.path, 'openchamber/fallback-wt', 'merge'),
943
+ { timeout: 5_000 }
944
+ ).toBe('refs/heads/main');
945
+ expect(readBranchConfig(created.path, 'openchamber/fallback-wt', 'remote')).toBe('origin');
946
+ } finally {
947
+ if (previousXdgDataHome === undefined) {
948
+ delete process.env.XDG_DATA_HOME;
949
+ } else {
950
+ process.env.XDG_DATA_HOME = previousXdgDataHome;
951
+ }
952
+ }
953
+ }, 30_000);
954
+
955
+ it('falls back to the tracked local branch when the source fetch fails', async () => {
956
+ if (!canRunGit()) return;
957
+
958
+ const previousXdgDataHome = process.env.XDG_DATA_HOME;
959
+ const dataHome = createTempDir();
960
+ process.env.XDG_DATA_HOME = dataHome;
961
+
962
+ try {
963
+ const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' });
964
+ runGit(repository, ['branch', '--set-upstream-to=origin/main', 'next']);
965
+ runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']);
966
+
967
+ const created = await createWorktree(repository, {
968
+ mode: 'new',
969
+ branchName: 'openchamber/stale-ref-wt',
970
+ worktreeName: 'stale-ref-wt',
971
+ startRef: 'remotes/origin/main',
972
+ });
973
+
974
+ expect(created.branch).toBe('openchamber/stale-ref-wt');
975
+ expect(created.sourceFetchFailed).toBe(true);
976
+ const expectedHead = runGit(repository, ['rev-parse', 'next']).trim();
977
+ expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(expectedHead);
978
+ } finally {
979
+ if (previousXdgDataHome === undefined) {
980
+ delete process.env.XDG_DATA_HOME;
981
+ } else {
982
+ process.env.XDG_DATA_HOME = previousXdgDataHome;
983
+ }
984
+ }
985
+ }, 30_000);
986
+
987
+ it('rejects creation from a remote start ref that was never fetched and cannot be fetched', async () => {
988
+ if (!canRunGit()) return;
989
+
990
+ const previousXdgDataHome = process.env.XDG_DATA_HOME;
991
+ const dataHome = createTempDir();
992
+ process.env.XDG_DATA_HOME = dataHome;
993
+
994
+ try {
995
+ const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' });
996
+ runGit(repository, ['update-ref', '-d', 'refs/remotes/origin/main']);
997
+ runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']);
998
+
999
+ await expect(createWorktree(repository, {
1000
+ mode: 'new',
1001
+ branchName: 'openchamber/never-fetched-wt',
1002
+ worktreeName: 'never-fetched-wt',
1003
+ startRef: 'remotes/origin/main',
1004
+ })).rejects.toThrow(/does not appear to be a git repository|Could not read from remote repository/i);
1005
+ } finally {
1006
+ if (previousXdgDataHome === undefined) {
1007
+ delete process.env.XDG_DATA_HOME;
1008
+ } else {
1009
+ process.env.XDG_DATA_HOME = previousXdgDataHome;
1010
+ }
1011
+ }
1012
+ }, 30_000);
857
1013
  });
858
1014
 
859
1015
  // ---------------------------------------------------------------------------
@@ -1498,6 +1654,21 @@ describe.runIf(canRunGit())('getBranches', () => {
1498
1654
  });
1499
1655
  });
1500
1656
 
1657
+ describe.runIf(canRunGit())('getUnpushedBranchCounts', () => {
1658
+ it('counts only commits ahead of a locally known upstream', async () => {
1659
+ const { repository } = createRepositoryWithRemote();
1660
+ runGit(repository, ['branch', '--set-upstream-to=origin/react', 'next']);
1661
+ fs.writeFileSync(path.join(repository, 'ahead.txt'), 'ahead\n');
1662
+ runGit(repository, ['add', 'ahead.txt']);
1663
+ runGit(repository, ['commit', '-m', 'ahead']);
1664
+ runGit(repository, ['checkout', '-b', 'no-upstream']);
1665
+
1666
+ await expect(getUnpushedBranchCounts(repository, ['next', 'no-upstream', 'remotes/origin/react'])).resolves.toEqual({
1667
+ counts: { next: 1 },
1668
+ });
1669
+ });
1670
+ });
1671
+
1501
1672
  describe.runIf(canRunGit())('getRangeDiff', () => {
1502
1673
  it('resolves a base that exists only on a remote other than origin', async () => {
1503
1674
  const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
@@ -0,0 +1,55 @@
1
+ # Managed MCP Reconnect
2
+
3
+ ## Purpose
4
+
5
+ OpenCode connects each configured MCP server once, when a project directory is
6
+ first used. A server that does not come up then is marked `failed` and never
7
+ retried; a server whose live connection later drops is marked `failed` too and
8
+ stays that way until OpenCode restarts. This module injects a small plugin into
9
+ the OpenCode process OpenChamber launches that reconnects those servers, so a
10
+ server that was slow to start or crashed mid-session comes back on its own.
11
+
12
+ ## Runtime flow
13
+
14
+ 1. `prepareManagedOpenCodeEnv(configContent)` materializes the plugin under
15
+ `<openchamber-data-dir>/mcp-reconnect/` and appends its `file://` URL to
16
+ `OPENCODE_CONFIG_CONTENT` through the shared merge in
17
+ `packages/web/server/lib/opencode/managed-plugin-config.js`.
18
+ 2. It is always on for managed OpenCode. There is no setting, because it only
19
+ acts on servers OpenCode has already given up on.
20
+ 3. OpenCode loads the plugin once per project directory with an SDK client
21
+ scoped to that directory, so each directory reconnects its own servers.
22
+ 4. The plugin reads MCP status one second after load, calls connect for every
23
+ server in the `failed` state, then re-reads status after a per-server delay
24
+ that doubles from one second to a cap of thirty. A server seen in any other
25
+ state resets its counter. While nothing is failed it checks every thirty
26
+ seconds.
27
+ 5. A dropped connection publishes `mcp.tools.changed`, which the plugin uses to
28
+ check right away instead of waiting out the idle interval.
29
+ 6. OpenCode calls the plugin's `dispose` hook when it tears the directory down,
30
+ which stops the loop.
31
+
32
+ ## Invariants
33
+
34
+ - Only `failed` is retried. `disabled` is the user's choice, and `needs_auth`
35
+ or `needs_client_registration` need the user to act; retrying those would
36
+ either re-enable a server the user turned off or loop on a login prompt.
37
+ - The plugin logs nothing and swallows every error. OpenCode already logs each
38
+ failed attempt, and a status call failing during an OpenCode restart is not
39
+ news.
40
+ - One check runs at a time; a wake-up arriving during a check is honored once
41
+ it finishes rather than starting a second loop.
42
+
43
+ ## What the UI sees
44
+
45
+ OpenCode publishes no event when a reconnect succeeds. The chat picks the
46
+ server up on the next prompt because tools are resolved from live state, but
47
+ the MCP page reads status on bootstrap and refresh, so it can show `failed` for
48
+ a while after the server is back.
49
+
50
+ ## Runtime parity
51
+
52
+ - Web and Desktop managed OpenCode: injected automatically.
53
+ - External OpenCode (`OPENCODE_HOST` or skip-start) and VS Code's separate
54
+ OpenCode lifecycle: not injected, because OpenChamber does not control that
55
+ process environment.
@@ -0,0 +1,122 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { appendManagedPlugin } from '../opencode/managed-plugin-config.js';
3
+
4
+ /**
5
+ * OpenCode marks an MCP server `failed` when it does not come up at startup or
6
+ * when a live connection drops, and never tries again. This plugin runs inside
7
+ * the managed OpenCode process and reconnects those servers with a per-server
8
+ * exponential backoff, so a server that was merely slow to start, or a local
9
+ * one that crashed, comes back without an OpenCode restart.
10
+ *
11
+ * Only `failed` is retried. `needs_auth`, `needs_client_registration`, and
12
+ * `disabled` are user decisions or need user action, and retrying them would
13
+ * either loop on a login prompt or re-enable a server the user turned off.
14
+ *
15
+ * The plugin talks to OpenCode through the SDK client OpenCode hands it, which
16
+ * is scoped to one project directory, so each open directory reconnects its
17
+ * own servers. Nothing is logged: OpenCode already reports each failed attempt.
18
+ */
19
+ const createPluginSource = () => String.raw`
20
+ const INITIAL_RETRY_MS = 1000
21
+ const MAX_RETRY_MS = 30000
22
+ const IDLE_CHECK_MS = 30000
23
+
24
+ export const OpenChamberMcpReconnectPlugin = async ({ client }) => {
25
+ let disposed = false
26
+ let running = false
27
+ let wakeRequested = false
28
+ let timer
29
+ // Consecutive failed attempts per server, cleared once it is seen healthy.
30
+ const attempts = new Map()
31
+ const dueAt = new Map()
32
+
33
+ const schedule = (delayMs) => {
34
+ if (disposed) return
35
+ clearTimeout(timer)
36
+ timer = setTimeout(tick, delayMs)
37
+ }
38
+
39
+ const tick = async () => {
40
+ if (running) {
41
+ wakeRequested = true
42
+ return
43
+ }
44
+ running = true
45
+ let delay = IDLE_CHECK_MS
46
+ try {
47
+ const statuses = (await client.mcp.status())?.data ?? {}
48
+ const now = Date.now()
49
+ const due = []
50
+ for (const [name, entry] of Object.entries(statuses)) {
51
+ if (entry?.status !== "failed") {
52
+ attempts.delete(name)
53
+ dueAt.delete(name)
54
+ continue
55
+ }
56
+ const at = dueAt.get(name) ?? now
57
+ if (at > now) {
58
+ delay = Math.min(delay, at - now)
59
+ continue
60
+ }
61
+ due.push(name)
62
+ }
63
+ for (const name of attempts.keys()) {
64
+ if (!Object.hasOwn(statuses, name)) {
65
+ attempts.delete(name)
66
+ dueAt.delete(name)
67
+ }
68
+ }
69
+
70
+ await Promise.allSettled(due.map((name) => client.mcp.connect({ path: { name } })))
71
+
72
+ // The result is read on the next tick: a server that came back clears
73
+ // its counter there, one still failed waits out its backoff.
74
+ const after = Date.now()
75
+ for (const name of due) {
76
+ const count = (attempts.get(name) ?? 0) + 1
77
+ const wait = Math.min(INITIAL_RETRY_MS * 2 ** (count - 1), MAX_RETRY_MS)
78
+ attempts.set(name, count)
79
+ dueAt.set(name, after + wait)
80
+ delay = Math.min(delay, wait)
81
+ }
82
+ } catch {
83
+ // Status is unavailable while OpenCode is shutting down or restarting;
84
+ // the idle check picks up again when it is back.
85
+ } finally {
86
+ running = false
87
+ const wake = wakeRequested
88
+ wakeRequested = false
89
+ schedule(wake ? INITIAL_RETRY_MS : delay)
90
+ }
91
+ }
92
+
93
+ schedule(INITIAL_RETRY_MS)
94
+
95
+ return {
96
+ // A dropped connection publishes this event; checking right away beats
97
+ // waiting out the idle interval.
98
+ event: async ({ event }) => {
99
+ if (event?.type === "mcp.tools.changed") schedule(INITIAL_RETRY_MS)
100
+ },
101
+ dispose: async () => {
102
+ disposed = true
103
+ clearTimeout(timer)
104
+ },
105
+ }
106
+ }
107
+ `;
108
+
109
+ export const createMcpReconnectRuntime = ({ fsPromises, path, dataDir }) => {
110
+ const pluginDirectory = path.join(dataDir, 'mcp-reconnect');
111
+ const pluginPath = path.join(pluginDirectory, 'openchamber-mcp-reconnect-plugin.js');
112
+
113
+ const prepareManagedOpenCodeEnv = async (rawConfig) => {
114
+ await fsPromises.mkdir(pluginDirectory, { recursive: true });
115
+ await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
116
+ return {
117
+ OPENCODE_CONFIG_CONTENT: appendManagedPlugin(rawConfig, pathToFileURL(pluginPath).href, 'MCP reconnect plugin'),
118
+ };
119
+ };
120
+
121
+ return { prepareManagedOpenCodeEnv };
122
+ };
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import { createMcpReconnectRuntime } from './runtime.js';
8
+
9
+ const temporaryDirectories = [];
10
+ const disposers = [];
11
+
12
+ afterEach(async () => {
13
+ await Promise.all(disposers.splice(0).map((dispose) => dispose()));
14
+ vi.useRealTimers();
15
+ await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
16
+ });
17
+
18
+ const materialize = async (rawConfig = '{}') => {
19
+ const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-mcp-reconnect-'));
20
+ temporaryDirectories.push(dataDir);
21
+ const runtime = createMcpReconnectRuntime({ fsPromises: fs, path, dataDir });
22
+ const prepared = await runtime.prepareManagedOpenCodeEnv(rawConfig);
23
+ const pluginPath = path.join(dataDir, 'mcp-reconnect', 'openchamber-mcp-reconnect-plugin.js');
24
+ const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}-${Math.random()}`);
25
+ return { prepared, pluginPath, plugin: pluginModule.OpenChamberMcpReconnectPlugin };
26
+ };
27
+
28
+ /**
29
+ * A stand-in for the SDK client OpenCode hands to plugins. `statuses` is live
30
+ * state the test mutates; `connect` records when each attempt happened.
31
+ */
32
+ const createClient = (statuses, { onConnect } = {}) => {
33
+ const attempts = [];
34
+ return {
35
+ attempts,
36
+ statuses,
37
+ mcp: {
38
+ status: vi.fn(async () => ({ data: { ...statuses } })),
39
+ connect: vi.fn(async ({ path: { name } }) => {
40
+ attempts.push({ name, at: Date.now() });
41
+ onConnect?.(name);
42
+ return { data: true };
43
+ }),
44
+ },
45
+ };
46
+ };
47
+
48
+ const start = async (plugin, client) => {
49
+ const hooks = await plugin({ client });
50
+ disposers.push(hooks.dispose);
51
+ return hooks;
52
+ };
53
+
54
+ describe('managed MCP reconnect runtime', () => {
55
+ it('materializes the plugin and preserves existing plugin entries', async () => {
56
+ const { prepared, pluginPath } = await materialize('{ "plugin": ["file:///existing.js"], "model": "test/model" }');
57
+ const config = JSON.parse(prepared.OPENCODE_CONFIG_CONTENT);
58
+ expect(config.model).toBe('test/model');
59
+ expect(config.plugin).toEqual(['file:///existing.js', pathToFileURL(pluginPath).href]);
60
+ });
61
+
62
+ it('reconnects only servers in the failed state', async () => {
63
+ vi.useFakeTimers();
64
+ const { plugin } = await materialize();
65
+ const client = createClient({
66
+ broken: { status: 'failed', error: 'spawn ENOENT' },
67
+ healthy: { status: 'connected' },
68
+ off: { status: 'disabled' },
69
+ login: { status: 'needs_auth' },
70
+ registration: { status: 'needs_client_registration', error: 'no client id' },
71
+ });
72
+ await start(plugin, client);
73
+
74
+ await vi.advanceTimersByTimeAsync(1000);
75
+
76
+ expect(client.attempts.map((attempt) => attempt.name)).toEqual(['broken']);
77
+ });
78
+
79
+ it('backs off per server up to a cap while it keeps failing', async () => {
80
+ vi.useFakeTimers();
81
+ const { plugin } = await materialize();
82
+ const client = createClient({ broken: { status: 'failed', error: 'refused' } });
83
+ const started = Date.now();
84
+ await start(plugin, client);
85
+
86
+ await vi.advanceTimersByTimeAsync(92_000);
87
+
88
+ expect(client.attempts.map((attempt) => attempt.at - started)).toEqual([
89
+ 1000, 2000, 4000, 8000, 16_000, 32_000, 62_000, 92_000,
90
+ ]);
91
+ });
92
+
93
+ it('stops retrying once a server is back and starts fresh when it drops again', async () => {
94
+ vi.useFakeTimers();
95
+ const { plugin } = await materialize();
96
+ const statuses = { flaky: { status: 'failed', error: 'refused' } };
97
+ const client = createClient(statuses, {
98
+ onConnect: () => { statuses.flaky = { status: 'connected' }; },
99
+ });
100
+ const hooks = await start(plugin, client);
101
+
102
+ await vi.advanceTimersByTimeAsync(60_000);
103
+ expect(client.attempts).toHaveLength(1);
104
+
105
+ statuses.flaky = { status: 'failed', error: 'Connection closed' };
106
+ await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'flaky' } } });
107
+ await vi.advanceTimersByTimeAsync(1000);
108
+
109
+ expect(client.attempts).toHaveLength(2);
110
+ expect(client.attempts[1].at - client.attempts[0].at).toBeGreaterThan(30_000);
111
+ });
112
+
113
+ it('keeps going when status is temporarily unavailable', async () => {
114
+ vi.useFakeTimers();
115
+ const { plugin } = await materialize();
116
+ const client = createClient({ broken: { status: 'failed', error: 'refused' } });
117
+ client.mcp.status.mockRejectedValueOnce(new Error('ECONNREFUSED'));
118
+ await start(plugin, client);
119
+
120
+ await vi.advanceTimersByTimeAsync(1000);
121
+ expect(client.attempts).toHaveLength(0);
122
+
123
+ await vi.advanceTimersByTimeAsync(30_000);
124
+ expect(client.attempts).toHaveLength(1);
125
+ });
126
+
127
+ it('does nothing after dispose', async () => {
128
+ vi.useFakeTimers();
129
+ const { plugin } = await materialize();
130
+ const client = createClient({ broken: { status: 'failed', error: 'refused' } });
131
+ const hooks = await plugin({ client });
132
+
133
+ await hooks.dispose();
134
+ await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'broken' } } });
135
+ await vi.advanceTimersByTimeAsync(120_000);
136
+
137
+ expect(client.mcp.status).not.toHaveBeenCalled();
138
+ expect(client.attempts).toHaveLength(0);
139
+ });
140
+ });
@@ -0,0 +1,158 @@
1
+ # Message Queue
2
+
3
+ ## Purpose
4
+
5
+ Owns the messages a user queued while a session was busy, and sends them the
6
+ moment the session goes idle. The queue lives in the web server so a closed
7
+ tab, a locked phone, or a dropped connection no longer strands it. Structural
8
+ template: `permission-auto-accept` — the server is authoritative, the shared UI
9
+ renders a projection, and VS Code (which has no server of its own) keeps its
10
+ UI-side queue and foreground auto-send hook.
11
+
12
+ ## Files
13
+
14
+ - `runtime.js` — `createMessageQueueRuntime(...)` (state, persistence,
15
+ dispatch loop, event handling) and `registerMessageQueueRoutes(app, runtime)`.
16
+ - `runtime.test.js` — delivery, idleness gates, retries, holds, persistence,
17
+ concurrency with in-flight sends, slash commands, and project knowledge.
18
+
19
+ Wiring: created in `server/index.js` after the global event hub and the
20
+ session-knowledge runtime; routes registered in
21
+ `opencode/feature-routes-runtime.js` (before the generic OpenCode proxy) with
22
+ JSON bodies enabled in `opencode/core-routes.js`; stopped by
23
+ `opencode/shutdown-runtime.js`.
24
+
25
+ ## Item
26
+
27
+ An item is what the UI would have sent itself, captured at queue time so the
28
+ send never re-resolves mutable UI state:
29
+
30
+ ```
31
+ {
32
+ id, createdAt,
33
+ content, // raw text for display and editing
34
+ text, // text to deliver (agent mention stripped, file mentions resolved); defaults to content
35
+ agentMention?, // delivered as an `agent` part
36
+ attachments: [{ id, filename, mimeType, size, source, serverPath?, dataUrl }],
37
+ context: [ // what the composer had attached, in send order
38
+ { kind: 'context', text, metadata, instructions? }, // a draft chip or linked issue/PR; metadata is the UI's structured payload
39
+ { kind: 'instruction', text }, // derived from the text (skill instruction)
40
+ { kind: 'synthetic', text }, // handed to the composer by another surface
41
+ ],
42
+ sendConfig: { providerID, modelID, agent?, variant? } // required
43
+ }
44
+ ```
45
+
46
+ The server is a courier for `context`: it validates the shape (a kind it
47
+ knows, a `metadata` object on `context` entries) and delivers each entry as a
48
+ synthetic text part, an entry's `instructions` going out as its own part just
49
+ before it and its `metadata` riding the part verbatim so the timeline renders
50
+ the context block back. The payload inside `metadata` is the UI's contract
51
+ (`lib/messages/contextParts.ts`), parsed by the UI on the way back.
52
+
53
+ `parseQueuedItemInput` rejects anything the server could not deliver later
54
+ (no text, attachments, or context; missing model; malformed attachment or
55
+ context entry). Public snapshots and broadcasts strip the payloads —
56
+ attachment `dataUrl` (megabytes of base64) and `context` (a PR diff, say) —
57
+ so they do not ride every update; the only way to get them back is a `take`.
58
+
59
+ ## Persistence
60
+
61
+ `<data-dir>/message-queue.json` (`OPENCHAMBER_DATA_DIR` or
62
+ `~/.config/openchamber`): `{ version, revision, sessions: { [sessionId]:
63
+ { directory, items } } }`, written atomically (temp file + rename) through a
64
+ serialized write chain. A missing file is an empty queue. A malformed file is
65
+ a failure, not an empty queue: it is moved aside as
66
+ `message-queue.json.corrupt-<timestamp>` before the runtime starts empty, so
67
+ the next write cannot overwrite the user's data. A failed read leaves writes
68
+ disabled until a later load succeeds. `revision` is a global monotonic counter
69
+ bumped on every mutation; clients use it to reject stale snapshots.
70
+
71
+ In-memory only, deliberately: the in-flight item (`sendingId`), retry
72
+ backoff, abort timestamps, and holds. A restart has no in-flight sends; a
73
+ persisted "sending" flag would strand a message forever.
74
+
75
+ ## Delivery loop
76
+
77
+ 1. `start()` subscribes to the global upstream hub and loads the file; on
78
+ load and on every hub `connect` it arms every session that has items.
79
+ 2. `session.status` for a queued session: `idle` arms a short quiet timer
80
+ (500 ms, coalescing the burst around a turn boundary), `busy`/`retry`
81
+ clears it. A `message.updated` for a completed assistant reply arms as
82
+ well, so a missed idle event cannot strand the queue. `session.deleted`
83
+ drops the session's queue. An assistant `MessageAbortedError` records an
84
+ abort.
85
+ 3. `tick(sessionId)` bails when the queue is empty, an item is in flight, or
86
+ the session is held. It re-arms after a 2 s post-abort hold (the UI's
87
+ old behavior: a stop is not immediately followed by the next prompt) or
88
+ while the head item is in retry backoff.
89
+ 4. Idleness is re-verified against OpenCode before sending, because
90
+ `prompt_async` into a running turn steers into it instead of starting the
91
+ next one: `GET /session/status` must not list the session as busy/retry,
92
+ and the trailing message must not be an unfinished assistant reply (the
93
+ status map only lists busy sessions, so a missed busy event leaves no
94
+ entry while a turn still streams). A failed fetch is unknown, never idle:
95
+ the tick re-arms with backoff.
96
+ 5. The head is marked in flight (broadcast), then sent:
97
+ - text starting with `/` that names a command in OpenCode's `/command`
98
+ list (skills included) and carries no captured context goes to
99
+ `POST /session/:id/command` with the captured model, agent, variant, and
100
+ file parts. That route accepts file parts only, so a command queued
101
+ **with** context takes the prompt route instead, the same rule the
102
+ composer applies: the command's template is expanded with its arguments
103
+ (`$ARGUMENTS`, `$1..$N`, or appended), a skill keeps its `/name args` text
104
+ and gets an explicit "the user invoked this skill" synthetic part after
105
+ the context;
106
+ - otherwise `POST /session/:id/prompt_async` with the parts in the same
107
+ order a UI send uses: text, files, the captured context, the skill
108
+ invocation when there is one, pending project knowledge
109
+ (`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic, recorded
110
+ as delivered only after the prompt is accepted), then the agent mention.
111
+ Success removes the item, persists, broadcasts, and marks the user
112
+ message sent for notifications. Failure keeps the item, backs off
113
+ 2 s → 60 s (doubling per consecutive failure of that item), and re-arms.
114
+ 6. The next item goes out after the next busy → idle cycle.
115
+
116
+ ## Holds
117
+
118
+ Auto-review is driven from the UI and bounces the original session through
119
+ idle between iterations; the UI tells the server to hold that session's queue
120
+ (`PUT .../hold { held: true, ttlMs? }`) while a run is going and releases it
121
+ when the run ends. A hold expires on its own (default 5 min, cap 10 min)
122
+ because the UI that asserted it may be gone; the UI re-asserts it every two
123
+ minutes while the run continues. Releasing arms a dispatch.
124
+
125
+ ## Routes (`/api/message-queue`)
126
+
127
+ Normal authenticated OpenChamber runtime routes; never on browser URL-token
128
+ allowlists.
129
+
130
+ | Route | Purpose |
131
+ |---|---|
132
+ | `GET /api/message-queue` | Full snapshot `{ revision, sessions[] }` |
133
+ | `POST .../sessions/:id/items` | Append `{ directory, item }`; returns `{ revision, session, itemId }` and arms a dispatch (the session may already be idle) |
134
+ | `DELETE .../sessions/:id/items/:itemId` | Remove; `409` while that item is in flight |
135
+ | `POST .../sessions/:id/items/:itemId/take` | Remove and return the full item (payloads included); `404`/`409` |
136
+ | `POST .../sessions/:id/take` | Remove and return every item not in flight, in order |
137
+ | `PUT .../sessions/:id/order` | `{ itemIds }` must be a complete permutation |
138
+ | `DELETE .../sessions/:id` | Clear; the in-flight item stays |
139
+ | `PUT .../sessions/:id/hold` | `{ held, ttlMs? }` |
140
+
141
+ Every mutation broadcasts `openchamber:message-queue.updated` with
142
+ `{ revision, session }` to all connected clients (SSE and WS), so several
143
+ devices on one server see one queue. The session in that payload always names
144
+ its `directory`, including the broadcast that removes the last item: the UI
145
+ keys its projection by directory, and a broadcast without one left the
146
+ delivered message on screen (a session's directory is remembered until the
147
+ session is deleted or evicted).
148
+
149
+ Limits: 20 items per session, 50 sessions (oldest evicted, never one with an
150
+ item in flight), 200k characters of content; attachment payloads are bounded
151
+ by the route family's 50 MB JSON limit.
152
+
153
+ ## UI ownership
154
+
155
+ `packages/ui/src/stores/messageQueueStore.ts` is the projection: see its
156
+ section in `packages/ui/src/stores/DOCUMENTATION.md`. VS Code intentionally
157
+ does not use this module; with all OpenChamber webviews closed, queued
158
+ messages there are not delivered.