@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 CHANGED
@@ -39,10 +39,12 @@ terminal.
39
39
  | Node.js | 22 or newer |
40
40
  | Windows | 10 version 1809 or later, x64 or arm64. No WSL needed. |
41
41
  | macOS | 11 or later, Intel or Apple silicon |
42
- | Linux | x64 or arm64, with a desktop or over SSH |
42
+ | Linux | x64 or arm64 with glibc 2.28 or later (for example Debian 10, Ubuntu 20.04 or RHEL 8), with a desktop or over SSH |
43
43
 
44
- No compiler is needed. A missing tool shows **Install** on its card;
45
- Antigravity CLI, which is not on npm, shows its install command instead.
44
+ No compiler is needed, except on Linux without glibc, such as Alpine:
45
+ `agent-guild` prints the commands that build its terminal library there. A
46
+ missing tool shows **Install** on its card; Antigravity CLI, which is not on
47
+ npm, shows its install command instead.
46
48
 
47
49
  ## Setup
48
50
 
@@ -140,6 +142,8 @@ The first Claude Code usage meter may ask for access to the
140
142
  | `agent-guild restart` | Restart the manager on the installed version and end every session. |
141
143
  | `agent-guild url` | Print the page link with the access token. |
142
144
  | `agent-guild start` | Run the manager in the foreground. |
145
+ | `agent-guild doctor` | Diagnose the health of the installation and related tooling. |
146
+ | `agent-guild --version` | Print the installed Agent Guild version (`-v` or `version`). |
143
147
 
144
148
  Sessions end when the manager stops or the computer restarts. tmux and herdr
145
149
  sessions survive a manager stop.
@@ -21,6 +21,7 @@ import {
21
21
  } from '../src/manager/config.mjs';
22
22
  import { defaultBoot, serviceFor, spawnManager, startService } from '../src/manager/launch.mjs';
23
23
  import { recordSignIn } from '../src/manager/autostart.mjs';
24
+ import { ptyProblem } from '../src/manager/pty.mjs';
24
25
  import { EXIT_PORT_IN_USE, describeStartup, startupState, startupSummary, unitPort } from '../src/manager/systemd-service.mjs';
25
26
 
26
27
  function usage() {
@@ -33,6 +34,11 @@ Commands:
33
34
  restart Stop the session manager and start it again; ends every session
34
35
  status Show whether the session manager is running
35
36
  url Print the web page URL, including the access token
37
+ doctor Diagnose the health of the installation and related tooling
38
+
39
+ Options:
40
+ -v, --version Print the installed Agent Guild version
41
+ -h, --help Show this help message
36
42
 
37
43
  Environment:
38
44
  AGENT_GUILD_PORT Port for the local API (default 47821)
@@ -106,6 +112,10 @@ async function ensureManager({ port = null } = {}) {
106
112
  const running = await health(knownUrl);
107
113
  if (running) return { url: knownUrl, started: false, version: running.version };
108
114
 
115
+ // Say why no manager can run here rather than start one that fails.
116
+ const problem = ptyProblem();
117
+ if (problem) throw new Error(problem);
118
+
109
119
  // While the boot service is on for this port, systemd is the one that starts a manager.
110
120
  const { boot, note } = await serviceFor(listenPort);
111
121
  if (boot) return startService({ boot, port: listenPort, url: expectedUrl, health });
@@ -161,7 +171,10 @@ async function requestShutdown(url, { restart = false } = {}) {
161
171
  headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
162
172
  body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
163
173
  });
164
- if (!res.ok) throw new Error(`${restart ? 'restart' : 'stop'} failed: HTTP ${res.status}`);
174
+ if (!res.ok) {
175
+ const { error } = await res.json().catch(() => ({}));
176
+ throw new Error(`${restart ? 'restart' : 'stop'} failed: ${error?.message ?? `HTTP ${res.status}`}`);
177
+ }
165
178
  const body = await res.json().catch(() => ({}));
166
179
  const running = body.running ?? 0;
167
180
  if (running > 0) console.log(`Ending ${running} running session(s).`);
@@ -268,6 +281,10 @@ async function main() {
268
281
  const flags = new Set(args.filter((a) => a.startsWith('-')));
269
282
  const command = args.find((a) => !a.startsWith('-')) || 'open';
270
283
  if (flags.has('-h') || flags.has('--help') || command === 'help') return usage();
284
+ if (flags.has('-v') || flags.has('--version') || command === 'version') {
285
+ console.log(VERSION);
286
+ return undefined;
287
+ }
271
288
 
272
289
  switch (command) {
273
290
  case 'open': return cmdOpen({ browser: !flags.has('--no-browser'), signIn: flags.has('--sign-in') });
@@ -295,6 +312,12 @@ async function main() {
295
312
  case 'stop': return cmdStop();
296
313
  case 'restart': return cmdRestart();
297
314
  case 'status': return cmdStatus();
315
+ case 'doctor': {
316
+ const { runDoctor } = await import('../src/manager/doctor.mjs');
317
+ const ok = await runDoctor();
318
+ process.exitCode = ok ? 0 : 1;
319
+ return undefined;
320
+ }
298
321
  case 'url': {
299
322
  console.log(pageUrl(baseUrl(), loadOrCreateToken()));
300
323
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.35.0",
3
+ "version": "0.37.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": {
@@ -31,6 +31,7 @@
31
31
  "manager": "node bin/agent-guild.mjs start",
32
32
  "stop": "node bin/agent-guild.mjs stop",
33
33
  "status": "node bin/agent-guild.mjs status",
34
+ "doctor": "node bin/agent-guild.mjs doctor",
34
35
  "demo:build": "node docs/demo/build.mjs",
35
36
  "demo:check": "node docs/demo/check.mjs",
36
37
  "test": "node --test --test-concurrency=1 \"tests/*.test.mjs\""
@@ -0,0 +1,406 @@
1
+ // Installation and tooling health diagnostics for Agent Guild.
2
+ //
3
+ // Checks:
4
+ // - Node.js version (minimum 22 required)
5
+ // - Platform and OS details
6
+ // - Terminal PTY subsystem (node-pty native bindings, C library)
7
+ // - Data directory permissions and configuration files
8
+ // - Session manager health, port availability, and version alignment
9
+ // - Availability and versions of AI coding tools and terminal multiplexers
10
+
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import net from 'node:net';
14
+ import path from 'node:path';
15
+ import {
16
+ DEFAULT_HOST,
17
+ VERSION,
18
+ dataDir,
19
+ ensureDataDir,
20
+ paths,
21
+ readRuntimeFile,
22
+ resolvePort,
23
+ } from './config.mjs';
24
+ import { ptyProblem, loadPty, glibcVersion } from './pty.mjs';
25
+ import { defaultBoot } from './launch.mjs';
26
+ import { startupState, startupSummary, unitPort } from './systemd-service.mjs';
27
+ import { resolveCommand } from './command-resolver.mjs';
28
+ import { probeVersion } from './versions.mjs';
29
+
30
+ export const MIN_NODE_MAJOR = 22;
31
+
32
+ const KNOWN_TOOLS = [
33
+ {
34
+ id: 'google',
35
+ name: 'Antigravity CLI',
36
+ command: 'agy',
37
+ versionArgs: ['--version'],
38
+ install: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
39
+ docs: 'https://antigravity.google/docs/cli/install',
40
+ },
41
+ {
42
+ id: 'anthropic',
43
+ name: 'Claude Code',
44
+ command: 'claude',
45
+ versionArgs: ['--version'],
46
+ install: 'curl -fsSL https://claude.ai/install.sh | bash',
47
+ docs: 'https://code.claude.com/docs/en/setup',
48
+ },
49
+ {
50
+ id: 'openai',
51
+ name: 'Codex CLI',
52
+ command: 'codex',
53
+ versionArgs: ['--version'],
54
+ install: 'curl -fsSL https://chatgpt.com/codex/install.sh | sh',
55
+ docs: 'https://github.com/openai/codex',
56
+ },
57
+ {
58
+ id: 'xai',
59
+ name: 'Grok Build',
60
+ command: 'grok',
61
+ versionArgs: ['--version'],
62
+ install: 'curl -fsSL https://x.ai/cli/install.sh | bash',
63
+ docs: 'https://docs.x.ai/build/overview',
64
+ },
65
+ {
66
+ id: 'tmux',
67
+ name: 'tmux',
68
+ command: 'tmux',
69
+ versionArgs: ['-V'],
70
+ docs: 'https://github.com/tmux/tmux/wiki/Installing',
71
+ },
72
+ {
73
+ id: 'herdr',
74
+ name: 'herdr',
75
+ command: 'herdr',
76
+ versionArgs: ['--version'],
77
+ docs: 'https://herdr.dev/docs/install/',
78
+ },
79
+ ];
80
+
81
+ /** Check if port is available to listen on host. */
82
+ export function checkPortAvailable(port, host = DEFAULT_HOST) {
83
+ return new Promise((resolve) => {
84
+ const srv = net.createServer();
85
+ srv.once('error', (err) => {
86
+ if (err.code === 'EADDRINUSE') resolve(false);
87
+ else resolve(null);
88
+ });
89
+ srv.once('listening', () => {
90
+ srv.close(() => resolve(true));
91
+ });
92
+ srv.listen(port, host);
93
+ });
94
+ }
95
+
96
+ /** Check manager health via HTTP. */
97
+ async function checkHealth(url, timeoutMs = 1000) {
98
+ try {
99
+ const res = await fetch(`${url}/api/v1/health`, { signal: AbortSignal.timeout(timeoutMs) });
100
+ if (!res.ok) return null;
101
+ const body = await res.json();
102
+ return body?.name === 'agent-guild' ? body : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Perform all diagnostic checks. All dependencies can be injected for testing.
110
+ */
111
+ export async function diagnose({
112
+ nodeVersion = process.versions.node,
113
+ platform = process.platform,
114
+ arch = process.arch,
115
+ env = process.env,
116
+ dir = dataDir(),
117
+ port = resolvePort(),
118
+ fetchHealth = checkHealth,
119
+ testPortAvailable = checkPortAvailable,
120
+ checkPtyProblem = ptyProblem,
121
+ verifyLoadPty = loadPty,
122
+ tools = KNOWN_TOOLS,
123
+ } = {}) {
124
+ // 1. Node.js check
125
+ const nodeMajor = Number(nodeVersion.split('.')[0]);
126
+ const nodeOk = nodeMajor >= MIN_NODE_MAJOR;
127
+ const node = {
128
+ version: nodeVersion,
129
+ major: nodeMajor,
130
+ ok: nodeOk,
131
+ };
132
+
133
+ // 2. Platform & OS
134
+ const glibc = platform === 'linux' ? glibcVersion() : null;
135
+ const sys = {
136
+ platform,
137
+ arch,
138
+ type: os.type(),
139
+ release: os.release(),
140
+ glibc,
141
+ };
142
+
143
+ // 3. PTY subsystem
144
+ let ptyOk = true;
145
+ let ptyError = checkPtyProblem({ platform });
146
+ if (ptyError) {
147
+ ptyOk = false;
148
+ } else {
149
+ try {
150
+ verifyLoadPty();
151
+ } catch (err) {
152
+ ptyOk = false;
153
+ ptyError = err.message;
154
+ }
155
+ }
156
+ const pty = {
157
+ ok: ptyOk,
158
+ error: ptyError,
159
+ };
160
+
161
+ // 4. Data directory & configuration
162
+ let dirExists = false;
163
+ let dirWritable = false;
164
+ let writeError = null;
165
+ try {
166
+ ensureDataDir(dir);
167
+ dirExists = fs.existsSync(dir);
168
+ const probe = path.join(dir, `.doctor-probe-${process.pid}-${Date.now()}`);
169
+ fs.writeFileSync(probe, 'ok');
170
+ fs.rmSync(probe, { force: true });
171
+ dirWritable = true;
172
+ } catch (err) {
173
+ writeError = err.message;
174
+ }
175
+
176
+ const tokenPath = path.join(dir, 'auth-token');
177
+ const hasToken = fs.existsSync(tokenPath);
178
+
179
+ let providersConfig = null;
180
+ const userProvidersPath = path.join(dir, 'providers.json');
181
+ if (fs.existsSync(userProvidersPath)) {
182
+ try {
183
+ JSON.parse(fs.readFileSync(userProvidersPath, 'utf8'));
184
+ providersConfig = { exists: true, valid: true };
185
+ } catch (err) {
186
+ providersConfig = { exists: true, valid: false, error: err.message };
187
+ }
188
+ }
189
+
190
+ const storage = {
191
+ dir,
192
+ exists: dirExists,
193
+ writable: dirWritable,
194
+ writeError,
195
+ hasToken,
196
+ providersConfig,
197
+ };
198
+
199
+ // 5. Session manager & port
200
+ const runtime = readRuntimeFile();
201
+ const url = runtime?.url || `http://${DEFAULT_HOST}:${port}`;
202
+ const health = await fetchHealth(url);
203
+ let managerInfo = null;
204
+
205
+ if (health) {
206
+ let sessionsCount = null;
207
+ if (hasToken) {
208
+ try {
209
+ const token = fs.readFileSync(tokenPath, 'utf8').trim();
210
+ const res = await fetch(`${url}/api/v1/sessions`, {
211
+ headers: { Authorization: `Bearer ${token}` },
212
+ signal: AbortSignal.timeout(1000),
213
+ });
214
+ if (res.ok) {
215
+ const body = await res.json();
216
+ sessionsCount = Array.isArray(body.sessions) ? body.sessions.length : null;
217
+ }
218
+ } catch { /* optional */ }
219
+ }
220
+ managerInfo = {
221
+ running: true,
222
+ url,
223
+ port,
224
+ pid: health.pid,
225
+ version: health.version,
226
+ versionMatch: health.version === VERSION,
227
+ sessionsCount,
228
+ };
229
+ } else {
230
+ const isFree = await testPortAvailable(port, DEFAULT_HOST);
231
+ managerInfo = {
232
+ running: false,
233
+ url,
234
+ port,
235
+ portFree: isFree === true,
236
+ portConflict: isFree === false,
237
+ };
238
+ }
239
+
240
+ // Systemd startup check on Linux
241
+ let startup = null;
242
+ const boot = defaultBoot();
243
+ if (boot) {
244
+ try {
245
+ const read = await boot.read();
246
+ if (read?.enabled) {
247
+ const uPort = unitPort(await boot.text().catch(() => null));
248
+ startup = startupSummary(startupState(read.show, { port: uPort }));
249
+ }
250
+ } catch { /* systemd not accessible */ }
251
+ }
252
+ managerInfo.startup = startup;
253
+
254
+ // 6. AI Assistants & Tools
255
+ const toolResults = [];
256
+ for (const tool of tools) {
257
+ const resolved = resolveCommand(tool.command, env, platform);
258
+ if (resolved) {
259
+ const spec = { command: resolved, args: tool.versionArgs || ['--version'] };
260
+ const probe = await probeVersion(spec, { env, timeoutMs: 3000 });
261
+ toolResults.push({
262
+ ...tool,
263
+ found: true,
264
+ path: resolved,
265
+ version: probe.version,
266
+ });
267
+ } else {
268
+ toolResults.push({
269
+ ...tool,
270
+ found: false,
271
+ });
272
+ }
273
+ }
274
+
275
+ const fatalIssues = [
276
+ !nodeOk && 'Node.js version is below requirement (>= 22)',
277
+ !ptyOk && (ptyError || 'node-pty native bindings failed to load'),
278
+ !dirWritable && `Data directory is not writable (${writeError})`,
279
+ managerInfo.portConflict && `Port ${port} is in use by another process`,
280
+ providersConfig && !providersConfig.valid && `providers.json contains invalid JSON: ${providersConfig.error}`,
281
+ ].filter(Boolean);
282
+
283
+ return {
284
+ version: VERSION,
285
+ node,
286
+ sys,
287
+ pty,
288
+ storage,
289
+ manager: managerInfo,
290
+ tools: toolResults,
291
+ fatalIssues,
292
+ healthy: fatalIssues.length === 0,
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Format the diagnostics object as human-readable markdown / terminal text.
298
+ */
299
+ export function formatDiagnostics(diag) {
300
+ const lines = [];
301
+ lines.push(`Agent Guild Doctor (v${diag.version})`);
302
+ lines.push('');
303
+
304
+ // 1. Environment
305
+ lines.push('Environment:');
306
+ if (diag.node.ok) {
307
+ lines.push(` ✔ Node.js v${diag.node.version} (meets requirement >= ${MIN_NODE_MAJOR})`);
308
+ } else {
309
+ lines.push(` ✖ Node.js v${diag.node.version} (unsupported; Node.js ${MIN_NODE_MAJOR} or newer is required)`);
310
+ }
311
+
312
+ const glibcNote = diag.sys.glibc ? ` · glibc ${diag.sys.glibc}` : '';
313
+ lines.push(` ✔ Platform: ${diag.sys.platform} (${diag.sys.arch} · ${diag.sys.type} ${diag.sys.release}${glibcNote})`);
314
+
315
+ if (diag.pty.ok) {
316
+ lines.push(' ✔ Terminal subsystem (node-pty): functional');
317
+ } else {
318
+ lines.push(' ✖ Terminal subsystem (node-pty): problem detected');
319
+ for (const msgLine of String(diag.pty.error || '').split('\n')) {
320
+ lines.push(` ${msgLine}`);
321
+ }
322
+ }
323
+ lines.push('');
324
+
325
+ // 2. Data Directory & Files
326
+ lines.push('Data Directory & Configuration:');
327
+ lines.push(` ✔ Location: ${diag.storage.dir}`);
328
+ if (diag.storage.writable) {
329
+ lines.push(' ✔ Permissions: directory is writable');
330
+ } else {
331
+ lines.push(` ✖ Permissions: cannot write to data directory (${diag.storage.writeError})`);
332
+ }
333
+
334
+ if (diag.storage.hasToken) {
335
+ lines.push(' ✔ Access token: present');
336
+ } else {
337
+ lines.push(' ℹ Access token: not yet generated (created automatically on first launch)');
338
+ }
339
+
340
+ if (diag.storage.providersConfig) {
341
+ if (diag.storage.providersConfig.valid) {
342
+ lines.push(' ✔ Custom tools (providers.json): valid');
343
+ } else {
344
+ lines.push(` ✖ Custom tools (providers.json): invalid syntax (${diag.storage.providersConfig.error})`);
345
+ }
346
+ } else {
347
+ lines.push(' ✔ Custom tools (providers.json): default configuration');
348
+ }
349
+ lines.push('');
350
+
351
+ // 3. Session Manager
352
+ lines.push('Session Manager:');
353
+ if (diag.manager.running) {
354
+ lines.push(` ✔ Status: running at ${diag.manager.url} (pid ${diag.manager.pid})`);
355
+ if (diag.manager.versionMatch) {
356
+ lines.push(` ✔ Version: v${diag.manager.version} (matches installed package)`);
357
+ } else {
358
+ lines.push(` ! Version: running v${diag.manager.version}, installed v${diag.version} (run "agent-guild stop" then start again to update)`);
359
+ }
360
+ if (typeof diag.manager.sessionsCount === 'number') {
361
+ lines.push(` ℹ Active sessions: ${diag.manager.sessionsCount}`);
362
+ }
363
+ } else if (diag.manager.portConflict) {
364
+ lines.push(` ✖ Status: not running, but port ${diag.manager.port} is in use by another process`);
365
+ } else {
366
+ lines.push(` ℹ Status: not running (port ${diag.manager.port} is available)`);
367
+ }
368
+
369
+ if (diag.manager.startup) {
370
+ lines.push(` ✔ Startup service: ${diag.manager.startup}`);
371
+ }
372
+ lines.push('');
373
+
374
+ // 4. Tools
375
+ lines.push('Coding Assistants & Multiplexers:');
376
+ for (const t of diag.tools) {
377
+ if (t.found) {
378
+ const v = t.version ? `v${t.version}` : 'installed';
379
+ lines.push(` ✔ ${t.name} (${t.command}): ${v} (${t.path})`);
380
+ } else {
381
+ const tip = t.install ? ` · install: ${t.install}` : t.docs ? ` · ${t.docs}` : '';
382
+ lines.push(` ℹ ${t.name} (${t.command}): not found on PATH${tip}`);
383
+ }
384
+ }
385
+ lines.push('');
386
+
387
+ // Summary
388
+ if (diag.healthy) {
389
+ lines.push('Doctor found no fatal problems.');
390
+ } else {
391
+ const count = diag.fatalIssues.length;
392
+ lines.push(`Doctor found ${count} fatal problem${count === 1 ? '' : 's'} (see ✖ above).`);
393
+ }
394
+
395
+ return lines.join('\n');
396
+ }
397
+
398
+ /**
399
+ * Run doctor command and output to console. Returns true if healthy, false if fatal issues found.
400
+ */
401
+ export async function runDoctor(opts = {}) {
402
+ const out = opts.log || console.log;
403
+ const diag = await diagnose(opts);
404
+ out(formatDiagnostics(diag));
405
+ return diag.healthy;
406
+ }