@sanqianx/claude-ai-workbench 0.11.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 (90) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +129 -0
  3. package/apps/agent-terminal/app.css +567 -0
  4. package/apps/agent-terminal/app.js +2392 -0
  5. package/apps/agent-terminal/index.html +158 -0
  6. package/apps/ai-profile-settings-host/index.html +25 -0
  7. package/apps/ai-profile-settings-host/page.css +1 -0
  8. package/apps/ai-profile-settings-host/page.js +113 -0
  9. package/apps/minimal-test-host/README.md +15 -0
  10. package/apps/minimal-test-host/host.css +2 -0
  11. package/apps/minimal-test-host/host.js +35 -0
  12. package/apps/minimal-test-host/index.html +58 -0
  13. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
  14. package/docs/sdk/FILE_LAYOUT.md +31 -0
  15. package/docs/sdk/INTEGRATION.md +126 -0
  16. package/docs/sdk/README.md +21 -0
  17. package/docs/sdk/openapi.yaml +760 -0
  18. package/package.json +51 -0
  19. package/packages/client/README.md +11 -0
  20. package/packages/client/index.d.ts +81 -0
  21. package/packages/client/index.js +198 -0
  22. package/packages/client/package.json +29 -0
  23. package/packages/contracts/README.md +9 -0
  24. package/packages/contracts/index.d.ts +252 -0
  25. package/packages/contracts/index.js +295 -0
  26. package/packages/contracts/package.json +24 -0
  27. package/packages/core/README.md +8 -0
  28. package/packages/core/index.d.ts +76 -0
  29. package/packages/core/index.js +214 -0
  30. package/packages/core/package.json +29 -0
  31. package/packages/server/README.md +17 -0
  32. package/packages/server/bin/agent-terminal-server.js +36 -0
  33. package/packages/server/bin/claude-workbench-server.js +46 -0
  34. package/packages/server/bin/claude-workbench.js +225 -0
  35. package/packages/server/index.d.ts +145 -0
  36. package/packages/server/index.js +183 -0
  37. package/packages/server/lib/agent-discovery.js +112 -0
  38. package/packages/server/lib/agent-registry.js +98 -0
  39. package/packages/server/lib/attachment-store.js +78 -0
  40. package/packages/server/lib/bridges/app-server-bridge.js +372 -0
  41. package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
  42. package/packages/server/lib/capture-settings-store.js +34 -0
  43. package/packages/server/lib/context-resolver.js +40 -0
  44. package/packages/server/lib/drivers/cli-json-runner.js +960 -0
  45. package/packages/server/lib/drivers/codex-driver.js +1413 -0
  46. package/packages/server/lib/drivers/opencode-driver.js +1527 -0
  47. package/packages/server/lib/drivers/zcode-driver.js +1167 -0
  48. package/packages/server/lib/event-stream.js +35 -0
  49. package/packages/server/lib/folder-picker.js +623 -0
  50. package/packages/server/lib/http-handler.js +254 -0
  51. package/packages/server/lib/json-file.js +19 -0
  52. package/packages/server/lib/model-detection.js +295 -0
  53. package/packages/server/lib/profile-store.js +149 -0
  54. package/packages/server/lib/project-store.js +102 -0
  55. package/packages/server/lib/session-archive-store.js +53 -0
  56. package/packages/server/lib/system-doctor.js +119 -0
  57. package/packages/server/lib/ui-preferences-store.js +48 -0
  58. package/packages/server/lib/workbench-service.js +620 -0
  59. package/packages/server/package.json +34 -0
  60. package/packages/ui/README.md +30 -0
  61. package/packages/ui/ai-profile-settings.d.ts +45 -0
  62. package/packages/ui/ai-profile-settings.js +283 -0
  63. package/packages/ui/claude-terminal.d.ts +31 -0
  64. package/packages/ui/claude-terminal.js +343 -0
  65. package/packages/ui/index.d.ts +18 -0
  66. package/packages/ui/index.js +51 -0
  67. package/packages/ui/package.json +42 -0
  68. package/packages/ui/theme.css +13 -0
  69. package/src/assets/claude-prompts.json +21 -0
  70. package/src/backend/http/ai-profile-routes.js +61 -0
  71. package/src/backend/http/claude-routes.js +170 -0
  72. package/src/backend/http/node-helpers.js +45 -0
  73. package/src/backend/index.js +21 -0
  74. package/src/backend/lib/ai-adapter.js +144 -0
  75. package/src/backend/lib/ai-profile-store.js +259 -0
  76. package/src/backend/lib/ai-vendor-presets.js +83 -0
  77. package/src/backend/lib/ai-workspace.js +140 -0
  78. package/src/backend/lib/automation-config.js +253 -0
  79. package/src/backend/lib/claude-cli-runner.js +1749 -0
  80. package/src/backend/lib/data-dir.js +169 -0
  81. package/src/backend/lib/llm-client.js +176 -0
  82. package/src/backend/lib/model-context-windows.js +82 -0
  83. package/src/backend/lib/prompt-registry.js +166 -0
  84. package/src/backend/lib/runtime-config.js +121 -0
  85. package/src/index.js +7 -0
  86. package/src/sdk-wave-a.ts +1 -0
  87. package/src/sdk-wave-b.ts +1 -0
  88. package/src/sdk-wave-c.ts +1 -0
  89. package/src/sdk-wave-d.ts +1 -0
  90. package/src/sdk-wave-e.ts +1 -0
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ function normalizeEvent(event, sessionId, contextId, sequence, startedAt) {
4
+ const input = event && typeof event === 'object' ? event : { type: 'claude/error', message: String(event) };
5
+ const baseTime = Date.parse(startedAt || '') || Date.now();
6
+ return {
7
+ ...input,
8
+ type: String(input.type || 'claude/state'),
9
+ sessionId,
10
+ contextId,
11
+ sequence,
12
+ at: input.at || new Date(baseTime + sequence).toISOString(),
13
+ };
14
+ }
15
+
16
+ function subscribeNormalized(runner, sessionId, afterSequence, onEvent) {
17
+ const state = runner.getState(sessionId);
18
+ if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
19
+ let sequence = 0;
20
+ return runner.subscribe(sessionId, raw => {
21
+ sequence += 1;
22
+ if (sequence <= afterSequence) return;
23
+ onEvent(normalizeEvent(raw, sessionId, state.projectSlug, sequence, state.startedAt));
24
+ });
25
+ }
26
+
27
+ function snapshotEvents(runner, sessionId) {
28
+ const session = runner.getSession(sessionId);
29
+ const state = runner.getState(sessionId);
30
+ if (!session || !state) return [];
31
+ return (session.outputBuffer || []).map((event, index) =>
32
+ normalizeEvent(event, sessionId, state.projectSlug, index + 1, state.startedAt));
33
+ }
34
+
35
+ module.exports = { normalizeEvent, subscribeNormalized, snapshotEvents };
@@ -0,0 +1,623 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+
5
+ // UNIFIED native folder picker — three copies of this file are kept in
6
+ // lockstep (identical logic; only the module syntax differs):
7
+ // vector-hub src/server/folder-picker.ts (TypeScript twin)
8
+ // ai-coding-event-bridge packages/console/src/pick-folder.js
9
+ // claude-ai-workbench packages/server/lib/folder-picker.js
10
+ //
11
+ // Windows keeps the dialog the user already knows — the modern IFileDialog
12
+ // (the same folder picker Chrome/Edge surface: breadcrumb bar, Quick Access
13
+ // sidebar) driven by the battle-tested inline C# shim, with the
14
+ // AttachThreadInput foreground fix so the modal opens above the browser.
15
+ // What changed is the process model: spawning powershell.exe and running
16
+ // Add-Type (a full C# compilation) on every click cost 1-3s or more, so the
17
+ // helper is now started once in the background when the server boots
18
+ // (warmFolderPicker) and stays resident, serving every pick over a
19
+ // newline-framed stdin/stdout protocol. A click only writes one line and
20
+ // the dialog appears in milliseconds. The helper self-exits when its stdin
21
+ // pipe breaks, and the exit hook below kills it on shutdown.
22
+ //
23
+ // Other platforms keep one-shot helpers: zenity on Linux, osascript on macOS.
24
+ //
25
+ // Wire protocol (requests are pure ASCII; each reply is one line):
26
+ // -> ping <- !PONG
27
+ // -> pick <x> <y> <- !OK <selected path>
28
+ // -> pick - - <- !CANCEL (user dismissed the dialog)
29
+ // <- !ERR <message> (helper-side failure)
30
+ // and the first line after startup is !READY.
31
+
32
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // a user may browse for minutes
33
+ const STARTUP_TIMEOUT_MS = 60 * 1000; // powershell.exe start + Add-Type compile
34
+ const MAX_OUTPUT = 64 * 1024;
35
+ const DEFAULT_PICKER_TITLE = '选择文件夹';
36
+
37
+ // One native dialog at a time: concurrent callers (double click, two tabs)
38
+ // share the in-flight pick instead of stacking modal dialogs.
39
+ let activePick = null;
40
+
41
+ const WINDOWS_DIALOG_ARGS = ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass'];
42
+ const WINDOWS_DIALOG_SCRIPT = `\
43
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
44
+ Add-Type -AssemblyName System.Windows.Forms
45
+ $cs = @"
46
+ using System;
47
+ using System.Runtime.InteropServices;
48
+ using System.Windows.Forms;
49
+
50
+ public class FolderPicker {
51
+ public string ResultPath { get; private set; }
52
+
53
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
54
+ private static extern int SHCreateItemFromParsingName(string path, IntPtr pbc, ref Guid riid, out IntPtr ppv);
55
+ [DllImport("user32.dll")]
56
+ private static extern IntPtr GetForegroundWindow();
57
+ [DllImport("user32.dll")]
58
+ private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
59
+ [DllImport("user32.dll")]
60
+ private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
61
+ [DllImport("user32.dll")]
62
+ private static extern bool SetForegroundWindow(IntPtr hWnd);
63
+ [DllImport("kernel32.dll")]
64
+ private static extern uint GetCurrentThreadId();
65
+
66
+ public bool ShowDialog(int cx, int cy) {
67
+ // The dialog centers on its owner, so the owner is a 1x1 transparent
68
+ // topmost window parked at the anchor point (browser window center,
69
+ // or the primary screen center when no anchor was supplied).
70
+ var owner = new Form();
71
+ owner.StartPosition = FormStartPosition.Manual;
72
+ owner.FormBorderStyle = FormBorderStyle.None;
73
+ owner.ShowInTaskbar = false;
74
+ owner.TopMost = true;
75
+ owner.Size = new System.Drawing.Size(1, 1);
76
+ try {
77
+ var area = Screen.FromPoint(new System.Drawing.Point(cx, cy)).WorkingArea;
78
+ if (cx < area.Left || cx > area.Right) cx = area.Left + area.Width / 2;
79
+ if (cy < area.Top || cy > area.Bottom) cy = area.Top + area.Height / 2;
80
+ } catch { }
81
+ owner.Location = new System.Drawing.Point(cx, cy);
82
+ owner.Opacity = 0;
83
+ owner.Show();
84
+ // A background process normally may not steal the foreground from the
85
+ // browser. Attaching our input thread to the foreground window's
86
+ // thread makes the activation legal (classic AttachThreadInput
87
+ // technique); the topmost owner then keeps the modal above the
88
+ // browser even when the OS still denies the focus change.
89
+ IntPtr fg = GetForegroundWindow();
90
+ uint fgPid;
91
+ uint fgThread = GetWindowThreadProcessId(fg, out fgPid);
92
+ uint thisThread = GetCurrentThreadId();
93
+ bool attached = fgThread != thisThread && fgThread != 0 && AttachThreadInput(thisThread, fgThread, true);
94
+ try {
95
+ SetForegroundWindow(owner.Handle);
96
+ owner.Activate();
97
+ } finally {
98
+ if (attached) AttachThreadInput(thisThread, fgThread, false);
99
+ }
100
+ try {
101
+ var dialog = (IFileDialog)(new FileOpenDialogRCW());
102
+ try {
103
+ dialog.SetOptions(FOS.PICKFOLDERS | FOS.FORCEFILESYSTEM | FOS.PATHMUSTEXIST);
104
+ dialog.SetTitle("__PICKER_TITLE__");
105
+ int hr = dialog.Show(owner.Handle);
106
+ if (hr != 0) return false;
107
+ IShellItem item;
108
+ dialog.GetResult(out item);
109
+ IntPtr pathPtr;
110
+ item.GetDisplayName(SIGDN.FILESYSPATH, out pathPtr);
111
+ ResultPath = Marshal.PtrToStringUni(pathPtr);
112
+ Marshal.FreeCoTaskMem(pathPtr);
113
+ Marshal.ReleaseComObject(item);
114
+ return true;
115
+ } finally { Marshal.ReleaseComObject(dialog); }
116
+ } finally { owner.Close(); }
117
+ }
118
+
119
+ [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")]
120
+ private class FileOpenDialogRCW { }
121
+
122
+ [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
123
+ private interface IShellItem {
124
+ // Full vtable order matters: COM slots before GetDisplayName must be
125
+ // declared even though this picker never calls them.
126
+ void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
127
+ IShellItem GetParent();
128
+ [PreserveSig] int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName);
129
+ [PreserveSig] int GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
130
+ [PreserveSig] int Compare(IShellItem psi, uint hint);
131
+ }
132
+
133
+ [ComImport, Guid("d57c7288-d4ad-4768-be02-9d969532d960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
134
+ private interface IFileDialog {
135
+ [PreserveSig] int Show(IntPtr hwndOwner);
136
+ void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
137
+ void SetFileTypeIndex(uint iFileType);
138
+ void GetFileTypeIndex(out uint piFileType);
139
+ void Advise(IntPtr pfde, out uint pdwCookie);
140
+ void Unadvise(uint dwCookie);
141
+ void SetOptions(FOS fos);
142
+ void GetOptions(out FOS pfos);
143
+ void SetDefaultFolder(IShellItem psi);
144
+ void SetFolder(IShellItem psi);
145
+ void GetFolder(out IShellItem ppsi);
146
+ void GetCurrentSelection(out IShellItem ppsi);
147
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
148
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
149
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
150
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
151
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
152
+ void GetResult(out IShellItem ppsi);
153
+ void AddPlace(IShellItem psi, int fdap);
154
+ void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
155
+ void Close(int hr);
156
+ void SetClientGuid(ref Guid guid);
157
+ void ClearClientData();
158
+ void SetFilter(IntPtr pFilter);
159
+ }
160
+
161
+ private enum SIGDN : uint { FILESYSPATH = 0x80058000 }
162
+
163
+ [Flags]
164
+ private enum FOS : uint {
165
+ PICKFOLDERS = 0x20,
166
+ FORCEFILESYSTEM = 0x40,
167
+ PATHMUSTEXIST = 0x800,
168
+ }
169
+ }
170
+ "@
171
+ Add-Type -TypeDefinition $cs -ReferencedAssemblies System.Windows.Forms.dll,System.Drawing.dll
172
+ [Console]::Out.WriteLine('!READY')
173
+ while ($true) {
174
+ $line = [Console]::In.ReadLine()
175
+ if ($null -eq $line) { break }
176
+ $line = $line.Trim()
177
+ if ($line -eq 'ping') { [Console]::Out.WriteLine('!PONG'); continue }
178
+ if (-not $line.StartsWith('pick ')) { continue }
179
+ $bits = $line.Substring(5) -split ' '
180
+ $cx = $bits[0]
181
+ $cy = $bits[1]
182
+ if ($null -eq $cx -or $cx -eq '' -or $cx -eq '-' -or $null -eq $cy -or $cy -eq '' -or $cy -eq '-') {
183
+ $wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
184
+ $cx = $wa.X + [int]($wa.Width / 2)
185
+ $cy = $wa.Y + [int]($wa.Height / 2)
186
+ }
187
+ $picker = New-Object FolderPicker
188
+ try {
189
+ if ($picker.ShowDialog([int]$cx, [int]$cy)) {
190
+ [Console]::Out.WriteLine('!OK ' + $picker.ResultPath)
191
+ } else {
192
+ [Console]::Out.WriteLine('!CANCEL')
193
+ }
194
+ } catch {
195
+ [Console]::Out.WriteLine('!ERR ' + $_.Exception.Message)
196
+ }
197
+ }
198
+ `;
199
+
200
+ // The title lives inside the C# source that Add-Type compiles, so it is
201
+ // baked in when the helper starts; a different title restarts the helper.
202
+ // Quotes, backslashes, dollars and control characters would break either
203
+ // the C# literal or the PowerShell here-string and are stripped.
204
+ function sanitizeTitle(title) {
205
+ const value = String(title === undefined || title === null ? '' : title)
206
+ .replace(/["\\`$\r\n\t\u0000]/g, '')
207
+ .trim()
208
+ .slice(0, 128);
209
+ return value.length > 0 ? value : DEFAULT_PICKER_TITLE;
210
+ }
211
+
212
+ function windowsWorkerScript(title) {
213
+ return WINDOWS_DIALOG_SCRIPT.replace('__PICKER_TITLE__', sanitizeTitle(title));
214
+ }
215
+
216
+ // The dialog centers on its owner, and the owner is parked at the caller's
217
+ // anchor point (the browser window center) — `windowRect` is the UI page's
218
+ // {x, y, width, height} in screen coordinates. Anything malformed falls
219
+ // back to the '-' markers, which make the helper center on the primary
220
+ // screen instead.
221
+ function anchorPoint(windowRect) {
222
+ if (windowRect && [windowRect.x, windowRect.y, windowRect.width, windowRect.height].every(Number.isFinite)) {
223
+ const x = Math.round(windowRect.x + Math.min(Math.max(windowRect.width, 0), 32768) / 2);
224
+ const y = Math.round(windowRect.y + Math.min(Math.max(windowRect.height, 0), 32768) / 2);
225
+ // Guard against absurd coordinates; Screen.FromPoint clamps the rest.
226
+ if (Math.abs(x) <= 100000 && Math.abs(y) <= 100000) return [x, y];
227
+ }
228
+ return null;
229
+ }
230
+
231
+ /* ---- resident Windows helper (warm worker) ---- */
232
+
233
+ // One helper per spawn function: the real server shares node's spawn, so a
234
+ // single resident process serves every pick; injected test spawns each get
235
+ // an isolated worker. A strong set tracks live workers for the exit hook.
236
+ const workers = new WeakMap();
237
+ const liveWorkers = new Set();
238
+
239
+ function workerFor(doSpawn) {
240
+ let worker = workers.get(doSpawn);
241
+ if (!worker) {
242
+ worker = { child: null, ready: null, title: null, buffer: '', pending: null, ping: null, resolveReady: null, starting: false };
243
+ workers.set(doSpawn, worker);
244
+ }
245
+ return worker;
246
+ }
247
+
248
+ // While a request is in flight (helper startup, an open dialog, a ping) the
249
+ // worker's streams keep node alive so the reply is never lost; when idle
250
+ // they are unref'd again, letting a one-shot script exit once it is done.
251
+ // A listening server never notices either way.
252
+ function syncWorkerRefs(worker) {
253
+ const held = Boolean(worker.pending || worker.ping || worker.starting);
254
+ const child = worker.child;
255
+ if (!child) return;
256
+ [child, child.stdin, child.stdout, child.stderr].forEach((handle) => {
257
+ if (!handle) return;
258
+ if (held) {
259
+ if (handle.ref) handle.ref();
260
+ } else if (handle.unref) {
261
+ handle.unref();
262
+ }
263
+ });
264
+ }
265
+
266
+ function ensureWorker(worker, doSpawn, title, startupTimeoutMs) {
267
+ if (worker.title !== title) {
268
+ killWorker(worker);
269
+ worker.title = title;
270
+ }
271
+ if (!worker.ready) {
272
+ worker.ready = startWorker(worker, doSpawn, startupTimeoutMs).catch((error) => {
273
+ worker.ready = null;
274
+ throw error;
275
+ });
276
+ }
277
+ return worker.ready;
278
+ }
279
+
280
+ function startWorker(worker, doSpawn, startupTimeoutMs) {
281
+ return new Promise((resolve, reject) => {
282
+ let child;
283
+ try {
284
+ child = doSpawn('powershell.exe', [...WINDOWS_DIALOG_ARGS, '-Command', windowsWorkerScript(worker.title)], {
285
+ windowsHide: true,
286
+ stdio: ['pipe', 'pipe', 'pipe'],
287
+ });
288
+ } catch (error) {
289
+ reject(pickerError('powershell.exe', error));
290
+ return;
291
+ }
292
+ worker.child = child;
293
+ worker.starting = true;
294
+ worker.buffer = '';
295
+ liveWorkers.add(worker);
296
+ [child.stdin, child.stdout, child.stderr].forEach((stream) => {
297
+ if (stream && stream.on) stream.on('error', () => { /* EPIPE when the helper died */ });
298
+ });
299
+ syncWorkerRefs(worker);
300
+ let settled = false;
301
+ const timer = setTimeout(() => {
302
+ if (settled) return;
303
+ settled = true;
304
+ killWorker(worker);
305
+ reject(Object.assign(
306
+ new Error(`folder picker helper failed to start within ${Math.round(startupTimeoutMs / 1000)}s`),
307
+ { status: 500 },
308
+ ));
309
+ }, startupTimeoutMs);
310
+ if (timer.unref) timer.unref();
311
+ worker.resolveReady = () => {
312
+ if (settled) return;
313
+ settled = true;
314
+ worker.starting = false;
315
+ clearTimeout(timer);
316
+ syncWorkerRefs(worker);
317
+ resolve();
318
+ };
319
+ if (child.stdout && child.stdout.on) {
320
+ child.stdout.on('data', (chunk) => {
321
+ if (worker.buffer.length < MAX_OUTPUT) {
322
+ worker.buffer += String(chunk).slice(0, MAX_OUTPUT - worker.buffer.length);
323
+ }
324
+ drainWorkerLines(worker);
325
+ });
326
+ }
327
+ if (child.stderr && child.stderr.on) child.stderr.on('data', () => { /* startup chatter */ });
328
+ child.on('error', (error) => {
329
+ if (!settled) {
330
+ settled = true;
331
+ clearTimeout(timer);
332
+ reject(pickerError('powershell.exe', error));
333
+ }
334
+ worker.starting = false;
335
+ detachWorker(worker);
336
+ });
337
+ child.on('close', () => {
338
+ if (!settled) {
339
+ settled = true;
340
+ clearTimeout(timer);
341
+ reject(Object.assign(new Error('folder picker helper exited before becoming ready'), { status: 500 }));
342
+ }
343
+ worker.starting = false;
344
+ detachWorker(worker);
345
+ });
346
+ });
347
+ }
348
+
349
+ function drainWorkerLines(worker) {
350
+ let index = worker.buffer.indexOf('\n');
351
+ while (index >= 0) {
352
+ const line = worker.buffer.slice(0, index).replace(/\r$/, '').replace(/^\uFEFF/, '').trim();
353
+ worker.buffer = worker.buffer.slice(index + 1);
354
+ handleWorkerLine(worker, line);
355
+ index = worker.buffer.indexOf('\n');
356
+ }
357
+ }
358
+
359
+ function handleWorkerLine(worker, line) {
360
+ if (line === '!READY') {
361
+ if (worker.resolveReady) worker.resolveReady();
362
+ } else if (line === '!PONG') {
363
+ const ping = worker.ping;
364
+ if (ping) {
365
+ worker.ping = null;
366
+ clearTimeout(ping.timer);
367
+ syncWorkerRefs(worker);
368
+ ping.resolve();
369
+ }
370
+ } else if (line === '!CANCEL') {
371
+ settleWorkerPick(worker, (pending) => pending.resolve({ path: null }));
372
+ } else if (line.startsWith('!OK ')) {
373
+ const path = parseSelectedPath(line.slice(4), false);
374
+ settleWorkerPick(worker, (pending) => pending.resolve({ path }));
375
+ } else if (line.startsWith('!ERR ')) {
376
+ settleWorkerPick(worker, (pending) => pending.reject(pickerError(
377
+ 'folder picker helper',
378
+ Object.assign(new Error(line.slice(5)), { status: 500 }),
379
+ )));
380
+ killWorker(worker);
381
+ }
382
+ /* anything else is noise and ignored */
383
+ }
384
+
385
+ function settleWorkerPick(worker, finish) {
386
+ const pending = worker.pending;
387
+ if (!pending) return;
388
+ worker.pending = null;
389
+ clearTimeout(pending.timer);
390
+ finish(pending);
391
+ syncWorkerRefs(worker);
392
+ }
393
+
394
+ async function pickViaWindowsWorker(options) {
395
+ const doSpawn = options.spawn || spawn;
396
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
397
+ ? options.timeoutMs
398
+ : DEFAULT_TIMEOUT_MS;
399
+ const startupTimeoutMs = Number.isFinite(options.startupTimeoutMs) && options.startupTimeoutMs > 0
400
+ ? options.startupTimeoutMs
401
+ : STARTUP_TIMEOUT_MS;
402
+ const worker = workerFor(doSpawn);
403
+ await ensureWorker(worker, doSpawn, sanitizeTitle(options.title), startupTimeoutMs);
404
+ const anchor = anchorPoint(options.windowRect);
405
+ return await new Promise((resolve, reject) => {
406
+ const pending = { resolve, reject, timer: null };
407
+ pending.timer = setTimeout(() => {
408
+ // The helper is stuck mid-dialog and its state is unknown, so settle
409
+ // first (with the timeout message), then kill it — the next pick
410
+ // respawns a fresh helper.
411
+ settleWorkerPick(worker, (p) => p.reject(Object.assign(
412
+ new Error(`folder picker timed out after ${Math.round(timeoutMs / 1000)}s`),
413
+ { status: 500 },
414
+ )));
415
+ killWorker(worker);
416
+ }, timeoutMs);
417
+ if (pending.timer.unref) pending.timer.unref();
418
+ worker.pending = pending;
419
+ syncWorkerRefs(worker);
420
+ if (!worker.child || !worker.child.stdin) {
421
+ settleWorkerPick(worker, (p) => p.reject(Object.assign(
422
+ new Error('folder picker helper is not running'),
423
+ { status: 500 },
424
+ )));
425
+ return;
426
+ }
427
+ try {
428
+ worker.child.stdin.write(anchor ? `pick ${anchor[0]} ${anchor[1]}\n` : 'pick - -\n');
429
+ } catch (error) {
430
+ settleWorkerPick(worker, (p) => p.reject(pickerError('folder picker helper', error)));
431
+ }
432
+ });
433
+ }
434
+
435
+ function detachWorker(worker) {
436
+ liveWorkers.delete(worker);
437
+ worker.child = null;
438
+ worker.ready = null; // the helper died: force the next pick to respawn
439
+ worker.starting = false;
440
+ worker.buffer = '';
441
+ if (worker.pending) {
442
+ settleWorkerPick(worker, (pending) => pending.reject(Object.assign(
443
+ new Error('folder picker helper exited unexpectedly'),
444
+ { status: 500 },
445
+ )));
446
+ }
447
+ }
448
+
449
+ function killWorker(worker) {
450
+ const child = worker.child;
451
+ worker.child = null;
452
+ worker.ready = null;
453
+ worker.starting = false;
454
+ liveWorkers.delete(worker);
455
+ if (worker.pending) {
456
+ settleWorkerPick(worker, (pending) => pending.reject(Object.assign(
457
+ new Error('folder picker helper was stopped'),
458
+ { status: 500 },
459
+ )));
460
+ }
461
+ try { child && child.kill && child.kill(); } catch { /* already gone */ }
462
+ }
463
+
464
+ process.on('exit', () => {
465
+ for (const worker of [...liveWorkers]) killWorker(worker);
466
+ });
467
+
468
+ /* ---- public API ---- */
469
+
470
+ /** Opens the native folder picker. Resolves with `{ path: null }` on cancel. */
471
+ function pickFolder(options = {}) {
472
+ if (activePick) return activePick;
473
+ const shared = pickFolderUnlocked(options);
474
+ activePick = shared;
475
+ // Release on settle; the attached handler also means a dropped shared
476
+ // rejection never surfaces as unhandled.
477
+ const release = () => { if (activePick === shared) activePick = null; };
478
+ shared.then(release, release);
479
+ return shared;
480
+ }
481
+
482
+ async function pickFolderUnlocked(options = {}) {
483
+ const platform = options.platform || process.platform;
484
+ if (platform === 'win32') {
485
+ return pickViaWindowsWorker(options);
486
+ }
487
+ const doSpawn = options.spawn || spawn;
488
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
489
+ ? options.timeoutMs
490
+ : DEFAULT_TIMEOUT_MS;
491
+ const run = (command, args) => runOneShot(command, args, doSpawn, timeoutMs);
492
+ try {
493
+ const { stdout } = await run('zenity', ['--file-selection', '--directory']);
494
+ return { path: parseSelectedPath(stdout, false) };
495
+ } catch (error) {
496
+ // Only "not installed" falls through to osascript; real failures (crash,
497
+ // timeout) are already terminal.
498
+ if (error.status !== 501) throw error;
499
+ }
500
+ const { stdout } = await run('osascript', ['-e', 'POSIX path of (choose folder)']);
501
+ return { path: parseSelectedPath(stdout, true) };
502
+ }
503
+
504
+ /**
505
+ * Pre-starts the resident Windows helper (powershell.exe + one Add-Type
506
+ * compile) so the first user click only writes a stdin line. Call once at
507
+ * server startup; resolves false instead of throwing so a broken helper
508
+ * never takes the server down — the next pick retries the spawn.
509
+ */
510
+ async function warmFolderPicker(options = {}) {
511
+ if ((options.platform || process.platform) !== 'win32') return false;
512
+ try {
513
+ const doSpawn = options.spawn || spawn;
514
+ const startupTimeoutMs = Number.isFinite(options.startupTimeoutMs) && options.startupTimeoutMs > 0
515
+ ? options.startupTimeoutMs
516
+ : STARTUP_TIMEOUT_MS;
517
+ await ensureWorker(workerFor(doSpawn), doSpawn, sanitizeTitle(options.title), startupTimeoutMs);
518
+ return true;
519
+ } catch {
520
+ return false;
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Health probe for the resident helper: round-trips `ping`/`!PONG` and
526
+ * resolves with the elapsed milliseconds (null off Windows). Also handy for
527
+ * measuring the warm click latency.
528
+ */
529
+ async function pingFolderPicker(options = {}) {
530
+ if ((options.platform || process.platform) !== 'win32') return null;
531
+ const doSpawn = options.spawn || spawn;
532
+ const worker = workerFor(doSpawn);
533
+ const startupTimeoutMs = Number.isFinite(options.startupTimeoutMs) && options.startupTimeoutMs > 0
534
+ ? options.startupTimeoutMs
535
+ : STARTUP_TIMEOUT_MS;
536
+ await ensureWorker(worker, doSpawn, sanitizeTitle(options.title), startupTimeoutMs);
537
+ const started = Date.now();
538
+ await new Promise((resolve, reject) => {
539
+ const ping = {
540
+ resolve,
541
+ timer: setTimeout(() => {
542
+ worker.ping = null;
543
+ killWorker(worker);
544
+ reject(Object.assign(new Error('folder picker ping timed out'), { status: 500 }));
545
+ }, 5000),
546
+ };
547
+ if (ping.timer.unref) ping.timer.unref();
548
+ worker.ping = ping;
549
+ syncWorkerRefs(worker);
550
+ if (!worker.child || !worker.child.stdin) {
551
+ worker.ping = null;
552
+ clearTimeout(ping.timer);
553
+ reject(Object.assign(new Error('folder picker helper is not running'), { status: 500 }));
554
+ return;
555
+ }
556
+ try {
557
+ worker.child.stdin.write('ping\n');
558
+ } catch (error) {
559
+ worker.ping = null;
560
+ clearTimeout(ping.timer);
561
+ reject(pickerError('folder picker helper', error));
562
+ }
563
+ });
564
+ return Date.now() - started;
565
+ }
566
+
567
+ // Empty output means the user cancelled: every backend keeps that contract.
568
+ function parseSelectedPath(stdout, stripTrailingSlashes) {
569
+ let value = String(stdout || '').replace(/\u0000/g, '').replace(/^\uFEFF/, '').trim();
570
+ value = value.replace(/^"|"$/g, '');
571
+ if (stripTrailingSlashes && value) value = value.replace(/\/+$/, '') || '/';
572
+ return value || null;
573
+ }
574
+
575
+ function runOneShot(command, args, doSpawn, timeoutMs) {
576
+ return new Promise((resolve, reject) => {
577
+ let child;
578
+ try {
579
+ child = doSpawn(command, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
580
+ } catch (error) {
581
+ reject(pickerError(command, error));
582
+ return;
583
+ }
584
+ let stdout = '';
585
+ let settled = false;
586
+ const settle = finish => {
587
+ if (settled) return;
588
+ settled = true;
589
+ clearTimeout(timer);
590
+ finish();
591
+ };
592
+ const timer = setTimeout(() => settle(() => {
593
+ killChild(child);
594
+ reject(Object.assign(
595
+ new Error(`folder picker timed out after ${Math.round(timeoutMs / 1000)}s`),
596
+ { status: 500 },
597
+ ));
598
+ }), timeoutMs);
599
+ if (timer.unref) timer.unref();
600
+ if (child.stdout && child.stdout.on) {
601
+ child.stdout.on('data', chunk => {
602
+ if (stdout.length < MAX_OUTPUT) stdout += String(chunk).slice(0, MAX_OUTPUT - stdout.length);
603
+ });
604
+ }
605
+ if (child.stderr && child.stderr.on) child.stderr.on('data', () => { /* cancellation chatter */ });
606
+ child.on('error', error => settle(() => reject(pickerError(command, error))));
607
+ child.on('close', () => settle(() => resolve({ stdout })));
608
+ });
609
+ }
610
+
611
+ function pickerError(command, error) {
612
+ if (error && error.code === 'ENOENT') {
613
+ return Object.assign(new Error(`${command} is not available on this system`), { status: 501 });
614
+ }
615
+ const message = error && error.message ? error.message : 'unknown error';
616
+ return Object.assign(new Error(`failed to run ${command}: ${message}`), { status: 500 });
617
+ }
618
+
619
+ function killChild(child) {
620
+ try { child && child.kill && child.kill(); } catch { /* already gone */ }
621
+ }
622
+
623
+ module.exports = { pickFolder, warmFolderPicker, pingFolderPicker, parseSelectedPath };