@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 +1 -0
- package/bin/agent-guild.mjs +16 -0
- package/docs/api.md +33 -13
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/package.json +1 -1
- package/src/manager/antigravity-transcript.mjs +4 -70
- package/src/manager/card-names.mjs +110 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/history-transcripts.mjs +598 -0
- package/src/manager/main.mjs +4 -2
- package/src/manager/providers.mjs +1 -1
- package/src/manager/pty.mjs +26 -0
- package/src/manager/server.mjs +4 -0
- package/src/manager/session-history.mjs +135 -66
- package/src/manager/session-manager.mjs +53 -9
- package/src/manager/session.mjs +21 -18
- package/src/mcp/server.mjs +11 -0
- package/web/app.js +38 -45
- package/web/history-preview.js +7 -7
- package/web/index.html +1 -1
- package/web/yard/art.json +1 -1
- package/web/yard/model.mjs +1 -1
- package/web/yard/renderer.js +1 -1
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). |
|
package/bin/agent-guild.mjs
CHANGED
|
@@ -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
|
|
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`); `
|
|
273
|
-
|
|
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 (
|
|
281
|
-
or folder) **before** applying `limit`; `total` counts matching
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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. |
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/manager/config.mjs
CHANGED
|
@@ -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'); },
|