@oddessentials/agent-guild 0.4.0 → 0.6.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 +13 -8
- package/bin/agent-guild-report.mjs +14 -1
- package/bin/agent-guild.mjs +85 -46
- package/config/providers.default.json +4 -0
- package/package.json +1 -1
- package/src/manager/changelog.mjs +195 -0
- package/src/manager/launch.mjs +64 -0
- package/src/manager/main.mjs +27 -4
- package/src/manager/news.mjs +5 -5
- package/src/manager/providers.mjs +11 -0
- package/src/manager/server.mjs +52 -16
- package/src/manager/session-history.mjs +496 -0
- package/src/manager/session-manager.mjs +4 -0
- package/src/manager/session.mjs +16 -0
- package/src/report/hooks.mjs +4 -0
- package/web/app.js +754 -56
- package/web/art/art.css +372 -159
- package/web/art/characters/anthropic/idle.avif +0 -0
- package/web/art/characters/anthropic/idle.webp +0 -0
- package/web/art/characters/anthropic/locked.avif +0 -0
- package/web/art/characters/anthropic/locked.webp +0 -0
- package/web/art/characters/anthropic/working.avif +0 -0
- package/web/art/characters/anthropic/working.webp +0 -0
- package/web/art/characters/google/idle.avif +0 -0
- package/web/art/characters/google/idle.webp +0 -0
- package/web/art/characters/google/locked.avif +0 -0
- package/web/art/characters/google/locked.webp +0 -0
- package/web/art/characters/google/working.avif +0 -0
- package/web/art/characters/google/working.webp +0 -0
- package/web/art/characters/openai/idle.avif +0 -0
- package/web/art/characters/openai/idle.webp +0 -0
- package/web/art/characters/openai/locked.avif +0 -0
- package/web/art/characters/openai/locked.webp +0 -0
- package/web/art/characters/openai/working.avif +0 -0
- package/web/art/characters/openai/working.webp +0 -0
- package/web/art/characters/shell/idle.avif +0 -0
- package/web/art/characters/shell/idle.webp +0 -0
- package/web/art/characters/shell/locked.avif +0 -0
- package/web/art/characters/shell/locked.webp +0 -0
- package/web/art/characters/shell/working.avif +0 -0
- package/web/art/characters/shell/working.webp +0 -0
- package/web/art/characters/xai/idle.avif +0 -0
- package/web/art/characters/xai/idle.webp +0 -0
- package/web/art/characters/xai/locked.avif +0 -0
- package/web/art/characters/xai/locked.webp +0 -0
- package/web/art/characters/xai/working.avif +0 -0
- package/web/art/characters/xai/working.webp +0 -0
- package/web/art/icons/anthropic.avif +0 -0
- package/web/art/icons/anthropic.webp +0 -0
- package/web/art/icons/google.avif +0 -0
- package/web/art/icons/google.webp +0 -0
- package/web/art/icons/openai.avif +0 -0
- package/web/art/icons/openai.webp +0 -0
- package/web/art/icons/shell.avif +0 -0
- package/web/art/icons/shell.webp +0 -0
- package/web/art/icons/xai.avif +0 -0
- package/web/art/icons/xai.webp +0 -0
- package/web/art/page-light.avif +0 -0
- package/web/art/page-light.webp +0 -0
- package/web/art/page.avif +0 -0
- package/web/art/page.webp +0 -0
- package/web/art/ui/empty-state.avif +0 -0
- package/web/art/ui/empty-state.webp +0 -0
- package/web/art/ui/favicon.png +0 -0
- package/web/art/ui/frame-ornate.png +0 -0
- package/web/art/ui/guild-crest.png +0 -0
- package/web/fonts/OFL.txt +93 -0
- package/web/fonts/cinzel.woff2 +0 -0
- package/web/index.html +82 -14
- package/web/styles.css +208 -68
- package/web/art/characters/anthropic/idle.png +0 -0
- package/web/art/characters/anthropic/locked.png +0 -0
- package/web/art/characters/anthropic/working.png +0 -0
- package/web/art/characters/google/idle.png +0 -0
- package/web/art/characters/google/locked.png +0 -0
- package/web/art/characters/google/working.png +0 -0
- package/web/art/characters/openai/idle.png +0 -0
- package/web/art/characters/openai/locked.png +0 -0
- package/web/art/characters/openai/working.png +0 -0
- package/web/art/characters/shell/idle.png +0 -0
- package/web/art/characters/shell/locked.png +0 -0
- package/web/art/characters/shell/working.png +0 -0
- package/web/art/characters/xai/idle.png +0 -0
- package/web/art/characters/xai/locked.png +0 -0
- package/web/art/characters/xai/working.png +0 -0
- package/web/art/icons/anthropic.png +0 -0
- package/web/art/icons/google.png +0 -0
- package/web/art/icons/openai.png +0 -0
- package/web/art/icons/shell.png +0 -0
- package/web/art/icons/xai.png +0 -0
- package/web/art/page.png +0 -0
package/README.md
CHANGED
|
@@ -8,8 +8,9 @@ coding tool, and see at a glance which sessions and agents are working.
|
|
|
8
8
|
|
|
9
9
|
* **Start sessions from provider cards.** Anthropic (Claude Code), OpenAI
|
|
10
10
|
(Codex CLI), Google (Gemini CLI), xAI (Grok Build), and a plain shell.
|
|
11
|
-
**New** starts a fresh session; **Existing**
|
|
12
|
-
|
|
11
|
+
**New** starts a fresh session; **Existing** lists the tool's own earlier
|
|
12
|
+
sessions, read from where the tool keeps them, with their ids, and
|
|
13
|
+
resumes one in its own folder, or any session by id. Providers whose tool is not
|
|
13
14
|
installed are shown greyed out with an **Install** button that runs
|
|
14
15
|
`npm install -g` in a session you can watch, or with install instructions
|
|
15
16
|
when the tool is not an npm package. Installed tools show their version
|
|
@@ -33,15 +34,17 @@ coding tool, and see at a glance which sessions and agents are working.
|
|
|
33
34
|
* **Close the page any time.** A separate local session manager owns the
|
|
34
35
|
terminals. Reopen the page and it reconnects to the same sessions with
|
|
35
36
|
their screens intact, as long as the manager is still running. Sessions do
|
|
36
|
-
not survive a computer restart. **
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
not survive a computer restart. **Restart manager** in the top bar ends
|
|
38
|
+
every session and starts a fresh manager, and the page reconnects to it by
|
|
39
|
+
itself; **Stop manager** ends every session and leaves the manager
|
|
40
|
+
stopped. Both ask first while any session is still running. The top bar
|
|
41
|
+
also shows the version you are running.
|
|
39
42
|
* **Stay current.** When a newer Agent Guild is on npm, an **Upgrade**
|
|
40
43
|
button appears in the top bar and runs `npm install -g` in a session you
|
|
41
|
-
can watch. Sessions keep running;
|
|
42
|
-
|
|
44
|
+
can watch. Sessions keep running; once they are done, **Restart to use
|
|
45
|
+
vX.Y.Z** in the top bar switches to the new version.
|
|
43
46
|
* **Light or dark.** The page follows your system theme and the top-bar
|
|
44
|
-
toggle switches it.
|
|
47
|
+
toggle switches it.
|
|
45
48
|
|
|
46
49
|
## Requirements
|
|
47
50
|
|
|
@@ -69,6 +72,7 @@ page stores it and then removes it from the address bar.
|
|
|
69
72
|
| `agent-guild` or `agent-guild open` | Start the manager if needed and open the page. `--no-browser` prints the URL instead. |
|
|
70
73
|
| `agent-guild status` | Show whether the manager is running and list its sessions. |
|
|
71
74
|
| `agent-guild stop` | Stop the manager. This ends every session, without asking. The page's **Stop manager** button does the same and asks first while sessions are running. |
|
|
75
|
+
| `agent-guild restart` | Stop the manager and start it again, on the version installed on disk. This ends every session, without asking. The page's **Restart manager** button does the same and asks first while sessions are running. |
|
|
72
76
|
| `agent-guild start` | Run the manager in the foreground, for debugging. |
|
|
73
77
|
| `agent-guild url` | Print the page URL with its token. |
|
|
74
78
|
|
|
@@ -100,6 +104,7 @@ one platform under a `win32` or `darwin` key. See
|
|
|
100
104
|
| `usage` | Where the usage meters come from: `"claude"`, `"codex"`, `"gemini"`, `{ "command", "args" }` for a program that prints `{ "windows": [{ "label", "usedPercent", "resetsAt" }] }`, or `null` for none. |
|
|
101
105
|
| `modelPattern` | Regular expression that finds the model name on the tool's screen when the tool does not report it. |
|
|
102
106
|
| `resumeArgs` | Arguments that resume the tool's own session, with `{id}` standing for the id, e.g. `["--resume", "{id}"]`. Without it the card has no **Existing** button. |
|
|
107
|
+
| `history` | Where the list of earlier sessions comes from: `"claude"`, `"codex"`, `"gemini"`, `"grok"` (the tool's own session files under its home folder), `{ "command", "args" }` for a program that prints `{ "sessions": [{ "id", "title", "cwd", "startedAt", "updatedAt" }] }`, or `null` for none, in which case **Existing** asks for an id. |
|
|
103
108
|
| `env` | Extra environment variables for the tool. |
|
|
104
109
|
| `accounts` | Further sign-ins of the tool, each in its own home folder, e.g. `[{ "id": "work", "label": "Work", "dir": "~/.claude-work" }]`. Without `dir`, the folder is `accounts/<provider>/<account>` in the data folder. The card shows one chip per account with its own meters, and a session starts under the chip picked; the tool signs in from inside the first session, and its reporting hooks are copied into the folder on first use. An entry with id `default` renames the tool's own sign-in. Needs `homeVar`. |
|
|
105
110
|
| `homeVar` | The environment variable that moves the tool's home folder, e.g. `CLAUDE_CONFIG_DIR`. Set for Claude Code, Codex CLI, Gemini CLI and Grok Build by default. |
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// agent-guild-report <agent-id> [--name N] [--status working|waiting|idle|done]
|
|
10
10
|
// [--detail TEXT] [--kind KIND] [--remove]
|
|
11
11
|
// agent-guild-report --model NAME [--display-name TEXT]
|
|
12
|
+
// agent-guild-report --session ID (the tool's own session id, for resuming it later)
|
|
12
13
|
// agent-guild-report --hook (reads a hook event as JSON on stdin:
|
|
13
14
|
// Claude Code, Codex CLI, Gemini CLI, Grok Build)
|
|
14
15
|
// agent-guild-report --claude-statusline [--passthrough]
|
|
@@ -27,6 +28,7 @@ if (!inSession && env.AGENT_GUILD_SESSION_ID && !env.AGENT_GUILD_REPORT_TOKEN) {
|
|
|
27
28
|
|
|
28
29
|
const USAGE = `Usage: agent-guild-report <agent-id> [--name N] [--status working|waiting|idle|done] [--detail TEXT] [--kind KIND] [--remove]
|
|
29
30
|
agent-guild-report --model NAME [--display-name TEXT]
|
|
31
|
+
agent-guild-report --session ID (the tool's own session id)
|
|
30
32
|
agent-guild-report --hook (reads a coding tool's hook event JSON from stdin)
|
|
31
33
|
agent-guild-report --claude-statusline [--passthrough] (reads Claude Code status line JSON from stdin)`;
|
|
32
34
|
|
|
@@ -60,7 +62,8 @@ function readStdin() {
|
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
async function send(report) {
|
|
63
|
-
const kind = report.agentId !== undefined || report.finishForeground === true ? 'agents'
|
|
65
|
+
const kind = report.agentId !== undefined || report.finishForeground === true ? 'agents'
|
|
66
|
+
: report.toolSessionId !== undefined ? 'tool-session' : 'model';
|
|
64
67
|
const url = `${env.AGENT_GUILD_URL}/api/v1/sessions/${env.AGENT_GUILD_SESSION_ID}/${kind}`;
|
|
65
68
|
const res = await fetch(url, {
|
|
66
69
|
method: 'POST',
|
|
@@ -115,6 +118,16 @@ async function main() {
|
|
|
115
118
|
await send({ model: args.model, displayName: args['display-name'] });
|
|
116
119
|
return;
|
|
117
120
|
}
|
|
121
|
+
if (args.session !== undefined && args.positional.length === 0) {
|
|
122
|
+
if (!args.session) {
|
|
123
|
+
console.error('agent-guild-report: --session needs an id');
|
|
124
|
+
process.exitCode = 2;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!inSession) return;
|
|
128
|
+
await send({ toolSessionId: args.session });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
118
131
|
const agentId = args.positional[0];
|
|
119
132
|
if (!agentId) {
|
|
120
133
|
console.error('agent-guild-report: an agent id is required (see --help)');
|
package/bin/agent-guild.mjs
CHANGED
|
@@ -4,25 +4,21 @@
|
|
|
4
4
|
// agent-guild [open] start the session manager if needed, open the page
|
|
5
5
|
// agent-guild start run the session manager in the foreground
|
|
6
6
|
// agent-guild stop stop the manager (ends all sessions)
|
|
7
|
+
// agent-guild restart stop the manager and start it again (ends all sessions)
|
|
7
8
|
// agent-guild status show whether the manager is running
|
|
8
9
|
// agent-guild url print the page URL (includes the access token)
|
|
9
10
|
|
|
10
11
|
import fs from 'node:fs';
|
|
11
|
-
import path from 'node:path';
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
13
|
import {
|
|
15
14
|
DEFAULT_HOST,
|
|
16
15
|
VERSION,
|
|
17
|
-
ensureDataDir,
|
|
18
16
|
loadOrCreateToken,
|
|
19
17
|
paths,
|
|
20
18
|
readRuntimeFile,
|
|
21
19
|
resolvePort,
|
|
22
20
|
} from '../src/manager/config.mjs';
|
|
23
|
-
|
|
24
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
-
const managerEntry = path.resolve(here, '../src/manager/main.mjs');
|
|
21
|
+
import { spawnManager } from '../src/manager/launch.mjs';
|
|
26
22
|
|
|
27
23
|
function usage() {
|
|
28
24
|
console.log(`Usage: agent-guild [command] [--no-browser]
|
|
@@ -31,6 +27,7 @@ Commands:
|
|
|
31
27
|
open Start the session manager if needed and open the web page (default)
|
|
32
28
|
start Run the session manager in the foreground
|
|
33
29
|
stop Stop the session manager and every session it owns
|
|
30
|
+
restart Stop the session manager and start it again; ends every session
|
|
34
31
|
status Show whether the session manager is running
|
|
35
32
|
url Print the web page URL, including the access token
|
|
36
33
|
|
|
@@ -47,6 +44,12 @@ function baseUrl() {
|
|
|
47
44
|
return `http://${DEFAULT_HOST}:${resolvePort()}`;
|
|
48
45
|
}
|
|
49
46
|
|
|
47
|
+
/** The port a URL names, including the one its scheme implies. */
|
|
48
|
+
function portOf(url) {
|
|
49
|
+
const parsed = new URL(url);
|
|
50
|
+
return parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
|
51
|
+
}
|
|
52
|
+
|
|
50
53
|
async function health(url, timeoutMs = 1000) {
|
|
51
54
|
try {
|
|
52
55
|
const res = await fetch(`${url}/api/v1/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
@@ -88,36 +91,21 @@ function tailLog(lines = 15) {
|
|
|
88
91
|
}
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
/** Start a manager unless one answers. `port` pins the one to listen on; by default the configured one. */
|
|
95
|
+
async function ensureManager({ port = null } = {}) {
|
|
96
|
+
const listenPort = port ?? resolvePort();
|
|
97
|
+
const expectedUrl = `http://${DEFAULT_HOST}:${listenPort}`;
|
|
98
|
+
// A pinned port is the endpoint to serve, so it is also the one to check;
|
|
99
|
+
// a manager found anywhere else is not the one asked for.
|
|
100
|
+
const knownUrl = port === null ? baseUrl() : expectedUrl;
|
|
101
|
+
const running = await health(knownUrl);
|
|
102
|
+
if (running) return { url: knownUrl, started: false, version: running.version };
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
function rotateLog() {
|
|
95
|
-
try {
|
|
96
|
-
if (fs.statSync(paths.log).size > MAX_LOG_BYTES) fs.renameSync(paths.log, `${paths.log}.1`);
|
|
97
|
-
} catch { /* no log yet */ }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function ensureManager() {
|
|
101
|
-
const running = await health(baseUrl());
|
|
102
|
-
if (running) return { url: baseUrl(), started: false, version: running.version };
|
|
103
|
-
|
|
104
|
-
ensureDataDir();
|
|
105
|
-
rotateLog();
|
|
106
|
-
const log = fs.openSync(paths.log, 'a');
|
|
107
|
-
fs.writeSync(log, `\n--- starting manager ${new Date().toISOString()} ---\n`);
|
|
108
|
-
const child = spawn(process.execPath, [managerEntry], {
|
|
109
|
-
detached: true,
|
|
110
|
-
stdio: ['ignore', log, log],
|
|
111
|
-
windowsHide: true,
|
|
112
|
-
env: process.env,
|
|
113
|
-
});
|
|
114
|
-
child.unref();
|
|
115
|
-
fs.closeSync(log);
|
|
104
|
+
const child = spawnManager({ env: { ...process.env, AGENT_GUILD_PORT: String(listenPort) } });
|
|
116
105
|
|
|
117
106
|
let exited = false;
|
|
118
107
|
child.once('exit', () => { exited = true; });
|
|
119
108
|
const deadline = Date.now() + 20000;
|
|
120
|
-
const expectedUrl = `http://${DEFAULT_HOST}:${resolvePort()}`;
|
|
121
109
|
while (Date.now() < deadline && !exited) {
|
|
122
110
|
await new Promise((r) => setTimeout(r, 250));
|
|
123
111
|
const url = readRuntimeFile()?.pid === child.pid ? readRuntimeFile().url : expectedUrl;
|
|
@@ -147,30 +135,80 @@ async function cmdOpen({ browser }) {
|
|
|
147
135
|
}
|
|
148
136
|
}
|
|
149
137
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// `stop`
|
|
157
|
-
// web page's
|
|
138
|
+
/**
|
|
139
|
+
* Ask a running manager to stop, or to stop and start again. Resolves to
|
|
140
|
+
* what it reported: the running session count, and whether it will start
|
|
141
|
+
* a successor itself (a manager from before restarts only stops).
|
|
142
|
+
*/
|
|
143
|
+
async function requestShutdown(url, { restart = false } = {}) {
|
|
144
|
+
// `stop` and `restart` are documented as ending every session, so they do
|
|
145
|
+
// not ask; the web page's buttons are the ones that confirm first.
|
|
158
146
|
const res = await fetch(`${url}/api/v1/shutdown`, {
|
|
159
147
|
method: 'POST',
|
|
160
148
|
headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
|
|
161
|
-
body: JSON.stringify({ force: true }),
|
|
149
|
+
body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
|
|
162
150
|
});
|
|
163
|
-
if (!res.ok) throw new Error(
|
|
164
|
-
const
|
|
151
|
+
if (!res.ok) throw new Error(`${restart ? 'restart' : 'stop'} failed: HTTP ${res.status}`);
|
|
152
|
+
const body = await res.json().catch(() => ({}));
|
|
153
|
+
const running = body.running ?? 0;
|
|
165
154
|
if (running > 0) console.log(`Ending ${running} running session(s).`);
|
|
166
|
-
|
|
155
|
+
return { running, restart: body.restart === true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Resolves to true once nothing answers at `url`, false when it still does after `timeoutMs`. */
|
|
159
|
+
async function waitForStop(url, timeoutMs = 6000) {
|
|
160
|
+
const deadline = Date.now() + timeoutMs;
|
|
161
|
+
while (Date.now() < deadline) {
|
|
167
162
|
await new Promise((r) => setTimeout(r, 150));
|
|
168
|
-
if (!(await health(url, 300)))
|
|
169
|
-
|
|
163
|
+
if (!(await health(url, 300))) return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function cmdStop() {
|
|
169
|
+
const url = baseUrl();
|
|
170
|
+
if (!(await health(url))) {
|
|
171
|
+
console.log('Session manager is not running.');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await requestShutdown(url);
|
|
175
|
+
console.log(await waitForStop(url) ? 'Session manager stopped.' : 'Stop requested; the manager is still shutting down.');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function cmdRestart() {
|
|
179
|
+
const url = baseUrl();
|
|
180
|
+
const before = await health(url);
|
|
181
|
+
if (!before) {
|
|
182
|
+
// Nothing to stop: a restart of a stopped manager is a start.
|
|
183
|
+
const { url: started, version } = await ensureManager();
|
|
184
|
+
console.log(`Session manager was not running; started Agent Guild ${version} at ${started}.`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const { restart } = await requestShutdown(url, { restart: true });
|
|
188
|
+
if (!restart) {
|
|
189
|
+
// A manager from before restarts stops without starting a successor,
|
|
190
|
+
// which is the case right after an upgrade: start one here instead.
|
|
191
|
+
if (!(await waitForStop(url, 15000))) throw new Error('the session manager did not stop, so it could not be restarted.');
|
|
192
|
+
// On the port the old one served, which an ephemeral port setting would otherwise lose.
|
|
193
|
+
const { url: started, version } = await ensureManager({ port: portOf(url) });
|
|
194
|
+
const changed = version !== before.version ? `, now Agent Guild ${version} (was ${before.version})` : '';
|
|
195
|
+
console.log(`Session manager restarted at ${started}${changed}.`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// The old manager starts its successor from the package on disk once its
|
|
199
|
+
// sessions have ended and its port is free, then exits.
|
|
200
|
+
const deadline = Date.now() + 30000;
|
|
201
|
+
while (Date.now() < deadline) {
|
|
202
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
203
|
+
const now = await health(url, 500);
|
|
204
|
+
if (now && now.pid !== before.pid) {
|
|
205
|
+
const changed = now.version !== before.version ? `, now Agent Guild ${now.version} (was ${before.version})` : '';
|
|
206
|
+
console.log(`Session manager restarted at ${url}${changed}.`);
|
|
170
207
|
return;
|
|
171
208
|
}
|
|
172
209
|
}
|
|
173
|
-
|
|
210
|
+
const details = tailLog();
|
|
211
|
+
throw new Error(`the session manager did not come back after the restart.${details ? `\n\nRecent log (${paths.log}):\n${details}` : ''}`);
|
|
174
212
|
}
|
|
175
213
|
|
|
176
214
|
async function cmdStatus() {
|
|
@@ -222,6 +260,7 @@ async function main() {
|
|
|
222
260
|
return undefined;
|
|
223
261
|
}
|
|
224
262
|
case 'stop': return cmdStop();
|
|
263
|
+
case 'restart': return cmdRestart();
|
|
225
264
|
case 'status': return cmdStatus();
|
|
226
265
|
case 'url': {
|
|
227
266
|
console.log(pageUrl(baseUrl(), loadOrCreateToken()));
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"package": "@anthropic-ai/claude-code",
|
|
9
9
|
"versionArgs": ["--version"],
|
|
10
10
|
"usage": "claude",
|
|
11
|
+
"history": "claude",
|
|
11
12
|
"homeVar": "CLAUDE_CONFIG_DIR",
|
|
12
13
|
"accountEnv": { "CLAUDE_SECURESTORAGE_CONFIG_DIR": "{dir}" },
|
|
13
14
|
"hooks": { "path": "settings.json", "example": "claude-code-settings.json" },
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"package": "@openai/codex",
|
|
45
46
|
"versionArgs": ["--version"],
|
|
46
47
|
"usage": "codex",
|
|
48
|
+
"history": "codex",
|
|
47
49
|
"homeVar": "CODEX_HOME",
|
|
48
50
|
"hooks": { "path": "hooks.json", "example": "codex-hooks.json" },
|
|
49
51
|
"modelPattern": "\\bgpt-\\d[a-z0-9.-]*",
|
|
@@ -77,6 +79,7 @@
|
|
|
77
79
|
"package": "@google/gemini-cli",
|
|
78
80
|
"versionArgs": ["--version"],
|
|
79
81
|
"usage": "gemini",
|
|
82
|
+
"history": "gemini",
|
|
80
83
|
"homeVar": "GEMINI_CLI_HOME",
|
|
81
84
|
"accountEnv": { "GEMINI_FORCE_FILE_STORAGE": "true" },
|
|
82
85
|
"hooks": { "path": ".gemini/settings.json", "example": "gemini-settings.json" },
|
|
@@ -100,6 +103,7 @@
|
|
|
100
103
|
"command": "grok",
|
|
101
104
|
"package": "@xai-official/grok",
|
|
102
105
|
"versionArgs": ["--version"],
|
|
106
|
+
"history": "grok",
|
|
103
107
|
"homeVar": "GROK_HOME",
|
|
104
108
|
"hooks": { "path": "hooks/agent-guild.json", "example": "grok-hooks.json" },
|
|
105
109
|
"modelPattern": "\\bgrok-(?:build|\\d)[a-z0-9.-]*",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { compareVersions } from './versions.mjs';
|
|
3
|
+
import { FeedError, USER_AGENT, decodeEntities, failure, readBody, refusal, webUrl } from './news.mjs';
|
|
4
|
+
|
|
5
|
+
const REPOSITORY = 'oddessentials/agent-guild';
|
|
6
|
+
const RELEASES_URL = `https://github.com/${REPOSITORY}/releases`;
|
|
7
|
+
const API_URL = `https://api.github.com/repos/${REPOSITORY}/releases?per_page=30`;
|
|
8
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
9
|
+
const RETRY_MS = 10 * 60 * 1000;
|
|
10
|
+
const MISSING_MS = 2 * 60 * 1000;
|
|
11
|
+
const FETCH_TIMEOUT_MS = 20000;
|
|
12
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
const MAX_CHANGES = 100;
|
|
14
|
+
const MAX_NOTES = 20000;
|
|
15
|
+
const MAX_LINE = 1000;
|
|
16
|
+
const RELEASE_TAG = /^v?(\d+\.\d+\.\d+)$/;
|
|
17
|
+
const VERSION_HEADING = /^v?\d+\.\d+\.\d+(?![\d.])/;
|
|
18
|
+
const INSTALL_STEPS = /^install or update$/i;
|
|
19
|
+
const COMMIT_LINK = /\s*\(\[[0-9a-f]{7,40}\]\([^)\s]*\)\)/gi;
|
|
20
|
+
const INLINE = /\*\*(.+?)\*\*|`([^`]+)`|!\[[^\]]*\]\([^)]*\)|<(https?:\/\/[^>\s]+)>|\[([^\]]+)\]\(([^)\s]+)\)|<\/?[a-z][^>]*>/gi;
|
|
21
|
+
|
|
22
|
+
const iso = (time) => (time ? new Date(time).toISOString() : null);
|
|
23
|
+
|
|
24
|
+
function textRun(text, strong) {
|
|
25
|
+
return strong ? { text: decodeEntities(text), strong: true } : { text: decodeEntities(text) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function inline(source, strong = false) {
|
|
29
|
+
const runs = [];
|
|
30
|
+
let at = 0;
|
|
31
|
+
for (const match of source.matchAll(INLINE)) {
|
|
32
|
+
if (match.index > at) runs.push(textRun(source.slice(at, match.index), strong));
|
|
33
|
+
const [, bold, code, autolink, label, href] = match;
|
|
34
|
+
if (bold !== undefined) {
|
|
35
|
+
runs.push(...inline(bold, true));
|
|
36
|
+
} else if (code !== undefined) {
|
|
37
|
+
runs.push({ ...(strong && { strong: true }), text: code, code: true });
|
|
38
|
+
} else if (autolink !== undefined || label !== undefined) {
|
|
39
|
+
const url = webUrl(autolink ?? href);
|
|
40
|
+
runs.push({ ...textRun(label ?? autolink, strong), ...(url && { url }) });
|
|
41
|
+
}
|
|
42
|
+
at = match.index + match[0].length;
|
|
43
|
+
}
|
|
44
|
+
if (at < source.length) runs.push(textRun(source.slice(at), strong));
|
|
45
|
+
return runs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sameStyle = (a, b) => !a.url && !b.url && Boolean(a.code) === Boolean(b.code) && Boolean(a.strong) === Boolean(b.strong);
|
|
49
|
+
|
|
50
|
+
function tidy(runs) {
|
|
51
|
+
const tidied = [];
|
|
52
|
+
for (const run of runs) {
|
|
53
|
+
const text = run.text.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ');
|
|
54
|
+
if (!text) continue;
|
|
55
|
+
const last = tidied.at(-1);
|
|
56
|
+
if (last && sameStyle(last, run)) last.text += text;
|
|
57
|
+
else tidied.push({ ...run, text });
|
|
58
|
+
}
|
|
59
|
+
if (tidied.length) {
|
|
60
|
+
tidied[0].text = tidied[0].text.trimStart();
|
|
61
|
+
tidied.at(-1).text = tidied.at(-1).text.trimEnd();
|
|
62
|
+
}
|
|
63
|
+
return tidied.filter((run) => run.text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function plain(source) {
|
|
67
|
+
return tidy(inline(source)).map((run) => run.text).join('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseNotes(markdown) {
|
|
71
|
+
const sections = [];
|
|
72
|
+
let section = null;
|
|
73
|
+
let change = null;
|
|
74
|
+
let skipping = false;
|
|
75
|
+
let fenced = false;
|
|
76
|
+
let count = 0;
|
|
77
|
+
for (const whole of String(markdown ?? '').slice(0, MAX_NOTES).replace(/<!--[\s\S]*?(?:-->|$)/g, '').split(/\r?\n/)) {
|
|
78
|
+
const line = whole.slice(0, MAX_LINE);
|
|
79
|
+
if (/^\s*(?:```|~~~)/.test(line)) {
|
|
80
|
+
fenced = !fenced;
|
|
81
|
+
change = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (fenced) continue;
|
|
85
|
+
const heading = /^\s{0,3}#{1,6}\s+(.*?)(?:\s+#+)?\s*$/.exec(line);
|
|
86
|
+
if (heading) {
|
|
87
|
+
const title = plain(heading[1]);
|
|
88
|
+
change = null;
|
|
89
|
+
section = null;
|
|
90
|
+
skipping = INSTALL_STEPS.test(title);
|
|
91
|
+
if (title && !skipping && !VERSION_HEADING.test(title)) sections.push(section = { title, changes: [] });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (skipping) continue;
|
|
95
|
+
if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
|
|
96
|
+
change = null;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const bullet = /^\s*(?:[-*+]|\d{1,9}[.)])\s+(.*)$/.exec(line);
|
|
100
|
+
const body = (bullet ? bullet[1] : line.replace(/^\s*(?:>\s?)+/, '')).trim();
|
|
101
|
+
if (!body) {
|
|
102
|
+
change = null;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const runs = tidy(inline(body.replace(COMMIT_LINK, '')));
|
|
106
|
+
if (runs.length === 0) continue;
|
|
107
|
+
if (!bullet && change) {
|
|
108
|
+
change.splice(0, change.length, ...tidy([...change, { text: ' ' }, ...runs]));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (count === MAX_CHANGES) break;
|
|
112
|
+
if (!section) sections.push(section = { title: null, changes: [] });
|
|
113
|
+
section.changes.push(change = runs);
|
|
114
|
+
count++;
|
|
115
|
+
}
|
|
116
|
+
return sections.filter((s) => s.changes.length > 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseReleases(json) {
|
|
120
|
+
const list = JSON.parse(json);
|
|
121
|
+
if (!Array.isArray(list)) throw new FeedError('sent no releases');
|
|
122
|
+
const releases = new Map();
|
|
123
|
+
for (const release of list) {
|
|
124
|
+
if (!release || typeof release !== 'object' || release.draft || release.prerelease) continue;
|
|
125
|
+
const tag = String(release.tag_name ?? '');
|
|
126
|
+
const version = RELEASE_TAG.exec(tag)?.[1];
|
|
127
|
+
if (!version || releases.has(version)) continue;
|
|
128
|
+
const published = Date.parse(release.published_at);
|
|
129
|
+
releases.set(version, {
|
|
130
|
+
version,
|
|
131
|
+
url: webUrl(String(release.html_url ?? '')) ?? `${RELEASES_URL}/tag/${encodeURIComponent(tag)}`,
|
|
132
|
+
publishedAt: Number.isFinite(published) ? new Date(published).toISOString() : null,
|
|
133
|
+
sections: parseNotes(typeof release.body === 'string' ? release.body : ''),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return [...releases.values()].sort((a, b) => compareVersions(b.version, a.version));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class Changelog extends EventEmitter {
|
|
140
|
+
constructor({
|
|
141
|
+
latest = () => null, fetchImpl = fetch, url = API_URL,
|
|
142
|
+
ttlMs = TTL_MS, retryMs = RETRY_MS, missingMs = MISSING_MS, timeoutMs = FETCH_TIMEOUT_MS, maxBytes = MAX_BYTES,
|
|
143
|
+
} = {}) {
|
|
144
|
+
super();
|
|
145
|
+
this.latest = latest;
|
|
146
|
+
this.fetchImpl = fetchImpl;
|
|
147
|
+
this.url = url;
|
|
148
|
+
this.ttlMs = ttlMs;
|
|
149
|
+
this.retryMs = retryMs;
|
|
150
|
+
this.missingMs = missingMs;
|
|
151
|
+
this.timeoutMs = timeoutMs;
|
|
152
|
+
this.maxBytes = maxBytes;
|
|
153
|
+
this.releases = [];
|
|
154
|
+
this.etag = null;
|
|
155
|
+
this.checkedAt = 0;
|
|
156
|
+
this.okAt = 0;
|
|
157
|
+
this.error = null;
|
|
158
|
+
this.refreshing = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
snapshot() {
|
|
162
|
+
this._refreshDue();
|
|
163
|
+
return { refreshing: Boolean(this.refreshing), okAt: iso(this.okAt), error: this.error, releases: this.releases };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_refreshDue() {
|
|
167
|
+
if (this.refreshing) return;
|
|
168
|
+
const latest = this.latest();
|
|
169
|
+
const missing = Boolean(latest) && !this.releases.some((release) => release.version === latest);
|
|
170
|
+
const wait = this.error ? this.retryMs : missing ? this.missingMs : this.ttlMs;
|
|
171
|
+
if (Date.now() - this.checkedAt < wait) return;
|
|
172
|
+
this.refreshing = this._refresh().finally(() => {
|
|
173
|
+
this.refreshing = null;
|
|
174
|
+
this.emit('updated');
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async _refresh() {
|
|
179
|
+
this.checkedAt = Date.now();
|
|
180
|
+
try {
|
|
181
|
+
const headers = { 'User-Agent': USER_AGENT, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' };
|
|
182
|
+
if (this.etag) headers['If-None-Match'] = this.etag;
|
|
183
|
+
const res = await this.fetchImpl(this.url, { headers, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
184
|
+
if (res.status !== 304) {
|
|
185
|
+
if (!res.ok) throw new FeedError(refusal(res));
|
|
186
|
+
this.releases = parseReleases(await readBody(res, this.maxBytes));
|
|
187
|
+
this.etag = res.headers.get('etag');
|
|
188
|
+
}
|
|
189
|
+
this.error = null;
|
|
190
|
+
this.okAt = Date.now();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
this.error = failure(err, this.timeoutMs);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Starting a session manager as a detached background process. Shared by
|
|
2
|
+
// the `agent-guild` CLI, which starts one when none is running, and by a
|
|
3
|
+
// manager that restarts itself: both run the package on disk, so a restart
|
|
4
|
+
// after an upgrade comes up on the new version.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { ensureDataDir, paths } from './config.mjs';
|
|
11
|
+
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
/** The manager entry point, from the package files on disk. */
|
|
14
|
+
export const MANAGER_ENTRY = path.join(here, 'main.mjs');
|
|
15
|
+
/** The package root the manager runs from. */
|
|
16
|
+
export const ROOT_DIR = path.resolve(here, '../..');
|
|
17
|
+
|
|
18
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/** Keep one previous log so the file cannot grow without bound. */
|
|
21
|
+
function rotateLog() {
|
|
22
|
+
try {
|
|
23
|
+
if (fs.statSync(paths.log).size > MAX_LOG_BYTES) fs.renameSync(paths.log, `${paths.log}.1`);
|
|
24
|
+
} catch { /* no log yet */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start a manager that outlives this process, with its output appended to
|
|
29
|
+
* `manager.log`. Returns the child; the caller watches `/health` or the
|
|
30
|
+
* runtime file to learn when it is serving.
|
|
31
|
+
*
|
|
32
|
+
* @param {{ env?: NodeJS.ProcessEnv, note?: string }} [opts]
|
|
33
|
+
*/
|
|
34
|
+
export function spawnManager({ env = process.env, note = 'starting manager' } = {}) {
|
|
35
|
+
ensureDataDir();
|
|
36
|
+
rotateLog();
|
|
37
|
+
const log = fs.openSync(paths.log, 'a');
|
|
38
|
+
fs.writeSync(log, `\n--- ${note} ${new Date().toISOString()} ---\n`);
|
|
39
|
+
const child = spawn(process.execPath, [MANAGER_ENTRY], {
|
|
40
|
+
detached: true,
|
|
41
|
+
stdio: ['ignore', log, log],
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
env,
|
|
44
|
+
});
|
|
45
|
+
child.unref();
|
|
46
|
+
fs.closeSync(log);
|
|
47
|
+
return child;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The double-click launcher for this platform, when the package carries one
|
|
52
|
+
* (a checkout of the repository; the npm package does not include them).
|
|
53
|
+
* Null otherwise, so a page never names a file that is not there.
|
|
54
|
+
*/
|
|
55
|
+
export function launcherPath(platform = process.platform, rootDir = ROOT_DIR) {
|
|
56
|
+
const name = platform === 'win32' ? 'AgentGuild.cmd' : platform === 'darwin' ? 'AgentGuild.command' : null;
|
|
57
|
+
if (!name) return null;
|
|
58
|
+
const file = path.join(rootDir, 'launchers', name);
|
|
59
|
+
try {
|
|
60
|
+
return fs.statSync(file).isFile() ? file : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|