@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 +44 -10
- package/bin/agent-guild.mjs +2 -0
- package/package.json +2 -2
- package/src/manager/folder-opener.mjs +96 -0
- package/src/manager/folder-picker.mjs +116 -0
- package/src/manager/folder-picker.ps1 +84 -0
- package/src/manager/github-views.mjs +171 -0
- package/src/manager/github.mjs +56 -1
- package/src/manager/main.mjs +4 -1
- package/src/manager/server.mjs +81 -12
- package/web/activity-favicon.js +71 -0
- package/web/app.js +1136 -94
- package/web/index.html +139 -91
- package/web/layout.js +75 -0
- package/web/repo-search.js +91 -0
- package/web/skins/gnomeland/skin.css +1 -1
- package/web/skins/goblinville/skin.css +2 -2
- package/web/skins/grove/skin.css +1 -1
- package/web/skins/guild/skin.css +3 -3
- package/web/skins/orbital/skin.css +1 -1
- package/web/styles.css +246 -38
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
|
|
149
|
-
characters as you type and keeps them in this browser, never on the
|
|
150
|
-
every tab of the page shows the same notes.
|
|
151
|
-
|
|
152
|
-
**
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
package/bin/agent-guild.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|