@oddessentials/agent-guild 0.33.2 → 0.34.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
@@ -87,12 +87,16 @@ shell. Pick the default chip to clear a saved choice and follow the default
87
87
  again. The choice applies to new sessions.
88
88
 
89
89
  **See the manager environment.** The Shell card summarizes detected Node.js,
90
- Python, Go, .NET SDK, R and Rust versions. **Environment details** shows the
91
- resolved paths, detection results, .NET runtimes and detected nvm/NVM for
92
- Windows, vfox, uv and pnpm launchers. Tools are listed by presence; their
93
- activation is not inferred. This describes the computer running Agent Guild
94
- and its manager environment, including when viewed remotely. Selected shells,
95
- projects and existing sessions may use different versions.
90
+ Python, Go, .NET SDK, R and Rust versions. **Environment details** opens on
91
+ the manager result: resolved paths, detection results, .NET runtimes and
92
+ detected nvm/NVM for Windows, vfox, uv and pnpm launchers. Tools are listed
93
+ by presence; their activation is not inferred. The same dialog can show three
94
+ other facts, each refreshed on its own: pin files in the working folder
95
+ (configured text, not an installed version), the PATH a Guild session was
96
+ given when it started, and the PATH a new process receives before the shell
97
+ runs its startup files. tmux and herdr sessions have no spawn record to show.
98
+ The card itself stays the manager summary. This describes the computer running
99
+ Agent Guild, including when viewed remotely.
96
100
 
97
101
  Python always uses `python` when it is found on the manager's PATH; otherwise
98
102
  it uses `python3`. A broken `python` remains the primary result, with a
@@ -105,8 +109,10 @@ Discovery runs on first viewing and again on **Refresh**, in an isolated
105
109
  helper with a neutral temporary working directory. It does not load shell
106
110
  profiles, activate tools, download runtimes or modify installations. Unknown
107
111
  script/shim launchers and Windows execution aliases are left unverified.
108
- Refresh checks the manager's current environment; it does not reload its
109
- PATH from a shell. Existing results remain visible during refresh. Go reports
112
+ Refresh checks the fact selected in the dialog and leaves the others as they
113
+ are. The manager fact reads the manager's current environment and does not
114
+ reload its PATH from a shell. Existing results for that fact remain visible
115
+ during refresh. Go reports
110
116
  the local bundled toolchain with automatic switching disabled, and the Node
111
117
  version running Agent Guild is also shown in details.
112
118
 
@@ -233,6 +239,23 @@ does not start even while **Launch at sign-in** is checked. While it is on,
233
239
  the setting says when it last ran at sign-in and whether the session manager
234
240
  started then.
235
241
 
242
+ On Linux the setting is one choice: **Only when I start it**, **When I sign
243
+ in** (the desktop entry above) or **When the computer starts**. The last
244
+ runs the session manager as a systemd user service, `agent-guild.service`,
245
+ so it is running before you connect to a machine with no desktop, and
246
+ systemd starts it again if it fails. Only one of the two starters is on at
247
+ a time, and changing the choice never stops the running session manager: one
248
+ started by hand hands over to systemd at its next restart. While the service
249
+ is on, `agent-guild open` and `agent-guild restart` start the session manager
250
+ through systemd, `agent-guild stop` stops it until the next start, and tmux
251
+ cards survive restarts as they do without it. To start before anyone signs
252
+ in, systemd needs lingering, which only an administrator can always turn
253
+ on; the setting shows the command, `sudo loginctl enable-linger <user>`,
254
+ until it is on. It also shows what systemd reports, such as a port another
255
+ session manager was using, and for a failure the command that shows its
256
+ log, `journalctl --user -u agent-guild.service`. `agent-guild status` prints
257
+ the same state, read from systemd when no session manager answers.
258
+
236
259
  The browser tab icon adds a rotating green ring whenever any session card says
237
260
  **Working**, including Shell and task cards. It mirrors the cards' terminal
238
261
  activity, not assistant completion. The ring remains visible with reduced
@@ -255,7 +278,7 @@ There is no added polling while connected and no additional monitoring process.
255
278
  | Command | What it does |
256
279
  | --- | --- |
257
280
  | `agent-guild` or `agent-guild open` | Start the manager if needed and open the page. `--no-browser` prints the URL instead. |
258
- | `agent-guild status` | Show whether the manager is running and list its sessions. |
281
+ | `agent-guild status` | Show whether the manager is running, how it starts, and list its sessions. |
259
282
  | `agent-guild stop` | Stop the manager, ending every session. |
260
283
  | `agent-guild restart` | Stop the manager and start it again on the version installed on disk, ending every session. |
261
284
  | `agent-guild url` | Print the page URL with its access token. |
@@ -343,6 +366,14 @@ Any other tool or script can report agents and its model too. See
343
366
  when started with `AGENT_GUILD_PORT=0`. Enabled entries are updated when
344
367
  the manager starts. A macOS entry disabled through `launchctl` stays off
345
368
  until you explicitly turn **Launch at sign-in** back on.
369
+ * **When the computer starts**, on Linux, writes
370
+ `~/.config/systemd/user/agent-guild.service` and enables it, which links
371
+ it from `default.target.wants`; the unit runs `boot.sh` from the data
372
+ folder, which runs `agent-guild start` in the foreground. Choosing another
373
+ option disables and removes both. A unit whose Agent Guild package has
374
+ been uninstalled or moved disables and removes itself at the next start.
375
+ Lingering, which lets it start before anyone signs in, is a system
376
+ setting that Agent Guild reads but never changes.
346
377
 
347
378
  The manager makes these outbound requests, and none of them carry your code
348
379
  or prompts:
@@ -19,8 +19,9 @@ import {
19
19
  readRuntimeFile,
20
20
  resolvePort,
21
21
  } from '../src/manager/config.mjs';
22
- import { spawnManager } from '../src/manager/launch.mjs';
22
+ import { defaultBoot, serviceFor, spawnManager, startService } from '../src/manager/launch.mjs';
23
23
  import { recordSignIn } from '../src/manager/autostart.mjs';
24
+ import { EXIT_PORT_IN_USE, describeStartup, startupState, startupSummary, unitPort } from '../src/manager/systemd-service.mjs';
24
25
 
25
26
  function usage() {
26
27
  console.log(`Usage: agent-guild [command] [--no-browser]
@@ -105,6 +106,11 @@ async function ensureManager({ port = null } = {}) {
105
106
  const running = await health(knownUrl);
106
107
  if (running) return { url: knownUrl, started: false, version: running.version };
107
108
 
109
+ // While the boot service is on for this port, systemd is the one that starts a manager.
110
+ const { boot, note } = await serviceFor(listenPort);
111
+ if (boot) return startService({ boot, port: listenPort, url: expectedUrl, health });
112
+ if (note) console.log(note);
113
+
108
114
  const child = spawnManager({ env: { ...process.env, AGENT_GUILD_PORT: String(listenPort) } });
109
115
 
110
116
  let exited = false;
@@ -223,15 +229,26 @@ async function cmdStatus() {
223
229
  const h = await health(url);
224
230
  if (!h) {
225
231
  console.log('Session manager is not running.');
232
+ // With no manager to ask, the boot service is read from systemd directly.
233
+ const boot = defaultBoot();
234
+ const read = boot && await boot.read().catch(() => null);
235
+ if (read?.enabled) {
236
+ const port = unitPort(await boot.text().catch(() => null));
237
+ console.log(describeStartup(startupState(read.show, { port }), { linger: read.linger, user: boot.user }));
238
+ }
226
239
  process.exitCode = 3;
227
240
  return;
228
241
  }
229
- const res = await fetch(`${url}/api/v1/sessions`, { headers: { Authorization: `Bearer ${loadOrCreateToken()}` } });
242
+ const headers = { Authorization: `Bearer ${loadOrCreateToken()}` };
243
+ const res = await fetch(`${url}/api/v1/sessions`, { headers });
230
244
  const { sessions = [] } = res.ok ? await res.json() : {};
231
245
  const running = sessions.filter((s) => s.status === 'running').length;
232
246
  console.log(`Session manager ${h.version} running at ${url} (pid ${h.pid}).`);
233
247
  const notice = versionNotice(h.version);
234
248
  if (notice) console.log(notice);
249
+ const startup = await fetch(`${url}/api/v1/autostart`, { headers }).then((r) => (r.ok ? r.json() : {}), () => ({}));
250
+ const summary = startupSummary(startup.autostart);
251
+ if (summary) console.log(summary);
235
252
  console.log(`${sessions.length} session(s), ${running} running.`);
236
253
  for (const s of sessions) {
237
254
  const model = s.model ? ` [${s.model.displayName || s.model.name}]` : '';
@@ -255,10 +272,19 @@ async function main() {
255
272
  switch (command) {
256
273
  case 'open': return cmdOpen({ browser: !flags.has('--no-browser'), signIn: flags.has('--sign-in') });
257
274
  case 'start': {
275
+ // `--service`: run by the Linux boot service, which systemd supervises.
276
+ const supervised = flags.has('--service');
258
277
  await import('../src/manager/main.mjs').then(async (m) => {
259
278
  process.on('uncaughtException', (err) => console.error('[manager] unexpected error:', err));
260
279
  process.on('unhandledRejection', (err) => console.error('[manager] unhandled rejection:', err));
261
- const { shutdown } = await m.startManager();
280
+ const { shutdown } = await m.startManager({ supervised }).catch((err) => {
281
+ // Another manager has the port: an exit systemd does not retry, and the page reports.
282
+ if (supervised && err.code === 'EADDRINUSE') {
283
+ console.error(`agent-guild: ${err.message}`);
284
+ process.exit(EXIT_PORT_IN_USE);
285
+ }
286
+ throw err;
287
+ });
262
288
  console.log(`Open: ${pageUrl(readRuntimeFile()?.url ?? baseUrl(), loadOrCreateToken())}`);
263
289
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
264
290
  process.on(sig, () => shutdown(sig).then(() => process.exit(0)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.33.2",
3
+ "version": "0.34.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": {
@@ -14,6 +14,10 @@
14
14
  // runs a script in the data folder, as on macOS: desktops read Exec lines
15
15
  // by different rules (systemd's autostart generator keeps a backslash that
16
16
  // GLib removes), so the line holds only paths that read the same in all.
17
+ // Linux also offers a systemd user service that starts the manager when
18
+ // the computer starts (systemd-service.mjs). One starter at a time: the
19
+ // setting is a mode, off, sign-in or boot, and choosing one removes the
20
+ // other, so a sign-in never races the service for the port.
17
21
  //
18
22
  // Each launcher touches a file in the data folder before it starts Node.js,
19
23
  // and `open --sign-in` records whether a manager then answered, so the page
@@ -35,6 +39,7 @@ import os from 'node:os';
35
39
  import path from 'node:path';
36
40
  import { execFile } from 'node:child_process';
37
41
  import { DEFAULT_PORT } from './config.mjs';
42
+ import { BOOT_NOTE, UNIT_UNSAFE, bootSupported, createBootService, journalCommand, lingerCommand, startupState, systemdRunner, unitPort, userName } from './systemd-service.mjs';
38
43
 
39
44
  export const ARGS = ['open', '--no-browser', '--sign-in'];
40
45
  export const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
@@ -61,8 +66,8 @@ export const MAC_NOTE = 'Starts the session manager in the background when you s
61
66
  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
67
  /** Added to LINUX_NOTE when the manager itself was not started from a desktop session. */
63
68
  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 = /["`$\\]/;
69
+ /** Linux startup modes. */
70
+ export const MODES = ['off', 'sign-in', 'boot'];
66
71
 
67
72
  const failure = (message) => Object.assign(new Error(message), { status: 500, code: 'autostart_failed' });
68
73
 
@@ -318,6 +323,9 @@ async function clearSignIns(dataDir) {
318
323
  * @param {string|null} [opts.unavailable] a reason autostart cannot be offered, which turns it off here
319
324
  * @param {(args: string[]) => Promise<{ status: number, stdout: string, stderr: string }>} [opts.reg] reg.exe, replaceable in tests
320
325
  * @param {(args: string[]) => Promise<{ status: number, stdout: string, stderr: string }>} [opts.launchctl] launchctl, replaceable in tests
326
+ * @param {(command: string, args: string[]) => Promise<object>} [opts.systemd] systemctl and journalctl on Linux, replaceable in tests
327
+ * @param {string} [opts.lingerDir] where systemd records lingering users, replaceable in tests
328
+ * @param {number} [opts.pid] this manager, which the service state compares with systemd's
321
329
  */
322
330
  export function createAutostart({
323
331
  platform = process.platform,
@@ -333,6 +341,10 @@ export function createAutostart({
333
341
  reg = runCommand(path.win32.join(env.SystemRoot || env.SYSTEMROOT || env.WINDIR || 'C:\\Windows', 'System32', 'reg.exe')),
334
342
  uid = process.getuid?.(),
335
343
  launchctl = runCommand('/bin/launchctl'),
344
+ user = userName(uid),
345
+ systemd = systemdRunner({ uid, env }),
346
+ lingerDir,
347
+ pid = process.pid,
336
348
  }) {
337
349
  const entryExecPath = platform === 'win32' ? execPath : stableExecPath(execPath);
338
350
  const attempt = path.join(dataDir, SIGN_IN_ATTEMPT);
@@ -340,6 +352,9 @@ export function createAutostart({
340
352
  let note = null;
341
353
  let unusable = null;
342
354
  let refreshError = null;
355
+ /** The systemd user service, on a Linux that can offer it. */
356
+ let boot = null;
357
+ let bootRefreshError = null;
343
358
  let queue = Promise.resolve();
344
359
  /** One change or check at a time, so two clicks cannot interleave their writes. */
345
360
  const serial = (fn) => {
@@ -424,13 +439,15 @@ export function createAutostart({
424
439
  await fs.promises.rm(launcher, { force: true });
425
440
  },
426
441
  };
427
- } else if (platform === 'linux' && !(env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release))) {
442
+ } else if (bootSupported({ platform, env, release })) {
428
443
  const file = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'autostart', 'agent-guild.desktop');
429
444
  const launcher = path.join(dataDir, LINUX_LAUNCHER);
430
- const unsafe = [entryExecPath, script, file, launcher, log, attempt].find((p) => EXEC_UNSAFE.test(p));
445
+ const service = createBootService({ env, home, user, uid, dataDir, run: systemd, ...(lingerDir && { lingerDir }) });
446
+ const unsafe = [entryExecPath, script, file, launcher, log, attempt, ...service.paths].find((p) => UNIT_UNSAFE.test(p));
431
447
  if (unsafe) {
432
- unusable = `Not available because this path holds a character (", \`, $ or \\) that desktops read differently in a startup entry: ${unsafe}`;
448
+ unusable = `Not available because this path holds a character (", \`, $, \\ or a line break) that desktops and systemd read differently in a startup entry: ${unsafe}`;
433
449
  } else {
450
+ boot = service;
434
451
  const desktop = env.XDG_CURRENT_DESKTOP || env.WAYLAND_DISPLAY || env.DISPLAY;
435
452
  note = desktop ? LINUX_NOTE : `${LINUX_NOTE} ${LINUX_NO_DESKTOP}`;
436
453
  target = {
@@ -455,10 +472,25 @@ export function createAutostart({
455
472
  : unavailable || unusable || (platform === 'linux' ? 'Not available in WSL. Start Agent Guild from Windows to launch it at sign-in.'
456
473
  : 'Not available on this operating system.');
457
474
 
475
+ const FAILED = new Set(['failed', 'port-in-use']);
476
+ /** The service's side of a Linux description: its state, and the commands that are the next step. */
477
+ const describeBoot = async () => {
478
+ const read = await boot.read();
479
+ const base = { note: BOOT_NOTE, user, linger: read.linger };
480
+ if (!read.reachable) return { ...base, available: false, enabled: false, reason: read.reason, state: null, commands: [] };
481
+ const state = startupState(read.show, { pid, port: unitPort(await boot.text()) });
482
+ const commands = [];
483
+ if (read.enabled && FAILED.has(state.kind)) commands.push(journalCommand());
484
+ if (read.enabled && !read.linger) commands.push(lingerCommand(user));
485
+ return { ...base, available: true, enabled: read.enabled, reason: read.enabled ? bootRefreshError : null, state, commands };
486
+ };
487
+ const modeOf = (signIn, bootOn) => (signIn && bootOn ? 'both' : bootOn ? 'boot' : signIn ? 'sign-in' : 'off');
488
+
458
489
  const describe = async () => {
459
490
  if (!target) return { available: false, enabled: false, reason };
460
491
  try {
461
492
  const enabled = await target.enabled();
493
+ const linux = boot ? await describeBoot() : null;
462
494
  return {
463
495
  available: true,
464
496
  enabled,
@@ -467,38 +499,90 @@ export function createAutostart({
467
499
  // An unreadable record never keeps the setting from being turned off.
468
500
  lastRun: enabled ? await lastSignIn(dataDir).catch(() => null) : null,
469
501
  log,
502
+ ...(linux && { mode: modeOf(enabled, linux.enabled), boot: linux }),
470
503
  };
471
504
  } catch (err) {
472
505
  return { available: false, enabled: false, reason: `Could not read the startup setting: ${err.message}` };
473
506
  }
474
507
  };
475
508
 
476
- return {
477
- describe: () => serial(describe),
478
- /** Turns autostart on or off and resolves to the new description. */
479
- set: (enabled) => serial(async () => {
480
- if (!target) throw Object.assign(new Error(reason), { status: 409, code: 'autostart_unavailable' });
509
+ /**
510
+ * Makes `mode` the one starter. The new one is added and confirmed before
511
+ * the old one goes, and nothing here starts or stops a manager: a service
512
+ * takes over at the manager's next restart or the computer's next start.
513
+ */
514
+ const apply = async (mode) => {
515
+ const signedIn = await target.enabled().catch(() => false);
516
+ // A fresh start says it has not run yet rather than report an earlier time it was on.
517
+ if (mode !== 'sign-in' || !signedIn) await clearSignIns(dataDir);
518
+ if (mode === 'boot') {
519
+ const read = await boot.read();
520
+ if (!read.reachable) throw Object.assign(new Error(read.reason), { status: 409, code: 'autostart_unavailable' });
481
521
  try {
482
- // A fresh start says it has not run yet rather than report an earlier time it was on.
483
- if (!enabled || !(await target.enabled().catch(() => false))) await clearSignIns(dataDir);
484
- await (enabled ? target.enable() : target.disable());
485
- refreshError = null;
486
- const state = await describe();
487
- if (!state.available || state.enabled !== enabled) throw failure(state.reason || 'Could not verify the startup setting.');
488
- return state;
522
+ await boot.write(command());
523
+ await boot.reload();
524
+ await boot.enable();
525
+ if (!(await boot.read()).enabled) throw failure('systemd did not enable the unit.');
489
526
  } catch (err) {
490
- throw err.code === 'autostart_failed' ? err : failure(`Could not change the startup setting: ${err.message}`);
527
+ // Leave no half-made unit behind; the sign-in entry, if any, is still in place.
528
+ if (!read.enabled) await boot.remove().catch(() => {});
529
+ throw err;
491
530
  }
531
+ await target.disable();
532
+ return;
533
+ }
534
+ await (mode === 'sign-in' ? target.enable() : target.disable());
535
+ if (boot) await boot.remove();
536
+ };
537
+
538
+ /** Applies the mode `pick` resolves to, inside the queue, and checks the result against what the OS reports. */
539
+ const change = (pick) => serial(async () => {
540
+ if (!target) throw Object.assign(new Error(reason), { status: 409, code: 'autostart_unavailable' });
541
+ const mode = await pick();
542
+ if (mode === 'boot' && !boot) throw Object.assign(new Error('Starting when the computer starts is offered only on Linux with systemd.'), { status: 409, code: 'autostart_unavailable' });
543
+ try {
544
+ await apply(mode);
545
+ refreshError = null;
546
+ bootRefreshError = null;
547
+ const state = await describe();
548
+ const done = boot ? state.mode === mode : state.enabled === (mode === 'sign-in');
549
+ if (!state.available || !done) throw failure(state.reason || 'Could not verify the startup setting.');
550
+ return state;
551
+ } catch (err) {
552
+ throw err.code === 'autostart_failed' || err.code === 'autostart_unavailable' ? err : failure(`Could not change the startup setting: ${err.message}`);
553
+ }
554
+ });
555
+
556
+ return {
557
+ describe: () => serial(describe),
558
+ /**
559
+ * Turns the sign-in entry on or off and resolves to the new description.
560
+ * On Linux, on means the sign-in mode, and off keeps a service that is on.
561
+ */
562
+ set: (enabled) => change(async () => {
563
+ if (enabled) return 'sign-in';
564
+ return boot && ['boot', 'both'].includes((await describe()).mode) ? 'boot' : 'off';
492
565
  }),
493
- /** Points an entry that is on at this manager's Node.js, package and port. */
566
+ /** Linux: makes `mode` (off, sign-in or boot) the one way the manager starts. Elsewhere boot is unavailable. */
567
+ setMode: (mode) => change(async () => mode),
568
+ /** Points what is on at this manager's Node.js, package and port. */
494
569
  refresh: () => serial(async () => {
570
+ let first = null;
495
571
  try {
496
572
  if (target && await target.enabled()) await target.write();
497
573
  refreshError = null;
498
574
  } catch (err) {
499
575
  refreshError = `Could not update the sign-in entry: ${err.message}`;
500
- throw err;
576
+ first = err;
577
+ }
578
+ try {
579
+ if (boot && (await boot.read()).enabled && await boot.write(command())) await boot.reload();
580
+ bootRefreshError = null;
581
+ } catch (err) {
582
+ bootRefreshError = `Could not update the systemd unit: ${err.message}`;
583
+ first ??= err;
501
584
  }
585
+ if (first) throw first;
502
586
  }),
503
587
  };
504
588
  }