@oddessentials/agent-guild 0.31.0 → 0.33.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 +42 -4
- package/bin/agent-guild.mjs +8 -3
- package/package.json +1 -1
- package/src/manager/autostart.mjs +492 -0
- package/src/manager/github-views.mjs +37 -1
- package/src/manager/main.mjs +15 -2
- package/src/manager/server.mjs +13 -1
- package/src/manager/session.mjs +27 -2
- package/src/manager/shell-env.mjs +53 -13
- package/src/manager/uninstall.mjs +7 -2
- package/src/manager/usage.mjs +116 -10
- package/web/app.js +278 -6
- package/web/environment.js +2 -0
- package/web/index.html +28 -0
- package/web/layout.js +36 -0
- package/web/styles.css +25 -3
- package/web/terminal-controls.js +7 -25
package/README.md
CHANGED
|
@@ -195,8 +195,11 @@ of them from the box at its top; it starts on the repository the focused
|
|
|
195
195
|
terminal's folder is a clone of. **Issues** lists, opens, edits and closes
|
|
196
196
|
issues. **Actions** shows the latest workflow runs and whether one is still
|
|
197
197
|
going, refreshing while it is on screen. **Pull requests** lists the open
|
|
198
|
-
ones. **
|
|
199
|
-
|
|
198
|
+
ones. **Branches** lists every remote branch of the selected repository, with
|
|
199
|
+
its default and protected branches marked. Filter by name, copy a branch name,
|
|
200
|
+
or open it on GitHub. Larger lists load progressively; **Refresh** checks for
|
|
201
|
+
changes while keeping your place in the list. **Repositories** lists and creates
|
|
202
|
+
repositories and clones them over SSH with a key Agent Guild keeps for each account. On a wide window the side panel
|
|
200
203
|
makes room for itself; on a smaller one it lies over the cards, and over the
|
|
201
204
|
terminals when they would get too narrow. Drag its edge to resize it. Escape
|
|
202
205
|
or **Close** puts it away.
|
|
@@ -218,6 +221,17 @@ a stop or restart, remains unavailable after a brief recovery check, or a
|
|
|
218
221
|
new version is discovered after the initial version check. Session activity
|
|
219
222
|
and assistant responses have no sounds:
|
|
220
223
|
the providers do not yet offer consistently reliable completion signals.
|
|
224
|
+
**Launch at sign-in**, in the same menu, starts the session manager in the
|
|
225
|
+
background whenever you sign in to the computer running Agent Guild; open
|
|
226
|
+
the page as usual afterwards. On Windows it is an entry in Task Manager's
|
|
227
|
+
Startup apps, on macOS a background item named Agent Guild under System
|
|
228
|
+
Settings › General › Login Items & Extensions, and on Linux an entry named
|
|
229
|
+
Agent Guild in the desktop's startup settings, so it needs a desktop session
|
|
230
|
+
there: a server reached over SSH does not start it. macOS keeps its own
|
|
231
|
+
switch for that item, which Agent Guild cannot read: switched off there, it
|
|
232
|
+
does not start even while **Launch at sign-in** is checked. While it is on,
|
|
233
|
+
the setting says when it last ran at sign-in and whether the session manager
|
|
234
|
+
started then.
|
|
221
235
|
|
|
222
236
|
The browser tab icon adds a rotating green ring whenever any session card says
|
|
223
237
|
**Working**, including Shell and task cards. It mirrors the cards' terminal
|
|
@@ -277,8 +291,9 @@ turn on **Agent reporting** on its card once, which installs an Agent Guild
|
|
|
277
291
|
plugin into Antigravity. It reports the model and the conversation, but no
|
|
278
292
|
helper agents or shell commands. Grok Build cannot take hooks for one session yet; add the hooks
|
|
279
293
|
from [examples/grok-hooks.json](examples/grok-hooks.json) to see its agents.
|
|
280
|
-
When a tool's hooks
|
|
281
|
-
|
|
294
|
+
When a tool's hooks have not reported some time after a prompt was sent,
|
|
295
|
+
because hooks are turned off, restricted by an administrator or not trusted
|
|
296
|
+
for the folder, the card says so.
|
|
282
297
|
|
|
283
298
|
The card also names the model, from the hooks, from `--model` or from the
|
|
284
299
|
tool's screen.
|
|
@@ -305,6 +320,29 @@ Any other tool or script can report agents and its model too. See
|
|
|
305
320
|
* Usage meters are fetched by the manager with each tool's own sign-in. The
|
|
306
321
|
page only receives percentages. On macOS the first lookup may ask for
|
|
307
322
|
keychain access to the "Claude Code-credentials" item; choose Always Allow.
|
|
323
|
+
* **Launch at sign-in** writes outside the data folder only while it is on:
|
|
324
|
+
the `AgentGuild` value under
|
|
325
|
+
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run` on Windows, which
|
|
326
|
+
runs `autostart.js` from the data folder through `wscript.exe` so no
|
|
327
|
+
console window opens;
|
|
328
|
+
`~/Library/LaunchAgents/com.oddessentials.agent-guild.plist` on macOS,
|
|
329
|
+
which runs the `Agent Guild` script in the data folder so that macOS lists
|
|
330
|
+
it by that name; and `~/.config/autostart/agent-guild.desktop` on Linux,
|
|
331
|
+
which runs `autostart.sh` from the data folder. On Linux, a Node.js,
|
|
332
|
+
package or data folder path holding `"`, `` ` ``, `$` or `\` cannot be
|
|
333
|
+
used, because desktops read those differently in a startup entry; the
|
|
334
|
+
setting then says so. Each sign-in touches `sign-in-attempt` and records
|
|
335
|
+
its outcome in `sign-in.json` in the data folder.
|
|
336
|
+
Turning it off removes them, and an entry whose Agent Guild package has
|
|
337
|
+
been uninstalled or moved removes itself at the next sign-in. With nvm,
|
|
338
|
+
the package lives in the Node.js version's folder, so uninstalling that
|
|
339
|
+
version counts as uninstalling Agent Guild; a Homebrew Node.js upgrade
|
|
340
|
+
does not. On macOS and Linux what the entry runs at sign-in, including a
|
|
341
|
+
missing Node.js, is appended to `manager.log`.
|
|
342
|
+
The entry saves the manager's listening port, including the assigned port
|
|
343
|
+
when started with `AGENT_GUILD_PORT=0`. Enabled entries are updated when
|
|
344
|
+
the manager starts. A macOS entry disabled through `launchctl` stays off
|
|
345
|
+
until you explicitly turn **Launch at sign-in** back on.
|
|
308
346
|
|
|
309
347
|
The manager makes these outbound requests, and none of them carry your code
|
|
310
348
|
or prompts:
|
package/bin/agent-guild.mjs
CHANGED
|
@@ -13,12 +13,14 @@ import { spawn } from 'node:child_process';
|
|
|
13
13
|
import {
|
|
14
14
|
DEFAULT_HOST,
|
|
15
15
|
VERSION,
|
|
16
|
+
dataDir,
|
|
16
17
|
loadOrCreateToken,
|
|
17
18
|
paths,
|
|
18
19
|
readRuntimeFile,
|
|
19
20
|
resolvePort,
|
|
20
21
|
} from '../src/manager/config.mjs';
|
|
21
22
|
import { spawnManager } from '../src/manager/launch.mjs';
|
|
23
|
+
import { recordSignIn } from '../src/manager/autostart.mjs';
|
|
22
24
|
|
|
23
25
|
function usage() {
|
|
24
26
|
console.log(`Usage: agent-guild [command] [--no-browser]
|
|
@@ -122,8 +124,10 @@ async function ensureManager({ port = null } = {}) {
|
|
|
122
124
|
throw new Error(`the session manager did not start.${details ? `\n\nRecent log (${paths.log}):\n${details}` : ''}`);
|
|
123
125
|
}
|
|
124
126
|
|
|
125
|
-
|
|
127
|
+
/** `signIn`: run by the sign-in entry, which shows the page whether a manager answered. */
|
|
128
|
+
async function cmdOpen({ browser, signIn = false }) {
|
|
126
129
|
const { url, started, version } = await ensureManager();
|
|
130
|
+
if (signIn) recordSignIn(dataDir(), started ? 'started' : 'running');
|
|
127
131
|
const token = loadOrCreateToken();
|
|
128
132
|
const target = pageUrl(url, token);
|
|
129
133
|
console.log(started ? `Session manager started at ${url}` : `Session manager already running at ${url}`);
|
|
@@ -132,7 +136,8 @@ async function cmdOpen({ browser }) {
|
|
|
132
136
|
if (browser) {
|
|
133
137
|
openBrowser(target);
|
|
134
138
|
console.log('Opening Agent Guild in your browser. You can close the page at any time; sessions keep running.');
|
|
135
|
-
} else {
|
|
139
|
+
} else if (!signIn) {
|
|
140
|
+
// A sign-in entry's output goes to manager.log, which needs no token.
|
|
136
141
|
console.log(`Open: ${target}`);
|
|
137
142
|
}
|
|
138
143
|
}
|
|
@@ -248,7 +253,7 @@ async function main() {
|
|
|
248
253
|
if (flags.has('-h') || flags.has('--help') || command === 'help') return usage();
|
|
249
254
|
|
|
250
255
|
switch (command) {
|
|
251
|
-
case 'open': return cmdOpen({ browser: !flags.has('--no-browser') });
|
|
256
|
+
case 'open': return cmdOpen({ browser: !flags.has('--no-browser'), signIn: flags.has('--sign-in') });
|
|
252
257
|
case 'start': {
|
|
253
258
|
await import('../src/manager/main.mjs').then(async (m) => {
|
|
254
259
|
process.on('uncaughtException', (err) => console.error('[manager] unexpected error:', err));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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": {
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// Starting the session manager when the user signs in to the computer.
|
|
2
|
+
//
|
|
3
|
+
// Each platform gets one per-user entry that runs `agent-guild open
|
|
4
|
+
// --no-browser`, which starts a background manager unless one answers:
|
|
5
|
+
//
|
|
6
|
+
// * Windows: a value under HKCU's Run key. It runs a JScript wrapper in the
|
|
7
|
+
// data folder through wscript.exe, which starts Node.js with no console
|
|
8
|
+
// window; running node.exe directly would flash one at every sign-in.
|
|
9
|
+
// * macOS: a LaunchAgent with RunAtLoad. It runs a script named Agent Guild
|
|
10
|
+
// in the data folder, which System Settings lists by that name; running
|
|
11
|
+
// sh directly would list it as "sh". AbandonProcessGroup keeps launchd
|
|
12
|
+
// from killing the manager once the launching command exits.
|
|
13
|
+
// * Linux: an XDG autostart entry, run when a desktop session starts. It
|
|
14
|
+
// runs a script in the data folder, as on macOS: desktops read Exec lines
|
|
15
|
+
// by different rules (systemd's autostart generator keeps a backslash that
|
|
16
|
+
// GLib removes), so the line holds only paths that read the same in all.
|
|
17
|
+
//
|
|
18
|
+
// Each launcher touches a file in the data folder before it starts Node.js,
|
|
19
|
+
// and `open --sign-in` records whether a manager then answered, so the page
|
|
20
|
+
// can say whether the last sign-in started the manager.
|
|
21
|
+
//
|
|
22
|
+
// The entry and the OS's enabled state record whether autostart is on, so
|
|
23
|
+
// a user who turns it off outside the app sees it off here too.
|
|
24
|
+
// Its paths are absolute, so a running manager rewrites an entry that is on
|
|
25
|
+
// at every start: a Node.js version switch or a reinstall then takes effect
|
|
26
|
+
// at the next sign-in. npm runs no script when a package is uninstalled, so
|
|
27
|
+
// an entry whose package script is gone removes itself at sign-in instead
|
|
28
|
+
// of failing there at every sign-in after. A missing Node.js alone keeps
|
|
29
|
+
// the entry: the next manager to start points it at its own. Homebrew's
|
|
30
|
+
// Node.js is named by its opt link, which an upgrade keeps, rather than by
|
|
31
|
+
// the versioned Cellar folder the upgrade deletes.
|
|
32
|
+
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { execFile } from 'node:child_process';
|
|
37
|
+
import { DEFAULT_PORT } from './config.mjs';
|
|
38
|
+
|
|
39
|
+
export const ARGS = ['open', '--no-browser', '--sign-in'];
|
|
40
|
+
export const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
41
|
+
export const WINDOWS_APPROVED_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run';
|
|
42
|
+
export const WINDOWS_VALUE = 'AgentGuild';
|
|
43
|
+
export const WINDOWS_WRAPPER = 'autostart.js';
|
|
44
|
+
export const LAUNCH_AGENT_LABEL = 'com.oddessentials.agent-guild';
|
|
45
|
+
export const MAC_LAUNCHER = 'Agent Guild';
|
|
46
|
+
export const LINUX_LAUNCHER = 'autostart.sh';
|
|
47
|
+
/** Touched by an entry each time it runs at sign-in. */
|
|
48
|
+
export const SIGN_IN_ATTEMPT = 'sign-in-attempt';
|
|
49
|
+
/** What `open --sign-in` found: `{ at, outcome }`. */
|
|
50
|
+
export const SIGN_IN_RESULT = 'sign-in.json';
|
|
51
|
+
/** How long after an entry runs a manager may take to answer before it counts as not started. */
|
|
52
|
+
export const SIGN_IN_WAIT_MS = 30000;
|
|
53
|
+
/** File times may be rounded, so a result this much older than its attempt still belongs to it. */
|
|
54
|
+
const CLOCK_SLACK_MS = 2000;
|
|
55
|
+
const OUTCOMES = new Set(['started', 'running']);
|
|
56
|
+
/**
|
|
57
|
+
* macOS keeps its own switch for the item, which only an app signed to use
|
|
58
|
+
* SMAppService can read, so the page says where it is instead.
|
|
59
|
+
*/
|
|
60
|
+
export const MAC_NOTE = 'Starts the session manager in the background when you sign in to this Mac. The page does not open. macOS also lists it as Agent Guild under System Settings › General › Login Items & Extensions; switched off there, it does not start even while this is checked.';
|
|
61
|
+
export const LINUX_NOTE = 'Starts the session manager in the background when you sign in to a desktop session on this computer. The page does not open. A computer with no desktop session, such as a server reached over SSH, does not start it. Desktop startup settings list it as Agent Guild.';
|
|
62
|
+
/** Added to LINUX_NOTE when the manager itself was not started from a desktop session. */
|
|
63
|
+
export const LINUX_NO_DESKTOP = 'This manager was not started from a desktop session, so this computer may not have one.';
|
|
64
|
+
/** Characters that systemd's autostart generator and GLib read differently in an Exec line. */
|
|
65
|
+
const EXEC_UNSAFE = /["`$\\]/;
|
|
66
|
+
|
|
67
|
+
const failure = (message) => Object.assign(new Error(message), { status: 500, code: 'autostart_failed' });
|
|
68
|
+
|
|
69
|
+
function portString(port) {
|
|
70
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw failure('The manager must be listening before saving its startup port.');
|
|
71
|
+
return String(port);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A JScript string literal: JSON's escapes, with everything outside ASCII as \u escapes so the file's code page cannot matter. */
|
|
75
|
+
function jsString(value) {
|
|
76
|
+
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The JScript that wscript.exe runs at sign-in: the launcher with its
|
|
81
|
+
* window hidden (0), not waited for; or, once the package is gone, the
|
|
82
|
+
* removal of the Run value, its Task Manager setting and this file.
|
|
83
|
+
* `runKey` and `approvedKey` are replaceable in tests.
|
|
84
|
+
*/
|
|
85
|
+
export function windowsWrapper({ execPath, script, attempt, port = DEFAULT_PORT, runKey = WINDOWS_RUN_KEY, approvedKey = WINDOWS_APPROVED_KEY }) {
|
|
86
|
+
const command = [execPath, script].map((p) => `"${p}"`).concat(ARGS).join(' ');
|
|
87
|
+
return [
|
|
88
|
+
'// Agent Guild: starts the session manager at sign-in without a console window.',
|
|
89
|
+
'var files = new ActiveXObject("Scripting.FileSystemObject");',
|
|
90
|
+
'var shell = new ActiveXObject("WScript.Shell");',
|
|
91
|
+
`if (files.FileExists(${jsString(script)})) {`,
|
|
92
|
+
` try { files.CreateTextFile(${jsString(attempt)}, true).Close(); } catch (e) {}`,
|
|
93
|
+
` shell.Environment("Process")("AGENT_GUILD_PORT") = ${jsString(portString(port))};`,
|
|
94
|
+
` shell.Run(${jsString(command)}, 0, false);`,
|
|
95
|
+
'} else {',
|
|
96
|
+
' // Agent Guild is no longer installed here: remove this sign-in entry.',
|
|
97
|
+
` try { shell.RegDelete(${jsString(`${runKey}\\${WINDOWS_VALUE}`)}); } catch (e) {}`,
|
|
98
|
+
` try { shell.RegDelete(${jsString(`${approvedKey}\\${WINDOWS_VALUE}`)}); } catch (e) {}`,
|
|
99
|
+
' try { files.DeleteFile(WScript.ScriptFullName); } catch (e) {}',
|
|
100
|
+
'}',
|
|
101
|
+
'',
|
|
102
|
+
].join('\r\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The Run value's command line. */
|
|
106
|
+
export function windowsRunCommand({ env, wrapper }) {
|
|
107
|
+
const wscript = path.win32.join(env.SystemRoot || env.SYSTEMROOT || env.WINDIR || 'C:\\Windows', 'System32', 'wscript.exe');
|
|
108
|
+
return `"${wscript}" //B //NoLogo "${wrapper}"`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The sh launcher a macOS or Linux entry runs from the data folder, with
|
|
113
|
+
* Node.js, the package script, the entry's own file, the saved port, the
|
|
114
|
+
* manager log and the sign-in attempt file as its arguments, so no path is
|
|
115
|
+
* ever part of the script text. Its output, a missing Node.js included, is
|
|
116
|
+
* appended to the log when the log can be opened; the subshell tries first
|
|
117
|
+
* because a failed `exec` redirection would end sh before it starts
|
|
118
|
+
* anything. Once the package is gone it removes the entry and itself.
|
|
119
|
+
*/
|
|
120
|
+
export const POSIX_LAUNCHER = [
|
|
121
|
+
'#!/bin/sh',
|
|
122
|
+
'# Agent Guild: starts the session manager at sign-in. Its sign-in entry runs it',
|
|
123
|
+
'# with Node.js, the package script, the entry, the port, the log and a file it touches.',
|
|
124
|
+
'node="$1"; shift',
|
|
125
|
+
`if [ -f "$1" ]; then touch "$5" 2>/dev/null; if (exec >>"$4") 2>/dev/null; then exec >>"$4" 2>&1; echo "--- sign-in $(date -u +%Y-%m-%dT%H:%M:%SZ) ---"; fi; AGENT_GUILD_PORT="$3" exec "$node" "$1" ${ARGS.join(' ')}; fi`,
|
|
126
|
+
'rm -f "$2" "$0"',
|
|
127
|
+
'',
|
|
128
|
+
].join('\n');
|
|
129
|
+
|
|
130
|
+
/** What a macOS or Linux entry passes its launcher: Node.js, then the paths and port it reads. */
|
|
131
|
+
function launchArgs({ execPath, script, file, port = DEFAULT_PORT, log, attempt }) {
|
|
132
|
+
return [execPath, script, file, portString(port), log, attempt];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The Linux entry's command line: sh, the launcher, then the arguments it reads. */
|
|
136
|
+
export function posixCommand({ launcher, ...paths }) {
|
|
137
|
+
return ['/bin/sh', launcher, ...launchArgs(paths)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Homebrew runs Node.js from `<prefix>/Cellar/<formula>/<version>`, which
|
|
142
|
+
* the next upgrade deletes. `<prefix>/opt/<formula>` follows the formula
|
|
143
|
+
* across upgrades, so it names Node.js instead when it leads to the same file.
|
|
144
|
+
*/
|
|
145
|
+
export function stableExecPath(execPath, realpath = fs.realpathSync) {
|
|
146
|
+
const match = execPath.match(/^(.+)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/);
|
|
147
|
+
if (!match) return execPath;
|
|
148
|
+
const opt = `${match[1]}/opt/${match[2]}/bin/node`;
|
|
149
|
+
try {
|
|
150
|
+
return realpath(opt) === realpath(execPath) ? opt : execPath;
|
|
151
|
+
} catch {
|
|
152
|
+
return execPath;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const xml = (value) => value.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
157
|
+
|
|
158
|
+
export function launchAgentPlist({ launcher, execPath, script, file, port, log, attempt }) {
|
|
159
|
+
return [
|
|
160
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
161
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
162
|
+
'<plist version="1.0">',
|
|
163
|
+
'<dict>',
|
|
164
|
+
` <key>Label</key><string>${LAUNCH_AGENT_LABEL}</string>`,
|
|
165
|
+
' <key>ProgramArguments</key>',
|
|
166
|
+
' <array>',
|
|
167
|
+
...[launcher, ...launchArgs({ execPath, script, file, port, log, attempt })].map((arg) => ` <string>${xml(arg)}</string>`),
|
|
168
|
+
' </array>',
|
|
169
|
+
' <key>RunAtLoad</key><true/>',
|
|
170
|
+
' <key>AbandonProcessGroup</key><true/>',
|
|
171
|
+
'</dict>',
|
|
172
|
+
'</plist>',
|
|
173
|
+
'',
|
|
174
|
+
].join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* One Exec argument, quoted by the Desktop Entry rules: `"`, `` ` ``, `$`
|
|
179
|
+
* and `\` are escaped inside the quotes, and the string-value escape then
|
|
180
|
+
* doubles every backslash again. `%` is the field-code prefix, so it is doubled.
|
|
181
|
+
* Entries refuse paths with the escaped characters, which systemd's
|
|
182
|
+
* autostart generator reads differently, so in practice only `%` changes.
|
|
183
|
+
*/
|
|
184
|
+
export function desktopArg(value) {
|
|
185
|
+
const quoted = `"${value.replace(/["`$\\]/g, (c) => `\\${c}`)}"`;
|
|
186
|
+
return quoted.replace(/\\/g, '\\\\').replace(/%/g, '%%');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function desktopEntry(paths) {
|
|
190
|
+
return [
|
|
191
|
+
'[Desktop Entry]',
|
|
192
|
+
'Type=Application',
|
|
193
|
+
'Name=Agent Guild',
|
|
194
|
+
'Comment=Starts the Agent Guild session manager',
|
|
195
|
+
`Exec=${posixCommand(paths).map(desktopArg).join(' ')}`,
|
|
196
|
+
'Terminal=false',
|
|
197
|
+
'X-GNOME-Autostart-enabled=true',
|
|
198
|
+
'',
|
|
199
|
+
].join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Whether a desktop entry is on: present and not hidden or turned off by a desktop's startup settings. */
|
|
203
|
+
export function desktopEntryEnabled(text) {
|
|
204
|
+
return !/^Hidden\s*=\s*true\s*$/m.test(text) && !/^X-GNOME-Autostart-enabled\s*=\s*false\s*$/m.test(text);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Windows keeps a Task Manager "Disabled" in StartupApproved: an odd first byte means off. */
|
|
208
|
+
export function approvedDisabled(stdout) {
|
|
209
|
+
const hex = String(stdout).match(new RegExp(`^\\s*${WINDOWS_VALUE}\\s+REG_BINARY\\s+([0-9A-F]{2})`, 'mi'))?.[1];
|
|
210
|
+
return hex !== undefined && (parseInt(hex, 16) & 1) === 1;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** launchctl has used both booleans and enabled/disabled in its override list. */
|
|
214
|
+
export function launchAgentDisabled(stdout) {
|
|
215
|
+
const body = String(stdout).match(/^\s*disabled services = \{([\s\S]*)\}\s*$/)?.[1];
|
|
216
|
+
if (body === undefined) throw failure('Unrecognized launchd startup settings.');
|
|
217
|
+
let disabled = false;
|
|
218
|
+
for (const line of body.split('\n').filter((row) => row.trim())) {
|
|
219
|
+
const row = line.match(/^\s*"([^"]+)"\s*=>\s*(true|false|enabled|disabled)\s*$/);
|
|
220
|
+
if (!row) throw failure('Unrecognized launchd startup settings.');
|
|
221
|
+
if (row[1] === LAUNCH_AGENT_LABEL) disabled = row[2] === 'true' || row[2] === 'disabled';
|
|
222
|
+
}
|
|
223
|
+
return disabled;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function runCommand(command) {
|
|
227
|
+
return (args) => new Promise((resolve) => {
|
|
228
|
+
execFile(command, args, { windowsHide: true, timeout: 10000 }, (err, stdout, stderr) => {
|
|
229
|
+
resolve({ status: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout, stderr: stderr || err?.message || '' });
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function readText(file) {
|
|
235
|
+
try {
|
|
236
|
+
return await fs.promises.readFile(file, 'utf8');
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err.code === 'ENOENT') return null;
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Writes `contents` unless the file already holds exactly that, with `mode`
|
|
245
|
+
* whatever the umask. The default is writable only by its owner: launchd
|
|
246
|
+
* skips an agent that others can write.
|
|
247
|
+
*/
|
|
248
|
+
async function writeIfChanged(file, contents, mode = 0o644) {
|
|
249
|
+
if ((await readText(file)) === contents) {
|
|
250
|
+
if (((await fs.promises.stat(file)).mode & 0o777) !== mode) await fs.promises.chmod(file, mode);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
|
254
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
255
|
+
await fs.promises.writeFile(tmp, contents);
|
|
256
|
+
await fs.promises.chmod(tmp, mode);
|
|
257
|
+
await fs.promises.rename(tmp, file);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* What the entry did at the last sign-in it ran at, or null when it has not
|
|
262
|
+
* run since it was turned on: `{ at, outcome }`, where `outcome` is
|
|
263
|
+
* `started` or `running` (a manager already answered) as `open --sign-in`
|
|
264
|
+
* recorded it, `starting` while a manager may still be coming up, and
|
|
265
|
+
* `failed` once none answered in time.
|
|
266
|
+
*/
|
|
267
|
+
export async function lastSignIn(dataDir, now = Date.now()) {
|
|
268
|
+
let attempt;
|
|
269
|
+
try {
|
|
270
|
+
attempt = (await fs.promises.stat(path.join(dataDir, SIGN_IN_ATTEMPT))).mtimeMs;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err.code === 'ENOENT') return null;
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
let result = null;
|
|
276
|
+
try {
|
|
277
|
+
result = JSON.parse(await readText(path.join(dataDir, SIGN_IN_RESULT)));
|
|
278
|
+
} catch { /* unreadable reads as none */ }
|
|
279
|
+
if (Number.isFinite(result?.at) && OUTCOMES.has(result.outcome) && result.at >= attempt - CLOCK_SLACK_MS) {
|
|
280
|
+
return { at: new Date(result.at).toISOString(), outcome: result.outcome };
|
|
281
|
+
}
|
|
282
|
+
return { at: new Date(attempt).toISOString(), outcome: now - attempt < SIGN_IN_WAIT_MS ? 'starting' : 'failed' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Records what `open --sign-in` found; best effort, since the manager runs either way. */
|
|
286
|
+
export function recordSignIn(dataDir, outcome, now = Date.now()) {
|
|
287
|
+
try {
|
|
288
|
+
const file = path.join(dataDir, SIGN_IN_RESULT);
|
|
289
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
290
|
+
fs.writeFileSync(tmp, JSON.stringify({ at: now, outcome }));
|
|
291
|
+
fs.renameSync(tmp, file);
|
|
292
|
+
} catch { /* nothing to report it to */ }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function clearSignIns(dataDir) {
|
|
296
|
+
await fs.promises.rm(path.join(dataDir, SIGN_IN_ATTEMPT), { force: true });
|
|
297
|
+
await fs.promises.rm(path.join(dataDir, SIGN_IN_RESULT), { force: true });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param {object} opts
|
|
302
|
+
* @param {string} opts.script bin/agent-guild.mjs of the package that is running
|
|
303
|
+
* @param {string} opts.dataDir where the Windows wrapper is kept
|
|
304
|
+
* @param {string} [opts.log] the manager log a macOS or Linux entry appends to
|
|
305
|
+
* @param {() => number} [opts.getPort] the bound port, read only when writing an entry
|
|
306
|
+
* @param {string|null} [opts.unavailable] a reason autostart cannot be offered, which turns it off here
|
|
307
|
+
* @param {(args: string[]) => Promise<{ status: number, stdout: string, stderr: string }>} [opts.reg] reg.exe, replaceable in tests
|
|
308
|
+
* @param {(args: string[]) => Promise<{ status: number, stdout: string, stderr: string }>} [opts.launchctl] launchctl, replaceable in tests
|
|
309
|
+
*/
|
|
310
|
+
export function createAutostart({
|
|
311
|
+
platform = process.platform,
|
|
312
|
+
env = process.env,
|
|
313
|
+
home = os.homedir(),
|
|
314
|
+
release = os.release(),
|
|
315
|
+
execPath = process.execPath,
|
|
316
|
+
script,
|
|
317
|
+
dataDir,
|
|
318
|
+
log = path.join(dataDir, 'manager.log'),
|
|
319
|
+
getPort = () => DEFAULT_PORT,
|
|
320
|
+
unavailable = null,
|
|
321
|
+
reg = runCommand(path.win32.join(env.SystemRoot || env.SYSTEMROOT || env.WINDIR || 'C:\\Windows', 'System32', 'reg.exe')),
|
|
322
|
+
uid = process.getuid?.(),
|
|
323
|
+
launchctl = runCommand('/bin/launchctl'),
|
|
324
|
+
}) {
|
|
325
|
+
const entryExecPath = platform === 'win32' ? execPath : stableExecPath(execPath);
|
|
326
|
+
const attempt = path.join(dataDir, SIGN_IN_ATTEMPT);
|
|
327
|
+
const command = () => ({ execPath: entryExecPath, script, port: getPort(), log, attempt });
|
|
328
|
+
let note = null;
|
|
329
|
+
let unusable = null;
|
|
330
|
+
let refreshError = null;
|
|
331
|
+
let queue = Promise.resolve();
|
|
332
|
+
/** One change or check at a time, so two clicks cannot interleave their writes. */
|
|
333
|
+
const serial = (fn) => {
|
|
334
|
+
const run = queue.then(fn, fn);
|
|
335
|
+
queue = run.catch(() => {});
|
|
336
|
+
return run;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
let target = null;
|
|
340
|
+
if (unavailable) {
|
|
341
|
+
// Offered nowhere: `reason` says why.
|
|
342
|
+
} else if (platform === 'win32') {
|
|
343
|
+
const wrapper = path.join(dataDir, WINDOWS_WRAPPER);
|
|
344
|
+
const approved = async () => {
|
|
345
|
+
// Clears a Task Manager "Disabled", which would otherwise outrank the Run value.
|
|
346
|
+
const r = await reg(['delete', WINDOWS_APPROVED_KEY, '/v', WINDOWS_VALUE, '/f']);
|
|
347
|
+
if (r.status !== 0 && (await reg(['query', WINDOWS_APPROVED_KEY, '/v', WINDOWS_VALUE])).status === 0) {
|
|
348
|
+
throw failure(`Could not clear the Task Manager startup setting: ${r.stderr.trim()}`);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
target = {
|
|
352
|
+
async enabled() {
|
|
353
|
+
if ((await reg(['query', WINDOWS_RUN_KEY, '/v', WINDOWS_VALUE])).status !== 0) return false;
|
|
354
|
+
const state = await reg(['query', WINDOWS_APPROVED_KEY, '/v', WINDOWS_VALUE]);
|
|
355
|
+
return !(state.status === 0 && approvedDisabled(state.stdout));
|
|
356
|
+
},
|
|
357
|
+
write: () => writeIfChanged(wrapper, windowsWrapper(command())),
|
|
358
|
+
async enable() {
|
|
359
|
+
await this.write();
|
|
360
|
+
const r = await reg(['add', WINDOWS_RUN_KEY, '/v', WINDOWS_VALUE, '/t', 'REG_SZ', '/d', windowsRunCommand({ env, wrapper }), '/f']);
|
|
361
|
+
if (r.status !== 0) throw failure(`Could not add the startup entry: ${r.stderr.trim()}`);
|
|
362
|
+
await approved();
|
|
363
|
+
},
|
|
364
|
+
async disable() {
|
|
365
|
+
const r = await reg(['delete', WINDOWS_RUN_KEY, '/v', WINDOWS_VALUE, '/f']);
|
|
366
|
+
if (r.status !== 0 && (await reg(['query', WINDOWS_RUN_KEY, '/v', WINDOWS_VALUE])).status === 0) {
|
|
367
|
+
throw failure(`Could not remove the startup entry: ${r.stderr.trim()}`);
|
|
368
|
+
}
|
|
369
|
+
await reg(['delete', WINDOWS_APPROVED_KEY, '/v', WINDOWS_VALUE, '/f']);
|
|
370
|
+
await fs.promises.rm(wrapper, { force: true });
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
} else if (platform === 'darwin') {
|
|
374
|
+
const file = path.join(home, 'Library', 'LaunchAgents', `${LAUNCH_AGENT_LABEL}.plist`);
|
|
375
|
+
const launcher = path.join(dataDir, MAC_LAUNCHER);
|
|
376
|
+
const domain = `gui/${uid}`;
|
|
377
|
+
const disabled = async () => {
|
|
378
|
+
if (!Number.isInteger(uid) || uid < 0) throw failure('Could not determine the macOS user.');
|
|
379
|
+
const r = await launchctl(['print-disabled', domain]);
|
|
380
|
+
if (r.status !== 0) throw failure(`Could not read launchd startup settings: ${r.stderr.trim()}`);
|
|
381
|
+
return launchAgentDisabled(r.stdout);
|
|
382
|
+
};
|
|
383
|
+
target = {
|
|
384
|
+
async enabled() {
|
|
385
|
+
return (await readText(file)) !== null && !(await disabled());
|
|
386
|
+
},
|
|
387
|
+
async write() {
|
|
388
|
+
await writeIfChanged(launcher, POSIX_LAUNCHER, 0o755);
|
|
389
|
+
await writeIfChanged(file, launchAgentPlist({ ...command(), launcher, file }));
|
|
390
|
+
},
|
|
391
|
+
async enable() {
|
|
392
|
+
const previous = await readText(file);
|
|
393
|
+
// Check the domain before writing an entry we might be unable to enable.
|
|
394
|
+
await disabled();
|
|
395
|
+
await this.write();
|
|
396
|
+
try {
|
|
397
|
+
const r = await launchctl(['enable', `${domain}/${LAUNCH_AGENT_LABEL}`]);
|
|
398
|
+
if (r.status !== 0) throw failure(`Could not enable the launchd startup setting: ${r.stderr.trim()}`);
|
|
399
|
+
if (await disabled()) throw failure('launchd did not enable the startup setting.');
|
|
400
|
+
} catch (err) {
|
|
401
|
+
try {
|
|
402
|
+
if (previous === null) await this.disable();
|
|
403
|
+
else await writeIfChanged(file, previous);
|
|
404
|
+
} catch (restoreError) {
|
|
405
|
+
throw failure(`${err.message} Could not restore the previous startup entry: ${restoreError.message}`);
|
|
406
|
+
}
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
async disable() {
|
|
411
|
+
await fs.promises.rm(file, { force: true });
|
|
412
|
+
await fs.promises.rm(launcher, { force: true });
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
} else if (platform === 'linux' && !(env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release))) {
|
|
416
|
+
const file = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'autostart', 'agent-guild.desktop');
|
|
417
|
+
const launcher = path.join(dataDir, LINUX_LAUNCHER);
|
|
418
|
+
const unsafe = [entryExecPath, script, file, launcher, log, attempt].find((p) => EXEC_UNSAFE.test(p));
|
|
419
|
+
if (unsafe) {
|
|
420
|
+
unusable = `Not available because this path holds a character (", \`, $ or \\) that desktops read differently in a startup entry: ${unsafe}`;
|
|
421
|
+
} else {
|
|
422
|
+
const desktop = env.XDG_CURRENT_DESKTOP || env.WAYLAND_DISPLAY || env.DISPLAY;
|
|
423
|
+
note = desktop ? LINUX_NOTE : `${LINUX_NOTE} ${LINUX_NO_DESKTOP}`;
|
|
424
|
+
target = {
|
|
425
|
+
async enabled() {
|
|
426
|
+
const text = await readText(file);
|
|
427
|
+
return text !== null && desktopEntryEnabled(text);
|
|
428
|
+
},
|
|
429
|
+
async write() {
|
|
430
|
+
await writeIfChanged(launcher, POSIX_LAUNCHER, 0o755);
|
|
431
|
+
await writeIfChanged(file, desktopEntry({ ...command(), launcher, file }));
|
|
432
|
+
},
|
|
433
|
+
enable() { return this.write(); },
|
|
434
|
+
async disable() {
|
|
435
|
+
await fs.promises.rm(file, { force: true });
|
|
436
|
+
await fs.promises.rm(launcher, { force: true });
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (platform === 'darwin') note = MAC_NOTE;
|
|
442
|
+
const reason = target ? null
|
|
443
|
+
: unavailable || unusable || (platform === 'linux' ? 'Not available in WSL. Start Agent Guild from Windows to launch it at sign-in.'
|
|
444
|
+
: 'Not available on this operating system.');
|
|
445
|
+
|
|
446
|
+
const describe = async () => {
|
|
447
|
+
if (!target) return { available: false, enabled: false, reason };
|
|
448
|
+
try {
|
|
449
|
+
const enabled = await target.enabled();
|
|
450
|
+
return {
|
|
451
|
+
available: true,
|
|
452
|
+
enabled,
|
|
453
|
+
reason: enabled ? refreshError : null,
|
|
454
|
+
...(note && { note }),
|
|
455
|
+
// An unreadable record never keeps the setting from being turned off.
|
|
456
|
+
lastRun: enabled ? await lastSignIn(dataDir).catch(() => null) : null,
|
|
457
|
+
log,
|
|
458
|
+
};
|
|
459
|
+
} catch (err) {
|
|
460
|
+
return { available: false, enabled: false, reason: `Could not read the startup setting: ${err.message}` };
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
describe: () => serial(describe),
|
|
466
|
+
/** Turns autostart on or off and resolves to the new description. */
|
|
467
|
+
set: (enabled) => serial(async () => {
|
|
468
|
+
if (!target) throw Object.assign(new Error(reason), { status: 409, code: 'autostart_unavailable' });
|
|
469
|
+
try {
|
|
470
|
+
// A fresh start says it has not run yet rather than report an earlier time it was on.
|
|
471
|
+
if (!enabled || !(await target.enabled().catch(() => false))) await clearSignIns(dataDir);
|
|
472
|
+
await (enabled ? target.enable() : target.disable());
|
|
473
|
+
refreshError = null;
|
|
474
|
+
const state = await describe();
|
|
475
|
+
if (!state.available || state.enabled !== enabled) throw failure(state.reason || 'Could not verify the startup setting.');
|
|
476
|
+
return state;
|
|
477
|
+
} catch (err) {
|
|
478
|
+
throw err.code === 'autostart_failed' ? err : failure(`Could not change the startup setting: ${err.message}`);
|
|
479
|
+
}
|
|
480
|
+
}),
|
|
481
|
+
/** Points an entry that is on at this manager's Node.js, package and port. */
|
|
482
|
+
refresh: () => serial(async () => {
|
|
483
|
+
try {
|
|
484
|
+
if (target && await target.enabled()) await target.write();
|
|
485
|
+
refreshError = null;
|
|
486
|
+
} catch (err) {
|
|
487
|
+
refreshError = `Could not update the sign-in entry: ${err.message}`;
|
|
488
|
+
throw err;
|
|
489
|
+
}
|
|
490
|
+
}),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Issues, Actions runs and open pull requests of one repository, read with a
|
|
1
|
+
// Branches, Issues, Actions runs and open pull requests of one repository, read with a
|
|
2
2
|
// signed-in account. Reading and editing stay shallow: GitHub's page is the full view.
|
|
3
3
|
|
|
4
4
|
import { nextLink, parseRepo } from './github.mjs';
|
|
@@ -114,6 +114,42 @@ export function createViews(github) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
return {
|
|
117
|
+
async branches(accountId, owner, name, { page = '1' } = {}) {
|
|
118
|
+
if (!/^[1-9]\d*$/.test(String(page)) || !Number.isSafeInteger(Number(page))) {
|
|
119
|
+
throw refusal(400, 'bad_page', 'page must be a positive integer');
|
|
120
|
+
}
|
|
121
|
+
page = Number(page);
|
|
122
|
+
// One metadata read per listing, never a protection request per branch.
|
|
123
|
+
const [listing, metadata] = await Promise.all([
|
|
124
|
+
call(accountId, owner, name, `/branches?per_page=100&page=${page}`),
|
|
125
|
+
page === 1 ? call(accountId, owner, name, '').then(({ body }) => ({ body }), (error) => ({ error: error.message })) : null,
|
|
126
|
+
]);
|
|
127
|
+
const { body, res, repo } = listing;
|
|
128
|
+
if (!Array.isArray(body)) throw refusal(502, 'github_error', 'GitHub did not return a branch list');
|
|
129
|
+
let nextPage = null;
|
|
130
|
+
const next = nextLink(res.headers.get('link'));
|
|
131
|
+
if (next) {
|
|
132
|
+
// Reconstruct our own route; never send credentials to a supplied Link URL.
|
|
133
|
+
try { nextPage = Number(new URL(next).searchParams.get('page')); } catch { /* rejected below */ }
|
|
134
|
+
if (!Number.isSafeInteger(nextPage) || nextPage <= page) {
|
|
135
|
+
throw refusal(502, 'github_error', 'GitHub returned an invalid next page for branches');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const branches = body.filter((item) => typeof item?.name === 'string' && item.name.length > 0).map((item) => ({
|
|
139
|
+
name: item.name,
|
|
140
|
+
sha: typeof item.commit?.sha === 'string' && /^[a-f0-9]{40,64}$/i.test(item.commit.sha) ? item.commit.sha : null,
|
|
141
|
+
protected: item.protected === true,
|
|
142
|
+
url: `${WEB}/${repo.fullName}/tree/${encodeURIComponent(item.name)}`,
|
|
143
|
+
}));
|
|
144
|
+
return {
|
|
145
|
+
branches, nextPage,
|
|
146
|
+
defaultBranch: typeof metadata?.body?.default_branch === 'string' ? metadata.body.default_branch : null,
|
|
147
|
+
metadataError: metadata?.error ?? null,
|
|
148
|
+
fetchedAt: new Date().toISOString(),
|
|
149
|
+
url: `${WEB}/${repo.fullName}/branches`,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
|
|
117
153
|
async issues(accountId, owner, name, { state = 'open' } = {}) {
|
|
118
154
|
if (state !== 'open' && state !== 'closed' && state !== 'all') throw refusal(400, 'bad_state', 'state must be open, closed or all');
|
|
119
155
|
const { body, res, repo } = await call(accountId, owner, name, `/issues?state=${state}&per_page=${PAGE}&sort=updated&direction=desc`);
|