@oddessentials/agent-guild 0.35.0 → 0.37.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.
@@ -6,10 +6,10 @@ import { createHash } from 'node:crypto';
6
6
  import { EventEmitter } from 'node:events';
7
7
  import { fork } from 'node:child_process';
8
8
  import { killWindowsTree } from './command-resolver.mjs';
9
- import { RUNTIMES } from './environment-probe.mjs';
9
+ import { RUNTIMES, DESIGN_TOOLS } from './environment-probe.mjs';
10
10
  import { resolveProjectCwd } from './environment-pins.mjs';
11
11
 
12
- export const ENVIRONMENT_TIMEOUT_MS = 10000;
12
+ export const ENVIRONMENT_TIMEOUT_MS = 15000;
13
13
  export const LAUNCH_DETAIL = 'Launch PATH, profiles not applied. The selected shell is not consulted.';
14
14
  export const SESSION_DETAIL = 'Spawn PATH, before the shell startup files.';
15
15
  export const MULTIPLEXER_DETAIL = 'This session is tmux or herdr. Its environment is not the spawn record.';
@@ -20,7 +20,9 @@ const PATH_ENV = [
20
20
  'PATH', 'PATHEXT', 'SYSTEMROOT', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
21
21
  'NVM_DIR', 'VOLTA_HOME', 'NODENV_ROOT', 'NODENV_VERSION', 'PYENV_ROOT', 'PYENV_VERSION',
22
22
  'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'MISE_CONFIG_DIR', 'RUSTUP_HOME', 'RUSTUP_TOOLCHAIN', 'CARGO_HOME',
23
- 'GOROOT', 'GOTOOLCHAIN', 'DOTNET_ROOT', 'R_HOME',
23
+ 'GOROOT', 'GOTOOLCHAIN', 'DOTNET_ROOT', 'R_HOME', 'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG',
24
+ // Install folders searched for design tools that are not on PATH.
25
+ 'LOCALAPPDATA', 'PROGRAMFILES', 'PROGRAMW6432',
24
26
  ];
25
27
 
26
28
  function hostName() {
@@ -35,8 +37,41 @@ function cloneRow(row) {
35
37
  };
36
38
  }
37
39
 
38
- function pendingRuntimes() {
39
- return RUNTIMES.map(({ id, label }) => ({ id, label, status: 'pending', version: null, path: null }));
40
+ function pendingRows(list) {
41
+ return list.map(({ id, label }) => ({ id, label, status: 'pending', version: null, path: null }));
42
+ }
43
+
44
+ const PENDING_DOCKER = { status: 'pending' };
45
+
46
+ function scanRun(extra = {}) {
47
+ return { child: null, timer: null, rows: new Map(), designs: new Map(), tools: [], docker: null, system: undefined, withSystem: false, ...extra };
48
+ }
49
+
50
+ // Records one helper message. Returns true when the helper reports it is done.
51
+ function takeMessage(run, message) {
52
+ if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
53
+ if (message?.design && DESIGN_TOOLS.some((tool) => tool.id === message.design.id)) run.designs.set(message.design.id, message.design);
54
+ if (Array.isArray(message?.tools)) run.tools = message.tools;
55
+ if (message?.docker && typeof message.docker === 'object') run.docker = message.docker;
56
+ if (run.withSystem && message && 'system' in message) run.system = message.system ?? null;
57
+ return Boolean(message?.done);
58
+ }
59
+
60
+ function scanComplete(run) {
61
+ return run.rows.size === RUNTIMES.length && run.designs.size === DESIGN_TOOLS.length
62
+ && Boolean(run.docker) && (!run.withSystem || run.system !== undefined);
63
+ }
64
+
65
+ // Every row has a fresh result; an unfinished check is a failure, never a stale success.
66
+ function scanRows(run, error) {
67
+ const failed = ({ id, label }) => ({ id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.' });
68
+ return {
69
+ runtimes: RUNTIMES.map((row) => run.rows.get(row.id) ?? failed(row)),
70
+ designTools: DESIGN_TOOLS.map((row) => run.designs.get(row.id) ?? failed(row)),
71
+ docker: run.docker ?? { status: 'failed', detail: error || 'The environment check did not finish.' },
72
+ tools: run.tools,
73
+ ...(run.withSystem ? { system: run.system ?? null } : {}),
74
+ };
40
75
  }
41
76
 
42
77
  export function probePathEnv(env) {
@@ -76,7 +111,10 @@ export class Environment extends EventEmitter {
76
111
  return {
77
112
  scope: 'manager', host: this.host, platform: process.platform, revision: 0, refreshing: false, checkedAt: null, error: null,
78
113
  managerNode: { version: process.versions.node, path: process.execPath },
79
- runtimes: pendingRuntimes(),
114
+ runtimes: pendingRows(RUNTIMES),
115
+ designTools: pendingRows(DESIGN_TOOLS),
116
+ docker: PENDING_DOCKER,
117
+ system: null,
80
118
  tools: [],
81
119
  };
82
120
  }
@@ -93,7 +131,7 @@ export class Environment extends EventEmitter {
93
131
  }
94
132
  return publishLaunch ? this.launch : this.snapshot();
95
133
  }
96
- const run = { child: null, timer: null, rows: new Map(), tools: [], publishLaunch };
134
+ const run = scanRun({ publishLaunch, withSystem: true });
97
135
  this.run = run;
98
136
  this.value = { ...this.value, revision: this.value.revision + 1, refreshing: true, error: null };
99
137
  if (publishLaunch) this.armLaunch();
@@ -104,10 +142,7 @@ export class Environment extends EventEmitter {
104
142
  this.stopWorker(run.child);
105
143
  this.value = {
106
144
  ...this.value, revision: this.value.revision + 1, refreshing: false, checkedAt: new Date().toISOString(), error,
107
- runtimes: RUNTIMES.map(({ id, label }) => run.rows.get(id) ?? {
108
- id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.',
109
- }),
110
- tools: run.tools,
145
+ ...scanRows(run, error),
111
146
  };
112
147
  if (!this.closed) this.emit('updated', this.snapshot());
113
148
  if (run.publishLaunch) this.storeLaunch();
@@ -116,21 +151,18 @@ export class Environment extends EventEmitter {
116
151
  // A preload in NODE_OPTIONS must not execute inside a passive scan.
117
152
  const env = { ...this.env };
118
153
  for (const key of Object.keys(env)) if (key.toUpperCase() === 'NODE_OPTIONS') delete env[key];
119
- run.child = this.forkWorker(new URL('./environment-probe.mjs', import.meta.url), ['--scan-environment'], {
154
+ run.child = this.forkWorker(new URL('./environment-probe.mjs', import.meta.url), ['--scan-environment', '--system'], {
120
155
  env, execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
121
156
  windowsHide: true, detached: process.platform !== 'win32',
122
157
  });
123
158
  run.child.on('message', (message) => {
124
- if (this.run !== run) return;
125
- if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
126
- if (Array.isArray(message?.tools)) run.tools = message.tools;
127
- if (message?.done) finish();
159
+ if (this.run === run && takeMessage(run, message)) finish();
128
160
  });
129
161
  run.child.once('error', () => finish('Could not start the environment check.'));
130
- // Let messages already queued win. Exit is a failed check only when a runtime never reported.
162
+ // Let messages already queued win. Exit is a failed check only when a row never reported.
131
163
  run.child.once('exit', () => setImmediate(() => {
132
164
  if (this.run !== run) return;
133
- finish(run.rows.size === RUNTIMES.length ? null : 'The environment check stopped before finishing.');
165
+ finish(scanComplete(run) ? null : 'The environment check stopped before finishing.');
134
166
  }));
135
167
  run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
136
168
  run.timer.unref();
@@ -146,6 +178,8 @@ export class Environment extends EventEmitter {
146
178
  revision: (previous?.revision ?? 0) + 1, refreshing: true, checkedAt: previous?.checkedAt ?? null, error: null,
147
179
  detail: LAUNCH_DETAIL,
148
180
  runtimes: (previous?.runtimes ?? this.value.runtimes).map(cloneRow),
181
+ designTools: (previous?.designTools ?? this.value.designTools).map(cloneRow),
182
+ docker: { ...(previous?.docker ?? this.value.docker) },
149
183
  tools: (previous?.tools ?? this.value.tools).map((tool) => ({ ...tool })),
150
184
  };
151
185
  if (!this.closed) this.emit('updated', this.launch);
@@ -157,6 +191,8 @@ export class Environment extends EventEmitter {
157
191
  revision: (this.launch?.revision ?? 0) + 1, refreshing: false, checkedAt: this.value.checkedAt, error: this.value.error,
158
192
  detail: LAUNCH_DETAIL,
159
193
  runtimes: this.value.runtimes.map(cloneRow),
194
+ designTools: this.value.designTools.map(cloneRow),
195
+ docker: { ...this.value.docker },
160
196
  tools: this.value.tools.map((tool) => ({ ...tool })),
161
197
  };
162
198
  if (!this.closed) this.emit('updated', this.launch);
@@ -287,7 +323,7 @@ export class Environment extends EventEmitter {
287
323
  return {
288
324
  scope: 'session', host: this.host, platform: process.platform, sessionId: id, spawnCwd: found.spawnCwd,
289
325
  availability: 'ok', detail: SESSION_DETAIL, revision: 0, refreshing: false, checkedAt: null, error: null,
290
- runtimes: pendingRuntimes(), tools: [],
326
+ runtimes: pendingRows(RUNTIMES), designTools: pendingRows(DESIGN_TOOLS), docker: PENDING_DOCKER, tools: [],
291
327
  };
292
328
  }
293
329
 
@@ -299,7 +335,7 @@ export class Environment extends EventEmitter {
299
335
  const value = {
300
336
  scope: 'session', host: this.host, platform: process.platform, sessionId: id, spawnCwd: found.spawnCwd,
301
337
  availability: 'unavailable', detail: MULTIPLEXER_DETAIL, revision: 1, refreshing: false,
302
- checkedAt: new Date().toISOString(), error: null, runtimes: [], tools: [],
338
+ checkedAt: new Date().toISOString(), error: null, runtimes: [], designTools: [], docker: null, tools: [],
303
339
  };
304
340
  this.sessions.set(key, { value, run: null });
305
341
  return value;
@@ -307,7 +343,7 @@ export class Environment extends EventEmitter {
307
343
 
308
344
  startSession(entry, found) {
309
345
  if (this.closed || entry.run) return;
310
- const run = { child: null, timer: null, rows: new Map(), tools: [] };
346
+ const run = scanRun();
311
347
  entry.run = run;
312
348
  entry.value = { ...entry.value, revision: entry.value.revision + 1, refreshing: true, error: null };
313
349
  const finish = (error = null) => {
@@ -317,10 +353,7 @@ export class Environment extends EventEmitter {
317
353
  this.stopWorker(run.child);
318
354
  entry.value = {
319
355
  ...entry.value, revision: entry.value.revision + 1, refreshing: false, checkedAt: new Date().toISOString(), error,
320
- runtimes: RUNTIMES.map(({ id, label }) => run.rows.get(id) ?? {
321
- id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.',
322
- }),
323
- tools: run.tools,
356
+ ...scanRows(run, error),
324
357
  };
325
358
  if (!this.closed) this.emit('updated', entry.value);
326
359
  };
@@ -332,15 +365,12 @@ export class Environment extends EventEmitter {
332
365
  windowsHide: true, detached: process.platform !== 'win32',
333
366
  });
334
367
  run.child.on('message', (message) => {
335
- if (entry.run !== run) return;
336
- if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
337
- if (Array.isArray(message?.tools)) run.tools = message.tools;
338
- if (message?.done) finish();
368
+ if (entry.run === run && takeMessage(run, message)) finish();
339
369
  });
340
370
  run.child.once('error', () => finish('Could not start the environment check.'));
341
371
  run.child.once('exit', () => setImmediate(() => {
342
372
  if (entry.run !== run) return;
343
- finish(run.rows.size === RUNTIMES.length ? null : 'The environment check stopped before finishing.');
373
+ finish(scanComplete(run) ? null : 'The environment check stopped before finishing.');
344
374
  }));
345
375
  run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
346
376
  run.timer.unref();
@@ -14,6 +14,7 @@ import { NewsFeed } from './news.mjs';
14
14
  import { Changelog } from './changelog.mjs';
15
15
  import { GitHub } from './github.mjs';
16
16
  import { createManagerServer } from './server.mjs';
17
+ import { loadPty } from './pty.mjs';
17
18
  import { createNotesStore } from './notes.mjs';
18
19
  import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
19
20
  import { SelfUpdate } from './self-update.mjs';
@@ -48,7 +49,9 @@ const VERSION_REFRESH_MS = 60 * 60 * 1000;
48
49
  * `supervised`: started by the Linux boot service, so systemd starts the next manager after a restart.
49
50
  */
50
51
  export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {}, remoteAccess: remoteOptions = {}, autostart = null, boot, supervised = false } = {}) {
51
- // Validate before creating files, processes or timers, so a typo fails startup cleanly.
52
+ // Validate before creating files, processes or timers, so a typo, or a
53
+ // system node-pty cannot run on, fails startup cleanly.
54
+ loadPty();
52
55
  const remoteSettings = loadRemoteAccess(paths.remoteAccess);
53
56
  ensureDataDir();
54
57
  const token = loadOrCreateToken();
@@ -0,0 +1,112 @@
1
+ // node-pty, the library that runs each session's terminal. Its Linux builds
2
+ // in the package need glibc 2.28 or later: on an older glibc they do not
3
+ // load, and on musl (Alpine) the x64 one loads, then crashes the manager when
4
+ // the first session starts. So the C library is checked before node-pty is
5
+ // loaded, unless node-pty was compiled on this computer, which it then loads
6
+ // in place of the builds it comes with.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+
14
+ /** node-pty's folder. Looked up when needed, so commands that never run a terminal do not depend on it. */
15
+ export function ptyDir() {
16
+ return path.dirname(require.resolve('node-pty/package.json'));
17
+ }
18
+
19
+ let glibc;
20
+
21
+ /** The glibc version this process runs on, such as '2.39'; null on another C library, such as musl. */
22
+ export function glibcVersion() {
23
+ if (glibc !== undefined) return glibc;
24
+ // Node.js reports it only on glibc, as npm reads it for a package's `libc`.
25
+ // Leaving out network details keeps the report from looking up host names.
26
+ const { report } = process;
27
+ const excludeNetwork = report.excludeNetwork;
28
+ report.excludeNetwork = true;
29
+ try {
30
+ glibc = report.getReport().header.glibcVersionRuntime ?? null;
31
+ } finally {
32
+ report.excludeNetwork = excludeNetwork;
33
+ }
34
+ return glibc;
35
+ }
36
+
37
+ /** True when node-pty's own builds run here: other systems than Linux, or glibc 2.28 or later. */
38
+ function bundledBuildsRun({ platform = process.platform, glibc: version } = {}) {
39
+ if (platform !== 'linux') return true;
40
+ if (version === undefined) version = glibcVersion();
41
+ if (!version) return false;
42
+ const [major, minor] = version.split('.').map(Number);
43
+ return major > 2 || (major === 2 && minor >= 28);
44
+ }
45
+
46
+ /** Whether node-pty in `dir` was compiled on this computer; node-pty loads such a build before the ones it comes with. */
47
+ export function ptyBuiltHere(dir = ptyDir()) {
48
+ return ['Release', 'Debug'].some((build) => fs.existsSync(path.join(dir, 'build', build, 'pty.node')));
49
+ }
50
+
51
+ const shellWord = (word) => (/^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll('\'', '\'\\\'\'')}'`);
52
+
53
+ /** The command that compiles node-pty in `dir` for this computer. */
54
+ export function ptyBuildCommand(dir = ptyDir()) {
55
+ return `cd ${shellWord(dir)} && npx --yes node-gyp rebuild`;
56
+ }
57
+
58
+ /**
59
+ * On a computer that has to compile node-pty itself: the command that does,
60
+ * and whether the files on disk hold such a build. Null where node-pty's own
61
+ * builds run. `platform`, `glibc` and `dir` stand in for this computer's in tests.
62
+ */
63
+ export function ptyBuild({ platform, glibc: version, dir } = {}) {
64
+ if (bundledBuildsRun({ platform, glibc: version })) return null;
65
+ dir ??= ptyDir();
66
+ return { command: ptyBuildCommand(dir), built: ptyBuiltHere(dir) };
67
+ }
68
+
69
+ /**
70
+ * Why node-pty cannot run here, as a message for the user, or null when it
71
+ * can. Takes the same stand-ins as ptyBuild().
72
+ */
73
+ export function ptyProblem(opts = {}) {
74
+ const build = ptyBuild(opts);
75
+ if (!build || build.built) return null;
76
+ const version = opts.glibc === undefined ? glibcVersion() : opts.glibc;
77
+ if (version) {
78
+ return `Agent Guild needs glibc 2.28 or later on Linux, for example Debian 10, Ubuntu 20.04 or RHEL 8. This system has glibc ${version}.`;
79
+ }
80
+ return [
81
+ 'Agent Guild\'s terminal library, node-pty, comes built for Linux with glibc 2.28 or later, and this system uses another C library, such as musl on Alpine.',
82
+ 'To build it here, install Python 3, make, g++ and the Linux headers (on Alpine: apk add python3 make g++ linux-headers), then run this, and again after each Agent Guild upgrade:',
83
+ ` ${build.command}`,
84
+ ].join('\n');
85
+ }
86
+
87
+ /**
88
+ * Why a manager started from the files on disk could not run a terminal, or
89
+ * null. A manager running where node-pty's own builds do not means node-pty
90
+ * was compiled here, so the message is how to compile it again, on any C
91
+ * library. Takes the same stand-ins as ptyBuild().
92
+ */
93
+ export function ptyRestartProblem(opts = {}) {
94
+ const build = ptyBuild(opts);
95
+ if (!build || build.built) return null;
96
+ return [
97
+ 'Agent Guild\'s terminal library, node-pty, is built on this computer, and the files on disk no longer hold that build, as after an upgrade. Build it again, then restart:',
98
+ ` ${build.command}`,
99
+ 'The manager was not restarted, and its sessions keep running.',
100
+ ].join('\n');
101
+ }
102
+
103
+ let pty = null;
104
+
105
+ /** node-pty, loaded on first use. Throws with ptyProblem()'s message where it cannot run. */
106
+ export function loadPty() {
107
+ if (pty) return pty;
108
+ const problem = ptyProblem();
109
+ if (problem) throw new Error(problem);
110
+ pty = require('node-pty');
111
+ return pty;
112
+ }
@@ -7,6 +7,7 @@ import { EventEmitter } from 'node:events';
7
7
  import { compareVersions, fetchManifest } from './versions.mjs';
8
8
  import { formatCommand } from './install-channels.mjs';
9
9
  import { buildSpawnSpec } from './command-resolver.mjs';
10
+ import { ptyBuild } from './pty.mjs';
10
11
 
11
12
  const CHECK_TTL_MS = 60 * 60 * 1000;
12
13
  const FAILED_CHECK_TTL_MS = 5 * 60 * 1000;
@@ -39,13 +40,15 @@ export class SelfUpdate extends EventEmitter {
39
40
  * @param {string} opts.version the running version
40
41
  * @param {string|null} [opts.packageFile] the package.json the manager runs from; read again after an install
41
42
  * @param {import('./providers.mjs').ProviderRegistry} opts.registry for the registry URL, npm, and fetch
43
+ * @param {() => ({ command: string, built: boolean }|null)} [opts.ptyBuild] node-pty's build on this computer; a stand-in in tests
42
44
  */
43
- constructor({ pkg, version, packageFile = null, registry }) {
45
+ constructor({ pkg, version, packageFile = null, registry, ptyBuild: readPtyBuild = ptyBuild }) {
44
46
  super();
45
47
  this.pkg = pkg;
46
48
  this.version = version;
47
49
  this.packageFile = packageFile;
48
50
  this.registry = registry;
51
+ this.readPtyBuild = readPtyBuild;
49
52
  this.latest = null;
50
53
  this.error = null;
51
54
  this.checkedAt = 0;
@@ -139,7 +142,7 @@ export class SelfUpdate extends EventEmitter {
139
142
  if (this.installing) {
140
143
  return {
141
144
  version: this.version, latestVersion: this.latest, available: false, command: null, guidance: null,
142
- pendingVersion: null, installing: true, lastInstall: null,
145
+ pendingVersion: null, installing: true, lastInstall: null, ptyBuild: null,
143
146
  };
144
147
  }
145
148
  const installed = this.installedVersion();
@@ -155,6 +158,8 @@ export class SelfUpdate extends EventEmitter {
155
158
  pendingVersion: this.pendingVersion(installed),
156
159
  installing: false,
157
160
  lastInstall: this.lastInstall,
161
+ // Where node-pty has to be compiled here, an upgrade replaces that build.
162
+ ptyBuild: this.readPtyBuild(),
158
163
  };
159
164
  }
160
165
 
@@ -14,6 +14,7 @@ import { createFolderBrowser } from './folder-browser.mjs';
14
14
  import { normalizeAccess } from './access-policy.mjs';
15
15
  import { NOTES_BODY_LIMIT, createNotesStore } from './notes.mjs';
16
16
  import { MODES as AUTOSTART_MODES } from './autostart.mjs';
17
+ import { ptyRestartProblem } from './pty.mjs';
17
18
 
18
19
  const require = createRequire(import.meta.url);
19
20
  const API = '/api/v1';
@@ -154,6 +155,8 @@ export function createManagerServer({
154
155
  launcher = null,
155
156
  /** @type {(opts: { restart: boolean }) => void} */
156
157
  onShutdownRequest = () => {},
158
+ /** Why a manager started from the files on disk could not run a terminal, or null; a stand-in in tests. */
159
+ nextManagerProblem = ptyRestartProblem,
157
160
  }) {
158
161
  let access = normalizeAccess({ hosts: extraHosts, origins: extraOrigins });
159
162
  let policyVersion = 0;
@@ -429,6 +432,11 @@ export function createManagerServer({
429
432
  // so a client must say `force` while any of those is running. The same
430
433
  // guard serves every front end.
431
434
  const body = await readJsonBody(req);
435
+ // The next manager runs the files on disk. Where node-pty has to be
436
+ // built on this computer and an upgrade replaced that build, it would
437
+ // not start, so this one keeps running and says what to do first.
438
+ const problem = body.restart === true ? nextManagerProblem() : null;
439
+ if (problem) throw new HttpError(409, problem, 'pty_unavailable');
432
440
  const running = manager.runningCount();
433
441
  if (running > 0 && body.force !== true) {
434
442
  const err = new HttpError(409, `${running} session(s) are running; stopping the manager ends them`, 'sessions_running');
@@ -4,10 +4,10 @@
4
4
 
5
5
  import { EventEmitter } from 'node:events';
6
6
  import crypto from 'node:crypto';
7
- import pty from 'node-pty';
8
7
  import headless from '@xterm/headless';
9
8
  import serializeAddon from '@xterm/addon-serialize';
10
9
  import { killWindowsTree } from './command-resolver.mjs';
10
+ import { loadPty } from './pty.mjs';
11
11
 
12
12
  const { Terminal } = headless;
13
13
  const { SerializeAddon } = serializeAddon;
@@ -175,7 +175,7 @@ export class Session extends EventEmitter {
175
175
  }
176
176
 
177
177
  _start(spawnSpec, cwd = this.cwd) {
178
- const proc = pty.spawn(spawnSpec.file, spawnSpec.args, {
178
+ const proc = loadPty().spawn(spawnSpec.file, spawnSpec.args, {
179
179
  name: 'xterm-256color',
180
180
  cols: this.cols,
181
181
  rows: this.rows,
package/web/app.js CHANGED
@@ -879,26 +879,58 @@ function moveDockTab(e) {
879
879
 
880
880
  // ---- upgrading the manager ------------------------------------------------
881
881
 
882
+ /**
883
+ * On a computer that compiles node-pty, the terminal library, itself: the
884
+ * build an installed upgrade replaced, which the manager will not restart
885
+ * without. Null otherwise.
886
+ */
887
+ function ptyRebuild(u = state.upgrade) {
888
+ const build = u?.ptyBuild;
889
+ return u?.pendingVersion && build && !build.built ? build : null;
890
+ }
891
+
892
+ /**
893
+ * While an upgrade waits for node-pty to be compiled again, read the build
894
+ * once more when the user comes back to the page, from the terminal where
895
+ * they may have run the command. The manager sends no event for it.
896
+ */
897
+ async function recheckPtyBuild() {
898
+ if (!state.connected || !ptyRebuild()) return;
899
+ try {
900
+ setUpgrade((await api('GET', '/info')).upgrade);
901
+ } catch { /* the next hello brings it */ }
902
+ }
903
+
904
+ /** A message about compiling node-pty here, with a button that copies the command. */
905
+ function ptyBuildToast(message, build) {
906
+ toast(message, 20000, { label: 'Copy command', run: () => copyText(build.command, 'the command') });
907
+ }
908
+
882
909
  function renderUpgrade() {
883
910
  const u = state.upgrade;
884
911
  const button = $('upgrade');
885
912
  const note = $('upgrade-note');
886
913
  const offer = Boolean(state.connected && u?.available && u.command);
914
+ const build = u?.ptyBuild;
887
915
  button.hidden = !offer;
888
916
  if (offer) {
889
917
  button.textContent = `Upgrade to ${u.latestVersion}`;
890
- button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
918
+ button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.` +
919
+ (build ? ` This computer builds node-pty, the terminal library, itself: after the upgrade, run "${build.command}" in a terminal before restarting.` : '');
891
920
  }
892
921
  // A newer version on disk is used by the next manager, so the restart
893
922
  // button becomes the way to pick it up.
894
923
  const restart = $('restart-manager');
895
924
  const pending = u?.pendingVersion;
925
+ const rebuild = ptyRebuild(u);
896
926
  restart.classList.toggle('pending', Boolean(pending));
897
927
  $('manager').classList.toggle('pending', Boolean(pending) && state.restartable);
898
928
  restart.textContent = pending ? `Restart to use v${pending}` : 'Restart manager';
899
- restart.title = pending
900
- ? `Agent Guild ${pending} is installed, but this manager is still ${u.version}. Restarting ends every session and starts the new version; this page reconnects by itself.`
901
- : 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
929
+ restart.title = rebuild
930
+ ? `Agent Guild ${pending} is installed, and node-pty, the terminal library, has to be built for this computer again before the manager can restart. Run "${rebuild.command}" in a terminal first.`
931
+ : pending
932
+ ? `Agent Guild ${pending} is installed, but this manager is still ${u.version}. Restarting ends every session and starts the new version; this page reconnects by itself.`
933
+ : 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
902
934
  const panelUpgrade = $('changelog-upgrade');
903
935
  panelUpgrade.hidden = button.hidden;
904
936
  panelUpgrade.textContent = button.textContent;
@@ -914,6 +946,9 @@ function renderUpgrade() {
914
946
  if (u?.installing) {
915
947
  text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
916
948
  title = 'npm is running in a session. Keep the manager running until it finishes.';
949
+ } else if (rebuild) {
950
+ text = `v${pending} installed · build node-pty before restarting`;
951
+ title = `This computer builds node-pty, the terminal library, itself, and the upgrade replaced that build. Run "${rebuild.command}" in a terminal, then restart.`;
917
952
  } else if (pending && !state.restartable) {
918
953
  text = `v${pending} installed · run "agent-guild restart" to use it`;
919
954
  title = `Agent Guild ${pending} is installed, but this manager is still ${u.version} and cannot restart itself. Run "agent-guild restart" in a terminal when your sessions are done; this page reconnects by itself.`;
@@ -926,6 +961,9 @@ function renderUpgrade() {
926
961
  } else if (u?.available && !u.command) {
927
962
  text = `v${u.latestVersion} available`;
928
963
  title = u.guidance || '';
964
+ } else if (offer && build) {
965
+ text = 'Build node-pty again after upgrading';
966
+ title = `This computer builds node-pty, the terminal library, itself, and an upgrade replaces that build. After upgrading, run "${build.command}" in a terminal before restarting.`;
929
967
  }
930
968
  note.hidden = !state.connected || !text;
931
969
  note.textContent = text;
@@ -940,7 +978,10 @@ function setUpgrade(upgrade, baseline = false) {
940
978
  renderVersion();
941
979
  if ($('changelog').open) renderChangelog();
942
980
  const pending = state.upgrade?.pendingVersion;
943
- if (pending && pending !== before?.pendingVersion) {
981
+ const rebuild = ptyRebuild();
982
+ if (pending && pending !== before?.pendingVersion && rebuild) {
983
+ ptyBuildToast(`Agent Guild ${pending} is installed. Before restarting, build node-pty for this computer again: run "${rebuild.command}" in a terminal.`, rebuild);
984
+ } else if (pending && pending !== before?.pendingVersion) {
944
985
  toast(state.restartable
945
986
  ? `Agent Guild ${pending} is installed. Use "Restart to use v${pending}" in the top bar when your sessions are done.`
946
987
  : `Agent Guild ${pending} is installed. Run "agent-guild restart" in a terminal when your sessions are done.`, 10000);
@@ -5634,6 +5675,10 @@ async function stopManager({ force = false, restart = false } = {}) {
5634
5675
  }
5635
5676
  return;
5636
5677
  }
5678
+ const build = state.upgrade?.ptyBuild;
5679
+ if (err.code === 'pty_unavailable' && build) {
5680
+ return ptyBuildToast(`The manager was not restarted, and its sessions keep running. First build node-pty, the terminal library, for this computer: run "${build.command}" in a terminal.`, build);
5681
+ }
5637
5682
  toast(err.message, 8000);
5638
5683
  } finally {
5639
5684
  for (const button of buttons) button.disabled = false;
@@ -6123,10 +6168,12 @@ document.addEventListener('visibilitychange', () => {
6123
6168
  flushNotes();
6124
6169
  }
6125
6170
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
6171
+ if (document.visibilityState === 'visible') recheckPtyBuild();
6126
6172
  if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
6127
6173
  else scheduleRuns();
6128
6174
  if (branchesVisible() && !viewData('branches')?.error) loadBranches({ resume: true });
6129
6175
  });
6176
+ addEventListener('focus', recheckPtyBuild);
6130
6177
  addEventListener('pagehide', () => {
6131
6178
  flushNotes();
6132
6179
  state.pageAway = true;