@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
@@ -0,0 +1,201 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { resolveCommand } from './command-resolver.mjs';
5
+ import { parseAllowedHosts } from './access-policy.mjs';
6
+
7
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
8
+ export const remoteError = (code, message, status = 409) => Object.assign(new Error(message), { code, status });
9
+
10
+ export function findTailscale(env = process.env, platform = process.platform, exists = fs.existsSync) {
11
+ const onPath = resolveCommand(platform === 'win32' ? 'tailscale.exe' : 'tailscale', env, platform);
12
+ const candidates = [onPath];
13
+ if (platform === 'win32') {
14
+ for (const dir of [env.ProgramFiles, env['ProgramFiles(x86)'], 'C:\\Program Files']) {
15
+ if (dir) candidates.push(path.win32.join(dir, 'Tailscale', 'tailscale.exe'));
16
+ }
17
+ } else if (platform === 'darwin') {
18
+ candidates.push('/Applications/Tailscale.app/Contents/MacOS/Tailscale', '/usr/local/bin/tailscale', '/opt/homebrew/bin/tailscale');
19
+ } else candidates.push('/usr/bin/tailscale', '/usr/local/bin/tailscale');
20
+ return candidates.find((file) => file && (platform !== 'win32' || /\.exe$/i.test(file)) && exists(file)) || null;
21
+ }
22
+
23
+ export function approvalLink(output) {
24
+ for (const match of output.matchAll(/https:\/\/[^\s<>"']+/g)) {
25
+ try {
26
+ const url = new URL(match[0]);
27
+ if (url.hostname === 'login.tailscale.com' && !url.username && !url.password && !url.port) return url.href;
28
+ } catch {}
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export function runTailscale(file, args, { env = process.env, signal, onApproval, timeout = 15000 } = {}) {
34
+ return new Promise((resolve, reject) => {
35
+ let output = '';
36
+ const child = execFile(file, args, {
37
+ env: { ...env, TAILSCALE_BE_CLI: '1' }, windowsHide: true, timeout, maxBuffer: 1024 * 1024, encoding: 'utf8', signal,
38
+ }, (error, stdout, stderr) => {
39
+ if (!error) return resolve(stdout);
40
+ const detail = `${stderr || ''}\n${stdout || ''}`.trim().slice(-2000);
41
+ const url = approvalLink(detail || output);
42
+ if (url) return reject(Object.assign(remoteError('approval_required', 'Approve HTTPS in Tailscale, then continue setup.'), { approvalUrl: url }));
43
+ if (/access denied|permission denied|must be.*(?:root|admin)|sudo tailscale|not permitted|serve config denied/i.test(detail)) {
44
+ return reject(remoteError('permission_required', 'Tailscale needs permission to configure remote access.'));
45
+ }
46
+ if (error.killed || error.code === 'ABORT_ERR') return reject(remoteError('tailscale_timeout', 'Tailscale did not finish. Check its connection and try again.'));
47
+ reject(remoteError('tailscale_error', detail || 'Could not communicate with Tailscale. Open Tailscale and check its connection.'));
48
+ });
49
+ const collect = (chunk) => {
50
+ output = (output + chunk).slice(-8192);
51
+ const url = approvalLink(output);
52
+ if (url) onApproval?.(url);
53
+ };
54
+ child.stdout?.on('data', collect);
55
+ child.stderr?.on('data', collect);
56
+ });
57
+ }
58
+
59
+ function parseJson(raw) {
60
+ let value;
61
+ try { value = JSON.parse(raw); } catch {}
62
+ if (value === null) return {};
63
+ if (!object(value)) throw remoteError('tailscale_response', 'Tailscale returned an unreadable status. Update Tailscale and try again.');
64
+ return value;
65
+ }
66
+
67
+ export function validateServeConfig(config) {
68
+ if (!object(config)) throw remoteError('tailscale_response', 'Tailscale returned an unreadable Serve configuration.');
69
+ for (const key of ['TCP', 'Web', 'AllowFunnel', 'Foreground', 'Services']) {
70
+ if (config[key] !== undefined && config[key] !== null && !object(config[key])) {
71
+ throw remoteError('tailscale_response', 'Tailscale returned an unsupported Serve configuration.');
72
+ }
73
+ }
74
+ for (const [port, value] of Object.entries(config.TCP || {})) {
75
+ if (!/^[1-9]\d{0,4}$/.test(port) || Number(port) > 65535 || !object(value)) {
76
+ throw remoteError('tailscale_response', 'Tailscale returned an unsupported TCP configuration.');
77
+ }
78
+ }
79
+ for (const value of Object.values(config.Web || {})) {
80
+ if (!object(value) || !object(value.Handlers) || Object.values(value.Handlers).some((handler) => !object(handler))) {
81
+ throw remoteError('tailscale_response', 'Tailscale returned an unsupported web configuration.');
82
+ }
83
+ }
84
+ if (Object.values(config.AllowFunnel || {}).some((value) => typeof value !== 'boolean')) {
85
+ throw remoteError('tailscale_response', 'Tailscale returned an unsupported Funnel configuration.');
86
+ }
87
+ for (const value of Object.values(config.Foreground || {})) {
88
+ if (!object(value) || value.Foreground) throw remoteError('tailscale_response', 'Tailscale returned an unsupported foreground configuration.');
89
+ validateServeConfig(value);
90
+ }
91
+ return config;
92
+ }
93
+
94
+ const ordered = (value) => Array.isArray(value) ? value.map(ordered)
95
+ : object(value) ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, ordered(value[key])])) : value;
96
+ export const sameConfig = (a, b) => JSON.stringify(ordered(a)) === JSON.stringify(ordered(b));
97
+ export const routeAuthority = (route) => `${route.host}:${route.port}`;
98
+ export const routeUrl = (route) => `https://${route.host}${route.port === 443 ? '' : `:${route.port}`}`;
99
+
100
+ function foregroundPort(config, port) {
101
+ return Object.values(config.Foreground || {}).some((entry) => entry.TCP?.[port] || Object.keys(entry.Web || {}).some((key) => key.endsWith(`:${port}`)));
102
+ }
103
+
104
+ export function portOccupied(config, port) {
105
+ return Boolean(config.TCP?.[port] || Object.keys(config.Web || {}).some((key) => key.endsWith(`:${port}`))
106
+ || Object.keys(config.AllowFunnel || {}).some((key) => key.endsWith(`:${port}`)) || foregroundPort(config, port));
107
+ }
108
+
109
+ export function routeMatches(config, route) {
110
+ const authority = routeAuthority(route);
111
+ const web = config.Web?.[authority];
112
+ const handler = web?.Handlers?.['/'];
113
+ if (!sameConfig(config.TCP?.[route.port], { HTTPS: true }) || !object(web) || !object(handler)
114
+ || Object.entries(config.AllowFunnel || {}).some(([key, value]) => value && key.endsWith(`:${route.port}`)) || foregroundPort(config, route.port)
115
+ || Object.keys(web.Handlers).length !== 1 || Object.keys(web).some((key) => key !== 'Handlers')
116
+ || Object.keys(handler).some((key) => key !== 'Proxy')
117
+ || Object.keys(config.Web || {}).some((key) => key !== authority && key.endsWith(`:${route.port}`))) return false;
118
+ try {
119
+ const target = new URL(handler.Proxy);
120
+ return target.origin === new URL(route.target).origin && target.pathname === '/' && !target.search && !target.hash && !target.username && !target.password;
121
+ } catch { return false; }
122
+ }
123
+
124
+ export function chooseRoute(info, target, previous = null) {
125
+ if (previous) {
126
+ if (previous.nodeId !== info.nodeId || previous.host !== info.host) {
127
+ throw remoteError('network_changed', 'This computer’s Tailscale identity or address changed. Disable the saved connection before setting up the new address.');
128
+ }
129
+ if (portOccupied(info.config, previous.port) && !routeMatches(info.config, previous)) {
130
+ throw remoteError('route_changed', 'The saved Tailscale route was changed outside Agent Guild. Review it in Tailscale before continuing.');
131
+ }
132
+ return { ...previous, target };
133
+ }
134
+ for (const key of Object.keys(info.config.TCP || {})) {
135
+ const port = Number(key);
136
+ const route = { nodeId: info.nodeId, host: info.host, port, target };
137
+ if (Number.isInteger(port) && port > 0 && port <= 65535 && routeMatches(info.config, route)) return route;
138
+ }
139
+ const port = [443, ...Array.from({ length: 32 }, (_, i) => 8443 + i)].find((candidate) => !portOccupied(info.config, candidate));
140
+ if (!port) throw remoteError('no_port', 'No available HTTPS port was found. Free a Serve port in Tailscale and try again.');
141
+ return { nodeId: info.nodeId, host: info.host, port, target };
142
+ }
143
+
144
+ export class Tailscale {
145
+ constructor({ env = process.env, platform = process.platform, find = findTailscale, run = runTailscale } = {}) {
146
+ Object.assign(this, { env, platform, find, run });
147
+ }
148
+
149
+ async command(args, options = {}) {
150
+ const file = this.find(this.env, this.platform);
151
+ if (!file) throw remoteError('not_installed', 'Install Tailscale on this computer to connect it to your network.');
152
+ return this.run(file, args, { env: this.env, ...options });
153
+ }
154
+
155
+ async inspect(options = {}) {
156
+ const version = (await this.command(['version'], options)).trim().split(/\s/)[0];
157
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
158
+ if (!match || Number(match[1]) < 1 || (Number(match[1]) === 1 && Number(match[2]) < 52)) {
159
+ throw remoteError('update_required', 'Update Tailscale to version 1.52 or later to set up remote access.');
160
+ }
161
+ const status = parseJson(await this.command(['status', '--json', '--peers=false'], options));
162
+ if (!['Running', 'Starting'].includes(status.BackendState)) {
163
+ throw remoteError('not_connected', 'Open Tailscale and connect this computer to your network, then continue.');
164
+ }
165
+ const host = typeof status.Self?.DNSName === 'string' ? status.Self.DNSName.replace(/\.$/, '').toLowerCase() : null;
166
+ const nodeId = status.Self?.ID;
167
+ if (!host || typeof nodeId !== 'string' || !nodeId || !host.endsWith('.ts.net')) {
168
+ throw remoteError('address_unavailable', 'Tailscale has not assigned this computer an HTTPS address yet. Check its connection and MagicDNS settings.');
169
+ }
170
+ try { parseAllowedHosts(host); } catch { throw remoteError('address_unavailable', 'Tailscale returned an invalid HTTPS address.'); }
171
+ const config = validateServeConfig(parseJson(await this.command(['serve', 'status', '--json'], options)));
172
+ return { version, host, nodeId, config, httpsReady: Array.isArray(status.CertDomains) && status.CertDomains.includes(host) };
173
+ }
174
+
175
+ async enable(route, previous, options = {}) {
176
+ const before = await this.inspect(options);
177
+ if (before.nodeId !== route.nodeId || before.host !== route.host) throw remoteError('network_changed', 'Tailscale changed networks during setup. Check the connection and try again.');
178
+ if (routeMatches(before.config, route)) return;
179
+ if (portOccupied(before.config, route.port) && !(previous && routeMatches(before.config, previous))) {
180
+ throw remoteError('route_changed', 'The selected HTTPS port is now in use. Check the connection and try again.');
181
+ }
182
+ await this.command(['serve', '--bg', `--https=${route.port}`, '--set-path=/', route.target], { ...options, timeout: 30000 });
183
+ const after = await this.inspect(options);
184
+ if (after.nodeId !== route.nodeId || after.host !== route.host || !routeMatches(after.config, route)) {
185
+ throw remoteError('setup_incomplete', 'Tailscale has not finished setting up the private HTTPS route. Complete any approval, then continue.');
186
+ }
187
+ }
188
+
189
+ async remove(route, options = {}) {
190
+ const info = await this.inspect(options);
191
+ if (info.nodeId !== route.nodeId || info.host !== route.host) throw remoteError('network_changed', 'Connect Tailscale to the original network to remove its saved route.');
192
+ if (!portOccupied(info.config, route.port)) return info;
193
+ if (!routeMatches(info.config, route)) throw remoteError('route_changed', 'This Serve route was changed outside Agent Guild. It has been left in place; review it in Tailscale.');
194
+ await this.command(['serve', '--bg', `--https=${route.port}`, '--set-path=/', 'off'], options);
195
+ const after = await this.inspect(options);
196
+ if (after.nodeId !== route.nodeId || after.host !== route.host || portOccupied(after.config, route.port)) {
197
+ throw remoteError('cleanup_incomplete', 'The Serve route is still present. Check Tailscale and retry cleanup.');
198
+ }
199
+ return after;
200
+ }
201
+ }
package/web/app.js CHANGED
@@ -6,6 +6,8 @@ import { TerminalCopy } from './terminal-copy.js';
6
6
  import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
7
7
  import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
8
8
  import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
9
+ import { createRemoteAccessUI } from './remote-access.js';
10
+ import { matchFolders, readRecentFolders, rememberFolder } from './folders.js';
9
11
 
10
12
  const TOKEN_KEY = 'agentGuild.token';
11
13
  const CWD_KEY = 'agentGuild.cwd';
@@ -18,6 +20,8 @@ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
18
20
  const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
19
21
  const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
20
22
  const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
23
+ const HIDDEN_FOLDERS_KEY = 'agentGuild.showHiddenFolders';
24
+ const RECENT_CWDS_KEY = 'agentGuild.recentCwds';
21
25
  const GITHUB_REPO_KEY = 'agentGuild.githubRepo';
22
26
  const GITHUB_RECENT_KEY = 'agentGuild.githubRecent';
23
27
  const GITHUB_VIEW_KEY = 'agentGuild.githubView';
@@ -61,8 +65,8 @@ const state = {
61
65
  connected: false,
62
66
  folderOpener: null,
63
67
  folderOpening: false,
64
- folderPicker: null,
65
- folderPicking: false,
68
+ /** The manager's operating system, from `hello`. */
69
+ platform: null,
66
70
  /** The manager's own version check, from `hello` and `manager.upgrade`. */
67
71
  upgrade: null,
68
72
  /** The running manager's version and pid, from `hello`. */
@@ -120,6 +124,7 @@ function setConnection(kind, label) {
120
124
  el.title = label;
121
125
  // The manager can only be stopped, restarted or upgraded while the page can reach it.
122
126
  state.connected = kind === 'ok';
127
+ state.remoteAccessUI?.connectionChanged();
123
128
  $('manager').hidden = !state.connected;
124
129
  if (!state.connected) closeMenu($('manager-menu'));
125
130
  renderFolderTools();
@@ -735,11 +740,9 @@ function renderFolderTools() {
735
740
  : !feature?.available ? feature?.reason || text.missing
736
741
  : label;
737
742
  };
738
- paint('cwd-pick', state.folderPicker, state.folderPicking, 'Choose a folder…', {
739
- offline: 'Connect to the session manager to choose folders',
740
- busy: 'Choosing a folder…',
741
- missing: 'Choosing folders is unavailable with this manager',
742
- });
743
+ for (const id of ['cwd-pick', 'github-parent-pick']) {
744
+ paint(id, { available: true }, false, 'Choose a folder…', { offline: 'Connect to the session manager to choose folders' });
745
+ }
743
746
  paint('cwd-open', state.folderOpener, state.folderOpening, `Open working folder in ${state.folderOpener?.label || 'file manager'}`, {
744
747
  offline: 'Connect to the session manager to open folders',
745
748
  busy: 'Opening working folder…',
@@ -747,22 +750,6 @@ function renderFolderTools() {
747
750
  });
748
751
  }
749
752
 
750
- async function pickWorkingFolder() {
751
- if (!state.connected || !state.folderPicker?.available || state.folderPicking) return;
752
- state.folderPicking = true;
753
- renderFolderTools();
754
- try {
755
- const { path } = await api('POST', '/pick-folder', { cwd: $('cwd').value.trim() });
756
- if (path) useFolder(path);
757
- } catch (err) {
758
- if (err instanceof AuthError) showAuth(err.message);
759
- else toast(err.message || 'Could not choose a folder.');
760
- } finally {
761
- state.folderPicking = false;
762
- renderFolderTools();
763
- }
764
- }
765
-
766
753
  async function openWorkingFolder() {
767
754
  if (!state.connected || !state.folderOpener?.available || state.folderOpening) return;
768
755
  state.folderOpening = true;
@@ -778,6 +765,186 @@ async function openWorkingFolder() {
778
765
  }
779
766
  }
780
767
 
768
+ // ---- recent working folders -----------------------------------------------
769
+
770
+ const recentView = { open: false, typed: false, active: -1 };
771
+
772
+ function recentCwds() {
773
+ return readRecentFolders(load(RECENT_CWDS_KEY));
774
+ }
775
+
776
+ function rememberCwd(dir) {
777
+ if (dir) save(RECENT_CWDS_KEY, JSON.stringify(rememberFolder(recentCwds(), dir, { caseless: state.platform === 'win32' })));
778
+ }
779
+
780
+ function renderRecentCwds() {
781
+ const input = $('cwd'), list = $('cwd-recent');
782
+ const shown = recentView.open ? matchFolders(recentCwds(), recentView.typed ? input.value : '') : [];
783
+ recentView.active = Math.min(recentView.active, shown.length - 1);
784
+ list.replaceChildren(...shown.map((dir, i) => {
785
+ const option = el('li', 'cwd-recent-option', dir);
786
+ option.id = `cwd-recent-${i}`;
787
+ option.setAttribute('role', 'option');
788
+ option.setAttribute('aria-selected', String(i === recentView.active));
789
+ option.addEventListener('pointerdown', (e) => e.preventDefault());
790
+ option.addEventListener('click', () => pickRecentCwd(dir));
791
+ return option;
792
+ }));
793
+ list.hidden = !shown.length;
794
+ input.setAttribute('aria-expanded', String(!list.hidden));
795
+ if (recentView.active >= 0) input.setAttribute('aria-activedescendant', `cwd-recent-${recentView.active}`);
796
+ else input.removeAttribute('aria-activedescendant');
797
+ }
798
+
799
+ function showRecentCwds(typed = false) {
800
+ Object.assign(recentView, { open: true, typed, active: -1 });
801
+ renderRecentCwds();
802
+ }
803
+
804
+ function hideRecentCwds() {
805
+ Object.assign(recentView, { open: false, active: -1 });
806
+ renderRecentCwds();
807
+ }
808
+
809
+ function pickRecentCwd(dir) {
810
+ hideRecentCwds();
811
+ useFolder(dir);
812
+ }
813
+
814
+ function recentCwdKeys(e) {
815
+ const options = $('cwd-recent').hidden ? [] : [...$('cwd-recent').children];
816
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
817
+ e.preventDefault();
818
+ if (!recentView.open) showRecentCwds();
819
+ const count = $('cwd-recent').children.length;
820
+ if (!count) return;
821
+ recentView.active = e.key === 'ArrowDown' ? (recentView.active + 1) % count : recentView.active <= 0 ? count - 1 : recentView.active - 1;
822
+ renderRecentCwds();
823
+ $(`cwd-recent-${recentView.active}`).scrollIntoView?.({ block: 'nearest' });
824
+ } else if (e.key === 'Enter' && recentView.active >= 0 && options[recentView.active]) {
825
+ e.preventDefault();
826
+ pickRecentCwd(options[recentView.active].textContent);
827
+ } else if (e.key === 'Escape' && options.length) {
828
+ e.preventDefault();
829
+ hideRecentCwds();
830
+ }
831
+ }
832
+
833
+ // ---- folder browser -------------------------------------------------------
834
+
835
+ const FOLDER_FIELDS = {
836
+ cwd: { title: 'Choose working folder', input: 'cwd' },
837
+ clone: { title: 'Choose clone folder', input: 'github-parent' },
838
+ };
839
+ const folderView = { field: null, listing: null, loading: false, error: null, request: 0, opener: null };
840
+
841
+ function chooseFolder(field) {
842
+ if (!state.connected) return;
843
+ const dialog = $('folder-browser');
844
+ Object.assign(folderView, { field, listing: null, loading: false, error: null });
845
+ $('folder-title').textContent = FOLDER_FIELDS[field].title;
846
+ $('folder-filter').value = '';
847
+ $('folder-hidden').checked = load(HIDDEN_FOLDERS_KEY) === '1';
848
+ if (!dialog.open) {
849
+ folderView.opener = document.activeElement;
850
+ dialog.showModal();
851
+ }
852
+ browseTo($(FOLDER_FIELDS[field].input).value.trim());
853
+ }
854
+
855
+ async function browseTo(dir) {
856
+ const request = ++folderView.request;
857
+ folderView.loading = true;
858
+ folderView.error = null;
859
+ renderFolderBrowser();
860
+ try {
861
+ const listing = await api('GET', `/folders?path=${encodeURIComponent(dir)}`);
862
+ if (request !== folderView.request) return;
863
+ folderView.listing = listing;
864
+ $('folder-filter').value = '';
865
+ } catch (err) {
866
+ if (request !== folderView.request) return;
867
+ if (err instanceof AuthError) {
868
+ $('folder-browser').close();
869
+ return showAuth(err.message);
870
+ }
871
+ folderView.error = err.message || 'Could not list this folder.';
872
+ }
873
+ folderView.loading = false;
874
+ renderFolderBrowser();
875
+ if ($('folder-browser').open) ($('folder-list').querySelector('.folder-row') ?? $('folder-use')).focus();
876
+ }
877
+
878
+ function renderFolderBrowser() {
879
+ const { listing, loading, error } = folderView;
880
+ const query = $('folder-filter').value.trim().toLowerCase();
881
+ const showHidden = $('folder-hidden').checked;
882
+ $('folder-path').replaceChildren(...(listing?.segments ?? []).map((segment, i, all) => {
883
+ const crumb = button(segment.name, () => browseTo(segment.path), 'folder-crumb');
884
+ if (i === all.length - 1) crumb.setAttribute('aria-current', 'location');
885
+ return crumb;
886
+ }));
887
+ $('folder-roots').replaceChildren(...(listing?.roots ?? []).map((root) => button(root.name, () => browseTo(root.path), 'btn folder-chip')));
888
+ $('folder-up').disabled = loading || !listing?.parent;
889
+ $('folder-home').disabled = loading;
890
+ const note = $('folder-note');
891
+ note.hidden = !listing?.note;
892
+ note.textContent = listing?.note ? `${listing.note} was not found, so this shows the nearest folder that exists.` : '';
893
+ const entries = (listing?.entries ?? []).filter((entry) => (showHidden || !entry.hidden) && (!query || entry.name.toLowerCase().includes(query)));
894
+ $('folder-list').replaceChildren(...entries.map((entry) => {
895
+ const row = button(entry.name, () => browseTo(entry.path), 'folder-row');
896
+ row.classList.toggle('hidden-folder', entry.hidden);
897
+ row.title = entry.path;
898
+ return row;
899
+ }));
900
+ const status = $('folder-status');
901
+ status.textContent = loading ? 'Loading folders…'
902
+ : error ? error
903
+ : !listing ? ''
904
+ : !entries.length ? (listing.entries.length ? 'No folders match.' : 'No folders here.')
905
+ : listing.truncated ? `Showing the first ${listing.entries.length.toLocaleString()} folders.` : '';
906
+ status.classList.toggle('error', Boolean(error));
907
+ $('folder-current').textContent = listing?.path ?? '';
908
+ $('folder-use').disabled = loading || !listing;
909
+ }
910
+
911
+ function folderBrowserClosed() {
912
+ if (!$('folder-browser').open && folderView.opener?.isConnected) folderView.opener.focus();
913
+ }
914
+
915
+ function setCloneParent(dir) {
916
+ $('github-parent').value = dir;
917
+ save(CLONE_PARENT_KEY, dir);
918
+ loadRepos();
919
+ }
920
+
921
+ function useBrowsedFolder() {
922
+ const dir = folderView.listing?.path;
923
+ if (!dir || folderView.loading) return;
924
+ const field = folderView.field;
925
+ $('folder-browser').close();
926
+ if (field === 'clone') setCloneParent(dir);
927
+ else {
928
+ rememberCwd(dir);
929
+ useFolder(dir);
930
+ }
931
+ }
932
+
933
+ function moveInFolders(e) {
934
+ const rows = [...$('folder-list').querySelectorAll('.folder-row')];
935
+ const at = rows.indexOf(document.activeElement);
936
+ const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: rows.length - 1 }[e.key];
937
+ if (at === -1 || next === undefined) return;
938
+ e.preventDefault();
939
+ rows[Math.max(0, Math.min(rows.length - 1, next))].focus();
940
+ }
941
+
942
+ function folderBrowserKeys(e) {
943
+ if (e.key !== 'Backspace' || e.target.matches('input') || !folderView.listing?.parent || folderView.loading) return;
944
+ e.preventDefault();
945
+ browseTo(folderView.listing.parent);
946
+ }
947
+
781
948
  function wsUrl(path) {
782
949
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
783
950
  return `${proto}://${location.host}/api/v1${path}?token=${encodeURIComponent(state.token)}`;
@@ -2249,6 +2416,7 @@ async function startSession(provider, card, { resume, cwd, account = selectedAcc
2249
2416
  ({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
2250
2417
  }
2251
2418
  upsertSession(session);
2419
+ rememberCwd(session.cwd);
2252
2420
  closeHistory({ focusOpener: false });
2253
2421
  openPanel(session.id);
2254
2422
  } catch (err) {
@@ -4257,13 +4425,13 @@ class TerminalView {
4257
4425
  }
4258
4426
 
4259
4427
  connect() {
4260
- if (this.disposed) return;
4428
+ if (this.disposed || state.remoteRevoked) return;
4261
4429
  const ws = new WebSocket(wsUrl(`/sessions/${this.id}/terminal`));
4262
4430
  this.ws = ws;
4263
4431
  ws.onopen = () => { this.retry = 0; this.sent = { cols: 0, rows: 0 }; this.sendSize(); };
4264
4432
  ws.onmessage = (event) => this.onMessage(JSON.parse(event.data));
4265
4433
  ws.onclose = (event) => {
4266
- if (this.disposed || event.code === 4404 || event.code === 4410) return;
4434
+ if (this.disposed || event.code === 4403 || event.code === 4404 || event.code === 4410) return;
4267
4435
  const delay = Math.min(5000, 300 * 2 ** this.retry++);
4268
4436
  setTimeout(() => this.connect(), delay);
4269
4437
  };
@@ -4860,7 +5028,7 @@ function showManagerUnavailable() {
4860
5028
  }
4861
5029
 
4862
5030
  function connectEvents() {
4863
- if (state.pageAway) return;
5031
+ if (state.pageAway || state.remoteRevoked) return;
4864
5032
  const ws = new WebSocket(wsUrl('/events'));
4865
5033
  state.eventsSocket = ws;
4866
5034
  ws.onopen = () => {
@@ -4880,7 +5048,8 @@ function connectEvents() {
4880
5048
  state.restartable = typeof msg.pid === 'number';
4881
5049
  state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
4882
5050
  state.folderOpener = msg.folderOpener || null;
4883
- state.folderPicker = msg.folderPicker || null;
5051
+ state.platform = msg.platform || null;
5052
+ state.remoteAccessUI?.setAvailable(msg.remoteAccess);
4884
5053
  renderVersion();
4885
5054
  setConnection('ok', 'Connected to session manager');
4886
5055
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
@@ -4895,6 +5064,8 @@ function connectEvents() {
4895
5064
  // A changelog.updated sent while the socket was down is lost; catch up the open panel.
4896
5065
  if ($('changelog').open) loadChangelog();
4897
5066
  if (dockShows('github')) loadGitHub();
5067
+ } else if (msg.type === 'remote-access.updated') {
5068
+ state.remoteAccessUI?.updated();
4898
5069
  } else if (msg.type === 'news.updated') {
4899
5070
  loadNews();
4900
5071
  } else if (msg.type === 'github.updated') {
@@ -4921,8 +5092,15 @@ function connectEvents() {
4921
5092
  scheduleStats();
4922
5093
  }
4923
5094
  };
4924
- ws.onclose = () => {
5095
+ ws.onclose = (event) => {
4925
5096
  if (state.pageAway || state.eventsSocket !== ws) return;
5097
+ if (event?.code === 4403) {
5098
+ state.remoteRevoked = true;
5099
+ managerLoss.cancel();
5100
+ state.remoteAccessUI?.close();
5101
+ setConnection('down', 'Remote access changed. Your terminals are still running. Reopen an enabled address to reconnect.');
5102
+ return;
5103
+ }
4926
5104
  if (state.managerUnavailable) {
4927
5105
  showManagerUnavailable();
4928
5106
  } else if (state.stopping && state.stopRemaining !== null) {
@@ -4964,6 +5142,7 @@ let statsInterval;
4964
5142
  let newsTimer;
4965
5143
 
4966
5144
  function showAuth(message = '') {
5145
+ state.remoteAccessUI?.setAvailable(null);
4967
5146
  managerLoss.cancel();
4968
5147
  closePanel();
4969
5148
  closeModels();
@@ -5132,10 +5311,8 @@ $('github-views').addEventListener('click', (e) => {
5132
5311
  if (view) switchGitHubView(view);
5133
5312
  });
5134
5313
  $('github-views').addEventListener('keydown', moveGitHubView);
5135
- $('github-parent').addEventListener('change', () => {
5136
- save(CLONE_PARENT_KEY, cloneParent());
5137
- loadRepos();
5138
- });
5314
+ $('github-parent').addEventListener('change', () => setCloneParent(cloneParent()));
5315
+ $('github-parent-pick').addEventListener('click', firstClick(() => chooseFolder('clone')));
5139
5316
  $('history-here').addEventListener('change', renderHistory);
5140
5317
  $('history-form').addEventListener('submit', resumeById);
5141
5318
  $('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
@@ -5358,7 +5535,25 @@ $('panel-stop').addEventListener('click', () => {
5358
5535
  else removeSession(s.id);
5359
5536
  });
5360
5537
  $('cwd').value = load(CWD_KEY) || '';
5361
- $('cwd-pick').addEventListener('click', firstClick(pickWorkingFolder));
5538
+ $('cwd-pick').addEventListener('click', firstClick(() => chooseFolder('cwd')));
5539
+ $('cwd').addEventListener('focus', () => showRecentCwds());
5540
+ $('cwd').addEventListener('click', () => { if (!recentView.open) showRecentCwds(); });
5541
+ $('cwd').addEventListener('input', () => showRecentCwds(true));
5542
+ $('cwd').addEventListener('blur', hideRecentCwds);
5543
+ $('cwd').addEventListener('keydown', recentCwdKeys);
5544
+ $('folder-cancel').addEventListener('click', () => $('folder-browser').close());
5545
+ $('folder-browser').addEventListener('click', (e) => { if (e.target === $('folder-browser')) $('folder-browser').close(); });
5546
+ $('folder-browser').addEventListener('close', folderBrowserClosed);
5547
+ $('folder-browser').addEventListener('keydown', folderBrowserKeys);
5548
+ $('folder-list').addEventListener('keydown', moveInFolders);
5549
+ $('folder-up').addEventListener('click', () => browseTo(folderView.listing.parent));
5550
+ $('folder-home').addEventListener('click', () => browseTo('~'));
5551
+ $('folder-filter').addEventListener('input', renderFolderBrowser);
5552
+ $('folder-hidden').addEventListener('change', (e) => {
5553
+ save(HIDDEN_FOLDERS_KEY, e.target.checked ? '1' : null);
5554
+ renderFolderBrowser();
5555
+ });
5556
+ $('folder-use').addEventListener('click', useBrowsedFolder);
5362
5557
  $('cwd-open').addEventListener('click', firstClick(openWorkingFolder));
5363
5558
  try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
5364
5559
  try { state.shellPicks = JSON.parse(load(SHELLS_KEY)) || {}; } catch { state.shellPicks = {}; }
@@ -5380,5 +5575,6 @@ addEventListener('scroll', publishTopbarHeight, { passive: true });
5380
5575
  publishTopbarHeight();
5381
5576
  applyDockLayout();
5382
5577
 
5578
+ state.remoteAccessUI = createRemoteAccessUI({ api, getToken: () => state.token, isConnected: () => state.connected, onAuthError: showAuth });
5383
5579
  state.token = readTokenFromHash() || load(TOKEN_KEY);
5384
5580
  boot();
package/web/folders.js ADDED
@@ -0,0 +1,17 @@
1
+ export const RECENT_FOLDERS_MAX = 5;
2
+
3
+ export function readRecentFolders(text) {
4
+ let list;
5
+ try { list = JSON.parse(text); } catch { return []; }
6
+ return Array.isArray(list) ? list.filter((dir) => typeof dir === 'string' && dir.trim()).slice(0, RECENT_FOLDERS_MAX) : [];
7
+ }
8
+
9
+ export function rememberFolder(list, dir, { caseless = false } = {}) {
10
+ const key = (value) => (caseless ? value.toLowerCase() : value);
11
+ return [dir, ...list.filter((known) => key(known) !== key(dir))].slice(0, RECENT_FOLDERS_MAX);
12
+ }
13
+
14
+ export function matchFolders(list, query) {
15
+ const wanted = query.trim().toLowerCase();
16
+ return wanted ? list.filter((dir) => dir.toLowerCase().includes(wanted)) : list;
17
+ }