@oddessentials/agent-guild 0.26.0 → 0.28.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 (47) hide show
  1. package/README.md +3 -3
  2. package/bin/agent-guild.mjs +2 -2
  3. package/node_modules/qrcode-generator/README.md +101 -0
  4. package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
  5. package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
  6. package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
  7. package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
  8. package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
  9. package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
  10. package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
  11. package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
  12. package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
  13. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
  14. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
  15. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
  16. package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
  17. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
  18. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
  19. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
  20. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
  21. package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
  22. package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
  23. package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
  24. package/node_modules/qrcode-generator/package.json +37 -0
  25. package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
  26. package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
  27. package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
  28. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
  29. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
  30. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
  31. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
  32. package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
  33. package/package.json +2 -1
  34. package/src/manager/access-policy.mjs +39 -0
  35. package/src/manager/config.mjs +1 -0
  36. package/src/manager/folder-browser.mjs +85 -0
  37. package/src/manager/main.mjs +11 -9
  38. package/src/manager/remote-access.mjs +286 -0
  39. package/src/manager/server.mjs +59 -38
  40. package/src/manager/tailscale.mjs +201 -0
  41. package/web/app.js +229 -33
  42. package/web/folders.js +17 -0
  43. package/web/index.html +107 -2
  44. package/web/remote-access.js +236 -0
  45. package/web/styles.css +57 -0
  46. package/src/manager/folder-picker.mjs +0 -116
  47. package/src/manager/folder-picker.ps1 +0 -84
package/web/index.html CHANGED
@@ -35,6 +35,7 @@
35
35
  <button id="notes-open" class="btn notes-open" type="button" aria-controls="dock" aria-pressed="false">Notes</button>
36
36
  <button id="settings" class="btn settings menu-button" type="button" popovertarget="settings-menu" aria-haspopup="dialog">Settings</button>
37
37
  <div id="settings-menu" class="settings-menu menu" popover role="dialog" aria-label="Settings">
38
+ <button id="remote-access-open" class="btn" type="button" aria-haspopup="dialog" hidden>Remote access</button>
38
39
  <fieldset id="skin-choices"><legend>Skin</legend></fieldset>
39
40
  <fieldset>
40
41
  <legend>Mode</legend>
@@ -68,7 +69,8 @@
68
69
  <div class="cwd">
69
70
  <label for="cwd">Working folder</label>
70
71
  <div class="cwd-field">
71
- <input id="cwd" type="text" spellcheck="false" autocomplete="off" placeholder="Home folder">
72
+ <input id="cwd" type="text" spellcheck="false" autocomplete="off" placeholder="Home folder" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="cwd-recent">
73
+ <ul id="cwd-recent" class="cwd-recent" role="listbox" aria-label="Recent working folders" hidden></ul>
72
74
  <div class="cwd-tools">
73
75
  <button id="cwd-pick" class="cwd-tool" type="button" aria-label="Choose a folder…" title="Connect to the session manager to choose folders" disabled>
74
76
  <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
@@ -233,7 +235,17 @@
233
235
  <div id="github-pulls" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-pulls" hidden></div>
234
236
  <div id="github-clone-into" class="history-form github-form">
235
237
  <label for="github-parent">Clone into</label>
236
- <input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off">
238
+ <div class="cwd-field single">
239
+ <input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off">
240
+ <div class="cwd-tools">
241
+ <button id="github-parent-pick" class="cwd-tool" type="button" aria-label="Choose a folder…" title="Connect to the session manager to choose folders" disabled>
242
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
243
+ <path d="M3.5 9V6.5A1.5 1.5 0 0 1 5 5h4l2 2h7.5A1.5 1.5 0 0 1 20 8.5V10"/>
244
+ <path d="M3.5 10.5H21l-2.5 8H5.5z"/>
245
+ </svg>
246
+ </button>
247
+ </div>
248
+ </div>
237
249
  </div>
238
250
  </section>
239
251
  <section id="notes" class="dock-panel notes-panel" role="tabpanel" aria-labelledby="notes-title" hidden>
@@ -259,6 +271,70 @@
259
271
  </div>
260
272
  </dialog>
261
273
 
274
+ <dialog id="remote-access" class="models remote-access" aria-labelledby="remote-access-title">
275
+ <div class="models-bar">
276
+ <div class="models-heading">
277
+ <h2 id="remote-access-title">Remote access</h2>
278
+ <p class="sub">Use Agent Guild on another device</p>
279
+ </div>
280
+ <button id="remote-close" class="btn" type="button">Close</button>
281
+ </div>
282
+ <div class="models-body remote-body">
283
+ <p>Access your running terminals through your Tailscale network. Your computer must be awake with Agent Guild and Tailscale running.</p>
284
+ <div class="remote-status" role="status" aria-live="polite" aria-atomic="true">
285
+ <strong id="remote-status-title">Checking connection…</strong>
286
+ <p id="remote-status-text"></p>
287
+ </div>
288
+ <p id="remote-error" class="remote-error" role="alert" hidden></p>
289
+ <div id="remote-address-row" hidden>
290
+ <label for="remote-address">Your remote address</label>
291
+ <input id="remote-address" class="remote-address" readonly spellcheck="false">
292
+ <div class="remote-actions">
293
+ <button id="remote-connect" class="btn primary" type="button" aria-expanded="false" aria-controls="remote-share">Connect another device</button>
294
+ <button id="remote-copy-address" class="btn" type="button">Copy address</button>
295
+ <a id="remote-open-address" class="btn" target="_blank" rel="noopener noreferrer">Open</a>
296
+ </div>
297
+ </div>
298
+ <div id="remote-share" class="remote-share" hidden>
299
+ <p>Connect the other device to Tailscale, then scan this code or open the sign-in link.</p>
300
+ <canvas id="remote-qr" role="img" aria-label="QR code to sign into Agent Guild" hidden></canvas>
301
+ <p id="remote-qr-note" class="sub"></p>
302
+ <label for="remote-signin">Anyone with this code or link can use your terminals.</label>
303
+ <input id="remote-signin" class="remote-address" readonly spellcheck="false" autocomplete="off">
304
+ <button id="remote-copy-signin" class="btn" type="button">Copy sign-in link</button>
305
+ </div>
306
+ <div class="remote-actions">
307
+ <button id="remote-primary" class="btn primary" type="button">Enable remote access</button>
308
+ <a id="remote-help" class="btn primary" target="_blank" rel="noopener noreferrer" hidden></a>
309
+ <button id="remote-check" class="btn" type="button">Check connection</button>
310
+ <button id="remote-disable" class="btn danger" type="button" hidden>Disable remote access</button>
311
+ </div>
312
+ <div id="remote-disable-confirm" class="remote-confirm" hidden>
313
+ <p>Disconnect remote browsers? Your terminals will keep running. Local access stays available.</p>
314
+ <div class="remote-actions">
315
+ <button id="remote-disable-yes" class="btn danger" type="button">Disable remote access</button>
316
+ <button id="remote-disable-no" class="btn" type="button">Keep enabled</button>
317
+ </div>
318
+ </div>
319
+ <details id="remote-details">
320
+ <summary>Connection details</summary>
321
+ <p id="remote-diagnostics" class="sub"></p>
322
+ <button id="remote-forget" class="btn" type="button" hidden>Leave the changed route in Tailscale</button>
323
+ </details>
324
+ <details id="remote-advanced">
325
+ <summary>Use another reverse proxy</summary>
326
+ <p id="remote-custom-note">Configure your proxy separately, then enter the hosts and browser origins it uses. Saving applies immediately and replaces environment settings.</p>
327
+ <form id="remote-custom-form">
328
+ <label for="remote-hosts">Allowed hosts, separated by commas</label>
329
+ <input id="remote-hosts" placeholder="guild.example.com" spellcheck="false" autocomplete="off" maxlength="16384">
330
+ <label for="remote-origins">Allowed origins, separated by commas</label>
331
+ <input id="remote-origins" placeholder="https://guild.example.com" spellcheck="false" autocomplete="off" maxlength="16384">
332
+ <button id="remote-custom-save" class="btn" type="submit">Save proxy settings</button>
333
+ </form>
334
+ </details>
335
+ </div>
336
+ </dialog>
337
+
262
338
  <dialog id="models" class="models" aria-labelledby="models-title">
263
339
  <div class="models-bar">
264
340
  <span id="models-icon" class="provider-icon small"></span>
@@ -337,6 +413,35 @@
337
413
  </form>
338
414
  </dialog>
339
415
 
416
+ <dialog id="folder-browser" class="models folder-browser" aria-labelledby="folder-title">
417
+ <div class="models-bar">
418
+ <div class="models-heading">
419
+ <h2 id="folder-title">Choose working folder</h2>
420
+ <p class="sub">Folders on the computer running Agent Guild</p>
421
+ </div>
422
+ <button id="folder-cancel" class="btn" type="button">Cancel</button>
423
+ </div>
424
+ <nav id="folder-path" class="history-tools folder-path" aria-label="Current folder"></nav>
425
+ <div class="history-tools folder-places">
426
+ <button id="folder-up" class="btn" type="button">Up</button>
427
+ <button id="folder-home" class="btn folder-chip" type="button">Home</button>
428
+ <div id="folder-roots" class="folder-roots"></div>
429
+ </div>
430
+ <div class="history-tools">
431
+ <input id="folder-filter" type="search" placeholder="Filter folders" aria-label="Filter folders" spellcheck="false" autocomplete="off">
432
+ <label class="history-here"><input id="folder-hidden" type="checkbox"><span>Show hidden</span></label>
433
+ </div>
434
+ <div class="models-body">
435
+ <p id="folder-note" class="models-note" hidden></p>
436
+ <div id="folder-list" class="folder-list" role="group" aria-label="Folders"></div>
437
+ <p id="folder-status" class="models-note" aria-live="polite"></p>
438
+ </div>
439
+ <div class="history-form folder-footer">
440
+ <span id="folder-current" class="folder-current"></span>
441
+ <button id="folder-use" class="btn primary" type="button">Use this folder</button>
442
+ </div>
443
+ </dialog>
444
+
340
445
  <div id="tip" class="tip" aria-hidden="true" hidden><strong id="tip-title"></strong><span id="tip-source"></span><p id="tip-text"></p></div>
341
446
 
342
447
  <div id="toast" class="toast" role="alert" hidden></div>
@@ -0,0 +1,236 @@
1
+ export function remotePresentation(data) {
2
+ if (!data) return { title: 'Checking connection…', text: 'Looking for Tailscale on this computer.', label: 'Enable remote access' };
3
+ const problem = data.problem;
4
+ if (data.busy && problem?.code !== 'approval_required') return {
5
+ title: data.busy === 'checking' ? 'Checking connection…' : data.busy === 'disabling' ? 'Disabling remote access…' : 'Preparing remote access…',
6
+ text: 'Your terminal sessions keep running.', label: 'Please wait…',
7
+ };
8
+ if (data.pending === 'disable') return { title: 'Tailscale cleanup needed', text: problem?.code === 'permission_required'
9
+ ? 'Remote access is blocked. Run the cleanup command in Connection details from a terminal with permission to manage Tailscale, then retry cleanup.'
10
+ : problem?.message || 'Access through the saved address is blocked. Retry to remove its Serve route.', label: 'Retry cleanup', action: 'disable',
11
+ ...(['not_installed', 'update_required'].includes(problem?.code) ? { help: 'https://tailscale.com/download', helpLabel: 'Get Tailscale' } : {}) };
12
+ if (problem?.approvalUrl) return { title: 'One step needed in Tailscale', text: problem.message, help: problem.approvalUrl, helpLabel: 'Open Tailscale approval', label: 'Continue setup' };
13
+ if (problem?.code === 'not_installed') return { title: 'Connect this computer to Tailscale', text: problem.message, help: 'https://tailscale.com/download', helpLabel: 'Install Tailscale', label: 'Continue setup' };
14
+ if (problem?.code === 'update_required') return { title: 'Update Tailscale', text: problem.message, help: 'https://tailscale.com/download', helpLabel: 'Get Tailscale', label: 'Continue setup' };
15
+ if (problem?.code === 'not_connected' || problem?.code === 'address_unavailable') return { title: 'Connect Tailscale', text: problem.message, label: 'Continue setup' };
16
+ if (problem?.code === 'permission_required') return { title: 'Tailscale needs permission', text: 'Open a terminal on this computer with permission to manage Tailscale. The connection details below show the command to run, then choose Continue setup.', label: 'Continue setup' };
17
+ if (problem) return { title: data.pending === 'enable' ? 'Finish setting up remote access' : 'Connection needs attention', text: problem.message, label: data.mode === 'tailscale' ? 'Repair connection' : data.candidate?.existing ? 'Use existing route' : 'Continue setup' };
18
+ if (data.mode === 'tailscale') return {
19
+ title: 'Remote access enabled', text: data.connection?.ok ? 'The secure address reaches this manager. Connect another device to start using it.'
20
+ : data.connection ? 'The route is configured, but this computer could not verify the secure connection. Check Tailscale, then try the address on your other device.' : 'Your saved Tailscale address is ready to check.',
21
+ label: 'Repair connection', hidePrimary: true,
22
+ };
23
+ if (data.candidate?.existing) return { title: 'Your Tailscale route is ready', text: 'A private route already points to this manager. Use it to let Agent Guild save and manage this connection.', label: 'Use existing route' };
24
+ if (data.mode === 'custom') return { title: 'Custom proxy access configured', text: data.source === 'environment' ? 'These settings came from the launch environment. Save them below to keep them across launches.' : 'Your proxy settings are saved and active.', label: 'Set up Tailscale' };
25
+ if (!data.tailscale) return { title: 'Set up remote access', text: 'Check this computer’s Tailscale connection to get started.', label: 'Enable remote access' };
26
+ return { title: 'Tailscale connected', text: 'Enable a private address for your running terminals. Your settings will be saved for future launches.', label: 'Enable remote access' };
27
+ }
28
+
29
+ export function createRemoteAccessUI({ api, getToken, isConnected, onAuthError }) {
30
+ const $ = (id) => document.getElementById(id);
31
+ const dialog = $('remote-access');
32
+ let data = null, request = 0, loading = false, posting = false, timer = null, sharing = false, shareGeneration = 0, editRevision = null, dirty = false;
33
+ let available = false, waitingForApproval = false;
34
+
35
+ function clearShare() {
36
+ sharing = false;
37
+ shareGeneration++;
38
+ $('remote-share').hidden = true;
39
+ $('remote-connect').setAttribute('aria-expanded', 'false');
40
+ $('remote-signin').value = '';
41
+ const canvas = $('remote-qr');
42
+ canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
43
+ canvas.hidden = true;
44
+ }
45
+
46
+ function render() {
47
+ const presentation = remotePresentation(data);
48
+ const busy = loading || posting || Boolean(data?.busy) || !isConnected();
49
+ $('remote-status-title').textContent = presentation.title;
50
+ $('remote-status-text').textContent = isConnected() ? presentation.text : 'Reconnect to the manager to change remote access.';
51
+ $('remote-status-title').parentElement.dataset.ready = String(data?.mode === 'tailscale' && !data.problem && !data.busy && Boolean(data.connection?.ok));
52
+ $('remote-primary').textContent = presentation.label;
53
+ $('remote-primary').hidden = Boolean(presentation.hidePrimary || (presentation.help && !presentation.action));
54
+ $('remote-primary').disabled = busy || !data;
55
+ $('remote-check').disabled = busy || !data;
56
+ $('remote-disable').disabled = busy || !data;
57
+ $('remote-disable').hidden = !data || (data.mode === 'off' && !data.pending);
58
+ $('remote-disable-yes').disabled = busy;
59
+ $('remote-custom-save').disabled = busy || !data || data.mode === 'tailscale' || Boolean(data.pending);
60
+ for (const id of ['remote-hosts', 'remote-origins']) $(id).disabled = !data || data.mode === 'tailscale' || Boolean(data.pending);
61
+ $('remote-custom-note').textContent = data?.mode === 'tailscale' || data?.pending
62
+ ? 'Disable Tailscale remote access and finish any cleanup before switching to another proxy.'
63
+ : 'Configure your proxy separately, then enter the hosts and browser origins it uses. Saving applies immediately and replaces environment settings.';
64
+ $('remote-help').hidden = !presentation.help;
65
+ if (presentation.help) { $('remote-help').href = presentation.help; $('remote-help').textContent = presentation.helpLabel; }
66
+ $('remote-address-row').hidden = !data?.url;
67
+ $('remote-address').value = data?.url || '';
68
+ $('remote-open-address').href = data?.url || '#';
69
+ $('remote-connect').disabled = busy;
70
+ $('remote-copy-address').disabled = busy;
71
+ $('remote-copy-signin').disabled = busy;
72
+ if (!data?.url || !isConnected() || data.busy === 'disabling') clearShare();
73
+ $('remote-forget').hidden = data?.pending !== 'disable' || !['route_changed', 'network_changed'].includes(data.problem?.code);
74
+ $('remote-forget').disabled = busy;
75
+ const diagnostics = [data?.tailscale ? `Tailscale ${data.tailscale.version} · ${data.tailscale.hostname}` : '',
76
+ data?.source === 'saved' ? 'Settings are saved on the manager computer.' : data?.source === 'environment' ? 'Settings currently come from the launch environment.' : '',
77
+ data?.connection?.checkedAt ? `Last connection check: ${new Date(data.connection.checkedAt).toLocaleString()}` : '',
78
+ data?.command ? `On the manager computer: ${data.command}` : '',
79
+ ].filter(Boolean);
80
+ $('remote-diagnostics').textContent = diagnostics.join('\n');
81
+ if (data && !dirty) {
82
+ $('remote-hosts').value = data.hosts.join(', ');
83
+ $('remote-origins').value = data.origins.join(', ');
84
+ editRevision = data.revision;
85
+ }
86
+ if (data?.problem?.approvalUrl) waitingForApproval = true;
87
+ }
88
+
89
+ function showError(error) {
90
+ if (error.name === 'AuthError' || error.message === 'The access token was rejected.') { close(); onAuthError(error.message); return; }
91
+ $('remote-error').textContent = error.message || 'Could not change remote access. Try again.';
92
+ $('remote-error').hidden = false;
93
+ }
94
+
95
+ function schedule() {
96
+ clearTimeout(timer);
97
+ if (dialog.open && available && isConnected()) timer = setTimeout(() => refresh(!data?.busy), data?.busy ? 800 : 30000);
98
+ }
99
+
100
+ async function refresh(check = false) {
101
+ if (!dialog.open || !available || !isConnected() || loading || posting) return;
102
+ const generation = ++request;
103
+ loading = true;
104
+ if (check) $('remote-error').hidden = true;
105
+ render();
106
+ try {
107
+ const result = await api(check ? 'POST' : 'GET', check ? '/remote-access/check' : '/remote-access');
108
+ if (generation !== request || !dialog.open) return;
109
+ if (data?.url !== result.remoteAccess.url) clearShare();
110
+ data = result.remoteAccess;
111
+ } catch (error) { if (generation === request && dialog.open) showError(error); }
112
+ finally { if (generation === request) { loading = false; render(); schedule(); } }
113
+ if (waitingForApproval && !data?.busy && data?.pending === 'enable' && data?.tailscale?.httpsReady && dialog.open) {
114
+ waitingForApproval = false;
115
+ await change('enable');
116
+ }
117
+ }
118
+
119
+ async function change(action, extra = {}) {
120
+ if (!data || posting || data.busy || !isConnected()) return;
121
+ posting = true;
122
+ const generation = ++request;
123
+ $('remote-error').hidden = true;
124
+ $('remote-disable-confirm').hidden = true;
125
+ if (action !== 'enable') clearShare();
126
+ render();
127
+ try {
128
+ const result = await api('PUT', '/remote-access', { revision: data.revision, action, adopt: Boolean(data.candidate?.existing), ...extra });
129
+ if (generation !== request || !dialog.open) return;
130
+ data = result.remoteAccess;
131
+ if (action === 'custom') dirty = false;
132
+ } catch (error) {
133
+ if (generation === request && dialog.open) {
134
+ showError(error);
135
+ if (error.code === 'stale_settings') {
136
+ dirty = false;
137
+ try { data = (await api('GET', '/remote-access')).remoteAccess; } catch (refreshError) { showError(refreshError); }
138
+ }
139
+ }
140
+ } finally { if (generation === request) { posting = false; render(); schedule(); } }
141
+ }
142
+
143
+ async function copy(input, button) {
144
+ try {
145
+ if (!navigator.clipboard) throw new Error('Clipboard unavailable');
146
+ await navigator.clipboard.writeText(input.value);
147
+ const label = button.textContent;
148
+ button.textContent = 'Copied';
149
+ setTimeout(() => { button.textContent = label; }, 1600);
150
+ } catch {
151
+ input.focus(); input.select();
152
+ $('remote-error').textContent = 'The link is selected. Use your browser’s Copy command.';
153
+ $('remote-error').hidden = false;
154
+ }
155
+ }
156
+
157
+ async function toggleShare() {
158
+ if (sharing) { clearShare(); return; }
159
+ if (!data?.url || !getToken() || !isConnected()) return;
160
+ sharing = true;
161
+ const generation = ++shareGeneration;
162
+ const url = new URL(data.url);
163
+ url.hash = new URLSearchParams({ token: getToken() }).toString();
164
+ $('remote-signin').value = url.href;
165
+ $('remote-share').hidden = false;
166
+ $('remote-connect').setAttribute('aria-expanded', 'true');
167
+ $('remote-qr-note').textContent = 'Preparing QR code…';
168
+ try {
169
+ const { default: qrcode } = await import('./vendor/qrcode.mjs');
170
+ if (!sharing || generation !== shareGeneration || !dialog.open) return;
171
+ const code = qrcode(0, 'M');
172
+ code.addData(url.href, 'Byte');
173
+ code.make();
174
+ const count = code.getModuleCount(), scale = 5, border = 4;
175
+ const canvas = $('remote-qr');
176
+ canvas.width = canvas.height = (count + border * 2) * scale;
177
+ const context = canvas.getContext('2d');
178
+ context.fillStyle = '#ffffff'; context.fillRect(0, 0, canvas.width, canvas.height);
179
+ context.fillStyle = '#000000';
180
+ for (let row = 0; row < count; row++) for (let col = 0; col < count; col++) {
181
+ if (code.isDark(row, col)) context.fillRect((col + border) * scale, (row + border) * scale, scale, scale);
182
+ }
183
+ canvas.hidden = false;
184
+ $('remote-qr-note').textContent = '';
185
+ } catch { if (generation === shareGeneration) $('remote-qr-note').textContent = 'The QR code could not load. Use the sign-in link below.'; }
186
+ }
187
+
188
+ function close() {
189
+ request++; loading = false; posting = false;
190
+ clearTimeout(timer); clearShare();
191
+ $('remote-disable-confirm').hidden = true;
192
+ if (dialog.open) dialog.close();
193
+ }
194
+
195
+ $('remote-access-open').addEventListener('click', async () => {
196
+ $('settings-menu').hidePopover?.();
197
+ $('topbar-menu').hidePopover?.();
198
+ if (!dialog.open) dialog.showModal();
199
+ $('remote-error').hidden = true;
200
+ dirty = false;
201
+ await refresh(false);
202
+ await refresh(true);
203
+ });
204
+ $('remote-close').addEventListener('click', close);
205
+ dialog.addEventListener('click', (event) => { if (event.target === dialog && event.clientX !== 0) {
206
+ const rect = dialog.getBoundingClientRect();
207
+ if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) close();
208
+ } });
209
+ dialog.addEventListener('close', () => { close(); ($('settings').checkVisibility() ? $('settings') : $('menu-toggle')).focus(); });
210
+ $('remote-primary').addEventListener('click', () => change(remotePresentation(data).action || 'enable'));
211
+ $('remote-check').addEventListener('click', () => refresh(true));
212
+ $('remote-disable').addEventListener('click', () => { $('remote-disable-confirm').hidden = false; $('remote-disable-yes').focus(); });
213
+ $('remote-disable-yes').addEventListener('click', () => change('disable'));
214
+ $('remote-disable-no').addEventListener('click', () => { $('remote-disable-confirm').hidden = true; $('remote-disable').focus(); });
215
+ $('remote-forget').addEventListener('click', () => change('forget'));
216
+ $('remote-connect').addEventListener('click', toggleShare);
217
+ $('remote-copy-address').addEventListener('click', () => copy($('remote-address'), $('remote-copy-address')));
218
+ $('remote-copy-signin').addEventListener('click', () => copy($('remote-signin'), $('remote-copy-signin')));
219
+ for (const id of ['remote-hosts', 'remote-origins']) $(id).addEventListener('input', () => { dirty = true; });
220
+ $('remote-custom-form').addEventListener('submit', (event) => {
221
+ event.preventDefault();
222
+ const list = (id) => $(id).value.split(',').map((value) => value.trim()).filter(Boolean);
223
+ change('custom', { revision: editRevision, hosts: list('remote-hosts'), origins: list('remote-origins') });
224
+ });
225
+ addEventListener('focus', () => { if (dialog.open && !data?.busy) refresh(true); });
226
+ document.addEventListener('visibilitychange', () => {
227
+ if (document.visibilityState === 'hidden') clearShare();
228
+ else if (dialog.open && !data?.busy) refresh(true);
229
+ });
230
+ return {
231
+ setAvailable(capability) { available = Boolean(capability?.available); $('remote-access-open').hidden = !available; if (!available) close(); },
232
+ updated() { if (dialog.open) refresh(false); },
233
+ connectionChanged() { render(); if (!isConnected()) { clearTimeout(timer); clearShare(); } else schedule(); },
234
+ close,
235
+ };
236
+ }
package/web/styles.css CHANGED
@@ -559,6 +559,29 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
559
559
  .models-heading { flex: 1; min-width: 0; }
560
560
  .models-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
561
561
  .models-body { flex: 1; min-height: 0; overflow: auto; padding: 0 14px 14px; }
562
+ .remote-access { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }
563
+ .remote-body { padding: 4px 20px 20px; font-size: 0.9rem; }
564
+ .remote-body > p { color: var(--muted); line-height: 1.6; }
565
+ .remote-status { padding: 16px; margin: 16px 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
566
+ .remote-status[data-ready="true"] { border-color: var(--ok); }
567
+ .remote-status strong { font-size: 1rem; }
568
+ .remote-status p { margin: 6px 0 0; color: var(--muted); line-height: 1.5; }
569
+ .remote-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
570
+ .remote-body .btn { min-height: 42px; }
571
+ .remote-body a.btn { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; }
572
+ .remote-body label { display: block; margin: 12px 0 6px; font-size: 0.8rem; }
573
+ .remote-body input { width: 100%; min-width: 0; box-sizing: border-box; padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text); font: inherit; font-size: 0.8rem; }
574
+ .remote-body input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
575
+ .remote-body details { border-top: 1px solid var(--border); margin-top: 18px; padding-top: 14px; }
576
+ .remote-body summary { cursor: pointer; color: var(--muted); }
577
+ .remote-body .sub { font-size: 0.8rem; color: var(--muted); line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; }
578
+ .remote-body form .btn { margin-top: 12px; }
579
+ .remote-body .remote-error { color: var(--danger); overflow-wrap: anywhere; }
580
+ .remote-share, .remote-confirm { padding: 14px; border: 1px solid var(--border); border-radius: var(--radius); margin: 14px 0; }
581
+ .remote-share p { margin-top: 0; line-height: 1.5; }
582
+ .remote-share canvas { display: block; width: min(100%, 260px); height: auto; margin: 16px auto; image-rendering: pixelated; }
583
+ .remote-share canvas[hidden] { display: none; }
584
+ .remote-share .btn { margin-top: 12px; }
562
585
  .model-columns, .model-toggle { display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, 6.5rem) 4.5rem; align-items: center; gap: 8px; }
563
586
  .model-columns {
564
587
  position: sticky; top: 0; z-index: 1; padding: 10px 8px 6px; background: var(--surface);
@@ -975,3 +998,37 @@ a.btn.github-out::after {
975
998
  .multiplexer-row .copies { margin-top: .4rem; }
976
999
  .multiplexer-row .copy-head { flex-wrap: wrap; }
977
1000
  .multiplexer-row .copy-name { flex: 1 0 100%; }
1001
+
1002
+ .cwd-field.single input { padding-right: 40px; }
1003
+ @media (pointer: coarse) { .cwd-field.single input { min-height: 46px; padding-right: 50px; } }
1004
+ .folder-browser { width: min(640px, calc(100vw - 32px)); height: min(720px, calc(100dvh - 48px)); }
1005
+ @media (max-width: 640px) { .folder-browser { width: 100vw; height: 100dvh; } }
1006
+ .folder-path { gap: 2px 0; font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; }
1007
+ .folder-crumb { min-height: 32px; padding: 2px 6px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font: inherit; cursor: pointer; overflow-wrap: anywhere; text-align: left; }
1008
+ .folder-crumb:not(:first-child)::before { content: "›"; margin-right: 6px; color: var(--muted); }
1009
+ .folder-crumb:hover, .folder-crumb[aria-current] { color: var(--text); }
1010
+ .folder-crumb:focus-visible, .folder-row:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
1011
+ .folder-places, .folder-roots { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1012
+ .folder-places .btn { min-height: 36px; }
1013
+ .folder-list { display: flex; flex-direction: column; }
1014
+ .folder-row {
1015
+ display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px; border: 0; border-bottom: 1px solid var(--border);
1016
+ background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere;
1017
+ }
1018
+ .folder-row::before { content: ""; flex: none; width: 18px; height: 14px; border: 1.6px solid var(--muted); border-radius: 2px 4px 3px 3px; }
1019
+ .folder-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
1020
+ .folder-row.hidden-folder { color: var(--muted); }
1021
+ #folder-status.error { color: var(--danger); }
1022
+ .folder-footer { justify-content: space-between; }
1023
+ .folder-current { flex: 1; min-width: 0; font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.8rem; overflow-wrap: anywhere; }
1024
+ .folder-footer .btn { min-height: 42px; }
1025
+ .cwd-recent {
1026
+ position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 5; margin: 0; padding: 4px; list-style: none;
1027
+ border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
1028
+ }
1029
+ .cwd-recent-option {
1030
+ display: flex; align-items: center; min-height: 40px; padding: 6px 10px; border-radius: max(3px, calc(var(--radius) - 3px));
1031
+ color: var(--text); font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; overflow-wrap: anywhere; cursor: pointer;
1032
+ }
1033
+ .cwd-recent-option:hover, .cwd-recent-option[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 14%, transparent); }
1034
+ @media (pointer: coarse) { .cwd-recent-option { min-height: 46px; } }
@@ -1,116 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { spawn } from 'node:child_process';
5
-
6
- const TITLE = 'Choose a working folder';
7
- const failure = (status, code, message) => Object.assign(new Error(message), { status, code });
8
- const isFile = (file) => { try { return fs.statSync(file).isFile(); } catch { return false; } };
9
- const isFolder = (dir) => { try { return fs.statSync(dir).isDirectory(); } catch { return false; } };
10
-
11
- const APPLESCRIPT = [
12
- 'on run argv',
13
- 'activate',
14
- 'try',
15
- `return POSIX path of (choose folder with prompt "${TITLE}" default location (POSIX file (item 1 of argv)))`,
16
- 'on error number -128',
17
- 'return ""',
18
- 'end try',
19
- 'end run',
20
- ];
21
-
22
- let windowsScript;
23
- function encodedWindowsScript() {
24
- windowsScript ??= Buffer.from(fs.readFileSync(new URL('./folder-picker.ps1', import.meta.url), 'utf8'), 'utf16le').toString('base64');
25
- return windowsScript;
26
- }
27
-
28
- export function folderPickTarget({ platform = process.platform, env = process.env, release = os.release(), fileExists = isFile } = {}) {
29
- const onPath = (name) => (env.PATH || '').split(':').filter((dir) => path.posix.isAbsolute(dir))
30
- .map((dir) => path.posix.join(dir, name)).find(fileExists);
31
- let command, kind, translate, reason;
32
- if (platform === 'win32') {
33
- kind = 'windows';
34
- command = path.win32.join(env.SystemRoot || env.WINDIR || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
35
- } else if (platform === 'darwin') {
36
- kind = 'mac';
37
- command = '/usr/bin/osascript';
38
- } else if (platform === 'linux' && (env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release))) {
39
- kind = 'windows';
40
- translate = onPath('wslpath');
41
- command = translate && onPath('powershell.exe');
42
- reason = 'Windows PowerShell is unavailable. Enable Windows interoperability in WSL to choose folders.';
43
- } else if (platform === 'linux') {
44
- if (env.DISPLAY || env.WAYLAND_DISPLAY) {
45
- command = onPath('zenity');
46
- kind = command ? 'zenity' : 'kdialog';
47
- command ||= onPath('kdialog');
48
- reason = 'Install zenity or kdialog to choose folders.';
49
- } else reason = 'Choosing folders requires a desktop session on the computer running Agent Guild.';
50
- } else reason = 'Choosing folders is unavailable on this operating system.';
51
- const available = Boolean(command && fileExists(command));
52
- return { command, kind, translate, available, reason: available ? null : reason || 'The folder dialog is unavailable on the computer running Agent Guild.' };
53
- }
54
-
55
- export function createFolderPicker({ resolveCwd, spawnImpl = spawn, env = process.env, ...targetOptions }) {
56
- let pending = false;
57
-
58
- const run = (command, args, { signal, env: childEnv = env } = {}) => new Promise((resolve, reject) => {
59
- let child, out = '';
60
- try {
61
- child = spawnImpl(command, args, { env: childEnv, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
62
- } catch (error) { return reject(error); }
63
- const stop = () => child.kill();
64
- if (signal?.aborted) stop();
65
- else signal?.addEventListener('abort', stop, { once: true });
66
- child.stdout.setEncoding('utf8').on('data', (chunk) => { out += chunk; });
67
- child.once('error', reject);
68
- child.once('close', (code) => {
69
- signal?.removeEventListener('abort', stop);
70
- resolve({ code, out: out.replace(/[\r\n]+$/, '') });
71
- });
72
- });
73
-
74
- function dialog(target, start, signal) {
75
- if (target.kind === 'windows') {
76
- const vars = { AGENT_GUILD_PICK_START: start, AGENT_GUILD_PICK_TITLE: TITLE };
77
- if (target.translate) vars.WSLENV = [env.WSLENV, 'AGENT_GUILD_PICK_START/p', 'AGENT_GUILD_PICK_TITLE'].filter(Boolean).join(':');
78
- return run(target.command, ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodedWindowsScript()], { signal, env: { ...env, ...vars } });
79
- }
80
- if (target.kind === 'mac') return run(target.command, [...APPLESCRIPT.flatMap((line) => ['-e', line]), start], { signal });
81
- if (target.kind === 'zenity') return run(target.command, ['--file-selection', '--directory', `--title=${TITLE}`, `--filename=${path.join(start, path.sep)}`], { signal });
82
- return run(target.command, ['--title', TITLE, '--getexistingdirectory', start], { signal });
83
- }
84
-
85
- return {
86
- describe() {
87
- const { available, reason } = folderPickTarget({ env, ...targetOptions });
88
- return { available, reason };
89
- },
90
- async pick(cwd, { signal } = {}) {
91
- if (cwd !== undefined && (typeof cwd !== 'string' || /[\0\r\n]/.test(cwd))) {
92
- throw failure(400, 'bad_cwd', 'The working folder must be a directory path.');
93
- }
94
- if (pending) throw failure(409, 'folder_pick_busy', 'A folder dialog is already open on the computer running Agent Guild.');
95
- const target = folderPickTarget({ env, ...targetOptions });
96
- if (!target.available) throw failure(409, 'folder_pick_unavailable', target.reason);
97
- let start;
98
- try { start = resolveCwd(cwd); } catch { start = os.homedir(); }
99
- pending = true;
100
- try {
101
- const broken = () => failure(409, 'folder_pick_failed', 'Could not show the folder dialog. Check that a desktop session is available on the computer running Agent Guild.');
102
- let result = await dialog(target, start, signal).catch(() => { throw broken(); });
103
- const cancelled = result.code === 0 || (result.code === 1 && target.kind !== 'windows' && target.kind !== 'mac');
104
- if (signal?.aborted || (!result.out && cancelled)) return null;
105
- if (result.code !== 0) throw broken();
106
- if (target.translate) {
107
- result = await run(target.translate, ['-u', result.out], { signal }).catch(() => ({ code: 1 }));
108
- if (result.code !== 0) throw failure(409, 'folder_pick_failed', 'The chosen folder cannot be reached from WSL.');
109
- }
110
- const dir = path.resolve(result.out);
111
- if (!isFolder(dir)) throw failure(409, 'folder_pick_failed', 'The chosen folder cannot be used as a working folder.');
112
- return dir;
113
- } finally { pending = false; }
114
- },
115
- };
116
- }