@aiworker/web 1.22.2 → 1.23.1

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 (217) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  3. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  5. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
  11. package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
  14. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  15. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  21. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DJsQr-p7.js +10 -0
  25. package/dist/assets/es-BuYMlA9g.js +16 -0
  26. package/dist/assets/fr-ePDay33C.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-BD8ILbxF.js +40 -0
  29. package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
  30. package/dist/assets/index-zhgg-SDz.css +1 -0
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
  33. package/dist/assets/ja-BzR9xOY_.js +16 -0
  34. package/dist/assets/ko-BEjHAm14.js +16 -0
  35. package/dist/assets/main-BydQ1tr_.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
  37. package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
  40. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  41. package/dist/assets/pl-rQmpxNIF.js +16 -0
  42. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  44. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  46. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
  48. package/dist/assets/surface-COKb77Ua.js +1 -0
  49. package/dist/assets/tr-CoAMXPtI.js +7 -0
  50. package/dist/assets/uk-YlAX0rRU.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
  52. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  53. package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
  54. package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  57. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  58. package/dist/assets/vendor-zod-B6551dwG.js +39 -0
  59. package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
  60. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  61. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  62. package/dist/index.html +5 -43
  63. package/dist/mini-chat.html +4 -4
  64. package/dist/mobile.html +4 -4
  65. package/package.json +2 -3
  66. package/server/index.d.ts +15 -0
  67. package/server/index.js +48 -22
  68. package/server/lib/agent-memory/runtime.js +7 -2
  69. package/server/lib/agent-memory/runtime.test.js +22 -0
  70. package/server/lib/data-dir-migration.js +40 -0
  71. package/server/lib/data-dir-migration.test.js +59 -0
  72. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  73. package/server/lib/event-stream/global-hub.js +29 -4
  74. package/server/lib/event-stream/global-hub.test.js +22 -0
  75. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  76. package/server/lib/event-stream/protocol.js +21 -7
  77. package/server/lib/event-stream/runtime.js +9 -6
  78. package/server/lib/event-stream/runtime.test.js +72 -12
  79. package/server/lib/fs/DOCUMENTATION.md +3 -0
  80. package/server/lib/fs/routes.js +31 -0
  81. package/server/lib/fs/routes.test.js +110 -1
  82. package/server/lib/git/DOCUMENTATION.md +39 -6
  83. package/server/lib/git/routes.js +45 -12
  84. package/server/lib/git/routes.test.js +78 -0
  85. package/server/lib/git/service.js +343 -202
  86. package/server/lib/git/service.test.js +496 -25
  87. package/server/lib/github/DOCUMENTATION.md +5 -0
  88. package/server/lib/github/routes.js +2 -2
  89. package/server/lib/inherited-env.js +16 -9
  90. package/server/lib/inherited-env.test.js +8 -9
  91. package/server/lib/message-queue/DOCUMENTATION.md +11 -1
  92. package/server/lib/message-queue/runtime.js +13 -0
  93. package/server/lib/message-queue/runtime.test.js +31 -1
  94. package/server/lib/network-defaults.js +23 -0
  95. package/server/lib/network-defaults.test.js +52 -0
  96. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  97. package/server/lib/notifications/emitter-runtime.js +4 -3
  98. package/server/lib/opencode/DOCUMENTATION.md +35 -5
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/env-runtime.js +5 -1
  101. package/server/lib/opencode/env-runtime.test.js +72 -0
  102. package/server/lib/opencode/feature-routes-runtime.js +17 -1
  103. package/server/lib/opencode/lifecycle.js +4 -3
  104. package/server/lib/opencode/lifecycle.test.js +29 -0
  105. package/server/lib/opencode/managed-plugin-config.js +1 -1
  106. package/server/lib/opencode/openchamber-routes.js +64 -4
  107. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  108. package/server/lib/opencode/proxy.js +33 -0
  109. package/server/lib/opencode/routes.js +5 -3
  110. package/server/lib/opencode/settings-files.js +214 -0
  111. package/server/lib/opencode/settings-helpers.js +88 -41
  112. package/server/lib/opencode/settings-helpers.test.js +176 -1
  113. package/server/lib/opencode/settings-registry.json +750 -0
  114. package/server/lib/opencode/settings-runtime.js +175 -27
  115. package/server/lib/opencode/settings-runtime.test.js +321 -7
  116. package/server/lib/project-context/DOCUMENTATION.md +38 -4
  117. package/server/lib/project-context/routes.http.test.js +18 -0
  118. package/server/lib/project-context/routes.js +15 -0
  119. package/server/lib/project-context/runtime.js +256 -25
  120. package/server/lib/project-context/runtime.test.js +160 -3
  121. package/server/lib/projects/DOCUMENTATION.md +77 -0
  122. package/server/lib/projects/project-config.js +179 -17
  123. package/server/lib/projects/project-config.test.js +97 -0
  124. package/server/lib/projects/project-id.js +43 -0
  125. package/server/lib/projects/project-setup.js +409 -0
  126. package/server/lib/projects/project-setup.test.js +557 -0
  127. package/server/lib/projects/routes.js +59 -0
  128. package/server/lib/quota/DOCUMENTATION.md +31 -1
  129. package/server/lib/quota/providers/cline-pass.js +125 -0
  130. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  131. package/server/lib/quota/providers/index.js +7 -0
  132. package/server/lib/quota/providers/neuralwatt.js +3 -6
  133. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  134. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  135. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  136. package/server/lib/quota/providers/opencode-go.js +1 -1
  137. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  138. package/server/lib/quota/providers/openrouter.js +119 -24
  139. package/server/lib/quota/providers/openrouter.test.js +330 -0
  140. package/server/lib/relay/DOCUMENTATION.md +75 -0
  141. package/server/lib/relay/cross-compat.test.js +26 -0
  142. package/server/lib/relay/downstream-scheduler.js +154 -0
  143. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  144. package/server/lib/relay/e2ee.js +10 -8
  145. package/server/lib/relay/flow-control.test.js +177 -0
  146. package/server/lib/relay/host-client.js +44 -8
  147. package/server/lib/relay/host-client.test.js +14 -0
  148. package/server/lib/relay/tunnel-codec.js +9 -0
  149. package/server/lib/relay/tunnel-host.js +66 -17
  150. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  151. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  152. package/server/lib/scheduled-tasks/runtime.js +12 -2
  153. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  154. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  155. package/server/lib/session-assist/context.js +127 -0
  156. package/server/lib/session-assist/context.test.js +132 -0
  157. package/server/lib/session-assist/prompt.js +54 -0
  158. package/server/lib/session-assist/runtime.js +117 -267
  159. package/server/lib/session-assist/runtime.test.js +203 -0
  160. package/server/lib/session-goal/runtime.js +4 -9
  161. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  162. package/server/lib/small-model/call.js +9 -4
  163. package/server/lib/small-model/call.test.js +29 -0
  164. package/server/lib/small-model/index.js +5 -10
  165. package/server/lib/small-model/runtime-providers.js +15 -7
  166. package/server/lib/small-model/runtime-providers.test.js +7 -1
  167. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  168. package/server/lib/terminal/runtime.js +11 -6
  169. package/server/lib/terminal/runtime.test.js +20 -13
  170. package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
  171. package/server/lib/walkthrough/model-settings.js +8 -12
  172. package/server/lib/walkthrough/pr-source.test.js +21 -0
  173. package/server/lib/walkthrough/prompt.js +3 -1
  174. package/server/lib/walkthrough/pull-request.js +7 -4
  175. package/server/lib/walkthrough/pull-request.test.js +16 -0
  176. package/server/lib/walkthrough/routes.js +19 -0
  177. package/server/lib/walkthrough/routes.test.js +26 -0
  178. package/server/lib/walkthrough/sources.js +36 -4
  179. package/server/opencode-worktree-readiness.test.js +130 -0
  180. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  181. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  182. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  183. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  184. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  185. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  186. package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
  187. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  188. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  189. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  190. package/dist/assets/de-B5JaA3SA.js +0 -10
  191. package/dist/assets/es-EPIH88ii.js +0 -16
  192. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  193. package/dist/assets/index-DF5kMP-i.js +0 -39
  194. package/dist/assets/index-Dl3xbAB_.css +0 -1
  195. package/dist/assets/ja-CifOE1VN.js +0 -16
  196. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  197. package/dist/assets/main-MhSaOZoB.js +0 -930
  198. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  199. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  200. package/dist/assets/pl-DgEHYfFM.js +0 -16
  201. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  202. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  203. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  204. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  205. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  206. package/dist/assets/tr-D5m2yIWL.js +0 -7
  207. package/dist/assets/uk-D_q60oog.js +0 -16
  208. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  209. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  210. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  211. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  212. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  213. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  214. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
  215. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  216. package/server/lib/mcp-reconnect/runtime.js +0 -122
  217. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -0,0 +1,203 @@
1
+ import { createServer } from 'node:http';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { createSessionAssistRuntime } from './runtime.js';
4
+
5
+ const resources = [];
6
+ const message = (id, role, text, extra = {}) => ({
7
+ info: { id, role, parentID: 'user', finish: 'stop', time: { completed: 1 }, providerID: 'test-provider', modelID: 'test-model', ...extra },
8
+ parts: [{ type: 'text', text }],
9
+ });
10
+ const output = (recap = 'Зміни готові', suggestion = '') => ({ text: JSON.stringify({ recap, suggestion }), providerID: 'test-provider', modelID: 'test-model' });
11
+ const pause = () => new Promise((resolve) => setTimeout(resolve, 15));
12
+
13
+ async function fixture(generate = async () => output()) {
14
+ const state = {
15
+ messages: [message('user', 'user', 'Виправ помилку'), message('answer', 'assistant', 'Виправлено')],
16
+ session: { id: 'session', directory: '/project', time: {}, metadata: { external: 'keep', aiworker: { note: 'keep' } } },
17
+ targets: { recap: true, suggestion: true },
18
+ gets: 0, failFresh: false, patches: [], requests: [], calls: [],
19
+ };
20
+ const server = createServer(async (request, response) => {
21
+ const url = new URL(request.url, 'http://localhost');
22
+ state.requests.push({ method: request.method, directory: url.searchParams.get('directory'), limit: url.searchParams.get('limit'), auth: request.headers['x-test-auth'] });
23
+ response.setHeader('content-type', 'application/json');
24
+ if (request.method === 'PATCH') {
25
+ const chunks = [];
26
+ for await (const chunk of request) chunks.push(chunk);
27
+ state.patches.push(JSON.parse(Buffer.concat(chunks).toString()));
28
+ response.end(JSON.stringify(state.session));
29
+ } else if (url.pathname.endsWith('/message')) {
30
+ response.end(JSON.stringify(url.searchParams.get('limit') === '1' ? state.messages.slice(-1) : state.messages));
31
+ } else {
32
+ state.gets++;
33
+ response.statusCode = state.failFresh && state.gets > 1 ? 500 : 200;
34
+ response.end(JSON.stringify(state.session));
35
+ }
36
+ });
37
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
38
+ const base = `http://127.0.0.1:${server.address().port}`;
39
+ state.base = base;
40
+ const runtime = createSessionAssistRuntime({
41
+ buildOpenCodeUrl: (route) => state.base + route,
42
+ getOpenCodeAuthHeaders: () => ({ 'x-test-auth': 'fixture' }),
43
+ getTargets: () => state.targets,
44
+ quietMs: 1,
45
+ getSmallModelService: async () => ({
46
+ describeSmallModel: async () => ({ inputCharBudget: 64_000 }),
47
+ generateSmallModelText: async (args) => { state.calls.push(args); return generate(args, state); },
48
+ }),
49
+ });
50
+ resources.push({ runtime, server });
51
+ const status = (type) => runtime.processPayload({ type: 'session.status', properties: { sessionID: 'session', status: { type } } }, '/project');
52
+ return { state, runtime, status };
53
+ }
54
+
55
+ afterEach(async () => {
56
+ for (const { runtime, server } of resources.splice(0)) {
57
+ runtime.stop();
58
+ server.closeAllConnections();
59
+ await new Promise((resolve) => server.close(resolve));
60
+ }
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ describe('session assist runtime', () => {
65
+ it('uses bounded authenticated SDK reads and preserves metadata with an empty suggestion', async () => {
66
+ const { state, status } = await fixture(async (_args, current) => {
67
+ current.session.metadata.aiworker.concurrent = 'new';
68
+ return output();
69
+ });
70
+ status('idle');
71
+ await vi.waitFor(() => expect(state.patches).toHaveLength(1));
72
+ expect(state.requests.every((r) => r.directory === '/project' && r.auth === 'fixture')).toBe(true);
73
+ expect(state.requests.filter((r) => r.limit).map((r) => r.limit)).toEqual(['50', '1']);
74
+ expect(state.calls[0]).toMatchObject({ restrictToPreferredProvider: true, onOverflow: 'error', preferredProviderID: 'test-provider', preferredModelID: 'test-model', sessionID: 'session' });
75
+ expect(state.patches[0].metadata).toMatchObject({ external: 'keep', aiworker: {
76
+ note: 'keep', concurrent: 'new', assist: { recap: 'Зміни готові', suggestion: '', forMessageID: 'answer' },
77
+ } });
78
+ });
79
+
80
+ it('does no work with both settings off and skips child, archived, or reverted sessions', async () => {
81
+ const { state, status } = await fixture();
82
+ state.targets = { recap: false, suggestion: false };
83
+ status('idle');
84
+ await pause();
85
+ expect(state.requests).toHaveLength(0);
86
+ state.targets.recap = true;
87
+ state.session.parentID = 'parent';
88
+ status('idle');
89
+ await vi.waitFor(() => expect(state.gets).toBe(1));
90
+ delete state.session.parentID;
91
+ state.session.revert = { messageID: 'user' };
92
+ status('idle');
93
+ await vi.waitFor(() => expect(state.gets).toBe(2));
94
+ delete state.session.revert;
95
+ state.session.time.archived = 1;
96
+ status('idle');
97
+ await vi.waitFor(() => expect(state.gets).toBe(3));
98
+ expect(state.calls).toHaveLength(0);
99
+ });
100
+
101
+ it('keeps recent context for recap-only and performs no write for an empty suggestion-only result', async () => {
102
+ const { state, status } = await fixture();
103
+ state.targets.suggestion = false;
104
+ state.messages.unshift(message('previous-user', 'user', 'Попередня задача'), message('previous-answer', 'assistant', 'Зміст зробленого', { parentID: 'previous-user' }));
105
+ status('idle');
106
+ await vi.waitFor(() => expect(state.patches).toHaveLength(1));
107
+ expect(state.calls[0].prompt).toContain('Зміст зробленого');
108
+ expect(state.calls[0].system).not.toContain('suggestion');
109
+ state.targets = { recap: false, suggestion: true };
110
+ status('idle');
111
+ await vi.waitFor(() => expect(state.calls).toHaveLength(2));
112
+ await pause();
113
+ expect(state.patches).toHaveLength(1);
114
+ });
115
+
116
+ it('does not write a stale result when the tail moves during generation', async () => {
117
+ const { state, status } = await fixture(async (_args, current) => {
118
+ current.messages.push(message('new-user', 'user', 'Нова задача'));
119
+ return output();
120
+ });
121
+ status('idle');
122
+ await vi.waitFor(() => expect(state.requests.some((r) => r.limit === '1')).toBe(true));
123
+ await pause();
124
+ expect(state.patches).toHaveLength(0);
125
+ });
126
+
127
+ it('does not fall back to stale metadata when the fresh read fails', async () => {
128
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
129
+ const { state, status } = await fixture();
130
+ state.failFresh = true;
131
+ status('idle');
132
+ await vi.waitFor(() => expect(warning).toHaveBeenCalled());
133
+ expect(state.gets).toBe(2);
134
+ expect(state.patches).toHaveLength(0);
135
+ });
136
+
137
+ it('cancels old work and retains an expired newer idle timer until it can run', async () => {
138
+ const releases = [];
139
+ const { state, status } = await fixture(() => new Promise((resolve) => releases.push(resolve)));
140
+ status('idle');
141
+ await vi.waitFor(() => expect(releases).toHaveLength(1));
142
+ status('busy');
143
+ expect(state.calls[0].signal.aborted).toBe(true);
144
+ state.messages.push(message('next-user', 'user', 'Далі'), message('next-answer', 'assistant', 'Готово', { parentID: 'next-user' }));
145
+ status('idle');
146
+ await pause();
147
+ releases[0](output('Старий результат'));
148
+ await vi.waitFor(() => expect(releases).toHaveLength(2));
149
+ expect(state.patches).toHaveLength(0);
150
+ releases[1](output('Новий результат'));
151
+ await vi.waitFor(() => expect(state.patches).toHaveLength(1));
152
+ expect(state.patches[0].metadata.aiworker.assist).toMatchObject({ recap: 'Новий результат', forMessageID: 'next-answer' });
153
+ });
154
+
155
+ it('aborts on stop and honors settings switched off during generation', async () => {
156
+ let release;
157
+ const { state, runtime, status } = await fixture(() => new Promise((resolve) => { release = resolve; }));
158
+ status('idle');
159
+ await vi.waitFor(() => expect(state.calls).toHaveLength(1));
160
+ runtime.stop();
161
+ expect(state.calls[0].signal.aborted).toBe(true);
162
+ release(output());
163
+ await pause();
164
+ expect(state.patches).toHaveLength(0);
165
+ const second = await fixture(async (_args, current) => {
166
+ current.targets = { recap: false, suggestion: false };
167
+ return output();
168
+ });
169
+ second.status('idle');
170
+ await vi.waitFor(() => expect(second.state.gets).toBe(2));
171
+ await pause();
172
+ expect(second.state.patches).toHaveLength(0);
173
+ });
174
+
175
+ it('ignores historical user updates but cancels a new request during generation', async () => {
176
+ let release;
177
+ const { state, runtime, status } = await fixture(() => new Promise((resolve) => { release = resolve; }));
178
+ status('idle');
179
+ await vi.waitFor(() => expect(state.calls).toHaveLength(1));
180
+ const userUpdate = (created) => runtime.processPayload({ type: 'message.updated', properties: {
181
+ info: { id: 'user', sessionID: 'session', role: 'user', time: { created } },
182
+ } });
183
+ userUpdate(1);
184
+ expect(state.calls[0].signal.aborted).toBe(false);
185
+ userUpdate(Date.now());
186
+ expect(state.calls[0].signal.aborted).toBe(true);
187
+ release(output());
188
+ await pause();
189
+ expect(state.patches).toHaveLength(0);
190
+ });
191
+
192
+ it('rejects endpoint changes before writing instead of carrying a session into a new runtime', async () => {
193
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
194
+ const { state, status } = await fixture(async (_args, current) => {
195
+ current.base = 'http://unreachable.invalid';
196
+ return output();
197
+ });
198
+ status('idle');
199
+ await vi.waitFor(() => expect(warning).toHaveBeenCalled());
200
+ expect(state.patches).toHaveLength(0);
201
+ expect(state.gets).toBe(1);
202
+ });
203
+ });
@@ -19,6 +19,7 @@ import os from 'os';
19
19
  import path from 'path';
20
20
 
21
21
  import { GOAL_OBJECTIVE_CHAR_LIMIT, readObjective } from './objectives.js';
22
+ import { readMergedSettingsSync } from '../opencode/settings-files.js';
22
23
 
23
24
  const AIWORKER_SETTINGS_FILE = path.join(
24
25
  process.env.AIWORKER_DATA_DIR
@@ -27,15 +28,9 @@ const AIWORKER_SETTINGS_FILE = path.join(
27
28
  'settings.json',
28
29
  );
29
30
 
30
- const isSessionGoalEnabled = () => {
31
- try {
32
- const raw = fs.readFileSync(AIWORKER_SETTINGS_FILE, 'utf8');
33
- const settings = JSON.parse(raw);
34
- return settings?.sessionGoalEnabled !== false;
35
- } catch {
36
- return true;
37
- }
38
- };
31
+ const isSessionGoalEnabled = () => (
32
+ readMergedSettingsSync({ fs, path, settingsFilePath: AIWORKER_SETTINGS_FILE }).sessionGoalEnabled !== false
33
+ );
39
34
 
40
35
  const IDLE_QUIET_MS = 15_000;
41
36
  // A goal set while the session is already idle should kick off promptly.
@@ -117,8 +117,9 @@ other runtime API.
117
117
  - Everything else: OpenAI-compatible `/chat/completions` against the
118
118
  provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
119
119
  in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
120
- endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) the
121
- provider's `api` field from the models.dev catalog. The credential follows
120
+ endpoint, (3) the selected model's endpoint OpenCode resolved at runtime,
121
+ (4) the provider-level runtime endpoint, or (5) the provider's `api` field
122
+ from the models.dev catalog. The credential follows
122
123
  the same shape: config `options.apiKey`, then the runtime credential, then
123
124
  the auth.json entry. `provider.<id>.options.headers` is sent with the
124
125
  request and overrides the bearer default, so gateways that authenticate on
@@ -606,6 +606,8 @@ const readProviderConfig = (workingDirectory, providerID) => {
606
606
  }
607
607
  }
608
608
 
609
+ const getRuntimeModel = (runtimeProvider, modelID) => runtimeProvider?.models?.get(modelID) ?? null;
610
+
609
611
  // ---------------------------------------------------------------------------
610
612
  // Dispatch
611
613
  // ---------------------------------------------------------------------------
@@ -650,6 +652,7 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
650
652
  const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
651
653
  const providerConfig = readProviderConfig(workingDirectory, providerID);
652
654
  const runtimeProvider = await getRuntimeProvider(providerID);
655
+ const runtimeModel = getRuntimeModel(runtimeProvider, modelID);
653
656
  // Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
654
657
  // wins, then what OpenCode itself resolved at runtime (the only place a
655
658
  // plugin's credential exists), and the auth.json entry last.
@@ -759,9 +762,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
759
762
  // base URL for that provider (openai itself included). When a custom provider
760
763
  // is not in the catalog (e.g. a user-configured OpenAI-compatible proxy),
761
764
  // fall back to its baseURL from the OpenCode provider config, then to the
762
- // endpoint OpenCode resolved at runtime which for a plugin provider is the
763
- // only place it exists, and for several of them is a local proxy the plugin
764
- // itself runs. The openai provider also respects
765
+ // selected model's endpoint OpenCode resolved at runtime, then to the
766
+ // provider-level runtime endpoint. For a plugin provider, the runtime listing
767
+ // is the only place those endpoints exist, and several are local proxies the
768
+ // plugin itself runs. The openai provider also respects
765
769
  // provider.openai.options.baseURL — OpenCode itself uses the same config for
766
770
  // all providers including openai.
767
771
  const provider = getCatalogProvider(catalog, providerID);
@@ -771,7 +775,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
771
775
  ? providerConfigUrl
772
776
  : providerID === 'openai'
773
777
  ? defaultOpenaiUrl
774
- : runtimeProvider?.baseURL
778
+ : runtimeModel?.api?.url
779
+ ?? runtimeProvider?.baseURL
775
780
  ?? (typeof provider?.api === 'string' && provider.api
776
781
  ? provider.api
777
782
  : null);
@@ -565,6 +565,35 @@ describe('callSmallModel — custom provider config', () => {
565
565
  expect(init.headers.Authorization).toBe('Bearer plugin-key');
566
566
  });
567
567
 
568
+ it('uses the selected runtime model endpoint', async () => {
569
+ readConfig.mockReturnValue({});
570
+ getRuntimeProvider.mockResolvedValue({
571
+ id: 'runtime-provider',
572
+ apiKey: 'plugin-key',
573
+ baseURL: 'https://runtime-provider/v1beta',
574
+ models: new Map([
575
+ ['first-model', { api: { url: 'https://runtime-provider/v1beta', npm: '@ai-sdk/google' } }],
576
+ ['selected-model', { api: { url: 'https://runtime-provider/v1', npm: '@ai-sdk/openai' } }],
577
+ ]),
578
+ anonymousZen: false,
579
+ });
580
+ fetchMock.mockResolvedValue(ok('done'));
581
+
582
+ await callSmallModel({
583
+ auth: {},
584
+ catalog: {},
585
+ workingDirectory: '/proj',
586
+ providerID: 'runtime-provider',
587
+ modelID: 'selected-model',
588
+ prompt: 'hi',
589
+ });
590
+
591
+ const { url, init } = lastCall(fetchMock);
592
+ expect(url).toBe('https://runtime-provider/v1/chat/completions');
593
+ expect(url).not.toContain('/v1beta');
594
+ expect(JSON.parse(init.body).model).toBe('selected-model');
595
+ });
596
+
568
597
  it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => {
569
598
  readConfig.mockReturnValue({});
570
599
  // OpenCode reports an OAuth access token as `options.apiKey` for openai;
@@ -6,6 +6,7 @@ import { readConfigLayers } from '../opencode/shared.js';
6
6
  import { getModelCatalog } from './catalog.js';
7
7
  import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
8
8
  import { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js';
9
+ import { readMergedSettingsSync } from '../opencode/settings-files.js';
9
10
  import { getRuntimeProviderSnapshot } from './runtime-providers.js';
10
11
 
11
12
  // Never a small model, whatever the transport looks like. A plugin can publish
@@ -26,16 +27,10 @@ const AIWORKER_SETTINGS_FILE = path.join(
26
27
  // AiWorker's own settings: when the user unchecks "use default small model"
27
28
  // their explicit override outranks every other resolution step.
28
29
  const readSmallModelSettingsOverride = () => {
29
- try {
30
- const raw = fs.readFileSync(AIWORKER_SETTINGS_FILE, 'utf8');
31
- const settings = JSON.parse(raw);
32
- if (!settings || typeof settings !== 'object') return null;
33
- if (settings.smallModelUseDefault !== false) return null;
34
- const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
35
- return override || null;
36
- } catch {
37
- return null;
38
- }
30
+ const settings = readMergedSettingsSync({ fs, path, settingsFilePath: AIWORKER_SETTINGS_FILE });
31
+ if (settings.smallModelUseDefault !== false) return null;
32
+ const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
33
+ return override || null;
39
34
  };
40
35
 
41
36
  // Rough safety clamp so a huge input never blows the model's context window.
@@ -8,7 +8,8 @@
8
8
  //
9
9
  // `GET /provider` is where that state becomes visible. It reports, per
10
10
  // provider, the resolved `options.baseURL` and `options.apiKey`, and per model
11
- // the wire adapter (`api.npm`) and endpoint (`api.url`).
11
+ // the wire adapter (`api.npm`) and endpoint (`api.url`). The provider-level
12
+ // endpoint remains only as a fallback when the selected model has no endpoint.
12
13
  //
13
14
  // What it does NOT report is `options.fetch`. OpenCode strips functions from
14
15
  // the response, and a plugin is free to put its whole protocol in there:
@@ -61,8 +62,8 @@ export function resetOpenCodeRuntimeProviders() {
61
62
  /**
62
63
  * The boundary. Everything the `/provider` payload claims is checked here, so
63
64
  * the rest of this module and its callers work with settled values:
64
- * a credential we may use, an endpoint, and whether the provider is the
65
- * anonymous zen case.
65
+ * a credential we may use, provider and model endpoints, and whether the
66
+ * provider is the anonymous zen case.
66
67
  *
67
68
  * The credential deliberately prefers `options.apiKey` over the `key` field:
68
69
  * for a plugin provider the former is what its auth loader produced and what
@@ -82,13 +83,22 @@ function parseProviderListing(payload) {
82
83
  const id = text(record(raw).id);
83
84
  if (!id) continue;
84
85
  const options = record(record(raw).options);
85
- const firstModel = record(Object.values(record(record(raw).models))[0]);
86
+ const models = new Map();
87
+ for (const [modelID, rawModel] of Object.entries(record(record(raw).models))) {
88
+ const model = record(rawModel);
89
+ const api = record(model.api);
90
+ const modelURL = endpoint(api.url);
91
+ const modelNpm = text(api.npm);
92
+ models.set(modelID, { api: { url: modelURL, npm: modelNpm } });
93
+ }
94
+ const firstModel = models.values().next().value;
86
95
  const declaredKey = text(options.apiKey);
87
96
  providers.set(id, {
88
97
  id,
89
98
  source: text(record(raw).source),
90
99
  apiKey: declaredKey === ZEN_ANONYMOUS_API_KEY ? null : (declaredKey ?? text(record(raw).key)),
91
- baseURL: endpoint(options.baseURL) ?? endpoint(record(firstModel.api).url),
100
+ baseURL: endpoint(options.baseURL) ?? firstModel?.api?.url ?? null,
101
+ models,
92
102
  // True only for the zen-without-login case: a provider that is present
93
103
  // and usable through OpenCode, but that we must not call ourselves.
94
104
  anonymousZen: declaredKey === ZEN_ANONYMOUS_API_KEY,
@@ -152,5 +162,3 @@ export async function getRuntimeProvider(providerID) {
152
162
  const current = await getRuntimeProviderSnapshot();
153
163
  return current?.providers.get(providerID) ?? null;
154
164
  }
155
-
156
-
@@ -14,7 +14,10 @@ const providerPayload = (overrides = {}) => ({
14
14
  id: 'llmapi',
15
15
  source: 'config',
16
16
  options: { apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1/' },
17
- models: { 'claude-opus-4-8': { api: { id: 'claude-opus-4-8', url: '', npm: '@ai-sdk/anthropic' } } },
17
+ models: {
18
+ 'claude-opus-4-8': { api: { id: 'claude-opus-4-8', url: '', npm: '@ai-sdk/anthropic' } },
19
+ 'gpt-5.6-luna': { api: { id: 'gpt-5.6-luna', url: 'https://api.llmapi.ai/v1', npm: '@ai-sdk/openai' } },
20
+ },
18
21
  },
19
22
  {
20
23
  id: 'opencode',
@@ -59,6 +62,9 @@ describe('OpenCode runtime provider snapshot', () => {
59
62
  const provider = await getRuntimeProvider('llmapi');
60
63
 
61
64
  expect(provider).toMatchObject({ apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1' });
65
+ expect(provider.models.get('gpt-5.6-luna')).toEqual({
66
+ api: { url: 'https://api.llmapi.ai/v1', npm: '@ai-sdk/openai' },
67
+ });
62
68
  expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:4096/provider');
63
69
  expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Basic test' });
64
70
  });
@@ -9,7 +9,7 @@
9
9
  `/api/terminal/ws` is the only terminal data transport. It uses v3 binary JSON control frames and is opened through `openRuntimeWebSocket`, preserving direct, Electron proxy, URL-token authentication, and private-relay routing.
10
10
 
11
11
  - `attach` registers a connection for one terminal. One socket may attach to many terminals.
12
- - Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence.
12
+ - Every attach and reconnect begins with an authoritative `snapshot` containing bounded history, the current sequence, and the PTY `cols`/`rows` the history was drawn for. The client replays the history at that size before fitting its viewport; replaying shell output at another width leaves stray fragments that the shell's own SIGWINCH redraw never clears.
13
13
  - A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery.
14
14
  - `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
15
15
  - Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
@@ -28,14 +28,14 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
28
28
  - Concurrent creates for one ID are single-flight only when working directory, shell preference, login mode, launch mode, and session purpose match. Command-mode creates must also match the command text, unless the purpose is a project action that is already running for the same resolved `(cwd, actionId)` pair. In that case the runtime returns the existing session and its existing execution identity, even when another client requested a different session ID. Existing IDs cannot be reused for another working directory or another purpose.
29
29
  - Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
30
30
  - A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
31
- - PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
32
- - PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete.
31
+ - PTY children never inherit `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup, and an inherited value (even empty) makes Node CLIs such as opencode print `Failed to parse IPC channel number` on exit.
32
+ - PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On POSIX, PTY spawn is wrapped with `env -u ARGV0 -u NODE_CHANNEL_FD` (`resolvePosixPtyLaunch`) because `bun-pty` merges the native OS environ and would otherwise reintroduce both after a JS-only delete.
33
33
  - `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Interactive shells still launch as before. Command-mode launches reuse the same environment and login support, but switch argv by shell family: POSIX and Fish use interactive `-c`, Nushell uses `-c`, PowerShell uses `-Command`, and cmd uses `/d /s /c`. Preference changes affect new sessions and explicit restarts, not running PTYs.
34
34
  - PTY data and exit callbacks enter one FIFO queue. The runtime wires those listeners in the same synchronous turn that receives the PTY object. `node-pty` and `bun-pty` both expose the PTY before dispatching registered callbacks. If a backend emitted exit before listener registration, this layer could not recover it, so the wiring stays adjacent to PTY creation.
35
35
  - Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
36
36
  - Exited sessions remain attachable until explicit close, idle cleanup, or a successful replacement of the same project action. Creating a replacement retires only exited records for the same resolved directory and action, after the new PTY starts. Failed creation preserves the old record and output. These replaced records do not exhaust the terminal capacity limit.
37
37
  - Deduplicated create responses may describe another client's execution. Cancellation cleanup closes only the terminal ID allocated for the cancelled request; it never closes an adopted peer execution.
38
- - Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside AiWorker) the body also carries `code: "TERMINAL_CWD_MISSING"`. That is the one rejection the client can recover from: the session, not the terminal, is stranded, and the shared UI moves it to its project directory and starts a terminal there. Every other rejection stays generic; the runtime never substitutes a parent directory on its own.
38
+ - Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside AiWorker) the body also carries `code: "TERMINAL_CWD_MISSING"`. The client shows the failure without moving the session. The runtime never substitutes a parent directory on its own.
39
39
  - Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata.
40
40
  - A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds.
41
41
  - Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.
@@ -10,7 +10,7 @@ import {
10
10
  import { sanitizeTerminalHistoryChunk } from './history.js';
11
11
  import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
12
12
  import { buildTerminalShellLaunch, createTerminalShellResolver, normalizeTerminalShell } from './shells.js';
13
- import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js';
13
+ import { stripAppImageArgv0Leak, resolvePosixPtyLaunch } from '../inherited-env.js';
14
14
 
15
15
  const MAX_SESSIONS = 20;
16
16
  const MAX_HISTORY_BYTES = 512 * 1024;
@@ -123,15 +123,16 @@ export function createTerminalRuntime({
123
123
  for (const executable of resolvedShell.executables) {
124
124
  try {
125
125
  const env = { ...process.env, PATH: buildAugmentedPath(), TERM: 'xterm-256color', COLORTERM: 'truecolor', COLORFGBG: themeMode === 'light' ? '0;15' : '15;0' };
126
- // The daemon's IPC fd is closed inside the PTY. An explicit override is
127
- // required because bun-pty also inherits Bun's native process environment.
128
- env.NODE_CHANNEL_FD = '';
126
+ // The daemon's IPC fd is closed inside the PTY; an inherited NODE_CHANNEL_FD
127
+ // (even an empty one) makes Node CLIs warn about an unparsable IPC channel.
128
+ delete env.NODE_CHANNEL_FD;
129
129
  delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE;
130
130
  // AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
131
- // bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
132
131
  stripAppImageArgv0Leak(env);
133
132
  const shellLaunch = buildTerminalShellLaunch(executable, { mode, command, loginShell });
134
- const launch = resolveLinuxPtyLaunch(shellLaunch.executable, shellLaunch.args);
133
+ // bun-pty merges the native OS environ back in, so the POSIX launch is
134
+ // wrapped with `env -u` for the variables deleted above.
135
+ const launch = resolvePosixPtyLaunch(shellLaunch.executable, shellLaunch.args);
135
136
  const options = { name: 'xterm-256color', cwd, cols, rows, env };
136
137
  if (process.platform === 'win32') options.useConpty = true;
137
138
  return { process: await provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
@@ -184,6 +185,10 @@ export function createTerminalRuntime({
184
185
 
185
186
  const snapshot = (session) => ({
186
187
  t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history,
188
+ // The PTY size the history was drawn for: a client replays history at this
189
+ // size before fitting its own viewport, so shell output wrapped for one
190
+ // width never gets re-laid-out at another.
191
+ cols: session.cols, rows: session.rows,
187
192
  status: session.status, exitCode: session.exitCode, signal: session.signal,
188
193
  mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session),
189
194
  runtime, ptyBackend: session.backend,
@@ -319,12 +319,12 @@ describe('terminal runtime', () => {
319
319
  expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running', mode: 'interactive', purpose: { type: 'terminal' } });
320
320
  expect(harness.processes[0].options.cwd).toBe('/repo');
321
321
  expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
322
- expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
322
+ expect(harness.processes[0].options.env).not.toHaveProperty('NODE_CHANNEL_FD');
323
323
  expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
324
324
  expect(harness.processes[0].options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
325
- if (process.platform === 'linux') {
325
+ if (process.platform !== 'win32') {
326
326
  expect(harness.processes[0].shell).toMatch(/\/env$/);
327
- expect(harness.processes[0].args.slice(0, 3)).toEqual(['-u', 'ARGV0', expect.any(String)]);
327
+ expect(harness.processes[0].args.slice(0, 5)).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', expect.any(String)]);
328
328
  }
329
329
  harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
330
330
  expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
@@ -380,7 +380,7 @@ describe('terminal runtime', () => {
380
380
  await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-argv0', cwd: '/repo', cols: 80, rows: 24 } }, response);
381
381
  expect(response.statusCode).toBe(200);
382
382
  expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
383
- if (process.platform === 'linux') {
383
+ if (process.platform !== 'win32') {
384
384
  expect(harness.processes[0].shell).toMatch(/\/env$/);
385
385
  expect(harness.processes[0].args[0]).toBe('-u');
386
386
  expect(harness.processes[0].args[1]).toBe('ARGV0');
@@ -417,9 +417,9 @@ describe('terminal runtime', () => {
417
417
  const created = createResponse();
418
418
  await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created);
419
419
  expect(created.statusCode).toBe(200);
420
- if (process.platform === 'linux') {
420
+ if (process.platform !== 'win32') {
421
421
  expect(harness.processes[0].shell).toMatch(/\/env$/);
422
- expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/zsh', '-l']);
422
+ expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/zsh', '-l']);
423
423
  } else {
424
424
  expect(harness.processes[0].shell).toBe('/bin/zsh');
425
425
  expect(harness.processes[0].args).toEqual(['-l']);
@@ -428,9 +428,9 @@ describe('terminal runtime', () => {
428
428
  const restarted = createResponse();
429
429
  await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted);
430
430
  expect(restarted.statusCode).toBe(200);
431
- if (process.platform === 'linux') {
431
+ if (process.platform !== 'win32') {
432
432
  expect(harness.processes[1].shell).toMatch(/\/env$/);
433
- expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l']);
433
+ expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/bash', '-l']);
434
434
  } else {
435
435
  expect(harness.processes[1].shell).toBe('/bin/bash');
436
436
  expect(harness.processes[1].args).toEqual(['-l']);
@@ -682,8 +682,8 @@ describe('terminal runtime', () => {
682
682
  sockets.push(first.socket);
683
683
  first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
684
684
  first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
685
- expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
686
- expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running' });
685
+ expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running', cols: 80, rows: 24 });
686
+ expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running', cols: 80, rows: 24 });
687
687
  first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo ok\r' }));
688
688
  first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-second', d: 'pwd\r' }));
689
689
  first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo next\r' }));
@@ -707,10 +707,17 @@ describe('terminal runtime', () => {
707
707
  expect(secondClosed.status).toBe(200);
708
708
  first.socket.close();
709
709
 
710
+ // A reconnecting client replays history at the size the PTY currently has.
711
+ const resized = await fetch(`${base}/api/terminal/term-live/resize`, {
712
+ method: 'POST', headers: { 'content-type': 'application/json' },
713
+ body: JSON.stringify({ cols: 120, rows: 40 }),
714
+ });
715
+ expect(resized.status).toBe(200);
716
+
710
717
  const second = await openTerminalSocket(socketUrl);
711
718
  sockets.push(second.socket);
712
719
  second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
713
- expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
720
+ expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running', cols: 120, rows: 40 });
714
721
  processes[0].emitExit(7);
715
722
  expect(await second.next('exit')).toMatchObject({ s: 'term-live', q: 3, exitCode: 7 });
716
723
 
@@ -750,9 +757,9 @@ describe('terminal runtime', () => {
750
757
 
751
758
  expect(response.statusCode).toBe(200);
752
759
  expect(response.body).toEqual({ sessionId: 'term-command', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'terminal' } });
753
- if (process.platform === 'linux') {
760
+ if (process.platform !== 'win32') {
754
761
  expect(harness.processes[0].shell).toMatch(/\/env$/);
755
- expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
762
+ expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
756
763
  } else {
757
764
  expect(harness.processes[0].args).toEqual(['-l', '-i', '-c', 'printf ready']);
758
765
  }