@oddessentials/agent-guild 0.19.0 → 0.21.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
@@ -67,6 +67,17 @@ Prompt are also offered when found. On macOS and Linux it follows your login
67
67
  shell. Pick the default chip to clear a saved choice and follow the default
68
68
  again. The choice applies to new sessions.
69
69
 
70
+ **Work inside tmux or herdr.** When installed, the Shell card also offers
71
+ tmux (3.2 or later, on macOS and Linux) and herdr. A tmux card runs a tmux
72
+ session of its own, with your own configuration, and tools in it report to
73
+ the card as they would in a shell. A herdr card opens your herdr session and
74
+ shows the agents herdr sees in its panes, working, blocked or idle. Stopping
75
+ or closing either card only detaches it, as closing a terminal window would:
76
+ the session keeps running, **Reattach** on the card brings it back, and the
77
+ card names the command that reattaches it from a terminal. Stopping or
78
+ restarting the manager detaches them the same way, and the cards come back,
79
+ closed and ready to reattach, when it starts again.
80
+
70
81
  <img src="docs/images/terminal.webp" alt="An open Claude Code session with its helper agents shown in the header">
71
82
 
72
83
  **Agents and models at work.** Helper agents that a tool starts appear on its
@@ -120,7 +131,23 @@ puts a crew of little robots in deep space; **Grove** is a calm moss garden
120
131
  of gentle nature spirits; **Gnomeland** is a lantern-lit village of gnome
121
132
  builders, engineers with a little magic. The page
122
133
  follows your system's light or dark setting until you pick one. See
123
- [docs/SKINS.md](docs/SKINS.md) to make another.
134
+ [docs/SKINS.md](docs/SKINS.md) to make another. **Alert sounds**, off until
135
+ you turn them on in the same menu, chime when the session manager confirms
136
+ a stop or restart, remains unavailable after a brief recovery check, or a
137
+ new version is discovered after the initial version check. Session activity
138
+ and assistant responses have no sounds:
139
+ the providers do not yet offer consistently reliable completion signals.
140
+
141
+ Sounds play in one eligible tab per browser and origin after browser playback
142
+ permission (usually a click or keypress). They require Web Locks and writable
143
+ local storage. Opening or reconnecting a page does not replay old alerts,
144
+ even when a different manager has taken over. Closing a browser does not
145
+ stop the manager. After an unexpected disconnect, an already connected page
146
+ checks the local health endpoint twice, two seconds apart, with a one-second
147
+ timeout per request. If both fail, it shows “Manager unavailable” and alerts
148
+ once; it cannot confirm whether the process exited or sessions ended.
149
+ A successful health check, reconnection, or closing the page cancels the alert.
150
+ There is no added polling while connected and no additional monitoring process.
124
151
 
125
152
  ## Commands
126
153
 
@@ -238,7 +265,9 @@ npm test
238
265
 
239
266
  ## Current limits
240
267
 
241
- * Sessions end when the manager stops or the computer restarts.
268
+ * Sessions end when the manager stops or the computer restarts, except a
269
+ Shell session in tmux or herdr, which keeps running in it while the
270
+ manager is stopped.
242
271
  * Antigravity CLI and Grok Build have no usage meter.
243
272
  * Antigravity CLI has one account per computer user: it keeps its sign-in in
244
273
  the system keychain and has no setting for another home folder.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.19.0",
3
+ "version": "0.21.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": {
@@ -83,8 +83,9 @@ export function resolveAllCommands(command, env = process.env, platform = proces
83
83
  return hits;
84
84
  }
85
85
 
86
- export function killWindowsTree(pid, done = () => {}) {
87
- execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 5000 }, (err) => done(err));
86
+ /** End a Windows process and, unless `tree` is false, every process it started. */
87
+ export function killWindowsTree(pid, done = () => {}, { tree = true } = {}) {
88
+ execFile('taskkill', ['/PID', String(pid), ...(tree ? ['/T'] : []), '/F'], { windowsHide: true, timeout: 5000 }, (err) => done(err));
88
89
  }
89
90
 
90
91
  /** Quote one argument for a cmd.exe command line. */
@@ -121,10 +122,11 @@ export function buildSpawnSpec(resolvedPath, args = [], env = process.env, platf
121
122
  }
122
123
 
123
124
  /**
124
- * Run a spawn spec to completion without a terminal. Resolves with its
125
- * output; rejects with the error carrying stdout and stderr.
125
+ * Run a spawn spec to completion without a terminal, with `input` on its
126
+ * stdin. Resolves with its output; rejects with the error carrying stdout
127
+ * and stderr.
126
128
  */
127
- export function runSpec(spec, { env, timeoutMs = 15000, cwd } = {}) {
129
+ export function runSpec(spec, { env, timeoutMs = 15000, cwd, input } = {}) {
128
130
  return new Promise((resolve, reject) => {
129
131
  const opts = { env, cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 };
130
132
  let args = spec.args;
@@ -133,10 +135,11 @@ export function runSpec(spec, { env, timeoutMs = 15000, cwd } = {}) {
133
135
  args = [args];
134
136
  }
135
137
  try {
136
- execFile(spec.file, args, opts, (err, stdout, stderr) => {
138
+ const child = execFile(spec.file, args, opts, (err, stdout, stderr) => {
137
139
  if (err) reject(Object.assign(err, { stdout, stderr }));
138
140
  else resolve({ stdout, stderr });
139
141
  });
142
+ if (input !== undefined) child.stdin.end(input);
140
143
  } catch (err) {
141
144
  reject(err);
142
145
  }
@@ -48,6 +48,8 @@ export const paths = {
48
48
  /** Launchers for agent-guild-report, put first on every session's PATH. */
49
49
  get shims() { return path.join(dataDir(), 'bin'); },
50
50
  get reporting() { return path.join(dataDir(), 'reporting'); },
51
+ /** The tmux and herdr cards a restarted manager brings back, report tokens included. */
52
+ get multiplexers() { return path.join(dataDir(), 'multiplexers.json'); },
51
53
  };
52
54
 
53
55
  function writePrivate(file, contents) {
@@ -72,6 +74,25 @@ export function loadOrCreateToken() {
72
74
  return token;
73
75
  }
74
76
 
77
+ /** Where the manager keeps its tmux and herdr cards, readable only by the user since they hold report tokens. */
78
+ export const multiplexerStore = {
79
+ load() {
80
+ try {
81
+ const { cards } = JSON.parse(fs.readFileSync(paths.multiplexers, 'utf8'));
82
+ return Array.isArray(cards) ? cards : [];
83
+ } catch {
84
+ return [];
85
+ }
86
+ },
87
+ save(cards) {
88
+ ensureDataDir();
89
+ // Written aside and renamed, so a crash never leaves half a file.
90
+ const next = `${paths.multiplexers}.${process.pid}.tmp`;
91
+ writePrivate(next, JSON.stringify({ cards }, null, 2) + '\n');
92
+ fs.renameSync(next, paths.multiplexers);
93
+ },
94
+ };
95
+
75
96
  export function writeRuntimeFile(info) {
76
97
  ensureDataDir();
77
98
  writePrivate(paths.runtime, JSON.stringify(info, null, 2) + '\n');
@@ -0,0 +1,156 @@
1
+ // The agents herdr sees in its panes, for a herdr card. herdr tracks every
2
+ // coding agent in its panes and whether it is working, blocked or idle, with
3
+ // no hooks of ours. Its socket API pushes an event when an agent appears or
4
+ // leaves and, for a pane named in the subscription, when its agent changes
5
+ // state; each event triggers a fresh agent.list, as herdr's documentation
6
+ // advises, since events and reads share no sequence.
7
+
8
+ import net from 'node:net';
9
+ import { buildSpawnSpec, runSpec } from './command-resolver.mjs';
10
+
11
+ const ANY_PANE = ['pane.agent_detected', 'pane.closed', 'pane.exited'];
12
+ const STATUS = { working: 'working', blocked: 'waiting' };
13
+ const REQUEST_TIMEOUT_MS = 10000;
14
+
15
+ /** Card agent reports for herdr's agent list. Idle, done and unknown agents show as idle. */
16
+ export function herdrAgentReports(agents) {
17
+ return (Array.isArray(agents) ? agents : [])
18
+ .filter((agent) => typeof agent?.pane_id === 'string' && typeof agent.agent === 'string')
19
+ .map((agent) => ({
20
+ agentId: `herdr:${agent.pane_id}`,
21
+ name: agent.agent,
22
+ kind: 'agent',
23
+ status: STATUS[agent.agent_status] ?? 'idle',
24
+ detail: typeof agent.cwd === 'string' ? agent.cwd : '',
25
+ }));
26
+ }
27
+
28
+ /**
29
+ * Where to reach the herdr session that a client started with `env` uses:
30
+ * the one HERDR_SESSION names, else the default. herdr names its Windows
31
+ * pipe after the socket path.
32
+ */
33
+ export function herdrSocket(listing, env, platform = process.platform) {
34
+ const sessions = Array.isArray(listing?.sessions) ? listing.sessions : [];
35
+ const session = sessions.find((s) => (env.HERDR_SESSION ? s.name === env.HERDR_SESSION : s.default === true));
36
+ if (!session?.running || typeof session.socket_path !== 'string') return null;
37
+ return platform === 'win32' ? `\\\\.\\pipe\\${session.socket_path}` : session.socket_path;
38
+ }
39
+
40
+ /** Call `onLine` with each JSON line read from `socket`. */
41
+ function readLines(socket, onLine) {
42
+ let buffer = '';
43
+ socket.setEncoding('utf8');
44
+ socket.on('data', (chunk) => {
45
+ buffer += chunk;
46
+ for (let end = buffer.indexOf('\n'); end !== -1; end = buffer.indexOf('\n')) {
47
+ const line = buffer.slice(0, end).trim();
48
+ buffer = buffer.slice(end + 1);
49
+ if (!line) continue;
50
+ try { onLine(JSON.parse(line)); } catch { /* not JSON */ }
51
+ }
52
+ });
53
+ }
54
+
55
+ /** One request on its own connection: resolves with its result. */
56
+ function request(socketPath, method, params = {}) {
57
+ return new Promise((resolve, reject) => {
58
+ const socket = net.connect(socketPath);
59
+ socket.setTimeout(REQUEST_TIMEOUT_MS, () => socket.destroy(new Error(`herdr did not answer ${method}`)));
60
+ socket.on('connect', () => socket.write(`${JSON.stringify({ id: 'agent-guild', method, params })}\n`));
61
+ readLines(socket, (msg) => {
62
+ socket.end();
63
+ if (msg.error) reject(new Error(msg.error.message || msg.error.code));
64
+ else resolve(msg.result);
65
+ });
66
+ socket.on('error', reject);
67
+ socket.on('close', () => reject(new Error(`herdr closed the connection before answering ${method}`)));
68
+ });
69
+ }
70
+
71
+ export class HerdrAgents {
72
+ /**
73
+ * @param {object} opts
74
+ * @param {string} opts.herdr the herdr executable
75
+ * @param {object} opts.env the environment the card's herdr client runs with
76
+ * @param {(agents: object[]) => void} opts.onAgents called with herdr's agent list after each change
77
+ */
78
+ constructor({ herdr, env, platform = process.platform, onAgents }) {
79
+ Object.assign(this, { herdr, env, platform, onAgents });
80
+ this.socketPath = null;
81
+ this.subscription = null;
82
+ this.panes = null;
83
+ this.connecting = false;
84
+ this.reading = false;
85
+ this.readAgain = false;
86
+ this.stopped = false;
87
+ }
88
+
89
+ /** Connect unless connected. A server that was still starting is found on a later call. */
90
+ poke() {
91
+ if (this.stopped || this.subscription || this.connecting) return;
92
+ this.connecting = true;
93
+ this._connect().finally(() => { this.connecting = false; });
94
+ }
95
+
96
+ stop() {
97
+ this.stopped = true;
98
+ this.subscription?.destroy();
99
+ this.subscription = null;
100
+ }
101
+
102
+ async _connect() {
103
+ try {
104
+ const spec = buildSpawnSpec(this.herdr, ['session', 'list', '--json'], this.env, this.platform);
105
+ const { stdout } = await runSpec(spec, { env: this.env, timeoutMs: REQUEST_TIMEOUT_MS });
106
+ this.socketPath = herdrSocket(JSON.parse(stdout), this.env, this.platform);
107
+ } catch {
108
+ return;
109
+ }
110
+ if (this.socketPath && !this.stopped) this._subscribe([]);
111
+ }
112
+
113
+ /** Replace the subscription: any pane's agent arriving or leaving, and each of `panes` changing state. */
114
+ _subscribe(panes) {
115
+ this.subscription?.destroy();
116
+ this.panes = panes;
117
+ const socket = net.connect(this.socketPath);
118
+ this.subscription = socket;
119
+ const subscriptions = [...ANY_PANE.map((type) => ({ type })), ...panes.map((pane) => ({ type: 'pane.agent_status_changed', pane_id: pane }))];
120
+ socket.on('connect', () => socket.write(`${JSON.stringify({ id: 'agent-guild', method: 'events.subscribe', params: { subscriptions } })}\n`));
121
+ readLines(socket, (msg) => {
122
+ if (this.subscription !== socket) return;
123
+ // herdr refuses a subscription naming a pane that has closed, and ends one that fell behind;
124
+ // start over from no panes. Anything else, the acknowledgement included, is a reason to read.
125
+ if (msg.error) return panes.length ? this._subscribe([]) : socket.destroy();
126
+ this._read();
127
+ });
128
+ socket.on('error', () => {});
129
+ socket.on('close', () => {
130
+ if (this.subscription === socket) this.subscription = null;
131
+ });
132
+ }
133
+
134
+ /** Read herdr's agent list, once more if an event arrives meanwhile, and follow the panes it names. */
135
+ async _read() {
136
+ if (this.reading) {
137
+ this.readAgain = true;
138
+ return;
139
+ }
140
+ this.reading = true;
141
+ try {
142
+ do {
143
+ this.readAgain = false;
144
+ const { agents = [] } = (await request(this.socketPath, 'agent.list')) ?? {};
145
+ if (this.stopped) return;
146
+ this.onAgents(agents);
147
+ const panes = agents.map((agent) => agent?.pane_id).filter((pane) => typeof pane === 'string').sort();
148
+ if (this.subscription && panes.join('\n') !== this.panes.join('\n')) this._subscribe(panes);
149
+ } while (this.readAgain && !this.stopped);
150
+ } catch {
151
+ // herdr went away or did not answer; the next event or poke tries again.
152
+ } finally {
153
+ this.reading = false;
154
+ }
155
+ }
156
+ }
@@ -25,6 +25,7 @@ import {
25
25
  VERSION,
26
26
  ensureDataDir,
27
27
  loadOrCreateToken,
28
+ multiplexerStore,
28
29
  paths,
29
30
  removeRuntimeFile,
30
31
  resolvePort,
@@ -67,7 +68,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
67
68
  registry.reportingEnabled = (provider) => sessionHooks.enabled(provider);
68
69
  sessionHooks.warm();
69
70
  const manager = new SessionManager({
70
- registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks,
71
+ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks, store: multiplexerStore,
71
72
  });
72
73
  const usage = new UsageMonitor({ registry, env: baseEnv });
73
74
  const history = new SessionHistory({ registry, env: baseEnv });
@@ -146,6 +147,10 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
146
147
  }
147
148
  throw err;
148
149
  }
150
+ // tmux and herdr cards come back closed, ready to reattach, before the
151
+ // runtime file announces this manager; after listening, since a card's
152
+ // environment names the API's URL.
153
+ await manager.restore();
149
154
 
150
155
  writeRuntimeFile({
151
156
  pid: process.pid,
@@ -669,7 +669,7 @@ export class ProviderRegistry extends EventEmitter {
669
669
  reporting: provider.reporting,
670
670
  reportingEnabled: this.reportingEnabled?.(provider) ?? null,
671
671
  accounts: provider.accounts.map((account) => ({ id: account.id, label: account.label })),
672
- shells: shells?.shells.map((shell) => ({ id: shell.id, label: shell.label, path: shell.path })) ?? null,
672
+ shells: shells?.shells.map((shell) => ({ id: shell.id, label: shell.label, path: shell.path, multiplexer: Boolean(shell.multiplexer) })) ?? null,
673
673
  defaultShell: shells?.defaultId ?? null,
674
674
  modelPattern: provider.modelPattern,
675
675
  color: provider.color,
@@ -25,6 +25,7 @@ const MIME = {
25
25
  '.webp': 'image/webp',
26
26
  '.woff2': 'font/woff2',
27
27
  '.ico': 'image/x-icon',
28
+ '.wav': 'audio/wav',
28
29
  '.json': 'application/json; charset=utf-8',
29
30
  };
30
31
 
@@ -305,8 +306,9 @@ export function createManagerServer({
305
306
  return sendJson(res, 201, { session: session.toJSON() });
306
307
  }
307
308
  if (route === '/shutdown' && method === 'POST') {
308
- // Stopping the manager ends every session, so a client must say
309
- // `force` while any is running. The same guard serves every front end.
309
+ // Stopping the manager ends every session but the tmux and herdr ones,
310
+ // so a client must say `force` while any of those is running. The same
311
+ // guard serves every front end.
310
312
  const body = await readJsonBody(req);
311
313
  const running = manager.runningCount();
312
314
  if (running > 0 && body.force !== true) {
@@ -327,7 +329,7 @@ export function createManagerServer({
327
329
  return undefined;
328
330
  }
329
331
 
330
- const sessionMatch = route.match(/^\/sessions\/([a-f0-9]+)(\/stop)?$/);
332
+ const sessionMatch = route.match(/^\/sessions\/([a-f0-9]+)(\/stop|\/reattach)?$/);
331
333
  if (sessionMatch) {
332
334
  const [, id, action] = sessionMatch;
333
335
  if (!action && method === 'GET') return sendJson(res, 200, { session: manager.get(id).toJSON() });
@@ -347,6 +349,9 @@ export function createManagerServer({
347
349
  if (action === '/stop' && method === 'POST') {
348
350
  return sendJson(res, 200, { session: manager.stop(id).toJSON() });
349
351
  }
352
+ if (action === '/reattach' && method === 'POST') {
353
+ return sendJson(res, 200, { session: (await manager.reattach(id)).toJSON() });
354
+ }
350
355
  }
351
356
  throw new HttpError(404, `no route for ${method} ${url.pathname}`, 'not_found');
352
357
  }
@@ -450,7 +455,7 @@ export function createManagerServer({
450
455
 
451
456
  function handleEvents(ws) {
452
457
  eventClients.add(ws);
453
- safeSend(ws, { type: 'hello', version, pid: process.pid, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
458
+ safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
454
459
  ws.on('close', () => eventClients.delete(ws));
455
460
  ws.on('message', () => { /* events socket is server -> client only */ });
456
461
  }