@oddessentials/agent-guild 0.4.0 → 0.5.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
@@ -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** resumes one of
12
- the tool's own earlier sessions from its id. Providers whose tool is not
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,13 +34,15 @@ 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. **Stop manager** in the top bar stops the
37
- manager and ends every session; it asks first while any session is still
38
- running.
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; stop the manager and run `agent-guild
42
- open` to use the new version.
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
47
  toggle switches it. The guild artwork is the dark theme.
45
48
 
@@ -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' : 'model';
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)');
@@ -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
- const MAX_LOG_BYTES = 5 * 1024 * 1024;
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
- /** Keep one previous log so the file cannot grow without bound. */
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
- async function cmdStop() {
151
- const url = baseUrl();
152
- if (!(await health(url))) {
153
- console.log('Session manager is not running.');
154
- return;
155
- }
156
- // `stop` is documented as ending every session, so it does not ask; the
157
- // web page's Stop manager button is the one that confirms first.
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(`stop failed: HTTP ${res.status}`);
164
- const { running = 0 } = await res.json().catch(() => ({}));
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
- for (let i = 0; i < 40; i++) {
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
- console.log('Session manager stopped.');
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
- console.log('Stop requested; the manager is still shutting down.');
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.4.0",
3
+ "version": "0.5.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,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
+ }
@@ -7,12 +7,14 @@ import { fileURLToPath } from 'node:url';
7
7
  import { ProviderRegistry } from './providers.mjs';
8
8
  import { SessionManager } from './session-manager.mjs';
9
9
  import { UsageMonitor } from './usage.mjs';
10
+ import { SessionHistory } from './session-history.mjs';
10
11
  import { ModelStats } from './model-stats.mjs';
11
12
  import { NewsFeed } from './news.mjs';
12
13
  import { createManagerServer } from './server.mjs';
13
14
  import { SelfUpdate } from './self-update.mjs';
14
15
  import { resolveBaseEnv, pathReader } from './shell-env.mjs';
15
16
  import { writeReportShims } from './report-shims.mjs';
17
+ import { launcherPath, spawnManager } from './launch.mjs';
16
18
  import {
17
19
  DEFAULT_HOST,
18
20
  PACKAGE_FILE,
@@ -59,6 +61,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
59
61
  const selfUpdate = new SelfUpdate({ pkg: PACKAGE_NAME, version, packageFile, registry });
60
62
  const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate });
61
63
  const usage = new UsageMonitor({ registry, env: baseEnv });
64
+ const history = new SessionHistory({ registry, env: baseEnv });
62
65
  const modelStats = new ModelStats({ registry });
63
66
  const news = new NewsFeed({ registry });
64
67
  let closing = null;
@@ -70,9 +73,14 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
70
73
  const versionTimer = setInterval(refreshVersions, VERSION_REFRESH_MS);
71
74
  versionTimer.unref();
72
75
 
73
- const shutdown = (reason = 'shutdown') => {
76
+ /**
77
+ * End every session and close the API. With `restart`, a new manager is
78
+ * then started from the package on disk, so it comes up on the version
79
+ * an upgrade installed; clients reconnect to it by themselves.
80
+ */
81
+ const shutdown = (reason = 'shutdown', { restart = false } = {}) => {
74
82
  if (closing) return closing;
75
- console.log(`[manager] stopping (${reason}); ending ${manager.sessions.size} session(s)`);
83
+ console.log(`[manager] ${restart ? 'restarting' : 'stopping'} (${reason}); ending ${manager.sessions.size} session(s)`);
76
84
  clearInterval(versionTimer);
77
85
  removeRuntimeFile();
78
86
  // Sessions end before the API closes, and the last event says whether
@@ -80,7 +88,17 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
80
88
  // from a timeout. The manager refuses new sessions meanwhile.
81
89
  closing = manager.shutdown().then(({ remaining }) => {
82
90
  if (remaining > 0) console.warn(`[manager] ${remaining} session process(es) did not confirm exiting in time`);
83
- return api.close({ notice: { type: 'manager.stopped', remaining } });
91
+ return api.close({ notice: { type: 'manager.stopped', remaining, restart } });
92
+ }).then(() => {
93
+ // Only once the port is released: the successor listens on the same one,
94
+ // the bound one rather than a configured 0, so clients find it again.
95
+ if (!restart) return;
96
+ try {
97
+ const child = spawnManager({ note: 'restarting manager', env: { ...process.env, AGENT_GUILD_PORT: String(api.port) } });
98
+ console.log(`[manager] started the next manager (pid ${child.pid})`);
99
+ } catch (err) {
100
+ console.error(`[manager] could not start the next manager: ${err.message}`);
101
+ }
84
102
  });
85
103
  return closing;
86
104
  };
@@ -92,6 +110,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
92
110
  manager,
93
111
  registry,
94
112
  usage,
113
+ history,
95
114
  modelStats,
96
115
  news,
97
116
  token,
@@ -101,7 +120,8 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
101
120
  version,
102
121
  selfUpdate,
103
122
  extraOrigins,
104
- onShutdownRequest: () => shutdown('requested via API').then(() => process.exit(0)),
123
+ launcher: launcherPath(),
124
+ onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
105
125
  });
106
126
 
107
127
  try {
@@ -60,6 +60,15 @@ function normalizeUsage(usage) {
60
60
  return null;
61
61
  }
62
62
 
63
+ /** "claude", "codex", "gemini", "grok", a { command, args } that prints past sessions as JSON, or null. */
64
+ function normalizeHistory(history) {
65
+ if (['claude', 'codex', 'gemini', 'grok'].includes(history)) return history;
66
+ if (history && typeof history === 'object' && typeof history.command === 'string' && history.command) {
67
+ return { command: history.command, args: Array.isArray(history.args) ? history.args.map(String) : [] };
68
+ }
69
+ return null;
70
+ }
71
+
63
72
  function stringList(value) {
64
73
  return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
65
74
  }
@@ -153,6 +162,7 @@ function normalize(raw, platform, warnings) {
153
162
  package: merged.package ? String(merged.package) : null,
154
163
  versionArgs: Array.isArray(merged.versionArgs) && merged.versionArgs.length ? merged.versionArgs.map(String) : null,
155
164
  usage: normalizeUsage(merged.usage),
165
+ history: normalizeHistory(merged.history),
156
166
  modelPattern: merged.modelPattern ? String(merged.modelPattern) : null,
157
167
  args: Array.isArray(merged.args) ? merged.args.map(String) : [],
158
168
  resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
@@ -612,6 +622,7 @@ export class ProviderRegistry extends EventEmitter {
612
622
  warnings: this.installWarnings(provider, installs),
613
623
  npmNote: provider.npmNote,
614
624
  usageSource: provider.usage === null ? null : typeof provider.usage === 'string' ? provider.usage : 'command',
625
+ historySource: provider.history === null ? null : typeof provider.history === 'string' ? provider.history : 'command',
615
626
  accounts: provider.accounts.map(({ id, label }) => ({ id, label })),
616
627
  modelPattern: provider.modelPattern,
617
628
  color: provider.color,
@@ -100,6 +100,7 @@ export function createManagerServer({
100
100
  manager,
101
101
  registry,
102
102
  usage,
103
+ history,
103
104
  modelStats,
104
105
  news = null,
105
106
  token,
@@ -109,6 +110,9 @@ export function createManagerServer({
109
110
  version = '0.0.0',
110
111
  selfUpdate = null,
111
112
  extraOrigins = [],
113
+ /** The double-click launcher file for this platform, or null when the package carries none. */
114
+ launcher = null,
115
+ /** @type {(opts: { restart: boolean }) => void} */
112
116
  onShutdownRequest = () => {},
113
117
  }) {
114
118
  const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
@@ -184,9 +188,10 @@ export function createManagerServer({
184
188
  return sendJson(res, 200, { ok: true, name: 'agent-guild', version, pid: process.pid });
185
189
  }
186
190
 
187
- // Agent and model reports may authenticate with the per-session report
188
- // token that the manager injects into each tool's environment.
189
- const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model)$/);
191
+ // Agent, model and tool-session reports may authenticate with the
192
+ // per-session report token that the manager injects into each tool's
193
+ // environment.
194
+ const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session)$/);
190
195
  if (reportMatch && method === 'POST') {
191
196
  const [, id, kind] = reportMatch;
192
197
  const body = await readJsonBody(req);
@@ -195,7 +200,8 @@ export function createManagerServer({
195
200
  reportToken: req.headers['x-agent-guild-report-token'],
196
201
  };
197
202
  if (kind === 'agents') return sendJson(res, 200, { agent: manager.reportAgent(id, body, auth) });
198
- return sendJson(res, 200, { model: manager.reportModel(id, body, auth) });
203
+ if (kind === 'model') return sendJson(res, 200, { model: manager.reportModel(id, body, auth) });
204
+ return sendJson(res, 200, { toolSessionId: manager.reportToolSession(id, body, auth) });
199
205
  }
200
206
 
201
207
  requireAuth(req, url);
@@ -210,6 +216,7 @@ export function createManagerServer({
210
216
  startedAt,
211
217
  warnings: registry.warnings,
212
218
  upgrade: upgradeInfo(),
219
+ launcher,
213
220
  });
214
221
  }
215
222
  if (route === '/upgrade' && method === 'POST') {
@@ -234,6 +241,14 @@ export function createManagerServer({
234
241
  if (route === '/news' && method === 'GET' && news) {
235
242
  return sendJson(res, 200, news.snapshot());
236
243
  }
244
+ const historyMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/history$/);
245
+ if (historyMatch && method === 'GET') {
246
+ const provider = registry.get(historyMatch[1]);
247
+ if (!provider) throw new HttpError(404, `unknown provider "${historyMatch[1]}"`, 'unknown_provider');
248
+ if (!provider.history) throw new HttpError(400, `${provider.tool} has no history source configured`, 'history_unsupported');
249
+ const account = registry.account(provider, url.searchParams.get('account'));
250
+ return sendJson(res, 200, { history: await history.list(provider, account, { limit: url.searchParams.get('limit') }) });
251
+ }
237
252
  const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
238
253
  if (installMatch && method === 'POST') {
239
254
  const body = await readJsonBody(req);
@@ -258,14 +273,16 @@ export function createManagerServer({
258
273
  err.running = running;
259
274
  throw err;
260
275
  }
276
+ // With `restart`, a new manager is started once this one has closed.
277
+ const restart = body.restart === true;
261
278
  // Refuse new sessions from this moment, before the shutdown itself
262
279
  // runs: a session accepted in between would be ended without warning.
263
280
  manager.closing = true;
264
- sendJson(res, 202, { ok: true, running });
281
+ sendJson(res, 202, { ok: true, running, restart });
265
282
  // Tell every client first, so a second page shows "stopped" rather
266
283
  // than "not reachable" when its socket drops.
267
- broadcast({ type: 'manager.stopping', running });
268
- setImmediate(onShutdownRequest);
284
+ broadcast({ type: 'manager.stopping', running, restart });
285
+ setImmediate(() => onShutdownRequest({ restart }));
269
286
  return undefined;
270
287
  }
271
288
 
@@ -350,7 +367,7 @@ export function createManagerServer({
350
367
 
351
368
  function handleEvents(ws) {
352
369
  eventClients.add(ws);
353
- safeSend(ws, { type: 'hello', version, upgrade: upgradeInfo(), sessions: manager.list() });
370
+ safeSend(ws, { type: 'hello', version, pid: process.pid, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
354
371
  ws.on('close', () => eventClients.delete(ws));
355
372
  ws.on('message', () => { /* events socket is server -> client only */ });
356
373
  }