@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
@@ -3,7 +3,6 @@ import http from 'node:http';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { describe, expect, it } from 'vitest';
6
- import express from 'express';
7
6
  import { WebSocket } from 'ws';
8
7
 
9
8
  import { createTerminalRuntime } from './runtime.js';
@@ -24,6 +23,98 @@ function createResponse() {
24
23
  };
25
24
  }
26
25
 
26
+ async function openTerminalSocket(socketUrl) {
27
+ const socket = new WebSocket(socketUrl);
28
+ const messages = [];
29
+ socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
30
+ await new Promise((resolve, reject) => {
31
+ socket.once('open', resolve);
32
+ socket.once('error', reject);
33
+ });
34
+ const next = async (type, sessionId) => {
35
+ for (let attempt = 0; attempt < 100; attempt += 1) {
36
+ const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
37
+ if (index >= 0) return messages.splice(index, 1)[0];
38
+ await new Promise((resolve) => setTimeout(resolve, 2));
39
+ }
40
+ throw new Error(`Timed out waiting for ${type}`);
41
+ };
42
+ await next('hello');
43
+ return { socket, next, messages };
44
+ }
45
+
46
+ function deferred() {
47
+ let resolve;
48
+ let reject;
49
+ const promise = new Promise((res, rej) => {
50
+ resolve = res;
51
+ reject = rej;
52
+ });
53
+ return { promise, resolve, reject };
54
+ }
55
+
56
+ function createHttpTestApp() {
57
+ const routes = { GET: [], POST: [], DELETE: [] };
58
+ const app = (req, res) => {
59
+ const methodRoutes = routes[req.method] ?? [];
60
+ const url = new URL(req.url, 'http://127.0.0.1');
61
+ const chunks = [];
62
+ req.on('data', (chunk) => chunks.push(chunk));
63
+ req.on('end', async () => {
64
+ const bodyText = Buffer.concat(chunks).toString('utf8');
65
+ const route = methodRoutes.find(({ pattern }) => pattern.test(url.pathname));
66
+ if (!route) {
67
+ res.statusCode = 404;
68
+ res.end('Not found');
69
+ return;
70
+ }
71
+ const match = route.pattern.exec(url.pathname);
72
+ const response = {
73
+ statusCode: 200,
74
+ headers: { 'content-type': 'application/json' },
75
+ body: null,
76
+ status(code) {
77
+ this.statusCode = code;
78
+ return this;
79
+ },
80
+ json(payload) {
81
+ this.body = payload;
82
+ return this;
83
+ },
84
+ };
85
+ try {
86
+ await route.handler({
87
+ method: req.method,
88
+ url: req.url,
89
+ query: Object.fromEntries(url.searchParams.entries()),
90
+ params: route.params.reduce((acc, name, index) => ({ ...acc, [name]: match[index + 1] }), {}),
91
+ body: bodyText ? JSON.parse(bodyText) : {},
92
+ }, response);
93
+ } catch (error) {
94
+ response.status(500).json({ error: error?.message || 'Route failed' });
95
+ }
96
+ res.writeHead(response.statusCode, response.headers);
97
+ res.end(JSON.stringify(response.body));
98
+ });
99
+ };
100
+ const register = (method, route, handler) => {
101
+ const params = [];
102
+ const escaped = route.replace(/:([^/]+)/g, (_, name) => {
103
+ params.push(name);
104
+ return '([^/]+)';
105
+ });
106
+ routes[method].push({
107
+ pattern: new RegExp(`^${escaped}$`),
108
+ params,
109
+ handler,
110
+ });
111
+ };
112
+ app.get = (route, handler) => register('GET', route, handler);
113
+ app.post = (route, handler) => register('POST', route, handler);
114
+ app.delete = (route, handler) => register('DELETE', route, handler);
115
+ return app;
116
+ }
117
+
27
118
  function createRuntime(server, overrides = {}) {
28
119
  const app = overrides.app ?? {
29
120
  post() {},
@@ -54,6 +145,7 @@ describe('terminal runtime', () => {
54
145
  const createHarness = (overrides = {}) => {
55
146
  const routes = { get: new Map(), post: new Map(), delete: new Map() };
56
147
  const processes = [];
148
+ const spawnDeferred = overrides.spawnDeferred ?? null;
57
149
  const app = {
58
150
  post(route, handler) { routes.post.set(route, handler); },
59
151
  get(route, handler) { routes.get.set(route, handler); },
@@ -61,7 +153,8 @@ describe('terminal runtime', () => {
61
153
  };
62
154
  const loadPtyProvider = async () => ({
63
155
  backend: 'fake-pty',
64
- spawn: (shell, args, options) => {
156
+ spawn: async (shell, args, options) => {
157
+ await spawnDeferred?.promise;
65
158
  const dataHandlers = new Set();
66
159
  const exitHandlers = new Set();
67
160
  const process = {
@@ -98,6 +191,45 @@ describe('terminal runtime', () => {
98
191
  return { routes, processes, runtime };
99
192
  };
100
193
 
194
+ it('replaces only exited action runs and keeps session capacity available across reruns', async () => {
195
+ const harness = createHarness();
196
+ try {
197
+ const create = harness.routes.post.get('/api/terminal/create');
198
+ await create({ body: { sessionId: 'interactive', cwd: '/repo' } }, createResponse());
199
+ for (let index = 0; index < 25; index += 1) {
200
+ const id = `execution-${index}`;
201
+ const response = createResponse();
202
+ await create({ body: { sessionId: id, cwd: '/repo', mode: 'command', command: 'echo hello', purpose: { type: 'project-action', actionId: 'build', executionId: id } } }, response);
203
+ expect(response.statusCode).toBe(200);
204
+ const listed = createResponse();
205
+ harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, listed);
206
+ expect(listed.body.sessions.map(session => session.sessionId)).toEqual(['interactive', id]);
207
+ expect(harness.processes[0].killed).toBe(false);
208
+ harness.processes.at(-1).emitExit(0);
209
+ }
210
+ } finally { await harness.runtime.shutdown(); }
211
+ });
212
+
213
+ it('retains completed output when the replacement command fails to start', async () => {
214
+ let available = true;
215
+ const harness = createHarness({ fs: { promises: { stat: async () => ({ isDirectory: () => available }) } } });
216
+ try {
217
+ const create = harness.routes.post.get('/api/terminal/create');
218
+ const options = { cwd: '/repo', mode: 'command', command: 'echo hello', purpose: { type: 'project-action', actionId: 'build', executionId: 'old' } };
219
+ await create({ body: { ...options, sessionId: 'old' } }, createResponse());
220
+ harness.processes[0].emitData('old output');
221
+ harness.processes[0].emitExit(0);
222
+ available = false;
223
+ const response = createResponse();
224
+ await create({ body: { ...options, sessionId: 'new', purpose: { ...options.purpose, executionId: 'new' } } }, response);
225
+ expect(response.statusCode).toBe(400);
226
+ const listed = createResponse();
227
+ harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, listed);
228
+ expect(listed.body.sessions.map(session => session.sessionId)).toEqual(['old']);
229
+ expect(harness.processes).toHaveLength(1);
230
+ } finally { await harness.runtime.shutdown(); }
231
+ });
232
+
101
233
  it('rejects regular files as terminal working directories', async () => {
102
234
  const postRoutes = new Map();
103
235
  const app = {
@@ -134,6 +266,40 @@ describe('terminal runtime', () => {
134
266
  }
135
267
  });
136
268
 
269
+ it('names a missing working directory so the client can recover the session', async () => {
270
+ let cwdMissing = false;
271
+ const harness = createHarness({
272
+ fs: {
273
+ promises: {
274
+ stat: async () => {
275
+ if (cwdMissing) throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
276
+ return { isDirectory: () => true };
277
+ },
278
+ },
279
+ },
280
+ });
281
+ try {
282
+ const create = harness.routes.post.get('/api/terminal/create');
283
+ const created = createResponse();
284
+ await create({ body: { sessionId: 'worktree-terminal', cwd: '/repo/.worktrees/feature' } }, created);
285
+ expect(created.statusCode).toBe(200);
286
+
287
+ cwdMissing = true;
288
+ const recreated = createResponse();
289
+ await create({ body: { sessionId: 'worktree-terminal-2', cwd: '/repo/.worktrees/feature' } }, recreated);
290
+ expect(recreated.statusCode).toBe(400);
291
+ expect(recreated.body).toEqual({ error: 'Invalid working directory', code: 'TERMINAL_CWD_MISSING' });
292
+
293
+ const restarted = createResponse();
294
+ await harness.routes.post.get('/api/terminal/:sessionId/restart')(
295
+ { params: { sessionId: 'worktree-terminal' }, body: { cwd: '/repo/.worktrees/feature' } },
296
+ restarted,
297
+ );
298
+ expect(restarted.statusCode).toBe(400);
299
+ expect(restarted.body).toEqual({ error: 'Invalid working directory', code: 'TERMINAL_CWD_MISSING' });
300
+ } finally { await harness.runtime.shutdown(); }
301
+ });
302
+
137
303
  it('removes its websocket upgrade listener on shutdown', async () => {
138
304
  const server = new EventEmitter();
139
305
  const runtime = createRuntime(server);
@@ -150,7 +316,7 @@ describe('terminal runtime', () => {
150
316
  try {
151
317
  const response = createResponse();
152
318
  await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', cols: 120, rows: 40, themeMode: 'light', terminalBackground: '#faf8f0', terminalForeground: '#1b1b1b' } }, response);
153
- expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running' });
319
+ expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running', mode: 'interactive', purpose: { type: 'terminal' } });
154
320
  expect(harness.processes[0].options.cwd).toBe('/repo');
155
321
  expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
156
322
  expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
@@ -192,7 +358,7 @@ describe('terminal runtime', () => {
192
358
  const scoped = createResponse();
193
359
  harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped);
194
360
  expect(scoped.body.sessions).toEqual([
195
- { sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) },
361
+ { sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number), mode: 'interactive', purpose: { type: 'terminal' } },
196
362
  ]);
197
363
 
198
364
  const touch = createResponse();
@@ -466,8 +632,7 @@ describe('terminal runtime', () => {
466
632
  });
467
633
 
468
634
  it('runs snapshot-first attach, scoped I/O, replay, reconnect, and close over a real websocket', async () => {
469
- const app = express();
470
- app.use(express.json());
635
+ const app = createHttpTestApp();
471
636
  const server = http.createServer(app);
472
637
  const processes = [];
473
638
  const loadPtyProvider = async () => ({
@@ -501,24 +666,6 @@ describe('terminal runtime', () => {
501
666
  const socketUrl = `ws://127.0.0.1:${address.port}/api/terminal/ws`;
502
667
  const sockets = [];
503
668
 
504
- const open = async () => {
505
- const socket = new WebSocket(socketUrl);
506
- sockets.push(socket);
507
- const messages = [];
508
- socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
509
- await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
510
- const next = async (type, sessionId) => {
511
- for (let attempt = 0; attempt < 100; attempt += 1) {
512
- const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
513
- if (index >= 0) return messages.splice(index, 1)[0];
514
- await new Promise((resolve) => setTimeout(resolve, 2));
515
- }
516
- throw new Error(`Timed out waiting for ${type}`);
517
- };
518
- await next('hello');
519
- return { socket, next, messages };
520
- };
521
-
522
669
  try {
523
670
  const created = await fetch(`${base}/api/terminal/create`, {
524
671
  method: 'POST', headers: { 'content-type': 'application/json' },
@@ -531,7 +678,8 @@ describe('terminal runtime', () => {
531
678
  });
532
679
  expect(secondCreated.status).toBe(200);
533
680
 
534
- const first = await open();
681
+ const first = await openTerminalSocket(socketUrl);
682
+ sockets.push(first.socket);
535
683
  first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
536
684
  first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
537
685
  expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
@@ -559,7 +707,8 @@ describe('terminal runtime', () => {
559
707
  expect(secondClosed.status).toBe(200);
560
708
  first.socket.close();
561
709
 
562
- const second = await open();
710
+ const second = await openTerminalSocket(socketUrl);
711
+ sockets.push(second.socket);
563
712
  second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
564
713
  expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
565
714
  processes[0].emitExit(7);
@@ -589,4 +738,382 @@ describe('terminal runtime', () => {
589
738
  await new Promise((resolve) => server.close(resolve));
590
739
  }
591
740
  }, 15_000);
741
+
742
+ it('creates command-mode sessions and echoes the effective mode', async () => {
743
+ const harness = createHarness({
744
+ searchPathFor: (name) => name === 'bash' ? '/bin/bash' : '/bin/sh',
745
+ isExecutable: (candidate) => candidate === '/bin/bash' || candidate === '/bin/sh',
746
+ });
747
+ try {
748
+ const response = createResponse();
749
+ await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready', shell: 'bash', loginShell: true } }, response);
750
+
751
+ expect(response.statusCode).toBe(200);
752
+ expect(response.body).toEqual({ sessionId: 'term-command', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'terminal' } });
753
+ if (process.platform === 'linux') {
754
+ expect(harness.processes[0].shell).toMatch(/\/env$/);
755
+ expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
756
+ } else {
757
+ expect(harness.processes[0].args).toEqual(['-l', '-i', '-c', 'printf ready']);
758
+ }
759
+ } finally { await harness.runtime.shutdown(); }
760
+ });
761
+
762
+ it('rejects invalid terminal mode and command combinations', async () => {
763
+ const harness = createHarness();
764
+ try {
765
+ for (const [body, error] of [
766
+ [{ cwd: '/repo', mode: 'script' }, 'Invalid terminal mode'],
767
+ [{ cwd: '/repo', mode: 'command' }, 'Terminal command is required'],
768
+ [{ cwd: '/repo', mode: 'command', command: ' ' }, 'Terminal command is required'],
769
+ [{ cwd: '/repo', mode: 'interactive', command: 'echo nope' }, 'Interactive terminal create does not accept a command'],
770
+ [{ cwd: '/repo', mode: 'command', command: 'x'.repeat(65_537) }, 'Terminal command exceeds the input limit'],
771
+ ]) {
772
+ const response = createResponse();
773
+ await harness.routes.post.get('/api/terminal/create')({ body }, response);
774
+ expect(response.statusCode).toBe(400);
775
+ expect(response.body).toEqual({ error });
776
+ }
777
+ expect(harness.processes).toHaveLength(0);
778
+ } finally { await harness.runtime.shutdown(); }
779
+ });
780
+
781
+ it('rejects same-id running creates when mode or command do not match', async () => {
782
+ const harness = createHarness();
783
+ try {
784
+ const create = harness.routes.post.get('/api/terminal/create');
785
+ await create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, createResponse());
786
+
787
+ const modeMismatch = createResponse();
788
+ await create({ body: { sessionId: 'term-shared', cwd: '/repo', mode: 'command', command: 'printf ready' } }, modeMismatch);
789
+ expect(modeMismatch.statusCode).toBe(400);
790
+ expect(modeMismatch.body).toEqual({ error: 'Terminal session is already running with a different mode' });
791
+
792
+ await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready' } }, createResponse());
793
+
794
+ const commandMismatch = createResponse();
795
+ await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf other' } }, commandMismatch);
796
+ expect(commandMismatch.statusCode).toBe(400);
797
+ expect(commandMismatch.body).toEqual({ error: 'Terminal session is already running with a different command' });
798
+ } finally { await harness.runtime.shutdown(); }
799
+ });
800
+
801
+ it('rejects pending creates when command mode does not match the in-flight request', async () => {
802
+ const spawnDeferred = deferred();
803
+ const harness = createHarness({ spawnDeferred });
804
+ try {
805
+ const create = harness.routes.post.get('/api/terminal/create');
806
+ const first = createResponse();
807
+ const conflictingMode = createResponse();
808
+ const conflictingCommand = createResponse();
809
+
810
+ const firstPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf ready' } }, first);
811
+ await Promise.resolve();
812
+ const secondPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo' } }, conflictingMode);
813
+ const thirdPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf other' } }, conflictingCommand);
814
+ spawnDeferred.resolve();
815
+ await Promise.all([firstPromise, secondPromise, thirdPromise]);
816
+
817
+ expect(first.statusCode).toBe(200);
818
+ expect(conflictingMode.statusCode).toBe(400);
819
+ expect(conflictingMode.body).toEqual({ error: 'Terminal session is already being created with a different mode' });
820
+ expect(conflictingCommand.statusCode).toBe(400);
821
+ expect(conflictingCommand.body).toEqual({ error: 'Terminal session is already being created with a different command' });
822
+ expect(harness.processes).toHaveLength(1);
823
+ } finally { await harness.runtime.shutdown(); }
824
+ });
825
+
826
+ it('validates purpose payloads and round-trips purpose through create, list, and snapshot without listing command text', async () => {
827
+ const app = createHttpTestApp();
828
+ const server = http.createServer(app);
829
+ const runtime = createRuntime(server, {
830
+ app,
831
+ loadPtyProvider: async () => ({
832
+ backend: 'fake-pty',
833
+ spawn: async () => ({
834
+ pid: 42,
835
+ write() {},
836
+ resize() {},
837
+ kill() {},
838
+ onData() { return { dispose() {} }; },
839
+ onExit() { return { dispose() {} }; },
840
+ }),
841
+ }),
842
+ terminalTerminationGraceMs: 10,
843
+ fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
844
+ searchPathFor: () => '/bin/sh',
845
+ isExecutable: () => true,
846
+ });
847
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
848
+ const { port } = server.address();
849
+ const base = `http://127.0.0.1:${port}`;
850
+ const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
851
+ const sockets = [];
852
+
853
+ try {
854
+ for (const [body, error] of [
855
+ [{ cwd: '/repo', purpose: 'terminal' }, 'Invalid terminal purpose'],
856
+ [{ cwd: '/repo', purpose: { type: 'project-action' } }, 'Terminal project action id is required'],
857
+ [{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build' } }, 'Terminal execution id is required'],
858
+ [{ cwd: '/repo', purpose: { type: 'project-action', actionId: ' ', executionId: 'exec-1' } }, 'Terminal project action id is required'],
859
+ [{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build', executionId: ' ' } }, 'Terminal execution id is required'],
860
+ ]) {
861
+ const response = await fetch(`${base}/api/terminal/create`, {
862
+ method: 'POST',
863
+ headers: { 'content-type': 'application/json' },
864
+ body: JSON.stringify(body),
865
+ });
866
+ expect(response.status).toBe(400);
867
+ expect(await response.json()).toEqual({ error });
868
+ }
869
+
870
+ const created = await fetch(`${base}/api/terminal/create`, {
871
+ method: 'POST',
872
+ headers: { 'content-type': 'application/json' },
873
+ body: JSON.stringify({
874
+ sessionId: 'action-tab',
875
+ cwd: '/repo',
876
+ mode: 'command',
877
+ command: 'printf ready',
878
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
879
+ }),
880
+ });
881
+ expect(created.status).toBe(200);
882
+ expect(await created.json()).toEqual({
883
+ sessionId: 'action-tab',
884
+ cols: 80,
885
+ rows: 24,
886
+ status: 'running',
887
+ mode: 'command',
888
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
889
+ });
890
+
891
+ const listed = await fetch(`${base}/api/terminal/sessions?cwd=%2Frepo`);
892
+ expect(listed.status).toBe(200);
893
+ expect(await listed.json()).toEqual({
894
+ sessions: [{
895
+ sessionId: 'action-tab',
896
+ cwd: '/repo',
897
+ status: 'running',
898
+ createdAt: expect.any(Number),
899
+ mode: 'command',
900
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
901
+ }],
902
+ });
903
+
904
+ const socket = await openTerminalSocket(socketUrl);
905
+ sockets.push(socket.socket);
906
+ socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'action-tab' }));
907
+ expect(await socket.next('snapshot', 'action-tab')).toMatchObject({
908
+ s: 'action-tab',
909
+ status: 'running',
910
+ mode: 'command',
911
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
912
+ });
913
+ } finally {
914
+ for (const socket of sockets) socket.terminate();
915
+ await runtime.shutdown();
916
+ server.closeAllConnections?.();
917
+ await new Promise((resolve) => server.close(resolve));
918
+ }
919
+ }, 15_000);
920
+
921
+ it('deduplicates running project actions by resolved cwd and action id across session ids and clients', async () => {
922
+ const harness = createHarness();
923
+ try {
924
+ const create = harness.routes.post.get('/api/terminal/create');
925
+ const first = createResponse();
926
+ await create({
927
+ body: {
928
+ sessionId: 'action-a',
929
+ cwd: '/repo/./nested/..',
930
+ mode: 'command',
931
+ command: 'npm run build',
932
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
933
+ },
934
+ }, first);
935
+ expect(first.statusCode).toBe(200);
936
+
937
+ const adopted = createResponse();
938
+ await create({
939
+ body: {
940
+ sessionId: 'action-b',
941
+ cwd: '/repo',
942
+ mode: 'command',
943
+ command: 'npm run build --watch',
944
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-2' },
945
+ },
946
+ }, adopted);
947
+
948
+ expect(adopted.statusCode).toBe(200);
949
+ expect(adopted.body).toEqual({
950
+ sessionId: 'action-a',
951
+ cols: 80,
952
+ rows: 24,
953
+ status: 'running',
954
+ mode: 'command',
955
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
956
+ });
957
+ expect(harness.processes).toHaveLength(1);
958
+ } finally { await harness.runtime.shutdown(); }
959
+ });
960
+
961
+ it('rejects purpose mismatches when the same session id is reused for a different action', async () => {
962
+ const harness = createHarness();
963
+ try {
964
+ const create = harness.routes.post.get('/api/terminal/create');
965
+ await create({
966
+ body: {
967
+ sessionId: 'action-tab',
968
+ cwd: '/repo',
969
+ mode: 'command',
970
+ command: 'npm run build',
971
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
972
+ },
973
+ }, createResponse());
974
+
975
+ const mismatch = createResponse();
976
+ await create({
977
+ body: {
978
+ sessionId: 'action-tab',
979
+ cwd: '/repo',
980
+ mode: 'command',
981
+ command: 'npm run test',
982
+ purpose: { type: 'project-action', actionId: 'test', executionId: 'exec-2' },
983
+ },
984
+ }, mismatch);
985
+
986
+ expect(mismatch.statusCode).toBe(400);
987
+ expect(mismatch.body).toEqual({ error: 'Terminal session is already running with a different purpose' });
988
+ } finally { await harness.runtime.shutdown(); }
989
+ });
990
+
991
+ it('keeps an immediately exited command session attachable once listeners are registered', async () => {
992
+ const app = createHttpTestApp();
993
+ const server = http.createServer(app);
994
+ const runtime = createRuntime(server, {
995
+ app,
996
+ loadPtyProvider: async () => ({
997
+ backend: 'fake-pty',
998
+ spawn: async () => {
999
+ const dataHandlers = new Set();
1000
+ const exitHandlers = new Set();
1001
+ return {
1002
+ pid: 404,
1003
+ write() {},
1004
+ resize() {},
1005
+ kill() {},
1006
+ onData(handler) { dataHandlers.add(handler); return { dispose: () => dataHandlers.delete(handler) }; },
1007
+ onExit(handler) {
1008
+ exitHandlers.add(handler);
1009
+ queueMicrotask(() => {
1010
+ for (const registered of exitHandlers) registered({ exitCode: 0, signal: 0 });
1011
+ });
1012
+ return { dispose: () => exitHandlers.delete(handler) };
1013
+ },
1014
+ };
1015
+ },
1016
+ }),
1017
+ terminalTerminationGraceMs: 10,
1018
+ fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
1019
+ searchPathFor: () => '/bin/sh',
1020
+ isExecutable: () => true,
1021
+ });
1022
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
1023
+ const { port } = server.address();
1024
+ const base = `http://127.0.0.1:${port}`;
1025
+ const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
1026
+ const sockets = [];
1027
+
1028
+ try {
1029
+ const created = await fetch(`${base}/api/terminal/create`, {
1030
+ method: 'POST',
1031
+ headers: { 'content-type': 'application/json' },
1032
+ body: JSON.stringify({
1033
+ sessionId: 'fast-exit',
1034
+ cwd: '/repo',
1035
+ mode: 'command',
1036
+ command: 'true',
1037
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
1038
+ }),
1039
+ });
1040
+ expect(created.status).toBe(200);
1041
+ await new Promise((resolve) => setTimeout(resolve, 0));
1042
+
1043
+ const socket = await openTerminalSocket(socketUrl);
1044
+ sockets.push(socket.socket);
1045
+ socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'fast-exit' }));
1046
+ expect(await socket.next('snapshot', 'fast-exit')).toMatchObject({
1047
+ s: 'fast-exit',
1048
+ status: 'exited',
1049
+ exitCode: 0,
1050
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
1051
+ });
1052
+ } finally {
1053
+ for (const socket of sockets) socket.terminate();
1054
+ await runtime.shutdown();
1055
+ server.closeAllConnections?.();
1056
+ await new Promise((resolve) => server.close(resolve));
1057
+ }
1058
+ }, 15_000);
1059
+
1060
+ it('tombstones a pending create when delete arrives first and kills the eventual pty', async () => {
1061
+ const spawnDeferred = deferred();
1062
+ const harness = createHarness({ spawnDeferred });
1063
+ try {
1064
+ const create = harness.routes.post.get('/api/terminal/create');
1065
+ const close = harness.routes.delete.get('/api/terminal/:sessionId');
1066
+ const created = createResponse();
1067
+ const closed = createResponse();
1068
+
1069
+ const createPromise = create({
1070
+ body: {
1071
+ sessionId: 'pending-action',
1072
+ cwd: '/repo',
1073
+ mode: 'command',
1074
+ command: 'npm run build',
1075
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-pending' },
1076
+ },
1077
+ }, created);
1078
+ await Promise.resolve();
1079
+
1080
+ const closePromise = close({ params: { sessionId: 'pending-action' } }, closed);
1081
+ spawnDeferred.resolve();
1082
+ await Promise.all([createPromise, closePromise]);
1083
+
1084
+ expect(closed.statusCode).toBe(200);
1085
+ expect(closed.body).toEqual({ success: true });
1086
+ expect(created.statusCode).toBe(400);
1087
+ expect(created.body).toEqual({ error: 'Terminal session was closed during creation' });
1088
+
1089
+ const listed = createResponse();
1090
+ harness.routes.get.get('/api/terminal/sessions')({ query: {} }, listed);
1091
+ expect(listed.body).toEqual({ sessions: [] });
1092
+ expect(harness.processes).toHaveLength(1);
1093
+ expect(harness.processes[0].killed).toBe(true);
1094
+ } finally { await harness.runtime.shutdown(); }
1095
+ });
1096
+
1097
+ it('rejects restart for command-mode sessions', async () => {
1098
+ const harness = createHarness();
1099
+ try {
1100
+ await harness.routes.post.get('/api/terminal/create')({
1101
+ body: {
1102
+ sessionId: 'action-tab',
1103
+ cwd: '/repo',
1104
+ mode: 'command',
1105
+ command: 'npm run build',
1106
+ purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
1107
+ },
1108
+ }, createResponse());
1109
+
1110
+ const restarted = createResponse();
1111
+ await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'action-tab' }, body: {} }, restarted);
1112
+
1113
+ expect(restarted.statusCode).toBe(400);
1114
+ expect(restarted.body).toEqual({ error: 'Command-mode terminal sessions cannot be restarted' });
1115
+ expect(harness.processes).toHaveLength(1);
1116
+ expect(harness.processes[0].killed).toBe(false);
1117
+ } finally { await harness.runtime.shutdown(); }
1118
+ });
592
1119
  });