@oddessentials/agent-guild 1.18.0 → 1.19.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
@@ -257,6 +257,7 @@ lets you try every skin.
257
257
  | `agent-guild stop` | Stop the manager and end every session. |
258
258
  | `agent-guild restart` | Restart the manager on the installed version and end every session. |
259
259
  | `agent-guild url` | Print the page link with the access token. |
260
+ | `agent-guild fireworks` | Launch fireworks over every open Yard. A coding agent in any terminal can run it too. |
260
261
  | `agent-guild start` | Run the manager in the foreground. |
261
262
  | `agent-guild doctor` | Diagnose the health of the installation and related tooling. |
262
263
  | `agent-guild mcp` | Serve MCP on stdio, so a coding agent can see and steer the sessions. See [Agent Guild for agents](docs/mcp.md). |
@@ -7,6 +7,7 @@
7
7
  // agent-guild restart stop the manager and start it again (ends all sessions)
8
8
  // agent-guild status show whether the manager is running
9
9
  // agent-guild url print the page URL (includes the access token)
10
+ // agent-guild fireworks launch fireworks over every open Yard
10
11
  // agent-guild mcp serve the Model Context Protocol on stdio, for coding agents
11
12
 
12
13
  import fs from 'node:fs';
@@ -35,6 +36,7 @@ Commands:
35
36
  restart Stop the session manager and start it again; ends every session
36
37
  status Show whether the session manager is running
37
38
  url Print the web page URL, including the access token
39
+ fireworks Launch fireworks over every open Yard
38
40
  doctor Diagnose the health of the installation and related tooling
39
41
  mcp Serve the Model Context Protocol on stdio, so a coding agent can
40
42
  see and steer the sessions (--allow-input lets it type into them)
@@ -287,6 +289,19 @@ async function cmdStatus() {
287
289
  }
288
290
  }
289
291
 
292
+ async function cmdFireworks() {
293
+ const url = baseUrl();
294
+ if (!(await health(url))) {
295
+ console.log('Session manager is not running.');
296
+ process.exitCode = 3;
297
+ return;
298
+ }
299
+ const res = await fetch(`${url}/api/v1/yard/fireworks`, { method: 'POST', headers: { Authorization: `Bearer ${loadOrCreateToken()}` } });
300
+ if (!res.ok) throw new Error(`the session manager answered ${res.status}.`);
301
+ const { pages } = await res.json();
302
+ console.log(pages ? `Fireworks sent to ${pages} open page(s); every Yard on show lights up.` : 'No page is open to see the fireworks.');
303
+ }
304
+
290
305
  const MIN_NODE_MAJOR = 22;
291
306
 
292
307
  async function main() {
@@ -335,6 +350,7 @@ async function main() {
335
350
  process.exitCode = ok ? 0 : 1;
336
351
  return undefined;
337
352
  }
353
+ case 'fireworks': return cmdFireworks();
338
354
  case 'url': {
339
355
  console.log(pageUrl(baseUrl(), loadOrCreateToken()));
340
356
  return undefined;
package/docs/api.md CHANGED
@@ -132,7 +132,8 @@ listed them once.
132
132
  null when `GET /usage`, `GET /providers/:id/history`,
133
133
  `GET /providers/:id/memory` or `GET /providers/:id/instructions` cannot report
134
134
  the tool. `historyDetails` is true when `GET /providers/:id/history/detail`
135
- can read saved messages. `accounts` lists the sign-ins the tool can run
135
+ can read saved messages: for every built-in history source, not for a
136
+ `command`. `accounts` lists the sign-ins the tool can run
136
137
  under: `default` is the tool's own, and each further one has its own home
137
138
  folder, so it keeps its own sign-in and usage. `POST /sessions` takes an
138
139
  account id. `accountsEditable` is true when accounts can be added, renamed
@@ -260,7 +261,7 @@ account, true when one was read, and null when that is unknown.
260
261
  "providerId": "anthropic",
261
262
  "accountId": "default",
262
263
  "sessions": [
263
- { "id": "581893e5-a93d-5e49-968b-1c1c277d3255", "title": "Fix the login bug", "cwd": "/Users/me/src/app", "startedAt": "2026-10-01T13:26:53.713Z", "updatedAt": "2026-10-01T13:49:28.000Z" }
264
+ { "id": "581893e5-a93d-5e49-968b-1c1c277d3255", "name": "Login bug", "title": "Fix the login bug", "cwd": "/Users/me/src/app", "startedAt": "2026-10-01T13:26:53.713Z", "updatedAt": "2026-10-01T13:49:28.000Z" }
264
265
  ],
265
266
  "total": 42,
266
267
  "fetchedAt": "2026-10-01T14:00:00.000Z",
@@ -269,16 +270,19 @@ account, true when one was read, and null when that is unknown.
269
270
  ```
270
271
 
271
272
  The tool's own earlier sessions, newest first. `id` is what the tool resumes
272
- by (`POST /sessions` with `resume`); `title` is the session's name or first
273
- prompt, or null; `cwd` is the folder the session ran in, or null. Claude Code
273
+ by (`POST /sessions` with `resume`); `name` is the name a card holding
274
+ that session was given, or null; `title` is the name the tool gave the
275
+ session, else its first prompt, or null; `cwd` is the folder the session ran
276
+ in, or null. Claude Code
274
277
  finds a session only from its own folder, so resume with that `cwd`.
275
278
  `sessions` holds at most `limit` entries of the `total` found. `error` says
276
279
  why nothing could be listed; a tool that has never run lists no sessions and
277
280
  no error. Antigravity CLI entries add `workspaces`, and its history may add a
278
281
  `note`.
279
282
 
280
- The optional `cwd` and `q` parameters filter by folder and by text (title, id
281
- or folder) **before** applying `limit`; `total` counts matching entries.
283
+ The optional `cwd` and `q` parameters filter by folder and by text (name,
284
+ title, id or folder) **before** applying `limit`; `total` counts matching
285
+ entries. Filtering uses the listing only and never reads a conversation.
282
286
 
283
287
  #### Saved messages
284
288
 
@@ -291,16 +295,26 @@ or folder) **before** applying `limit`; `total` counts matching entries.
291
295
  "messages": [{ "role": "user", "text": "Fix the login bug" }],
292
296
  "nextCursor": null,
293
297
  "incomplete": false,
294
- "omitted": false
298
+ "omitted": false,
299
+ "truncated": false
295
300
  }
296
301
  }
297
302
  ```
298
303
 
299
- Only the user's requests and the visible answers are included, as plain text.
300
- Follow `nextCursor` until it is null, even when a page has no visible
301
- messages. `omitted` says records were left out; `incomplete` says the last one
302
- was partial, and a later read may complete it. 409 `history_changed` means the
303
- transcript changed: start again without a cursor.
304
+ Only the user's requests and the visible answers are included, as plain text:
305
+ no thinking, tool calls or results, text the tool inserted, or turns it
306
+ rewound or rolled back. The first page holds the newest messages; each
307
+ `nextCursor` reads the page before it, and messages within a page are oldest
308
+ first. Every message is whole. Follow `nextCursor` until it is null, even when
309
+ a page has no visible messages. `omitted` says records were left out, such as
310
+ an unreadable one the tool itself skips; `truncated` says the history ends
311
+ early, at a record too large to read or one that cannot be read safely, and
312
+ nothing before it is shown; `incomplete` says the newest record was still being
313
+ written, and a later read may complete it. Messages added after the first page
314
+ appear when it is read again without a cursor. 409 `history_changed` means
315
+ the part a cursor depends on was rewritten, truncated or replaced: start again
316
+ without a cursor. 413 `history_too_large` means a compressed Codex rollout
317
+ decodes to more than 64 MiB.
304
318
 
305
319
  ### Memory
306
320
 
@@ -618,6 +632,7 @@ All paths are under `/api/v1`.
618
632
  | GET | `/yard-art` | | `{ yardArt }`: the Yard's art. `state` is `ready`, `missing`, `downloading` (with `received` bytes), `installing`, `failed` or `unavailable` (each with `error`), or `cancelled`; `size` is the pack's size in bytes. A checkout serves `web/yard/assets` and is always `ready`. `/yard/assets/` answers 404 until the art is `ready`. |
619
633
  | POST | `/yard-art/download` | | `202 { yardArt }`: starts the one download of the art pack named by the package, unless it is running or installed. Progress and the outcome arrive as `yard-art.updated`. |
620
634
  | POST | `/yard-art/cancel` | | `{ yardArt }`: stops the download for every client; the state becomes `cancelled`. |
635
+ | POST | `/yard/fireworks` | | `{ ok, pages }`: fireworks over the Yard of every open page, sent as `yard.fireworks`. `pages` is how many pages were listening. |
621
636
  | GET | `/changelog` | | `{ refreshing, okAt, error, releases }`: Agent Guild's own releases, newest first, each `{ version, url, publishedAt, sections: [{ title, changes }] }`, where `title` may be null and a change is a list of text runs. A `changelog.updated` event follows a refresh. |
622
637
  | GET | `/providers/:id/history?account=&limit=&cwd=&q=` | | `{ history }`: a History object for one account (default `default`). |
623
638
  | GET | `/providers/:id/history/detail?account=&id=&cursor=` | | `{ detail }`; see [Saved messages](#saved-messages). |
@@ -664,7 +679,11 @@ All paths are under `/api/v1`.
664
679
  `cwd` defaults to the user's home folder, and a leading `~` is expanded.
665
680
  `args` are appended to the provider's configured arguments. `resume` is an
666
681
  id or name of one of the tool's own sessions; one that starts with `-` is
667
- refused with 400 `bad_resume`. `account` defaults to `default`.
682
+ refused with 400 `bad_resume`. `account` defaults to `default`. A session
683
+ resumed without a `name` gets back the name a card holding it was given.
684
+ A name given to a session, by `name` or a rename, is kept for every tool
685
+ session the card holds; renaming the card to its default name drops it for
686
+ the tool session it holds then.
668
687
 
669
688
  The `POST /sessions/:id/…` report routes (`agents`, `model`, `tool-session`,
670
689
  `context`, `reply`, `reporting`, `docker` and `shells`) also accept the per-session
@@ -695,6 +714,7 @@ but `{ type: "ping" }`, which the manager answers with `{ type: "pong" }`.
695
714
  | `{ type: "environment.updated", environment }` | An environment check started or finished. |
696
715
  | `{ type: "news.updated" }` | Fetch `/news` again. |
697
716
  | `{ type: "yard-art.updated", yardArt }` | The Yard's art changed state or made download progress, as in `/yard-art`. |
717
+ | `{ type: "yard.fireworks" }` | Fireworks over the Yard, when it is on show. |
698
718
  | `{ type: "changelog.updated" }` | Fetch `/changelog` again. |
699
719
  | `{ type: "github.updated" }` | Fetch `/github` again. |
700
720
  | `{ type: "remote-access.updated" }` | Fetch `/remote-access` again. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "1.18.0",
3
+ "version": "1.19.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",
@@ -2,11 +2,9 @@
2
2
  // or the binary conversation/implicit stores as conversation text.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { createHash } from 'node:crypto';
6
5
  import { antigravityUserConversation } from '../report/hooks.mjs';
7
6
 
8
7
  export const ANTIGRAVITY_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
- export const TRANSCRIPT_PAGE_BYTES = 256 * 1024;
10
8
 
11
9
  export function historyError(status, code, message) {
12
10
  return Object.assign(new Error(message), { status, code });
@@ -15,14 +13,13 @@ export function historyError(status, code, message) {
15
13
  /** Reject links below the provider home, including a linked conversation or logs folder. */
16
14
  export async function transcriptFile(dir, id) {
17
15
  if (!ANTIGRAVITY_ID.test(id)) throw historyError(400, 'bad_history_id', 'Invalid conversation id.');
18
- let file = await fs.promises.realpath(dir);
16
+ const unavailable = () => historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
17
+ let file = await fs.promises.realpath(dir).catch(() => { throw unavailable(); });
19
18
  const parts = ['brain', id, '.system_generated', 'logs', 'transcript.jsonl'];
20
19
  for (const [i, part] of parts.entries()) {
21
20
  file = path.join(file, part);
22
- const stat = await fs.promises.lstat(file);
23
- if (stat.isSymbolicLink() || (i === parts.length - 1 ? !stat.isFile() : !stat.isDirectory())) {
24
- throw historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
25
- }
21
+ const stat = await fs.promises.lstat(file).catch(() => { throw unavailable(); });
22
+ if (stat.isSymbolicLink() || (i === parts.length - 1 ? !stat.isFile() : !stat.isDirectory())) throw unavailable();
26
23
  }
27
24
  return file;
28
25
  }
@@ -39,66 +36,3 @@ export function transcriptMessage(record) {
39
36
  }
40
37
  return text?.trim() ? { role, text: text.trim() } : null;
41
38
  }
42
-
43
- /** One bounded byte page. A cursor pins the file revision; partial final lines wait for Refresh. */
44
- export async function readAntigravityTranscript(dir, id, cursor = null) {
45
- let handle;
46
- try {
47
- handle = await fs.promises.open(await transcriptFile(dir, id), 'r');
48
- const stat = await handle.stat();
49
- const revision = createHash('sha256').update(`${id}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`).digest('hex').slice(0, 24);
50
- let offset = 0, skipping = false;
51
- if (cursor) {
52
- if (typeof cursor !== 'string' || cursor.length > 256) {
53
- throw historyError(400, 'bad_history_cursor', 'Invalid conversation page.');
54
- }
55
- let value;
56
- try { value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); } catch { /* invalid below */ }
57
- if (!value || !Number.isSafeInteger(value.offset)
58
- || value.offset < 0 || typeof value.skipping !== 'boolean' || typeof value.revision !== 'string') {
59
- throw historyError(400, 'bad_history_cursor', 'Invalid conversation page.');
60
- }
61
- if (value.revision !== revision) throw historyError(409, 'history_changed', 'The conversation changed. Refresh to read it again.');
62
- if (value.offset > stat.size) throw historyError(400, 'bad_history_cursor', 'Invalid conversation page.');
63
- ({ offset, skipping } = value);
64
- }
65
- // Check the first record on every request, including requests using a supplied cursor.
66
- const head = Buffer.alloc(Math.min(stat.size, TRANSCRIPT_PAGE_BYTES));
67
- const firstRead = await handle.read(head, 0, head.length, 0);
68
- const headEnd = head.subarray(0, firstRead.bytesRead).indexOf(10);
69
- let first;
70
- try { first = JSON.parse(head.subarray(0, headEnd < 0 ? firstRead.bytesRead : headEnd).toString('utf8')); } catch { /* unavailable */ }
71
- if (!antigravityUserConversation(first)) throw historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
72
- const buffer = offset === 0 ? head : Buffer.alloc(Math.min(stat.size - offset, TRANSCRIPT_PAGE_BYTES));
73
- const bytesRead = offset === 0 ? firstRead.bytesRead : (await handle.read(buffer, 0, buffer.length, offset)).bytesRead;
74
- const data = buffer.subarray(0, bytesRead);
75
- const messages = [];
76
- let start = 0, omitted = false;
77
- for (let end; (end = data.indexOf(10, start)) >= 0;) {
78
- if (skipping) { skipping = false; omitted = true; }
79
- else if (end > start) {
80
- try {
81
- const message = transcriptMessage(JSON.parse(data.subarray(start, end).toString('utf8')));
82
- if (message) messages.push(message);
83
- } catch { omitted = true; }
84
- }
85
- start = end + 1;
86
- }
87
- const atEnd = offset + bytesRead >= stat.size;
88
- const incomplete = atEnd && start < bytesRead;
89
- // Leave a split record for the next page. An oversized record is skipped in bounded pieces.
90
- if (!atEnd && start === 0) { start = bytesRead; skipping = true; omitted = true; }
91
- const nextCursor = !atEnd && bytesRead > 0
92
- ? Buffer.from(JSON.stringify({ offset: offset + start, skipping, revision })).toString('base64url') : null;
93
- const after = await handle.stat();
94
- if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) {
95
- throw historyError(409, 'history_changed', 'The conversation changed. Refresh to read it again.');
96
- }
97
- return { id, messages, nextCursor, incomplete, omitted };
98
- } catch (err) {
99
- if (err.status) throw err;
100
- throw historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
101
- } finally {
102
- await handle?.close();
103
- }
104
- }
@@ -0,0 +1,110 @@
1
+ // Names given to cards, by provider, account and the tool's own session id.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+
7
+ export const CARD_NAMES_LIMIT = 5000;
8
+ const FILE_LIMIT = 4 * 1024 * 1024;
9
+
10
+ const key = (providerId, accountId, toolSessionId) => `${providerId}\0${accountId}\0${toolSessionId}`;
11
+
12
+ function readNames(file) {
13
+ let stat;
14
+ try { stat = fs.statSync(file); } catch (error) {
15
+ if (error.code === 'ENOENT') return [];
16
+ throw error;
17
+ }
18
+ if (!stat.isFile() || stat.size > FILE_LIMIT) throw new Error('not a names file');
19
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
20
+ if (value?.version !== 1 || !Array.isArray(value.names)) throw new Error('not a names file');
21
+ return value.names.filter((n) => n && typeof n.provider === 'string' && typeof n.account === 'string' && typeof n.id === 'string'
22
+ && typeof n.name === 'string' && n.name && typeof n.at === 'string');
23
+ }
24
+
25
+ function writeNames(file, names) {
26
+ const contents = `${JSON.stringify({ version: 1, names }, null, 2)}\n`;
27
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
28
+ const next = `${file}.${randomUUID()}.tmp`;
29
+ let descriptor;
30
+ try {
31
+ descriptor = fs.openSync(next, 'wx', 0o600);
32
+ fs.writeFileSync(descriptor, contents);
33
+ fs.fsyncSync(descriptor);
34
+ fs.closeSync(descriptor);
35
+ descriptor = undefined;
36
+ fs.renameSync(next, file);
37
+ } finally {
38
+ if (descriptor !== undefined) fs.closeSync(descriptor);
39
+ try { fs.unlinkSync(next); } catch { /* renamed, or never created */ }
40
+ }
41
+ try { fs.chmodSync(file, 0o600); } catch { /* not supported on Windows */ }
42
+ }
43
+
44
+ /**
45
+ * @param {string} [file] omit for an in-memory store (tests). The real manager passes its names file.
46
+ * @param {{ limit?: number, warn?: (message: string) => void, now?: () => string }} [options]
47
+ */
48
+ export function createCardNames(file, { limit = CARD_NAMES_LIMIT, warn = (message) => console.warn(message), now = () => new Date().toISOString() } = {}) {
49
+ let names = null;
50
+ let warned = false;
51
+
52
+ /** An unreadable file is read again next time and never overwritten, so fixing or removing it needs no restart. */
53
+ function load() {
54
+ if (names) return names;
55
+ if (!file) return (names = new Map());
56
+ try {
57
+ names = new Map(readNames(file).map((n) => [key(n.provider, n.account, n.id), n]));
58
+ warned = false;
59
+ } catch {
60
+ if (!warned) warn(`[names] ${file} cannot be read, so card names are not kept until it is fixed or removed.`);
61
+ warned = true;
62
+ return null;
63
+ }
64
+ return names;
65
+ }
66
+
67
+ function save(current) {
68
+ if (!file) return true;
69
+ try {
70
+ writeNames(file, [...current.values()]);
71
+ return true;
72
+ } catch (err) {
73
+ warn(`[names] could not save card names: ${err.message}`);
74
+ return false;
75
+ }
76
+ }
77
+
78
+ return {
79
+ get(providerId, accountId, toolSessionId) {
80
+ return load()?.get(key(providerId, accountId, toolSessionId))?.name ?? null;
81
+ },
82
+ /** True once the name is stored. The cache changes only with the file, so a failed save is retried by the next call. */
83
+ set(providerId, accountId, toolSessionId, name) {
84
+ const current = load();
85
+ if (!current) return false;
86
+ const k = key(providerId, accountId, toolSessionId);
87
+ if (current.get(k)?.name === name) return true;
88
+ const next = new Map(current);
89
+ // Newest last, so the oldest are dropped first.
90
+ next.delete(k);
91
+ next.set(k, { provider: providerId, account: accountId, id: toolSessionId, name, at: now() });
92
+ while (next.size > limit) next.delete(next.keys().next().value);
93
+ if (!save(next)) return false;
94
+ names = next;
95
+ return true;
96
+ },
97
+ /** True once no name is stored. */
98
+ delete(providerId, accountId, toolSessionId) {
99
+ const current = load();
100
+ if (!current) return false;
101
+ const k = key(providerId, accountId, toolSessionId);
102
+ if (!current.has(k)) return true;
103
+ const next = new Map(current);
104
+ next.delete(k);
105
+ if (!save(next)) return false;
106
+ names = next;
107
+ return true;
108
+ },
109
+ };
110
+ }
@@ -47,6 +47,7 @@ export const paths = {
47
47
  get remoteAccess() { return path.join(dataDir(), 'remote-access.json'); },
48
48
  /** The shared notepad. A missing file means it was never stored. */
49
49
  get notes() { return path.join(dataDir(), 'notes.json'); },
50
+ get cardNames() { return path.join(dataDir(), 'card-names.json'); },
50
51
  get log() { return path.join(dataDir(), 'manager.log'); },
51
52
  /** Launchers for agent-guild-report, put first on every session's PATH. */
52
53
  get shims() { return path.join(dataDir(), 'bin'); },