@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.
- package/README.md +7 -3
- package/bin/agent-guild.mjs +24 -1
- package/package.json +2 -1
- package/src/manager/doctor.mjs +406 -0
- package/src/manager/environment-probe.mjs +252 -13
- package/src/manager/environment-system.mjs +205 -0
- package/src/manager/environment.mjs +60 -30
- package/src/manager/main.mjs +4 -1
- package/src/manager/pty.mjs +112 -0
- package/src/manager/self-update.mjs +7 -2
- package/src/manager/server.mjs +8 -0
- package/src/manager/session.mjs +2 -2
- package/web/app.js +52 -5
- package/web/environment.js +84 -8
- package/web/index.html +8 -0
|
@@ -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 =
|
|
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
|
|
39
|
-
return
|
|
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:
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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();
|
package/src/manager/main.mjs
CHANGED
|
@@ -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
|
|
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
|
|
package/src/manager/server.mjs
CHANGED
|
@@ -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');
|
package/src/manager/session.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
900
|
-
? `Agent Guild ${pending} is installed,
|
|
901
|
-
:
|
|
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
|
-
|
|
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;
|