@aiworker/web 1.22.2 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-B1Gizi3a.js} +1 -1
  3. package/dist/assets/DiffView-DXjVsEOw.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-aig6zJjg.js} +1 -1
  5. package/dist/assets/FilesView-CJdG0-wk.js +17 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-BXsS3jG6.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-D_hpXBnF.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-BoUiLQG1.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-B_B_a9KS.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-xgyyLiBM.js +12 -0
  11. package/dist/assets/OnboardingScreen-DIp84OWt.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-8p6nLJaU.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DjC8nTk9.js +161 -0
  14. package/dist/assets/{PlanView-DyDt_nZ_.js → PlanView-CJTmOF6I.js} +2 -2
  15. package/dist/assets/SettingsView-DD-JqIJx.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Cpb4hM6q.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-DfaKZXNU.js} +6 -6
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-DtZLzrSK.js} +2 -2
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-C8i9HpTB.js} +1 -1
  21. package/dist/assets/WalkthroughView-DeKMvZlL.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BBI7ZwrC.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DCrsQ5zS.js +10 -0
  25. package/dist/assets/es-Dwv4YfH6.js +16 -0
  26. package/dist/assets/fr-BCWg4Cnx.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-B68h5S5B.css +1 -0
  29. package/dist/assets/index-DVwMarHI.js +39 -0
  30. package/dist/assets/{index-Bl3LSbjn.js → index-jXSK5RrB.js} +1 -1
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-B_x_rVon.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-DW_xMer1.js} +1 -1
  33. package/dist/assets/ja-Bruw3RYC.js +16 -0
  34. package/dist/assets/ko-idhXGF8j.js +16 -0
  35. package/dist/assets/main-BkyaQhJX.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-CzdPuL0s.js} +2 -2
  37. package/dist/assets/markdownCore-Cn4Tyejo.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-CCkqKOLp.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-mvxTo055.js} +2 -2
  40. package/dist/assets/patchFileDiff-Csk4iNUi.js +13 -0
  41. package/dist/assets/pl-CGZWUqZJ.js +16 -0
  42. package/dist/assets/pt-BR-B8V7u7tA.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +2 -0
  44. package/dist/assets/renderMobileApp-B48CsP4r.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +17 -0
  46. package/dist/assets/scheduledTasksApi-BW0Etzwz.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-CRl0SUC9.js} +1 -1
  48. package/dist/assets/surface-oMvhsog3.js +1 -0
  49. package/dist/assets/tr-BK_KiO2V.js +7 -0
  50. package/dist/assets/uk-CG74xV8N.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-CPKeOpUo.js} +160 -157
  52. package/dist/assets/useGitComparison-DH220WmO.js +1 -0
  53. package/dist/assets/useInlineCommentController-BiWz4AVb.js +2 -0
  54. package/dist/assets/useRootScrollLock-BS4-kr7P.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Bg4AEeCr.js} +2 -2
  57. package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
  58. package/dist/assets/{web-components-_mNEMjFY.js → web-components-z7hfnS8n.js} +1 -1
  59. package/dist/assets/zh-CN-T4AXofZI.js +16 -0
  60. package/dist/assets/zh-TW-Dte40sxp.js +16 -0
  61. package/dist/index.html +5 -43
  62. package/dist/mini-chat.html +4 -4
  63. package/dist/mobile.html +4 -4
  64. package/package.json +2 -3
  65. package/server/index.d.ts +15 -0
  66. package/server/index.js +45 -9
  67. package/server/lib/data-dir-migration.js +40 -0
  68. package/server/lib/data-dir-migration.test.js +59 -0
  69. package/server/lib/event-stream/runtime.test.js +12 -12
  70. package/server/lib/fs/DOCUMENTATION.md +3 -0
  71. package/server/lib/fs/routes.js +31 -0
  72. package/server/lib/fs/routes.test.js +110 -1
  73. package/server/lib/git/DOCUMENTATION.md +29 -5
  74. package/server/lib/git/routes.js +24 -4
  75. package/server/lib/git/service.js +184 -198
  76. package/server/lib/git/service.test.js +422 -25
  77. package/server/lib/inherited-env.js +16 -9
  78. package/server/lib/inherited-env.test.js +8 -9
  79. package/server/lib/message-queue/DOCUMENTATION.md +4 -1
  80. package/server/lib/network-defaults.js +23 -0
  81. package/server/lib/network-defaults.test.js +52 -0
  82. package/server/lib/opencode/DOCUMENTATION.md +17 -1
  83. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  84. package/server/lib/opencode/feature-routes-runtime.js +2 -0
  85. package/server/lib/opencode/openchamber-routes.js +64 -4
  86. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  87. package/server/lib/opencode/routes.js +5 -3
  88. package/server/lib/opencode/settings-files.js +214 -0
  89. package/server/lib/opencode/settings-helpers.js +85 -41
  90. package/server/lib/opencode/settings-helpers.test.js +166 -1
  91. package/server/lib/opencode/settings-registry.json +747 -0
  92. package/server/lib/opencode/settings-runtime.js +139 -18
  93. package/server/lib/opencode/settings-runtime.test.js +163 -5
  94. package/server/lib/project-context/DOCUMENTATION.md +25 -1
  95. package/server/lib/project-context/routes.http.test.js +18 -0
  96. package/server/lib/project-context/routes.js +15 -0
  97. package/server/lib/project-context/runtime.js +248 -23
  98. package/server/lib/project-context/runtime.test.js +106 -3
  99. package/server/lib/projects/DOCUMENTATION.md +76 -0
  100. package/server/lib/projects/project-config.js +142 -9
  101. package/server/lib/projects/project-id.js +17 -0
  102. package/server/lib/projects/project-setup.js +409 -0
  103. package/server/lib/projects/project-setup.test.js +557 -0
  104. package/server/lib/projects/routes.js +59 -0
  105. package/server/lib/quota/DOCUMENTATION.md +31 -1
  106. package/server/lib/quota/providers/cline-pass.js +125 -0
  107. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  108. package/server/lib/quota/providers/index.js +7 -0
  109. package/server/lib/quota/providers/neuralwatt.js +3 -6
  110. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  111. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  112. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  113. package/server/lib/quota/providers/opencode-go.js +1 -1
  114. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  115. package/server/lib/quota/providers/openrouter.js +119 -24
  116. package/server/lib/quota/providers/openrouter.test.js +330 -0
  117. package/server/lib/relay/DOCUMENTATION.md +55 -0
  118. package/server/lib/relay/cross-compat.test.js +26 -0
  119. package/server/lib/relay/downstream-scheduler.js +154 -0
  120. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  121. package/server/lib/relay/e2ee.js +10 -8
  122. package/server/lib/relay/flow-control.test.js +177 -0
  123. package/server/lib/relay/host-client.js +37 -8
  124. package/server/lib/relay/tunnel-codec.js +9 -0
  125. package/server/lib/relay/tunnel-host.js +66 -17
  126. package/server/lib/session-assist/runtime.js +6 -10
  127. package/server/lib/session-goal/runtime.js +4 -9
  128. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  129. package/server/lib/small-model/call.js +9 -4
  130. package/server/lib/small-model/call.test.js +29 -0
  131. package/server/lib/small-model/index.js +5 -10
  132. package/server/lib/small-model/runtime-providers.js +15 -7
  133. package/server/lib/small-model/runtime-providers.test.js +7 -1
  134. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  135. package/server/lib/terminal/runtime.js +11 -6
  136. package/server/lib/terminal/runtime.test.js +20 -13
  137. package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
  138. package/server/lib/walkthrough/model-settings.js +8 -12
  139. package/server/lib/walkthrough/prompt.js +3 -1
  140. package/server/lib/walkthrough/sources.js +23 -2
  141. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  142. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  143. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  144. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  145. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  146. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  147. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  148. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  149. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  150. package/dist/assets/de-B5JaA3SA.js +0 -10
  151. package/dist/assets/es-EPIH88ii.js +0 -16
  152. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  153. package/dist/assets/index-DF5kMP-i.js +0 -39
  154. package/dist/assets/index-Dl3xbAB_.css +0 -1
  155. package/dist/assets/ja-CifOE1VN.js +0 -16
  156. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  157. package/dist/assets/main-MhSaOZoB.js +0 -930
  158. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  159. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  160. package/dist/assets/pl-DgEHYfFM.js +0 -16
  161. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  162. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  163. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  164. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  165. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  166. package/dist/assets/tr-D5m2yIWL.js +0 -7
  167. package/dist/assets/uk-D_q60oog.js +0 -16
  168. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  169. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  170. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  171. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  172. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  173. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
@@ -4,6 +4,8 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
6
6
  import simpleGit from 'simple-git';
7
+ import { loadSourceSections, parseSource, sourceKey } from '../walkthrough/sources.js';
8
+ import { registerGitRoutes } from './routes.js';
7
9
 
8
10
  import {
9
11
  checkoutBranch,
@@ -14,6 +16,10 @@ import {
14
16
  getBranches,
15
17
  getUnpushedBranchCounts,
16
18
  getRangeDiff,
19
+ getBranchBase,
20
+ getCommitDiff,
21
+ getCommitFiles,
22
+ getLog,
17
23
  getStatus,
18
24
  getWorktrees,
19
25
  isGitRepository,
@@ -29,6 +35,7 @@ import {
29
35
  unstageFiles,
30
36
  applyHunk,
31
37
  getDiff,
38
+ getUntrackedDiffs,
32
39
  getFileDiff,
33
40
  validateWorktreeCreate,
34
41
  parseBranchCreationSource,
@@ -183,28 +190,49 @@ describe.runIf(canRunGit())('setLocalIdentity', () => {
183
190
  "ssh -i '/tmp/test key' -o IdentitiesOnly=yes"
184
191
  );
185
192
  });
193
+
194
+ it('configures the stored credential helper for token auth with the targeted simple-git opt-in', async () => {
195
+ const { tmpDir } = await createTempRepo();
196
+
197
+ await setLocalIdentity(tmpDir, {
198
+ userName: 'Token User',
199
+ userEmail: 'token@example.com',
200
+ authType: 'token',
201
+ host: 'github.com',
202
+ });
203
+
204
+ expect(runGit(tmpDir, ['config', '--local', '--get', 'credential.helper']).trim()).toBe('store');
205
+ });
206
+
207
+ it('clears the stored credential helper when switching to SSH auth', async () => {
208
+ const { tmpDir } = await createTempRepo();
209
+
210
+ await setLocalIdentity(tmpDir, {
211
+ userName: 'Token User',
212
+ userEmail: 'token@example.com',
213
+ authType: 'token',
214
+ host: 'github.com',
215
+ });
216
+ await setLocalIdentity(tmpDir, {
217
+ userName: 'SSH User',
218
+ userEmail: 'ssh@example.com',
219
+ authType: 'ssh',
220
+ sshKey: '/tmp/test key',
221
+ });
222
+
223
+ expect(runGit(tmpDir, ['config', '--local', '--get', 'core.sshCommand']).trim()).toBe(
224
+ "ssh -i '/tmp/test key' -o IdentitiesOnly=yes"
225
+ );
226
+ expect(() => runGit(tmpDir, ['config', '--local', '--get', 'credential.helper'])).toThrow();
227
+ });
186
228
  });
187
229
 
188
230
  // ---------------------------------------------------------------------------
189
231
  // applyHunk (per-hunk stage / unstage / discard)
190
232
  // ---------------------------------------------------------------------------
191
233
 
192
- /** Minimal unified-diff splitter: returns standalone per-hunk patches. */
193
- const splitHunks = (patch) => {
194
- const lines = patch.split(/\r?\n/);
195
- const headerEnd = lines.findIndex((line) => /^@@\s/.test(line));
196
- if (headerEnd === -1) return [];
197
- const header = lines.slice(0, headerEnd);
198
- const hunks = [];
199
- for (let i = headerEnd; i < lines.length; i += 1) {
200
- const line = lines[i];
201
- if (/^@@\s/.test(line)) hunks.push([...header, line]);
202
- else if (hunks.length > 0) hunks[hunks.length - 1].push(line);
203
- }
204
- return hunks.map((hunk) => hunk.join('\n'))
205
- .filter((hunk) => hunk.trim().length > 0)
206
- .map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`));
207
- };
234
+ // Exercise the actual client splitter against the server apply boundary.
235
+ import { splitPatchIntoHunks as splitHunks } from '../../../../ui/src/lib/diff/patchFileDiff.ts';
208
236
 
209
237
  const writeFile = (repo, name, contents) =>
210
238
  fs.promises.writeFile(path.join(repo, name), contents, 'utf8');
@@ -220,6 +248,77 @@ const readWorking = (repo) => fs.promises.readFile(path.join(repo, 'file.txt'),
220
248
  const readStaged = async (git) => (await git.raw(['show', ':file.txt'])).replace(/\r\n/g, '\n');
221
249
 
222
250
  describe('applyHunk', () => {
251
+ it('stages successive hunks and never discards a stale staged or committed patch', async () => {
252
+ if (!canRunGit()) return;
253
+ const { tmpDir, git } = await createTempRepo();
254
+ const original = Array.from({ length: 60 }, (_, index) => `line${index}`);
255
+ const changed = [...original];
256
+ changed[1] = 'FIRST'; changed[25] = 'SECOND'; changed[50] = 'THIRD';
257
+ await writeFile(tmpDir, 'file.txt', original.join('\n') + '\n');
258
+ await git.add('file.txt'); await git.commit('Initial');
259
+ await writeFile(tmpDir, 'file.txt', changed.join('\n') + '\n');
260
+ const historical = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
261
+ expect(historical).toHaveLength(3);
262
+ await applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'stage' });
263
+ const remaining = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
264
+ expect(remaining).toHaveLength(2);
265
+ await applyHunk(tmpDir, 'file.txt', { patch: remaining[0], action: 'stage' });
266
+ const stalePath = path.join(tmpDir, 'stale.patch');
267
+ await fs.promises.writeFile(stalePath, historical[0]);
268
+ // Git's reverse applicability check accepts it, but it is no longer an
269
+ // unstaged hunk. The server must reject it before touching the working file.
270
+ await git.raw(['apply', '--reverse', '--check', stalePath]);
271
+ await expect(applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'discard' })).rejects.toThrow('refresh and try again');
272
+ expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
273
+ const last = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
274
+ expect(last).toHaveLength(1);
275
+ await applyHunk(tmpDir, 'file.txt', { patch: last[0], action: 'discard' });
276
+ changed[50] = original[50];
277
+ expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
278
+ expect(await readStaged(git)).toBe(changed.join('\n') + '\n');
279
+ const staged = splitHunks(await getDiff(tmpDir, { path: 'file.txt', staged: true }));
280
+ await applyHunk(tmpDir, 'file.txt', { patch: staged[0], action: 'unstage' });
281
+ expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
282
+ await git.add('file.txt'); await git.commit('Committed changes');
283
+ await expect(applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'discard' })).rejects.toThrow('refresh and try again');
284
+ });
285
+
286
+ it.each(['crlf', 'mixed'])('preserves %s file bytes through stage, unstage and discard', async (endings) => {
287
+ if (!canRunGit()) return;
288
+ const { tmpDir, git } = await createTempRepo();
289
+ await git.addConfig('core.autocrlf', 'false');
290
+ const serialize = (first, last) => Array.from({ length: 30 }, (_, index) => {
291
+ const text = index === 0 ? first : index === 29 ? last : `line${index}`;
292
+ return text + (endings === 'crlf' || index % 2 === 0 ? '\r\n' : '\n');
293
+ }).join('');
294
+ const original = serialize('first', 'last');
295
+ const edited = serialize('FIRST', 'LAST');
296
+ await writeFile(tmpDir, 'file.txt', original);
297
+ await git.add('file.txt'); await git.commit('Initial');
298
+ await writeFile(tmpDir, 'file.txt', edited);
299
+ const hunks = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
300
+ await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'stage' });
301
+ expect(await git.raw(['show', ':file.txt'])).toBe(serialize('FIRST', 'last'));
302
+ const staged = splitHunks(await getDiff(tmpDir, { path: 'file.txt', staged: true }));
303
+ await applyHunk(tmpDir, 'file.txt', { patch: staged[0], action: 'unstage' });
304
+ expect(await git.raw(['show', ':file.txt'])).toBe(original);
305
+ const working = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
306
+ await applyHunk(tmpDir, 'file.txt', { patch: working[0], action: 'discard' });
307
+ expect(await fs.promises.readFile(path.join(tmpDir, 'file.txt'), 'utf8')).toBe(serialize('first', 'LAST'));
308
+ });
309
+
310
+ it('rejects extra files hidden before the requested patch', async () => {
311
+ if (!canRunGit()) return;
312
+ const { tmpDir, git } = await createTempRepo();
313
+ for (const name of ['file.txt', 'other.txt']) await writeFile(tmpDir, name, ORIGINAL_FILE);
314
+ await git.add('.'); await git.commit('Initial');
315
+ for (const name of ['file.txt', 'other.txt']) await writeFile(tmpDir, name, EDITED_FILE);
316
+ const other = splitHunks(await getDiff(tmpDir, { path: 'other.txt' }))[0];
317
+ const requested = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }))[0];
318
+ await expect(applyHunk(tmpDir, 'file.txt', { patch: requested + other, action: 'stage' })).rejects.toThrow('refresh and try again');
319
+ expect(await git.raw(['diff', '--cached'])).toBe('');
320
+ });
321
+
223
322
  it('rejects an invalid action or a patch without a hunk header', async () => {
224
323
  const { tmpDir } = await createTempRepo();
225
324
  await expect(applyHunk(tmpDir, 'file.txt', { patch: '@@ -1 +1 @@\n a\n', action: 'bogus' })).rejects.toThrow(
@@ -302,10 +401,9 @@ describe('applyHunk', () => {
302
401
  );
303
402
  });
304
403
 
305
- it('accepts hunk patches for files with spaces in their path', async () => {
404
+ it.each(['file name.txt', 'зміни.txt'])('accepts hunk patches for %s', async (filePath) => {
306
405
  if (!canRunGit()) return;
307
406
  const { tmpDir, git } = await createTempRepo();
308
- const filePath = 'file name.txt';
309
407
  await writeFile(tmpDir, filePath, ORIGINAL_FILE);
310
408
  await git.add(filePath);
311
409
  await git.commit('Initial');
@@ -322,6 +420,71 @@ describe('applyHunk', () => {
322
420
  });
323
421
  });
324
422
 
423
+ describe.runIf(canRunGit())('untracked diffs', () => {
424
+ it.each(['false', 'warn'])('returns only the patch with core.safecrlf=%s', async (safecrlf) => {
425
+ const { tmpDir, git } = await createTempRepo();
426
+ await git.addConfig('core.autocrlf', 'true');
427
+ await git.addConfig('core.safecrlf', safecrlf);
428
+ fs.writeFileSync(path.join(tmpDir, 'new file.txt'), 'first\nsecond\n');
429
+
430
+ // Confirm this fixture produces a real diff exit, including stderr in the warning case.
431
+ let expectedPatch;
432
+ try {
433
+ runGit(tmpDir, ['diff', '--no-color', '--full-index', '--no-index', '--', '/dev/null', 'new file.txt']);
434
+ throw new Error('Expected git diff to exit with differences');
435
+ } catch (error) {
436
+ expect(error.status).toBe(1);
437
+ expectedPatch = error.stdout;
438
+ if (safecrlf === 'warn') {
439
+ expect(error.stderr).toContain('LF will be replaced by CRLF');
440
+ }
441
+ }
442
+
443
+ const diff = await getDiff(tmpDir, { path: 'new file.txt' });
444
+ expect(diff).toBe(expectedPatch);
445
+ expect(diff).toContain('+first\n+second\n');
446
+ expect(diff).not.toContain('warning:');
447
+ expect(await getUntrackedDiffs(tmpDir, ['new file.txt'])).toEqual([diff]);
448
+ });
449
+
450
+ it('accepts an empty untracked file without a process error', async () => {
451
+ const { tmpDir } = await createTempRepo();
452
+ fs.writeFileSync(path.join(tmpDir, 'empty.txt'), '');
453
+ const diff = await getDiff(tmpDir, { path: 'empty.txt' });
454
+ expect(diff).toContain('new file mode 100644');
455
+ expect(diff).not.toContain('@@');
456
+ expect(await getUntrackedDiffs(tmpDir, ['empty.txt'])).toEqual([diff]);
457
+ });
458
+
459
+ it('rejects fatal conversion errors while preserving other batch entries', async () => {
460
+ const { tmpDir } = await createTempRepo();
461
+ runGit(tmpDir, ['config', 'diff.broken.textconv', 'false']);
462
+ fs.writeFileSync(path.join(tmpDir, '.gitattributes'), 'bad.txt diff=broken\n');
463
+ fs.writeFileSync(path.join(tmpDir, 'first.safe'), 'first\n');
464
+ fs.writeFileSync(path.join(tmpDir, 'bad.txt'), 'bad\n');
465
+ fs.writeFileSync(path.join(tmpDir, 'last.safe'), 'last\n');
466
+
467
+ await expect(getDiff(tmpDir, { path: 'bad.txt' })).rejects.toThrow('unable to read files to diff');
468
+ const diffs = await getUntrackedDiffs(tmpDir, ['first.safe', 'bad.txt', 'last.safe'], { concurrency: 1 });
469
+ expect(diffs).toHaveLength(3);
470
+ expect(diffs[0]).toContain('+first\n');
471
+ expect(diffs[1]).toBe('');
472
+ expect(diffs[2]).toContain('+last\n');
473
+ });
474
+
475
+ it('rejects truncated patches when the process output exceeds the buffer limit', async () => {
476
+ const { tmpDir } = await createTempRepo();
477
+ fs.writeFileSync(path.join(tmpDir, 'large.txt'), 'x'.repeat(21 * 1024 * 1024) + '\n');
478
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
479
+ try {
480
+ await expect(getDiff(tmpDir, { path: 'large.txt' })).rejects.toThrow('maxBuffer');
481
+ expect(await getUntrackedDiffs(tmpDir, ['large.txt'])).toEqual(['']);
482
+ } finally {
483
+ errorSpy.mockRestore();
484
+ }
485
+ });
486
+ });
487
+
325
488
  describe('symlink diffs', () => {
326
489
  it('treats an untracked directory symlink as a link in patch and split diffs', async () => {
327
490
  if (!canRunGit() || process.platform === 'win32') return;
@@ -1669,18 +1832,249 @@ describe.runIf(canRunGit())('getUnpushedBranchCounts', () => {
1669
1832
  });
1670
1833
  });
1671
1834
 
1835
+ describe.runIf(canRunGit())('commit comparisons', () => {
1836
+ it('shows only the selected commit and gives walkthrough the identical patch', async () => {
1837
+ const { repository } = createRepositoryWithRemote();
1838
+ fs.writeFileSync(path.join(repository, 'README.md'), 'selected version\n');
1839
+ runGit(repository, ['add', '.']);
1840
+ runGit(repository, ['commit', '-m', 'selected']);
1841
+ const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
1842
+ fs.writeFileSync(path.join(repository, 'README.md'), 'later version\n');
1843
+ runGit(repository, ['add', '.']);
1844
+ runGit(repository, ['commit', '-m', 'later']);
1845
+ fs.writeFileSync(path.join(repository, 'README.md'), 'uncommitted version\n');
1846
+ const patch = await getCommitDiff(repository, { hash, path: 'README.md' });
1847
+ expect(patch).toContain('+selected version');
1848
+ expect(patch).not.toContain('later version');
1849
+ expect(patch).not.toContain('uncommitted version');
1850
+ expect((await getCommitFiles(repository, hash)).files).toEqual([
1851
+ { path: 'README.md', insertions: 1, deletions: 1, isBinary: false, changeType: 'M' },
1852
+ ]);
1853
+ const source = parseSource({ kind: 'commit', hash });
1854
+ expect(sourceKey(source)).toBe(`commit:${hash}`);
1855
+ expect((await loadSourceSections(repository, source)).sections).toEqual([{ scope: 'commit', patch }]);
1856
+ const routes = new Map();
1857
+ registerGitRoutes({
1858
+ get: (url, handler) => routes.set(url, handler), post() {}, put() {}, delete() {},
1859
+ });
1860
+ let response;
1861
+ await routes.get('/api/git/commit-diff')(
1862
+ { query: { directory: repository, hash, path: 'README.md' } },
1863
+ { json: (body) => { response = body; }, status: (code) => { throw new Error(`Unexpected status ${code}`); } },
1864
+ );
1865
+ expect(response).toEqual({ diff: patch });
1866
+ });
1867
+
1868
+ it('handles root and empty commits and rejects invalid hashes', async () => {
1869
+ const { repository } = createRepositoryWithRemote();
1870
+ const root = runGit(repository, ['rev-parse', 'HEAD']).trim();
1871
+ expect(await getCommitDiff(repository, { hash: root })).toContain('+# Test');
1872
+ expect((await getCommitFiles(repository, root)).files[0].changeType).toBe('A');
1873
+ runGit(repository, ['commit', '--allow-empty', '-m', 'empty']);
1874
+ const empty = runGit(repository, ['rev-parse', 'HEAD']).trim();
1875
+ expect(await getCommitDiff(repository, { hash: empty })).toBe('');
1876
+ expect(await getCommitFiles(repository, empty)).toEqual({ files: [] });
1877
+ expect(() => parseSource({ kind: 'commit', hash: 'HEAD' })).toThrow();
1878
+ expect(() => parseSource({ kind: 'commit', hash: [root] })).toThrow();
1879
+ await expect(getCommitDiff(repository, { hash: 'HEAD' })).rejects.toThrow();
1880
+ await expect(getCommitFiles(repository, '0'.repeat(40))).rejects.toThrow();
1881
+ });
1882
+
1883
+ it('keeps rename paths and original contents together, including whitespace in names', async () => {
1884
+ const { repository } = createRepositoryWithRemote();
1885
+ const destination = ' new\nname.md';
1886
+ runGit(repository, ['mv', 'README.md', destination]);
1887
+ runGit(repository, ['commit', '-m', 'rename']);
1888
+ const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
1889
+ const { files } = await getCommitFiles(repository, hash);
1890
+ expect(files).toEqual([{ path: destination, previousPath: 'README.md', changeType: 'R', insertions: 0, deletions: 0, isBinary: false }]);
1891
+ const patch = await getCommitDiff(repository, { hash, path: destination, previousPath: files[0].previousPath });
1892
+ expect(patch).toContain('rename from README.md');
1893
+ expect(patch).toContain('similarity index 100%');
1894
+ });
1895
+
1896
+ it('compares a merge commit against its first parent', async () => {
1897
+ const { repository } = createRepositoryWithRemote();
1898
+ runGit(repository, ['checkout', '-b', 'side']);
1899
+ fs.writeFileSync(path.join(repository, 'side.txt'), 'side\n');
1900
+ runGit(repository, ['add', '.']);
1901
+ runGit(repository, ['commit', '-m', 'side']);
1902
+ runGit(repository, ['checkout', 'next']);
1903
+ fs.writeFileSync(path.join(repository, 'main.txt'), 'main\n');
1904
+ runGit(repository, ['add', '.']);
1905
+ runGit(repository, ['commit', '-m', 'main']);
1906
+ runGit(repository, ['merge', '--no-ff', 'side', '-m', 'merge']);
1907
+ const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
1908
+ expect((await getCommitFiles(repository, hash)).files.map((file) => file.path)).toEqual(['side.txt']);
1909
+ const patch = await getCommitDiff(repository, { hash });
1910
+ expect(patch).toContain('+side');
1911
+ expect(patch).not.toContain('main.txt');
1912
+ });
1913
+
1914
+ it('limits current-branch history to 50 commits without including another branch', async () => {
1915
+ const { repository } = createRepositoryWithRemote();
1916
+ runGit(repository, ['checkout', '-b', 'other']);
1917
+ runGit(repository, ['commit', '--allow-empty', '-m', 'other branch only']);
1918
+ runGit(repository, ['checkout', 'next']);
1919
+ for (let index = 0; index < 51; index += 1) runGit(repository, ['commit', '--allow-empty', '-m', `current ${index}`]);
1920
+ const history = await getLog(repository, { maxCount: 50, to: 'refs/heads/next' });
1921
+ expect(history.all).toHaveLength(50);
1922
+ expect(history.all[0].message).toBe('current 50');
1923
+ expect(history.all.some((commit) => commit.message === 'other branch only')).toBe(false);
1924
+ });
1925
+ });
1926
+
1672
1927
  describe.runIf(canRunGit())('getRangeDiff', () => {
1673
- it('resolves a base that exists only on a remote other than origin', async () => {
1928
+ it('loads a committed deletion that no longer exists in HEAD or the working tree', async () => {
1929
+ const { repository } = createRepositoryWithRemote();
1930
+ runGit(repository, ['rm', 'README.md']);
1931
+ runGit(repository, ['commit', '-m', 'delete file']);
1932
+ const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', path: 'README.md', includeWorkingTree: true });
1933
+ expect(diff).toContain('deleted file mode');
1934
+ expect(diff).toContain('-# Test');
1935
+ });
1936
+
1937
+ it('carries the working-tree option through the actual HTTP route handlers', async () => {
1938
+ const { repository } = createRepositoryWithRemote();
1939
+ fs.writeFileSync(path.join(repository, 'local.txt'), 'current local work\n');
1940
+ const routes = new Map();
1941
+ registerGitRoutes({
1942
+ get: (url, handler) => routes.set(url, handler),
1943
+ post() {},
1944
+ put() {},
1945
+ delete() {},
1946
+ });
1947
+ const query = { directory: repository, base: 'origin/react', head: 'next', includeWorkingTree: 'true' };
1948
+ for (const endpoint of ['range-diff', 'range-files']) {
1949
+ let status = 200;
1950
+ let body;
1951
+ const response = {
1952
+ status(value) { status = value; return this; },
1953
+ json(value) { body = value; },
1954
+ };
1955
+ await routes.get(`/api/git/${endpoint}`)({ query }, response);
1956
+ expect(status).toBe(200);
1957
+ if (endpoint === 'range-diff') expect(body.diff).toContain('+current local work');
1958
+ else expect(body.files).toEqual([{ path: 'local.txt', status: 'A' }]);
1959
+ }
1960
+ });
1961
+
1962
+ it('asks for a new base after restacking and compares against the selected parent', async () => {
1963
+ const { repository } = createRepositoryWithRemote();
1964
+ runGit(repository, ['checkout', '-b', 'child', 'origin/react']);
1965
+ fs.writeFileSync(path.join(repository, 'child.txt'), 'child\n');
1966
+ runGit(repository, ['add', '.']);
1967
+ runGit(repository, ['commit', '-m', 'child']);
1968
+ expect(await getBranchBase(repository, 'child')).toEqual({ base: 'origin/react' });
1969
+ runGit(repository, ['checkout', '-b', 'parent', 'origin/react']);
1970
+ fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent\n');
1971
+ runGit(repository, ['add', '.']);
1972
+ runGit(repository, ['commit', '-m', 'parent']);
1973
+ runGit(repository, ['checkout', 'child']);
1974
+ runGit(repository, ['rebase', 'parent']);
1975
+ expect(await getBranchBase(repository, 'child')).toEqual({ base: null });
1976
+ fs.writeFileSync(path.join(repository, 'child.txt'), 'current child\n');
1977
+ const options = { base: 'refs/heads/parent', head: 'child', includeWorkingTree: true };
1978
+ expect(await getRangeFiles(repository, options)).toEqual([{ path: 'child.txt', status: 'A' }]);
1979
+ const diff = await getRangeDiff(repository, options);
1980
+ expect(diff).toContain('+current child');
1981
+ expect(diff).not.toContain('parent.txt');
1982
+ });
1983
+
1984
+ it('combines committed, staged, unstaged and untracked work without changing the real index', async () => {
1985
+ const { repository } = createRepositoryWithRemote();
1986
+ fs.writeFileSync(path.join(repository, 'README.md'), '# Committed\n');
1987
+ runGit(repository, ['add', 'README.md']);
1988
+ runGit(repository, ['commit', '-m', 'branch change']);
1989
+ fs.writeFileSync(path.join(repository, 'README.md'), '# Staged\n');
1990
+ fs.writeFileSync(path.join(repository, 'staged.txt'), 'staged only\n');
1991
+ runGit(repository, ['add', '.']);
1992
+ fs.writeFileSync(path.join(repository, 'README.md'), '# Current\n');
1993
+ fs.writeFileSync(path.join(repository, 'untracked.txt'), 'new local file\n');
1994
+ fs.writeFileSync(path.join(repository, ' leading space.txt'), 'space path\n');
1995
+ const indexBefore = fs.readFileSync(path.join(repository, '.git/index'));
1996
+ const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
1997
+
1998
+ const diff = await getRangeDiff(repository, options);
1999
+ expect(diff).toContain('-# Test');
2000
+ expect(diff).toContain('+# Current');
2001
+ expect(diff).not.toContain('+# Staged');
2002
+ expect(diff).not.toContain('+# Committed');
2003
+ expect(diff).toContain('+new local file');
2004
+ expect(diff).toContain('+staged only');
2005
+ expect(await getRangeFiles(repository, options)).toEqual(expect.arrayContaining([
2006
+ { path: 'README.md', status: 'M' },
2007
+ { path: 'staged.txt', status: 'A' },
2008
+ { path: 'untracked.txt', status: 'A' },
2009
+ { path: ' leading space.txt', status: 'A' },
2010
+ ]));
2011
+ const { sections } = await loadSourceSections(repository, { kind: 'branch', baseRef: options.base, headRef: options.head });
2012
+ expect(sections).toEqual([{ scope: 'branch', patch: diff }]);
2013
+ expect(fs.readFileSync(path.join(repository, '.git/index'))).toEqual(indexBefore);
2014
+
2015
+ const committed = await getRangeDiff(repository, { base: options.base, head: options.head });
2016
+ expect(committed).toContain('+# Committed');
2017
+ expect(committed).not.toContain('+new local file');
2018
+ fs.writeFileSync(path.join(repository, 'README.md'), '# Latest\n');
2019
+ expect(await getRangeDiff(repository, { ...options, path: 'README.md' })).toContain('+# Latest');
2020
+ });
2021
+
2022
+ it('reports the final file after a staged deletion is recreated, and omits undone branch changes', async () => {
2023
+ const { repository } = createRepositoryWithRemote();
2024
+ runGit(repository, ['rm', 'README.md']);
2025
+ fs.writeFileSync(path.join(repository, 'README.md'), '# Recreated\n');
2026
+ const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
2027
+ expect(await getRangeFiles(repository, options)).toEqual([{ path: 'README.md', status: 'M' }]);
2028
+ const diff = await getRangeDiff(repository, options);
2029
+ expect(diff).toContain('-# Test');
2030
+ expect(diff).toContain('+# Recreated');
2031
+ expect(diff.match(/diff --git/g)).toHaveLength(1);
2032
+ fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n');
2033
+ expect(await getRangeFiles(repository, options)).toEqual([]);
2034
+ expect(await getRangeDiff(repository, options)).toBe('');
2035
+ });
2036
+
2037
+ it('keeps local and remote bases distinct and rejects a different checked-out branch', async () => {
2038
+ const { repository } = createRepositoryWithRemote();
2039
+ runGit(repository, ['branch', 'react']);
2040
+ fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent work\n');
2041
+ runGit(repository, ['add', '.']);
2042
+ runGit(repository, ['commit', '-m', 'parent work']);
2043
+ runGit(repository, ['branch', '-f', 'react', 'HEAD']);
2044
+ fs.writeFileSync(path.join(repository, 'child.txt'), 'child work\n');
2045
+ const options = { head: 'next', includeWorkingTree: true };
2046
+ const local = await getRangeDiff(repository, { ...options, base: 'react' });
2047
+ const remote = await getRangeDiff(repository, { ...options, base: 'origin/react' });
2048
+ expect(local).not.toContain('parent.txt');
2049
+ expect(remote).toContain('parent.txt');
2050
+ expect(local).toContain('child.txt');
2051
+ expect(await getRangeFiles(repository, { ...options, base: 'react' })).toEqual([{ path: 'child.txt', status: 'A' }]);
2052
+ runGit(repository, ['checkout', 'react']);
2053
+ await expect(getRangeDiff(repository, { ...options, base: 'origin/react' })).rejects.toThrow(/checked-out branch/);
2054
+ });
2055
+
2056
+ it('includes untracked symlinks as links without reading their targets', async () => {
2057
+ const { repository } = createRepositoryWithRemote();
2058
+ const outside = path.join(createTempDir(), 'outside.txt');
2059
+ fs.writeFileSync(outside, 'must not be in a diff\n');
2060
+ fs.symlinkSync(outside, path.join(repository, 'link.txt'));
2061
+ const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', includeWorkingTree: true });
2062
+ expect(diff).toContain('new file mode 120000');
2063
+ expect(diff).toContain(outside);
2064
+ expect(diff).not.toContain('must not be in a diff');
2065
+ });
2066
+
2067
+ it('uses an explicitly selected base on a remote other than origin', async () => {
1674
2068
  const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
1675
- // Only refs/remotes/upstream/react carries the base git cannot resolve the
1676
- // bare name, so an unqualified `react...next` fails with "ambiguous argument".
2069
+ // The selected remote ref must work without a local branch of that name.
1677
2070
  fs.writeFileSync(path.join(repository, 'feature.txt'), 'work\n');
1678
2071
  runGit(repository, ['add', 'feature.txt']);
1679
2072
  runGit(repository, ['commit', '-m', 'feature']);
1680
2073
 
1681
- const diff = await getRangeDiff(repository, { base: 'react', head: 'next' });
2074
+ const diff = await getRangeDiff(repository, { base: 'upstream/react', head: 'next' });
1682
2075
 
1683
2076
  expect(diff).toContain('feature.txt');
2077
+ await expect(getRangeDiff(repository, { base: 'react', head: 'next' })).rejects.toThrow(/is not available locally/);
1684
2078
  });
1685
2079
 
1686
2080
  it('names an unfetched remote-only ref instead of failing with git\'s ambiguous argument (#2735)', async () => {
@@ -1693,6 +2087,9 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
1693
2087
  });
1694
2088
 
1695
2089
  describe('parseBranchCreationSource', () => {
2090
+ it('does not reuse the creation base after a rebase', () => {
2091
+ expect(parseBranchCreationSource('rebase (finish): refs/heads/feature onto abc123\nbranch: Created from main')).toBeNull();
2092
+ });
1696
2093
  it('returns the source ref from the oldest creation entry', () => {
1697
2094
  // Reflog lists newest entries first; creation is the last line.
1698
2095
  const reflog = [
@@ -1739,7 +2136,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
1739
2136
  runGit(repository, ['add', 'added.txt', 'README.md']);
1740
2137
  runGit(repository, ['commit', '-m', 'changes']);
1741
2138
 
1742
- const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
2139
+ const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
1743
2140
 
1744
2141
  expect(files).toEqual(expect.arrayContaining([
1745
2142
  { path: 'added.txt', status: 'A' },
@@ -1761,7 +2158,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
1761
2158
  runGit(repository, ['add', '-A']);
1762
2159
  runGit(repository, ['commit', '-m', 'rename']);
1763
2160
 
1764
- const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
2161
+ const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
1765
2162
 
1766
2163
  const renameEntry = files.find((file) => file.status === 'R');
1767
2164
  expect(renameEntry).toBeDefined();
@@ -1783,7 +2180,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
1783
2180
  runGit(repository, ['add', '-A']);
1784
2181
  runGit(repository, ['commit', '-m', 'copy']);
1785
2182
 
1786
- const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
2183
+ const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
1787
2184
 
1788
2185
  const copyEntry = files.find((file) => file.status === 'C');
1789
2186
  expect(copyEntry).toBeDefined();
@@ -12,7 +12,9 @@
12
12
  import { createRequire } from 'node:module';
13
13
  import { existsSync } from 'node:fs';
14
14
 
15
- const LINUX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
15
+ const POSIX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
16
+ /** Variables a PTY shell must never inherit from the AiWorker host process. */
17
+ const PTY_HOST_PRIVATE_VARIABLES = Object.freeze(['ARGV0', 'NODE_CHANNEL_FD']);
16
18
 
17
19
  /**
18
20
  * Remove AppImage `ARGV0` from a mutable env object (or `process.env`).
@@ -49,26 +51,31 @@ export function clearAppImageArgv0FromProcessEnv() {
49
51
  }
50
52
 
51
53
  /**
52
- * Resolve a Linux PTY launch that drops native `ARGV0` before the shell starts.
54
+ * Resolve a POSIX PTY launch that unsets host-private variables before the
55
+ * shell starts.
53
56
  *
54
- * `bun-pty` merges the OS environ into the child, so deleting `ARGV0` from the
55
- * JS env object alone is not enough. Wrapping with `env -u ARGV0` unsets it
56
- * before execing the real shell. No-op on non-Linux platforms.
57
+ * `bun-pty` merges the OS environ into the child, so deleting a variable from
58
+ * the JS env object alone is not enough: AppImage `ARGV0` came back on Linux,
59
+ * and the daemon's `NODE_CHANNEL_FD` came back everywhere (Node then warns
60
+ * "Failed to parse IPC channel number" when a CLI such as opencode exits).
61
+ * Wrapping with `env -u NAME ...` unsets them before execing the real shell.
62
+ * No-op on Windows and when no `env` binary is found.
57
63
  *
58
64
  * @param {string} executable
59
65
  * @param {string[]} args
66
+ * @param {readonly string[]} variables
60
67
  * @returns {{ executable: string, args: string[] }}
61
68
  */
62
- export function resolveLinuxPtyLaunch(executable, args = []) {
63
- if (process.platform !== 'linux') {
69
+ export function resolvePosixPtyLaunch(executable, args = [], variables = PTY_HOST_PRIVATE_VARIABLES) {
70
+ if (process.platform === 'win32' || variables.length === 0) {
64
71
  return { executable, args };
65
72
  }
66
- const envBinary = LINUX_ENV_BINARIES.find((candidate) => existsSync(candidate));
73
+ const envBinary = POSIX_ENV_BINARIES.find((candidate) => existsSync(candidate));
67
74
  if (!envBinary) {
68
75
  return { executable, args };
69
76
  }
70
77
  return {
71
78
  executable: envBinary,
72
- args: ['-u', 'ARGV0', executable, ...args],
79
+ args: [...variables.flatMap((name) => ['-u', name]), executable, ...args],
73
80
  };
74
81
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  clearAppImageArgv0FromProcessEnv,
4
- resolveLinuxPtyLaunch,
4
+ resolvePosixPtyLaunch,
5
5
  stripAppImageArgv0Leak,
6
6
  } from './inherited-env.js';
7
7
 
@@ -46,18 +46,17 @@ describe('clearAppImageArgv0FromProcessEnv', () => {
46
46
  });
47
47
  });
48
48
 
49
- describe('resolveLinuxPtyLaunch', () => {
50
- it('wraps the shell with env -u ARGV0 on Linux', () => {
51
- if (process.platform !== 'linux') return;
52
- expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
49
+ describe('resolvePosixPtyLaunch', () => {
50
+ it('wraps the shell with env -u for every host-private variable on POSIX', () => {
51
+ if (process.platform === 'win32') return;
52
+ expect(resolvePosixPtyLaunch('/bin/zsh', ['-l'])).toEqual({
53
53
  executable: expect.stringMatching(/\/env$/),
54
- args: ['-u', 'ARGV0', '/bin/zsh', '-l'],
54
+ args: ['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/zsh', '-l'],
55
55
  });
56
56
  });
57
57
 
58
- it('leaves non-Linux launches unchanged', () => {
59
- if (process.platform === 'linux') return;
60
- expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
58
+ it('leaves the launch unchanged with nothing to unset', () => {
59
+ expect(resolvePosixPtyLaunch('/bin/zsh', ['-l'], [])).toEqual({
61
60
  executable: '/bin/zsh',
62
61
  args: ['-l'],
63
62
  });
@@ -140,7 +140,10 @@ allowlists.
140
140
 
141
141
  Every mutation broadcasts `aiworker:message-queue.updated` with
142
142
  `{ revision, session }` to all connected clients (SSE and WS), so several
143
- devices on one server see one queue. The session in that payload always names
143
+ devices on one server see one queue. SSE uses the shared control stream at
144
+ `/api/openchamber/events`; `/api/global/event` carries no AiWorker events.
145
+ The UI subscribes independently of its OpenCode transport and re-reads the
146
+ snapshot whenever either stream reconnects. The session in that payload always names
144
147
  its `directory`, including the broadcast that removes the last item: the UI
145
148
  keys its projection by directory, and a broadcast without one left the
146
149
  delivered message on screen (a session's directory is remembered until the