@oddessentials/agent-guild 1.31.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66,17 +66,25 @@ function portOf(url) {
66
66
  return parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
67
67
  }
68
68
 
69
- async function health(url, timeoutMs = 1000) {
69
+ /** The manager's health, `{ busy: pid }` when the manager its runtime file names for `url` runs but did not answer in time, or null. */
70
+ async function reach(url, timeoutMs = 1000) {
71
+ const signal = AbortSignal.timeout(timeoutMs);
70
72
  try {
71
- const res = await fetch(`${url}/api/v1/health`, { signal: AbortSignal.timeout(timeoutMs) });
73
+ const res = await fetch(`${url}/api/v1/health`, { signal });
72
74
  if (!res.ok) return null;
73
75
  const body = await res.json();
74
76
  return body?.name === 'agent-guild' ? body : null;
75
77
  } catch {
76
- return null;
78
+ const owner = signal.aborted ? readRuntimeFile() : null;
79
+ return owner?.url === url && running(owner.pid) ? { busy: owner.pid } : null;
77
80
  }
78
81
  }
79
82
 
83
+ async function health(url, timeoutMs = 1000) {
84
+ const found = await reach(url, timeoutMs);
85
+ return found?.busy ? null : found;
86
+ }
87
+
80
88
  function pageUrl(url, token) {
81
89
  return `${url}/#token=${token}`;
82
90
  }
@@ -114,7 +122,7 @@ async function ensureManager({ port = null } = {}) {
114
122
  // A pinned port is the endpoint to serve, so it is also the one to check;
115
123
  // a manager found anywhere else is not the one asked for.
116
124
  const knownUrl = port === null ? baseUrl() : expectedUrl;
117
- const answering = await health(knownUrl);
125
+ const answering = await reach(knownUrl);
118
126
  if (answering) return { url: knownUrl, started: false, version: answering.version };
119
127
 
120
128
  // Say why no manager can run here rather than start one that fails.
@@ -167,21 +175,23 @@ async function cmdOpen({ browser, signIn = false }) {
167
175
  * a successor itself (a manager from before restarts only stops).
168
176
  */
169
177
  async function requestShutdown(url, { restart = false } = {}) {
170
- // `stop` and `restart` are documented as ending every session, so they do
171
- // not ask; the web page's buttons are the ones that confirm first.
172
- const res = await fetch(`${url}/api/v1/shutdown`, {
173
- method: 'POST',
174
- headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
175
- body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
176
- });
177
- if (!res.ok) {
178
- const { error } = await res.json().catch(() => ({}));
179
- throw new Error(`${restart ? 'restart' : 'stop'} failed: ${error?.message ?? `HTTP ${res.status}`}`);
178
+ const signal = AbortSignal.timeout(5000);
179
+ try {
180
+ const res = await fetch(`${url}/api/v1/shutdown`, {
181
+ method: 'POST', signal,
182
+ headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
183
+ body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
184
+ });
185
+ const body = await res.json().catch(() => ({}));
186
+ signal.throwIfAborted();
187
+ if (!res.ok) throw new Error(`${restart ? 'restart' : 'stop'} failed: ${body.error?.message ?? `HTTP ${res.status}`}`);
188
+ const running = body.running ?? 0;
189
+ if (running > 0) console.log(`Ending ${running} running session(s).`);
190
+ return { running, restart: body.restart === true };
191
+ } catch (err) {
192
+ if (signal.aborted) throw new Error(`the session manager did not answer the ${restart ? 'restart' : 'stop'} request within 5 seconds; its outcome could not be confirmed.`);
193
+ throw err;
180
194
  }
181
- const body = await res.json().catch(() => ({}));
182
- const running = body.running ?? 0;
183
- if (running > 0) console.log(`Ending ${running} running session(s).`);
184
- return { running, restart: body.restart === true };
185
195
  }
186
196
 
187
197
  function running(pid) {
@@ -209,13 +219,13 @@ async function waitForExit(pid, timeoutMs = 15000) {
209
219
 
210
220
  async function cmdStop() {
211
221
  const url = baseUrl();
212
- const h = await health(url);
222
+ const h = await reach(url);
213
223
  if (!h) {
214
224
  console.log('Session manager is not running.');
215
225
  return;
216
226
  }
217
227
  await requestShutdown(url);
218
- if (await waitForExit(h.pid)) {
228
+ if (await waitForExit(h.busy ?? h.pid)) {
219
229
  console.log('Session manager stopped.');
220
230
  return;
221
231
  }
@@ -225,13 +235,14 @@ async function cmdStop() {
225
235
 
226
236
  async function cmdRestart() {
227
237
  const url = baseUrl();
228
- const before = await health(url);
229
- if (!before) {
238
+ const found = await reach(url);
239
+ if (!found) {
230
240
  // Nothing to stop: a restart of a stopped manager is a start.
231
241
  const { url: started, version } = await ensureManager();
232
242
  console.log(`Session manager was not running; started Agent Guild ${version} at ${started}.`);
233
243
  return;
234
244
  }
245
+ const before = found.busy ? { pid: found.busy } : found;
235
246
  const { restart } = await requestShutdown(url, { restart: true });
236
247
  if (!restart) {
237
248
  // A manager from before restarts stops without starting a successor,
@@ -239,7 +250,7 @@ async function cmdRestart() {
239
250
  if (!(await waitForExit(before.pid))) throw new Error('the session manager did not stop, so it could not be restarted.');
240
251
  // On the port the old one served, which an ephemeral port setting would otherwise lose.
241
252
  const { url: started, version } = await ensureManager({ port: portOf(url) });
242
- const changed = version !== before.version ? `, now Agent Guild ${version} (was ${before.version})` : '';
253
+ const changed = before.version && version !== before.version ? `, now Agent Guild ${version} (was ${before.version})` : '';
243
254
  console.log(`Session manager restarted at ${started}${changed}.`);
244
255
  return;
245
256
  }
@@ -250,7 +261,7 @@ async function cmdRestart() {
250
261
  await new Promise((r) => setTimeout(r, 250));
251
262
  const now = await health(url, 500);
252
263
  if (now && now.pid !== before.pid) {
253
- const changed = now.version !== before.version ? `, now Agent Guild ${now.version} (was ${before.version})` : '';
264
+ const changed = before.version && now.version !== before.version ? `, now Agent Guild ${now.version} (was ${before.version})` : '';
254
265
  console.log(`Session manager restarted at ${url}${changed}.`);
255
266
  return;
256
267
  }
@@ -261,7 +272,12 @@ async function cmdRestart() {
261
272
 
262
273
  async function cmdStatus() {
263
274
  const url = baseUrl();
264
- const h = await health(url);
275
+ const h = await reach(url);
276
+ if (h?.busy) {
277
+ console.log(`Session manager running at ${url} (pid ${h.busy}) did not answer in time.`);
278
+ process.exitCode = 1;
279
+ return;
280
+ }
265
281
  if (!h) {
266
282
  console.log('Session manager is not running.');
267
283
  // With no manager to ask, the boot service is read from systemd directly.
@@ -294,15 +310,21 @@ async function cmdStatus() {
294
310
 
295
311
  async function cmdFireworks() {
296
312
  const url = baseUrl();
297
- if (!(await health(url))) {
313
+ if (!(await reach(url))) {
298
314
  console.log('Session manager is not running.');
299
315
  process.exitCode = 3;
300
316
  return;
301
317
  }
302
- const res = await fetch(`${url}/api/v1/yard/fireworks`, { method: 'POST', headers: { Authorization: `Bearer ${loadOrCreateToken()}` } });
303
- if (!res.ok) throw new Error(`the session manager answered ${res.status}.`);
304
- const { pages } = await res.json();
305
- console.log(pages ? `Fireworks sent to ${pages} open page(s); every Yard on show lights up.` : 'No page is open to see the fireworks.');
318
+ const signal = AbortSignal.timeout(5000);
319
+ try {
320
+ const res = await fetch(`${url}/api/v1/yard/fireworks`, { method: 'POST', signal, headers: { Authorization: `Bearer ${loadOrCreateToken()}` } });
321
+ if (!res.ok) throw new Error(`the session manager answered ${res.status}.`);
322
+ const { pages } = await res.json();
323
+ console.log(pages ? `Fireworks sent to ${pages} open page(s); every Yard on show lights up.` : 'No page is open to see the fireworks.');
324
+ } catch (err) {
325
+ if (signal.aborted) throw new Error('the session manager did not answer the fireworks request within 5 seconds; delivery could not be confirmed.');
326
+ throw err;
327
+ }
306
328
  }
307
329
 
308
330
  const MIN_NODE_MAJOR = 22;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "1.31.0",
3
+ "version": "1.33.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "mcpName": "ai.oddessentials/agent-guild",
6
6
  "license": "AGPL-3.0-only",
@@ -184,17 +184,19 @@ export function npmSpawnSpec(npm, args, env = process.env, platform = process.pl
184
184
  function runInOwnGroup(file, args, { env, cwd, timeoutMs, input, maxBuffer, signal }) {
185
185
  return new Promise((resolve, reject) => {
186
186
  let child;
187
+ // Registered before the spawn: a signal that lands once the command runs must find it.
188
+ const end = () => killGroup(child?.pid);
189
+ const release = endWithProcess(end);
187
190
  try {
188
191
  child = spawn(file, args, { env, cwd, detached: true });
189
192
  } catch (err) {
193
+ release();
190
194
  return reject(Object.assign(err, { stdout: '', stderr: '' }));
191
195
  }
192
196
  let stdout = '';
193
197
  let stderr = '';
194
198
  let timedOut = false;
195
199
  let settled = false;
196
- const end = () => killGroup(child.pid);
197
- const release = endWithProcess(end);
198
200
  const finish = (err) => {
199
201
  if (settled) return;
200
202
  settled = true;
@@ -214,6 +214,8 @@ export function codexHooksList(resolved, args, { env, platform = process.platfor
214
214
  const stopped = () => finish(new Error('The manager is stopping'));
215
215
  const timer = setTimeout(() => finish(new Error('Codex did not answer in time')), timeoutMs);
216
216
  timer.unref?.();
217
+ // Registered before the spawn: a signal that lands once Codex runs must find it.
218
+ if (platform !== 'win32') release = endWithProcess(() => killGroup(child?.pid));
217
219
  try {
218
220
  const verbatim = typeof spec.args === 'string';
219
221
  child = spawn(spec.file, verbatim ? [spec.args] : spec.args, {
@@ -222,7 +224,6 @@ export function codexHooksList(resolved, args, { env, platform = process.platfor
222
224
  } catch (err) {
223
225
  return finish(err);
224
226
  }
225
- if (platform !== 'win32') release = endWithProcess(() => killGroup(child.pid));
226
227
  signal?.addEventListener('abort', stopped, { once: true });
227
228
  let stderr = '';
228
229
  let buffer = '';
@@ -78,6 +78,9 @@ export function probeLoginShell({ shell, env = process.env, timeoutMs = LOGIN_SH
78
78
  if (process.platform === 'win32' || !shell) return Promise.resolve({ vars: null, failure: null });
79
79
  return new Promise((resolve) => {
80
80
  let child;
81
+ // Registered before the spawn: a signal that lands once the shell runs must find it.
82
+ const end = () => killGroup(child?.pid);
83
+ const release = endWithProcess(end);
81
84
  try {
82
85
  child = spawn(shell, LOGIN_SHELL_ARGS, {
83
86
  detached: true,
@@ -85,12 +88,11 @@ export function probeLoginShell({ shell, env = process.env, timeoutMs = LOGIN_SH
85
88
  env: { ...env, AGENT_GUILD_RESOLVING_ENV: '1' },
86
89
  });
87
90
  } catch (err) {
91
+ release();
88
92
  return resolve({ vars: null, failure: `could not start (${err.code || err.message})` });
89
93
  }
90
94
  let stdout = '';
91
95
  let settled = false;
92
- const end = () => killGroup(child.pid);
93
- const release = endWithProcess(end);
94
96
  const finish = (result) => {
95
97
  if (settled) return;
96
98
  settled = true;
@@ -24,7 +24,7 @@ const HIDDEN = '[token hidden]';
24
24
  // 64 bits of the known token; leave colors, short hashes and 12-digit session ids readable.
25
25
  const MIN_TOKEN_FRAGMENT = 16;
26
26
 
27
- const CUSTOMIZE = 'To customize a Yard, or the characters and companions of Agent Guild: World, follow https://agentguild.oddessentials.ai/agents/.';
27
+ const CUSTOMIZE = 'To customize a Yard or the characters and companions of Agent Guild: World, or to install Grove Companion on the desktop, follow https://agentguild.oddessentials.ai/agents/.';
28
28
 
29
29
  const INSTRUCTIONS = `Agent Guild runs coding agents (Claude Code, Codex CLI, Antigravity CLI, Grok Build, Docker Agent) and shells in real terminals on this computer, and opens them on every other screen.
30
30
  Use guild_status first, list_sessions to see the sessions, read_reply for the answer that ended a session's last turn, and read_screen for what its terminal shows now.
package/web/index.html CHANGED
@@ -151,7 +151,7 @@
151
151
  <div class="yard-overlay">
152
152
  <div id="yard-loading" class="yard-loading" role="status" data-mode="scene"><span id="yard-loading-text">Preparing your yard…</span><span id="yard-loading-size" class="yard-loading-size" aria-hidden="true"></span><span class="yard-loading-bar" aria-hidden="true"></span></div>
153
153
  <div id="yard-swap-failure" class="yard-swap-failure" hidden role="status"><span id="yard-swap-failure-text"></span><button id="yard-swap-retry" class="btn" type="button">Retry</button></div>
154
- <div class="yard-bar"><div id="yard-world" class="yard-world" role="group" aria-label="This Yard's world"><p id="yard-world-note" class="yard-world-note" role="status" hidden></p><div class="yard-world-actions"><button id="yard-customize-open" type="button" class="btn" aria-haspopup="dialog">Customize this Yard</button><a id="yard-world-get" class="btn" href="https://github.com/oddessentials/agent-guild-builders/releases/latest" target="_blank" rel="noopener noreferrer" hidden>Build a Yard</a></div></div><div class="yard-camera" role="group" aria-label="Yard controls"><button id="yard-zoom-in" type="button" class="btn" aria-label="Zoom in">+</button><button id="yard-zoom-out" type="button" class="btn" aria-label="Zoom out">−</button><button id="yard-fit" type="button" class="btn">Overview</button><button id="yard-focus" type="button" class="btn" disabled>Focus selected</button><button id="yard-photo" type="button" class="btn" aria-haspopup="dialog" aria-label="Save a picture of the Yard" title="Save a picture of the Yard" disabled hidden><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 8.5A1.5 1.5 0 0 1 5.5 7h2.3l1.4-2h5.6l1.4 2h2.3A1.5 1.5 0 0 1 20 8.5v9a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5z"/><circle cx="12" cy="12.75" r="3.25"/></svg></button><button id="yard-fireworks" type="button" class="btn" aria-label="Fireworks in this Yard only" title="Fireworks in this Yard only" disabled><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M12 3v3m0 9v6M3 10h3m12 0h3M5.6 3.6l2.2 2.2m8.4 8.4 2.2 2.2M18.4 3.6l-2.2 2.2m-8.4 8.4-2.2 2.2"/><circle cx="12" cy="10" r="1"/></svg></button></div></div>
154
+ <div class="yard-bar"><div id="yard-world" class="yard-world" role="group" aria-label="This Yard's world"><p id="yard-world-note" class="yard-world-note" role="status" hidden></p><div class="yard-world-actions"><button id="yard-customize-open" type="button" class="btn" aria-haspopup="dialog">Customize this Yard</button><a id="yard-world-get" class="btn" href="https://github.com/oddessentials/agent-guild-builders/releases/latest" target="_blank" rel="noopener noreferrer" hidden>Build a Yard</a></div></div><div class="yard-camera" role="group" aria-label="Yard controls"><button id="yard-zoom-in" type="button" class="btn" aria-label="Zoom in">+</button><button id="yard-zoom-out" type="button" class="btn" aria-label="Zoom out">−</button><button id="yard-fit" type="button" class="btn">Overview</button><button id="yard-focus" type="button" class="btn" disabled>Focus selected</button><button id="yard-photo" type="button" class="btn" aria-haspopup="dialog" aria-label="Save a picture of the Yard" title="Save a picture of the Yard" disabled hidden><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 8.5A1.5 1.5 0 0 1 5.5 7h2.3l1.4-2h5.6l1.4 2h2.3A1.5 1.5 0 0 1 20 8.5v9a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5z"/><circle cx="12" cy="12.75" r="3.25"/></svg></button><button id="yard-fireworks" type="button" class="btn" aria-label="Fireworks in this Yard only" title="Fireworks in this Yard only" disabled><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M12 3v3m0 9v6M3 10h3m12 0h3M5.6 3.6l2.2 2.2m8.4 8.4 2.2 2.2M18.4 3.6l-2.2 2.2m-8.4 8.4-2.2 2.2"/><circle cx="12" cy="10" r="1"/></svg></button><button id="yard-vibe" type="button" class="btn" aria-label="Vibes Mode" aria-pressed="false"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="14.5" cy="4.5" r="1.8"/><path d="M8 21l3.5-5.5-2.5-3 2.5-4.5 4 1.5 2 3h3"/><path d="M11.5 8 7.5 9.5 6 13"/><path d="M11 15.5l4 2V21"/></svg></button></div></div>
155
155
  <p id="yard-help"><span class="yard-help-pointer">Drag to explore · Scroll to zoom · Select to inspect · Double-click a session to open</span><span class="yard-help-touch">Drag to explore · Pinch to zoom · Tap to inspect · Open from the Inspector</span></p>
156
156
  </div>
157
157
  </div>