@oddessentials/agent-guild 0.25.0 → 0.26.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.
package/README.md CHANGED
@@ -60,6 +60,14 @@ and come back to the same screens. Drag a session card by its grip, with a
60
60
  mouse or a finger, to put the cards in the order you want; this browser
61
61
  remembers it, and new sessions join at the end.
62
62
 
63
+ **Two terminals at once.** **Split** in the terminal's header, or Ctrl+click
64
+ (⌘-click on a Mac) on a card's **Open**, shows a second session beside the
65
+ first, or above it on a tall, narrow window. Click a terminal to work in it;
66
+ drag the divider, or focus it and use the arrow keys, to resize them. The
67
+ session name in the header switches the focused terminal to another session.
68
+ On a phone one terminal shows at a time, with a button to switch to the
69
+ other. This browser reopens the terminals you had open after a reload.
70
+
63
71
  On a touchscreen, **Copy…** opens a still copy of the terminal text. Touch and
64
72
  hold to select, then use **Copy selection** or the browser's Copy menu. **Done**
65
73
  returns to the live terminal; the session keeps running while you copy. The
@@ -145,12 +153,27 @@ vX.Y.Z** switches over when you are ready. The version badge opens
145
153
  <img src="docs/images/whats-new.webp" alt="The What's new panel listing the release notes of each version">
146
154
 
147
155
  **Keep notes at hand.** **Notes** in the top bar opens a notepad for prompts,
148
- commands and to-dos, even while a terminal is open. It saves up to 100,000
149
- characters as you type and keeps them in this browser, never on the manager;
150
- every tab of the page shows the same notes.
151
-
152
- **Appearance.** The Appearance menu in the top bar picks a skin and light or
153
- dark mode, without a reload. **Guild** is the default fantasy look;
156
+ commands and to-dos in the side panel, next to your terminals. It saves up to
157
+ 100,000 characters as you type and keeps them in this browser, never on the
158
+ manager; every tab of the page shows the same notes.
159
+
160
+ **GitHub beside your work.** **GitHub** in the top bar opens the side panel.
161
+ Sign in to one or more GitHub accounts, then search the repositories of all
162
+ of them from the box at its top; it starts on the repository the focused
163
+ terminal's folder is a clone of. **Issues** lists, opens, edits and closes
164
+ issues. **Actions** shows the latest workflow runs and whether one is still
165
+ going, refreshing while it is on screen. **Pull requests** lists the open
166
+ ones. **Repositories** lists and creates repositories and clones them over SSH
167
+ with a key Agent Guild keeps for each account. On a wide window the side panel
168
+ makes room for itself; on a smaller one it lies over the cards, and over the
169
+ terminals when they would get too narrow. Drag its edge to resize it. Escape
170
+ or **Close** puts it away.
171
+
172
+ <img src="docs/images/workspace.webp" alt="Two terminals side by side with the GitHub panel showing the workflow runs of the focused terminal's repository">
173
+
174
+ **Settings.** The Settings menu in the top bar picks a skin and light or
175
+ dark mode, without a reload. On a narrow window the top bar's buttons gather
176
+ under its menu button. **Guild** is the default fantasy look;
154
177
  **Professional** is a plain business look with no characters; **Orbital**
155
178
  puts a crew of little robots in deep space; **Grove** is a calm moss garden
156
179
  of gentle nature spirits; **Gnomeland** is a lantern-lit village of gnome
@@ -164,6 +187,12 @@ new version is discovered after the initial version check. Session activity
164
187
  and assistant responses have no sounds:
165
188
  the providers do not yet offer consistently reliable completion signals.
166
189
 
190
+ The browser tab icon adds a rotating green ring whenever any session card says
191
+ **Working**, including Shell and task cards. It mirrors the cards' terminal
192
+ activity, not assistant completion. The ring remains visible with reduced
193
+ motion or when the browser pauses animation. During a connection interruption,
194
+ it reflects the last card state until the page reconnects.
195
+
167
196
  Sounds play in one eligible tab per browser and origin after browser playback
168
197
  permission (usually a click or keypress). They require Web Locks and writable
169
198
  local storage. Opening or reconnecting a page does not replay old alerts,
@@ -186,8 +215,8 @@ There is no added polling while connected and no additional monitoring process.
186
215
  | `agent-guild url` | Print the page URL with its access token. |
187
216
  | `agent-guild start` | Run the manager in the foreground, for debugging. |
188
217
 
189
- The page's **Restart manager** and **Stop manager** buttons do the same as
190
- `restart` and `stop`, but ask first while sessions are running. The page also
218
+ **Restart manager** and **Stop manager** in the page's **Manager** menu do the
219
+ same as `restart` and `stop`, but ask first while sessions are running. The page also
191
220
  asks before you close it with sessions running. Sessions end when the manager
192
221
  stops or the computer restarts.
193
222
 
@@ -227,7 +256,9 @@ Any other tool or script can report agents and its model too. See
227
256
 
228
257
  ## Security and privacy
229
258
 
230
- * The manager listens on `127.0.0.1` only.
259
+ * The manager listens on `127.0.0.1` only. To use its UI through a reverse
260
+ proxy such as Tailscale Serve, configure explicit
261
+ [allowed hosts and origins](docs/configuration.md#reverse-proxies).
231
262
  * Every API call needs a random per-user token, stored in the data folder
232
263
  with owner-only permissions. Anyone who can run programs as your user can
233
264
  read it, as with any local developer tool.
@@ -255,7 +286,7 @@ or prompts:
255
286
  | GitHub's releases API | What's new | Hourly |
256
287
  | herdr.dev and formulae.brew.sh | Multiplexer version checks | At most hourly, or on Refresh or a requested operation; disabled by `AGENT_GUILD_NO_UPDATE_CHECK=1` |
257
288
  | herdr.dev, GitHub release downloads, and the selected package manager's repositories | Multiplexer installation and updates | When you request the operation |
258
- | GitHub (sign-in, API, avatars and SSH) | Signing in to GitHub, listing your repositories, adding your SSH key and cloning | When you use **Clone from GitHub…** |
289
+ | GitHub (sign-in, API, avatars and SSH) | Signing in to GitHub, listing your repositories, issues, workflow runs and pull requests, editing issues, adding your SSH key and cloning | When you use the GitHub panel; while its Actions tab shows, every few seconds during a run and every 30 seconds otherwise |
259
290
 
260
291
  ## Other front ends
261
292
 
@@ -278,6 +309,9 @@ npm test
278
309
  coding tool in `tests/fixtures`. CI runs them on Windows, macOS and Linux
279
310
  with Node.js 22, 24 and 26, and installs the packed package on x64 and
280
311
  arm64.
312
+ * `node tests/browser/proxy.mjs` checks the real UI and terminal through a
313
+ local HTTPS proxy with an isolated manager and test certificates. Set
314
+ `CHROME_PATH` to Chrome or Edge if it is not in a standard location.
281
315
  * In a checkout, `launchers/AgentGuild.cmd` (Windows) and
282
316
  `launchers/AgentGuild.command` (macOS) start Agent Guild with a
283
317
  double-click.
@@ -34,6 +34,8 @@ Commands:
34
34
  Environment:
35
35
  AGENT_GUILD_PORT Port for the local API (default 47821)
36
36
  AGENT_GUILD_HOME Data directory (default: per-user app data folder)
37
+ AGENT_GUILD_ALLOWED_HOSTS Extra accepted Host values (comma-separated)
38
+ AGENT_GUILD_ALLOWED_ORIGINS Extra accepted browser origins (comma-separated)
37
39
  AGENT_GUILD_NPM_REGISTRY npm registry for version checks and installs
38
40
  AGENT_GUILD_NO_UPDATE_CHECK Set to 1 to skip version checks`);
39
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "status": "node bin/agent-guild.mjs status",
34
34
  "demo:build": "node docs/demo/build.mjs",
35
35
  "demo:check": "node docs/demo/check.mjs",
36
- "test": "node --test --test-concurrency=1"
36
+ "test": "node --test --test-concurrency=1 \"tests/*.test.mjs\""
37
37
  },
38
38
  "dependencies": {
39
39
  "@xterm/addon-fit": "^0.11.0",
@@ -0,0 +1,96 @@
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 failure = (status, code, message) => Object.assign(new Error(message), { status, code });
7
+ const isFile = (file) => { try { return fs.statSync(file).isFile(); } catch { return false; } };
8
+
9
+ export function folderOpenTarget({ platform = process.platform, env = process.env, release = os.release(), fileExists = isFile } = {}) {
10
+ const onPath = (name) => (env.PATH || '').split(':').filter((dir) => path.posix.isAbsolute(dir))
11
+ .map((dir) => path.posix.join(dir, name)).find(fileExists);
12
+ let command, kind, label, reason;
13
+ if (platform === 'win32') {
14
+ kind = 'explorer';
15
+ label = 'File Explorer';
16
+ command = path.win32.join(env.SystemRoot || env.WINDIR || 'C:\\Windows', 'explorer.exe');
17
+ } else if (platform === 'darwin') {
18
+ kind = 'finder';
19
+ label = 'Finder';
20
+ command = '/usr/bin/open';
21
+ } else if (platform === 'linux' && (env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release))) {
22
+ kind = 'explorer';
23
+ label = 'File Explorer';
24
+ command = onPath('explorer.exe');
25
+ reason = 'Windows Explorer is unavailable. Enable Windows interoperability in WSL to open folders.';
26
+ } else if (platform === 'linux') {
27
+ kind = 'desktop';
28
+ label = 'file manager';
29
+ if (env.DISPLAY || env.WAYLAND_DISPLAY) {
30
+ command = onPath('xdg-open');
31
+ reason = 'Install xdg-utils and configure a default file manager to open folders.';
32
+ } else reason = 'Opening folders requires a desktop session on the computer running Agent Guild.';
33
+ } else reason = 'Opening folders is unavailable on this operating system.';
34
+ const available = Boolean(command && fileExists(command));
35
+ return { command, kind, label: label || 'file manager', available, reason: available ? null : reason || `${label} is unavailable on the computer running Agent Guild.` };
36
+ }
37
+
38
+ export function createFolderOpener({ resolveCwd, spawnImpl = spawn, cooldownMs = 1000, handoffMs = 1500, ...targetOptions }) {
39
+ let pending = false, nextOpen = 0;
40
+ return {
41
+ describe() {
42
+ const { available, label, reason } = folderOpenTarget(targetOptions);
43
+ return { available, label, reason };
44
+ },
45
+ async open(cwd) {
46
+ if (cwd !== undefined && (typeof cwd !== 'string' || /[\0\r\n]/.test(cwd))) {
47
+ throw failure(400, 'bad_cwd', 'The working folder must be a directory path.');
48
+ }
49
+ if (typeof cwd === 'string' && /^[a-z][a-z\d+.-]*:\/\//i.test(cwd.trim()) && !/^[a-z]:[/\\]/i.test(cwd.trim())) {
50
+ throw failure(400, 'bad_cwd', 'Enter a folder path, not a URL.');
51
+ }
52
+ if (pending || Date.now() < nextOpen) throw failure(429, 'folder_open_busy', 'Please wait before opening another folder.');
53
+ const target = folderOpenTarget(targetOptions);
54
+ if (!target.available) throw failure(409, 'folder_open_unavailable', target.reason);
55
+ pending = true;
56
+ try {
57
+ const resolved = resolveCwd(cwd);
58
+ let dir;
59
+ try {
60
+ dir = await fs.promises.realpath(resolved);
61
+ if (!(await fs.promises.stat(dir)).isDirectory()) throw new Error();
62
+ } catch {
63
+ throw failure(400, 'bad_cwd', 'The working folder does not exist or cannot be accessed.');
64
+ }
65
+ let args;
66
+ if (target.kind === 'explorer') args = ['.'];
67
+ else if (target.kind === 'finder') {
68
+ // Reveal packages instead of asking Finder to open (and potentially execute) them.
69
+ args = /\.(app|bundle|framework|plugin|kext|prefpane|workflow)$/i.test(dir)
70
+ ? ['-R', dir] : ['-a', '/System/Library/CoreServices/Finder.app', dir];
71
+ } else args = [dir];
72
+ nextOpen = Date.now() + cooldownMs;
73
+ await new Promise((resolve, reject) => {
74
+ let child, timer, settled = false;
75
+ const finish = (error) => {
76
+ if (settled) return;
77
+ settled = true;
78
+ clearTimeout(timer);
79
+ child?.unref();
80
+ if (error) reject(failure(409, 'folder_open_failed', `Could not open ${target.label}. Check that a desktop session and file manager are available on the computer running Agent Guild.`));
81
+ else resolve();
82
+ };
83
+ try {
84
+ child = spawnImpl(target.command, args, {
85
+ cwd: target.kind === 'explorer' ? dir : undefined,
86
+ shell: false, windowsHide: true, detached: true, stdio: 'ignore',
87
+ });
88
+ child.once('error', finish);
89
+ child.once('spawn', () => { timer = setTimeout(() => finish(), handoffMs); });
90
+ child.once('exit', (code) => finish(code !== 0 && !(target.kind === 'explorer' && code === 1)));
91
+ } catch (error) { finish(error); }
92
+ });
93
+ } finally { pending = false; }
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,116 @@
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
+ }
@@ -0,0 +1,84 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
3
+ Add-Type -AssemblyName System.Windows.Forms
4
+ Add-Type -ReferencedAssemblies System.Windows.Forms -TypeDefinition @'
5
+ using System;
6
+ using System.Runtime.InteropServices;
7
+ using System.Windows.Forms;
8
+ public static class GuildFolderPicker {
9
+ [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class FileOpenDialog {}
10
+ [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
11
+ interface IFileDialog {
12
+ [PreserveSig] int Show(IntPtr owner);
13
+ void SetFileTypes(uint count, IntPtr specs);
14
+ void SetFileTypeIndex(uint index);
15
+ void GetFileTypeIndex(out uint index);
16
+ void Advise(IntPtr events, out uint cookie);
17
+ void Unadvise(uint cookie);
18
+ void SetOptions(uint options);
19
+ void GetOptions(out uint options);
20
+ void SetDefaultFolder(IShellItem item);
21
+ void SetFolder(IShellItem item);
22
+ void GetFolder(out IShellItem item);
23
+ void GetCurrentSelection(out IShellItem item);
24
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name);
25
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name);
26
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
27
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
28
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
29
+ void GetResult(out IShellItem item);
30
+ }
31
+ [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
32
+ interface IShellItem {
33
+ void BindToHandler(IntPtr context, ref Guid handler, ref Guid iid, out IntPtr result);
34
+ void GetParent(out IShellItem parent);
35
+ void GetDisplayName(uint form, [MarshalAs(UnmanagedType.LPWStr)] out string name);
36
+ }
37
+ [StructLayout(LayoutKind.Sequential)] struct RECT { public int L, T, R, B; }
38
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
39
+ static extern void SHCreateItemFromParsingName(string path, IntPtr context, ref Guid iid, out IShellItem item);
40
+ [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr window, out RECT rect);
41
+ [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
42
+ [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, IntPtr process);
43
+ [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
44
+ [DllImport("user32.dll")] static extern bool AttachThreadInput(uint from, uint to, bool attach);
45
+ [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr window);
46
+
47
+ public static string Pick(string start, string title) {
48
+ var dialog = (IFileDialog)new FileOpenDialog();
49
+ uint options;
50
+ dialog.GetOptions(out options);
51
+ dialog.SetOptions(options | 0x20 | 0x40 | 0x800);
52
+ dialog.SetTitle(title);
53
+ if (!string.IsNullOrEmpty(start)) {
54
+ try {
55
+ IShellItem folder;
56
+ Guid iid = typeof(IShellItem).GUID;
57
+ SHCreateItemFromParsingName(start, IntPtr.Zero, ref iid, out folder);
58
+ dialog.SetFolder(folder);
59
+ } catch {}
60
+ }
61
+ // A dialog from a background process opens behind the browser. An invisible owner that takes
62
+ // the foreground brings it to the front, just inside the window that asked.
63
+ using (var owner = new Form { TopMost = true, ShowInTaskbar = false, FormBorderStyle = FormBorderStyle.None, Opacity = 0, StartPosition = FormStartPosition.Manual }) {
64
+ IntPtr asking = GetForegroundWindow();
65
+ RECT r;
66
+ if (GetWindowRect(asking, out r)) owner.SetBounds(r.L + 48, r.T + 48, 1, 1);
67
+ else owner.StartPosition = FormStartPosition.CenterScreen;
68
+ owner.Show();
69
+ uint front = GetWindowThreadProcessId(asking, IntPtr.Zero), self = GetCurrentThreadId();
70
+ AttachThreadInput(self, front, true);
71
+ SetForegroundWindow(owner.Handle);
72
+ AttachThreadInput(self, front, false);
73
+ if (dialog.Show(owner.Handle) != 0) return null;
74
+ }
75
+ IShellItem picked;
76
+ dialog.GetResult(out picked);
77
+ string path;
78
+ picked.GetDisplayName(0x80058000, out path);
79
+ return path;
80
+ }
81
+ }
82
+ '@
83
+ $picked = [GuildFolderPicker]::Pick($env:AGENT_GUILD_PICK_START, $env:AGENT_GUILD_PICK_TITLE)
84
+ if ($picked) { [Console]::Out.Write($picked) }
@@ -0,0 +1,171 @@
1
+ // Issues, Actions runs and open pull requests of one repository, read with a
2
+ // signed-in account. Reading and editing stay shallow: GitHub's page is the full view.
3
+
4
+ import { nextLink, parseRepo } from './github.mjs';
5
+
6
+ const PAGE = 30;
7
+ const RUNNING = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
8
+ const WEB = 'https://github.com';
9
+ const ISSUE_RE = /^[1-9]\d{0,9}$/;
10
+
11
+ function refusal(status, code, message) {
12
+ return Object.assign(new Error(message), { status, code });
13
+ }
14
+
15
+ /** A github.com page, or the fallback built from checked parts. */
16
+ export function githubUrl(value, fallback) {
17
+ try {
18
+ const url = new URL(value);
19
+ if (url.protocol === 'https:' && url.hostname === 'github.com') return url.href;
20
+ } catch { /* the fallback */ }
21
+ return fallback;
22
+ }
23
+
24
+ function text(value, max) {
25
+ return typeof value === 'string' ? value.slice(0, max) : '';
26
+ }
27
+
28
+ function time(value) {
29
+ return Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
30
+ }
31
+
32
+ function loginOf(user) {
33
+ return typeof user?.login === 'string' && user.login.trim() ? user.login.trim().slice(0, 80) : 'unknown';
34
+ }
35
+
36
+ function cleanIssue(raw, fullName) {
37
+ const number = raw?.number;
38
+ if (!raw || typeof raw !== 'object' || !Number.isSafeInteger(number) || number < 1) return null;
39
+ if (typeof raw.title !== 'string' || !raw.title.trim()) return null;
40
+ return {
41
+ number,
42
+ title: raw.title.trim().slice(0, 256),
43
+ body: text(raw.body, 65536),
44
+ state: raw.state === 'closed' ? 'closed' : 'open',
45
+ user: loginOf(raw.user),
46
+ comments: Number.isSafeInteger(raw.comments) ? raw.comments : 0,
47
+ updatedAt: time(raw.updated_at),
48
+ url: githubUrl(raw.html_url, `${WEB}/${fullName}/issues/${number}`),
49
+ };
50
+ }
51
+
52
+ function cleanRun(raw, fullName) {
53
+ const id = raw?.id;
54
+ if (!raw || typeof raw !== 'object' || !Number.isSafeInteger(id) || id < 1) return null;
55
+ return {
56
+ id,
57
+ name: text(raw.name, 200) || text(raw.display_title, 200) || 'Workflow',
58
+ title: text(raw.display_title, 300) || text(raw.name, 300) || 'Workflow run',
59
+ branch: text(raw.head_branch, 200),
60
+ event: text(raw.event, 40),
61
+ status: typeof raw.status === 'string' ? raw.status.slice(0, 40) : 'unknown',
62
+ conclusion: typeof raw.conclusion === 'string' ? raw.conclusion.slice(0, 40) : null,
63
+ runNumber: Number.isSafeInteger(raw.run_number) ? raw.run_number : null,
64
+ updatedAt: time(raw.updated_at),
65
+ url: githubUrl(raw.html_url, `${WEB}/${fullName}/actions/runs/${id}`),
66
+ };
67
+ }
68
+
69
+ function cleanPull(raw, fullName) {
70
+ const number = raw?.number;
71
+ if (!raw || typeof raw !== 'object' || !Number.isSafeInteger(number) || number < 1) return null;
72
+ if (typeof raw.title !== 'string' || !raw.title.trim()) return null;
73
+ return {
74
+ number,
75
+ title: raw.title.trim().slice(0, 256),
76
+ draft: raw.draft === true,
77
+ user: loginOf(raw.user),
78
+ head: text(raw.head?.ref, 200),
79
+ base: text(raw.base?.ref, 200),
80
+ updatedAt: time(raw.updated_at),
81
+ url: githubUrl(raw.html_url, `${WEB}/${fullName}/pull/${number}`),
82
+ };
83
+ }
84
+
85
+ function checkTitle(title) {
86
+ if (typeof title !== 'string' || !title.trim()) throw refusal(400, 'bad_title', 'title must be a non-empty string');
87
+ if (title.trim().length > 256) throw refusal(400, 'bad_title', 'Keep the title to 256 characters or fewer.');
88
+ return title.trim();
89
+ }
90
+
91
+ function checkBody(body) {
92
+ if (body === undefined || body === null) return '';
93
+ if (typeof body !== 'string') throw refusal(400, 'bad_body', 'body must be a string');
94
+ if (body.length > 48000) throw refusal(400, 'bad_body', 'Keep the description to 48,000 characters or fewer, or edit it on GitHub.');
95
+ return body;
96
+ }
97
+
98
+ export function createViews(github) {
99
+ /** One request for the repository's account. GitHub's own refusals become answers the page can show as they are. */
100
+ async function call(accountId, owner, name, route, init) {
101
+ const account = github.account(accountId);
102
+ const repo = parseRepo(`${owner ?? ''}/${name ?? ''}`);
103
+ try {
104
+ const { body, res } = await github._apiJson(account, `/repos/${repo.owner}/${repo.name}${route}`, init);
105
+ return { body, res, repo };
106
+ } catch (err) {
107
+ if (err.limited) throw refusal(429, 'rate_limited', err.message);
108
+ if (err.github === 404) throw refusal(404, 'not_found', `GitHub could not find ${repo.fullName} for @${account.login}`);
109
+ if (err.github === 410) throw refusal(404, 'issues_disabled', `Issues are turned off for ${repo.fullName}`);
110
+ if (err.github === 403) throw refusal(403, 'forbidden', `GitHub did not let @${account.login} do that in ${repo.fullName}`);
111
+ if (err.github === 422) throw refusal(400, 'github_rejected', err.message);
112
+ throw err;
113
+ }
114
+ }
115
+
116
+ return {
117
+ async issues(accountId, owner, name, { state = 'open' } = {}) {
118
+ if (state !== 'open' && state !== 'closed' && state !== 'all') throw refusal(400, 'bad_state', 'state must be open, closed or all');
119
+ const { body, res, repo } = await call(accountId, owner, name, `/issues?state=${state}&per_page=${PAGE}&sort=updated&direction=desc`);
120
+ return {
121
+ issues: (Array.isArray(body) ? body : []).filter((item) => item && !item.pull_request).map((item) => cleanIssue(item, repo.fullName)).filter(Boolean),
122
+ truncated: Boolean(nextLink(res.headers.get('link'))),
123
+ url: `${WEB}/${repo.fullName}/issues`,
124
+ };
125
+ },
126
+
127
+ async createIssue(accountId, owner, name, { title, body } = {}) {
128
+ const payload = { title: checkTitle(title), body: checkBody(body) };
129
+ const { body: created, repo } = await call(accountId, owner, name, '/issues', { method: 'POST', body: JSON.stringify(payload) });
130
+ const issue = cleanIssue(created, repo.fullName);
131
+ if (!issue) throw refusal(502, 'github_error', 'GitHub did not describe the new issue');
132
+ return issue;
133
+ },
134
+
135
+ async updateIssue(accountId, owner, name, number, patch = {}) {
136
+ if (!ISSUE_RE.test(String(number ?? ''))) throw refusal(400, 'bad_issue', 'issue number must be a positive integer');
137
+ const payload = {};
138
+ if (patch.title !== undefined) payload.title = checkTitle(patch.title);
139
+ if (patch.body !== undefined) payload.body = checkBody(patch.body);
140
+ if (patch.state !== undefined) {
141
+ if (patch.state !== 'open' && patch.state !== 'closed') throw refusal(400, 'bad_state', 'state must be open or closed');
142
+ payload.state = patch.state;
143
+ }
144
+ if (Object.keys(payload).length === 0) throw refusal(400, 'bad_request', 'nothing to update');
145
+ const { body, repo } = await call(accountId, owner, name, `/issues/${Number(number)}`, { method: 'PATCH', body: JSON.stringify(payload) });
146
+ const issue = cleanIssue(body, repo.fullName);
147
+ if (!issue) throw refusal(502, 'github_error', 'GitHub did not describe the issue');
148
+ return issue;
149
+ },
150
+
151
+ async actions(accountId, owner, name) {
152
+ const { body, res, repo } = await call(accountId, owner, name, `/actions/runs?per_page=${PAGE}`);
153
+ const runs = (Array.isArray(body?.workflow_runs) ? body.workflow_runs : []).map((item) => cleanRun(item, repo.fullName)).filter(Boolean);
154
+ return {
155
+ runs,
156
+ running: runs.some((run) => RUNNING.has(run.status)),
157
+ truncated: Boolean(nextLink(res.headers.get('link'))),
158
+ url: `${WEB}/${repo.fullName}/actions`,
159
+ };
160
+ },
161
+
162
+ async pulls(accountId, owner, name) {
163
+ const { body, res, repo } = await call(accountId, owner, name, `/pulls?state=open&per_page=${PAGE}&sort=updated&direction=desc`);
164
+ return {
165
+ pulls: (Array.isArray(body) ? body : []).map((item) => cleanPull(item, repo.fullName)).filter(Boolean),
166
+ truncated: Boolean(nextLink(res.headers.get('link'))),
167
+ url: `${WEB}/${repo.fullName}/pulls`,
168
+ };
169
+ },
170
+ };
171
+ }