@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 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. **Repositories** lists and creates repositories and clones them over SSH
199
- with a key Agent Guild keeps for each account. On a wide window the side panel
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 do not run, because hooks are turned off, restricted by
281
- an administrator or not trusted for the folder, the card says so.
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:
@@ -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
- async function cmdOpen({ browser }) {
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.31.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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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`);