@oddessentials/agent-guild 0.20.0 → 0.22.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 +19 -1
- package/package.json +1 -1
- package/src/manager/command-resolver.mjs +9 -6
- package/src/manager/config.mjs +21 -0
- package/src/manager/herdr.mjs +156 -0
- package/src/manager/main.mjs +6 -1
- package/src/manager/providers.mjs +1 -1
- package/src/manager/server.mjs +7 -3
- package/src/manager/session-manager.mjs +229 -16
- package/src/manager/session.mjs +53 -15
- package/src/manager/shells.mjs +63 -7
- package/web/app.js +190 -15
- package/web/index.html +14 -0
- package/web/skins/gnomeland/skin.css +2 -2
- package/web/skins/grove/skin.css +2 -2
- package/web/skins/guild/skin.css +2 -2
- package/web/skins/orbital/skin.css +2 -2
- package/web/styles.css +18 -2
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
|
|
@@ -113,6 +124,11 @@ vX.Y.Z** switches over when you are ready. The version badge opens
|
|
|
113
124
|
|
|
114
125
|
<img src="docs/images/whats-new.webp" alt="The What's new panel listing the release notes of each version">
|
|
115
126
|
|
|
127
|
+
**Keep notes at hand.** **Notes** in the top bar opens a notepad for prompts,
|
|
128
|
+
commands and to-dos, even while a terminal is open. It saves up to 100,000
|
|
129
|
+
characters as you type and keeps them in this browser, never on the manager;
|
|
130
|
+
every tab of the page shows the same notes.
|
|
131
|
+
|
|
116
132
|
**Appearance.** The Appearance menu in the top bar picks a skin and light or
|
|
117
133
|
dark mode, without a reload. **Guild** is the default fantasy look;
|
|
118
134
|
**Professional** is a plain business look with no characters; **Orbital**
|
|
@@ -254,7 +270,9 @@ npm test
|
|
|
254
270
|
|
|
255
271
|
## Current limits
|
|
256
272
|
|
|
257
|
-
* Sessions end when the manager stops or the computer restarts
|
|
273
|
+
* Sessions end when the manager stops or the computer restarts, except a
|
|
274
|
+
Shell session in tmux or herdr, which keeps running in it while the
|
|
275
|
+
manager is stopped.
|
|
258
276
|
* Antigravity CLI and Grok Build have no usage meter.
|
|
259
277
|
* Antigravity CLI has one account per computer user: it keeps its sign-in in
|
|
260
278
|
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.
|
|
3
|
+
"version": "0.22.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
|
-
|
|
87
|
-
|
|
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
|
|
125
|
-
* output; rejects with the error carrying stdout
|
|
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
|
}
|
package/src/manager/config.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/manager/main.mjs
CHANGED
|
@@ -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,
|
package/src/manager/server.mjs
CHANGED
|
@@ -306,8 +306,9 @@ export function createManagerServer({
|
|
|
306
306
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
307
307
|
}
|
|
308
308
|
if (route === '/shutdown' && method === 'POST') {
|
|
309
|
-
// Stopping the manager ends every session
|
|
310
|
-
// `force` while any is running. The same
|
|
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.
|
|
311
312
|
const body = await readJsonBody(req);
|
|
312
313
|
const running = manager.runningCount();
|
|
313
314
|
if (running > 0 && body.force !== true) {
|
|
@@ -328,7 +329,7 @@ export function createManagerServer({
|
|
|
328
329
|
return undefined;
|
|
329
330
|
}
|
|
330
331
|
|
|
331
|
-
const sessionMatch = route.match(/^\/sessions\/([a-f0-9]+)(\/stop)?$/);
|
|
332
|
+
const sessionMatch = route.match(/^\/sessions\/([a-f0-9]+)(\/stop|\/reattach)?$/);
|
|
332
333
|
if (sessionMatch) {
|
|
333
334
|
const [, id, action] = sessionMatch;
|
|
334
335
|
if (!action && method === 'GET') return sendJson(res, 200, { session: manager.get(id).toJSON() });
|
|
@@ -348,6 +349,9 @@ export function createManagerServer({
|
|
|
348
349
|
if (action === '/stop' && method === 'POST') {
|
|
349
350
|
return sendJson(res, 200, { session: manager.stop(id).toJSON() });
|
|
350
351
|
}
|
|
352
|
+
if (action === '/reattach' && method === 'POST') {
|
|
353
|
+
return sendJson(res, 200, { session: (await manager.reattach(id)).toJSON() });
|
|
354
|
+
}
|
|
351
355
|
}
|
|
352
356
|
throw new HttpError(404, `no route for ${method} ${url.pathname}`, 'not_found');
|
|
353
357
|
}
|