@oddessentials/agent-guild 0.10.0 → 0.11.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
@@ -140,20 +140,18 @@ The full reference, including every field and environment variable, is in
140
140
 
141
141
  ## Show agents and models
142
142
 
143
- Agents are reported by the coding tool, not guessed from its output. To see
144
- them, add the hooks from the matching file in [examples/](examples/) to
145
- Claude Code, Codex CLI, Gemini CLI or Grok Build. Each helper agent then
146
- appears on the card while it runs, and the card shows the model in use.
147
- Without hooks, the card still names the model when it is given with
148
- `--model` or shown on the tool's screen.
149
-
150
- The hooks call `agent-guild-report`, which the manager puts on the PATH of
151
- every session, so nothing else needs installing. Two tools need a one-time
152
- approval:
153
-
154
- * Codex CLI runs no hook until you trust it: choose "Trust all and continue"
155
- when it starts, or run `/hooks`.
156
- * Claude Code runs none until you accept its workspace-trust prompt.
143
+ Agents are reported by the coding tool's hooks, not guessed from its output.
144
+ Each helper agent appears on the card while it runs. Agent Guild gives every
145
+ Claude Code and Codex CLI session its reporting hooks for that session only,
146
+ without changing the tool's own settings. Gemini CLI has no such option: turn
147
+ on **Agent reporting** on its card once, which links an Agent Guild extension
148
+ into Gemini. Grok Build cannot take hooks for one session yet; add the hooks
149
+ from [examples/grok-hooks.json](examples/grok-hooks.json) to see its agents.
150
+ When a tool's hooks do not run, because hooks are turned off, restricted by
151
+ an administrator or not trusted for the folder, the card says so.
152
+
153
+ The card also names the model, from the hooks, from `--model` or from the
154
+ tool's screen.
157
155
 
158
156
  Any other tool or script can report agents and its model too. See
159
157
  [docs/agent-reporting.md](docs/agent-reporting.md).
@@ -16,14 +16,28 @@
16
16
  // (reads Claude Code status line JSON on stdin; prints a
17
17
  // status line, or the JSON itself with --passthrough)
18
18
 
19
+ import fs from 'node:fs';
20
+ import { createRequire } from 'node:module';
19
21
  import { hookToReports, claudeStatuslineToReport, formatStatusLine } from '../src/report/hooks.mjs';
20
22
 
23
+ // Importing node:http as an ES module costs a hook about 50 ms more than requiring it.
24
+ const http = createRequire(import.meta.url)('node:http');
21
25
  const env = process.env;
22
- const inSession = env.AGENT_GUILD_URL && env.AGENT_GUILD_SESSION_ID && env.AGENT_GUILD_REPORT_TOKEN;
23
- // Gemini CLI's optional environment redaction removes every variable whose
24
- // name contains TOKEN before it runs a hook. Say so instead of staying silent.
25
- if (!inSession && env.AGENT_GUILD_SESSION_ID && !env.AGENT_GUILD_REPORT_TOKEN) {
26
- console.error('agent-guild-report: AGENT_GUILD_REPORT_TOKEN is missing from the environment; the tool may be redacting variables named *TOKEN*');
26
+
27
+ function reportToken() {
28
+ if (env.AGENT_GUILD_REPORT_TOKEN) return env.AGENT_GUILD_REPORT_TOKEN;
29
+ if (!env.AGENT_GUILD_REPORT_FILE) return null;
30
+ try {
31
+ return fs.readFileSync(env.AGENT_GUILD_REPORT_FILE, 'utf8').trim() || null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ const token = env.AGENT_GUILD_URL && env.AGENT_GUILD_SESSION_ID ? reportToken() : null;
38
+ const inSession = Boolean(token);
39
+ if (!inSession && env.AGENT_GUILD_SESSION_ID) {
40
+ console.error('agent-guild-report: the session report token is missing; the tool may have removed AGENT_GUILD_REPORT_TOKEN and AGENT_GUILD_REPORT_FILE from the environment, or the session has ended');
27
41
  }
28
42
 
29
43
  const USAGE = `Usage: agent-guild-report <agent-id> [--name N] [--status working|waiting|idle|done] [--detail TEXT] [--kind KIND] [--remove]
@@ -61,23 +75,31 @@ function readStdin() {
61
75
  });
62
76
  }
63
77
 
64
- async function send(report) {
65
- const kind = report.agentId !== undefined || report.finishForeground === true ? 'agents'
78
+ function send(report) {
79
+ const kind = report.shell !== undefined ? 'shells'
80
+ : report.agentId !== undefined || report.finishForeground === true ? 'agents'
81
+ : report.hello === true ? 'reporting'
66
82
  : report.toolSessionId !== undefined ? 'tool-session' : 'model';
67
- const url = `${env.AGENT_GUILD_URL}/api/v1/sessions/${env.AGENT_GUILD_SESSION_ID}/${kind}`;
68
- const res = await fetch(url, {
69
- method: 'POST',
70
- headers: {
71
- 'Content-Type': 'application/json',
72
- 'X-Agent-Guild-Report-Token': env.AGENT_GUILD_REPORT_TOKEN,
73
- },
74
- body: JSON.stringify(report),
75
- signal: AbortSignal.timeout(3000),
83
+ const body = JSON.stringify(report);
84
+ return new Promise((resolve, reject) => {
85
+ const req = http.request(`${env.AGENT_GUILD_URL}/api/v1/sessions/${env.AGENT_GUILD_SESSION_ID}/${kind}`, {
86
+ method: 'POST',
87
+ headers: {
88
+ 'Content-Type': 'application/json',
89
+ 'Content-Length': Buffer.byteLength(body),
90
+ 'X-Agent-Guild-Report-Token': token,
91
+ },
92
+ timeout: 3000,
93
+ }, (res) => {
94
+ let text = '';
95
+ res.setEncoding('utf8');
96
+ res.on('data', (chunk) => { text += chunk; });
97
+ res.on('end', () => (res.statusCode >= 200 && res.statusCode < 300 ? resolve() : reject(new Error(`HTTP ${res.statusCode}: ${text}`))));
98
+ });
99
+ req.on('timeout', () => req.destroy(new Error('the manager did not answer in time')));
100
+ req.on('error', reject);
101
+ req.end(body);
76
102
  });
77
- if (!res.ok) {
78
- const body = await res.text();
79
- throw new Error(`HTTP ${res.status}: ${body}`);
80
- }
81
103
  }
82
104
 
83
105
  /** A hook must never break the coding tool, so report failures quietly. */
@@ -9,6 +9,7 @@
9
9
  "versionArgs": ["--version"],
10
10
  "usage": "claude",
11
11
  "history": "claude",
12
+ "reporting": "claude",
12
13
  "homeVar": "CLAUDE_CONFIG_DIR",
13
14
  "accountEnv": { "CLAUDE_SECURESTORAGE_CONFIG_DIR": "{dir}" },
14
15
  "hooks": { "path": "settings.json", "example": "claude-code-settings.json" },
@@ -46,6 +47,7 @@
46
47
  "versionArgs": ["--version"],
47
48
  "usage": "codex",
48
49
  "history": "codex",
50
+ "reporting": "codex",
49
51
  "homeVar": "CODEX_HOME",
50
52
  "hooks": { "path": "hooks.json", "example": "codex-hooks.json" },
51
53
  "modelPattern": "\\bgpt-\\d[a-z0-9.-]*",
@@ -80,6 +82,7 @@
80
82
  "versionArgs": ["--version"],
81
83
  "usage": "gemini",
82
84
  "history": "gemini",
85
+ "reporting": "gemini",
83
86
  "homeVar": "GEMINI_CLI_HOME",
84
87
  "accountEnv": { "GEMINI_FORCE_FILE_STORAGE": "true" },
85
88
  "hooks": { "path": ".gemini/settings.json", "example": "gemini-settings.json" },
@@ -104,6 +107,7 @@
104
107
  "package": "@xai-official/grok",
105
108
  "versionArgs": ["--version"],
106
109
  "history": "grok",
110
+ "reporting": "grok",
107
111
  "homeVar": "GROK_HOME",
108
112
  "hooks": { "path": "hooks/agent-guild.json", "example": "grok-hooks.json" },
109
113
  "modelPattern": "\\bgrok-(?:build|\\d)[a-z0-9.-]*",
@@ -50,6 +50,49 @@
50
50
  }
51
51
  ]
52
52
  }
53
+ ],
54
+ "PreToolUse": [
55
+ {
56
+ "matcher": "run_terminal_command",
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "command": "agent-guild-report --hook"
61
+ }
62
+ ]
63
+ }
64
+ ],
65
+ "PostToolUse": [
66
+ {
67
+ "matcher": "run_terminal_command",
68
+ "hooks": [
69
+ {
70
+ "type": "command",
71
+ "command": "agent-guild-report --hook"
72
+ }
73
+ ]
74
+ }
75
+ ],
76
+ "PostToolUseFailure": [
77
+ {
78
+ "matcher": "run_terminal_command",
79
+ "hooks": [
80
+ {
81
+ "type": "command",
82
+ "command": "agent-guild-report --hook"
83
+ }
84
+ ]
85
+ }
86
+ ],
87
+ "Stop": [
88
+ {
89
+ "hooks": [
90
+ {
91
+ "type": "command",
92
+ "command": "agent-guild-report --hook"
93
+ }
94
+ ]
95
+ }
53
96
  ]
54
97
  }
55
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.10.0",
3
+ "version": "0.11.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,6 +83,10 @@ 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));
88
+ }
89
+
86
90
  /** Quote one argument for a cmd.exe command line. */
87
91
  export function quoteForCmd(arg) {
88
92
  const s = String(arg);
@@ -120,9 +124,9 @@ export function buildSpawnSpec(resolvedPath, args = [], env = process.env, platf
120
124
  * Run a spawn spec to completion without a terminal. Resolves with its
121
125
  * output; rejects with the error carrying stdout and stderr.
122
126
  */
123
- export function runSpec(spec, { env, timeoutMs = 15000 } = {}) {
127
+ export function runSpec(spec, { env, timeoutMs = 15000, cwd } = {}) {
124
128
  return new Promise((resolve, reject) => {
125
- const opts = { env, timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 };
129
+ const opts = { env, cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 };
126
130
  let args = spec.args;
127
131
  if (typeof args === 'string') {
128
132
  opts.windowsVerbatimArguments = true;
@@ -47,6 +47,8 @@ export const paths = {
47
47
  get log() { return path.join(dataDir(), 'manager.log'); },
48
48
  /** Launchers for agent-guild-report, put first on every session's PATH. */
49
49
  get shims() { return path.join(dataDir(), 'bin'); },
50
+ get reporting() { return path.join(dataDir(), 'reporting'); },
51
+ get reportTokens() { return path.join(dataDir(), 'report-tokens'); },
50
52
  };
51
53
 
52
54
  function writePrivate(file, contents) {
@@ -16,6 +16,7 @@ import { createManagerServer } from './server.mjs';
16
16
  import { SelfUpdate } from './self-update.mjs';
17
17
  import { resolveBaseEnv, pathReader } from './shell-env.mjs';
18
18
  import { writeReportShims } from './report-shims.mjs';
19
+ import { SessionHooks } from './session-hooks.mjs';
19
20
  import { launcherPath, spawnManager } from './launch.mjs';
20
21
  import {
21
22
  DEFAULT_HOST,
@@ -62,7 +63,12 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
62
63
  let api;
63
64
  const selfUpdate = new SelfUpdate({ pkg: PACKAGE_NAME, version, packageFile, registry });
64
65
  const github = new GitHub({ dir: paths.github, registry, ...githubOptions });
65
- const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github });
66
+ const sessionHooks = new SessionHooks({ registry, dir: paths.reporting, version, shimDir });
67
+ registry.reportingEnabled = (provider, account) => sessionHooks.enabled(provider, account);
68
+ sessionHooks.warm();
69
+ const manager = new SessionManager({
70
+ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks, reportTokenDir: paths.reportTokens,
71
+ });
66
72
  const usage = new UsageMonitor({ registry, env: baseEnv });
67
73
  const history = new SessionHistory({ registry, env: baseEnv });
68
74
  const modelStats = new ModelStats({ registry });
@@ -71,7 +77,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
71
77
  let closing = null;
72
78
 
73
79
  const refreshVersions = () => {
74
- registry.refreshVersions().catch(() => {});
80
+ registry.refreshVersions().then(() => sessionHooks.warm(), () => {});
75
81
  selfUpdate.refresh().catch(() => {});
76
82
  };
77
83
  const versionTimer = setInterval(refreshVersions, VERSION_REFRESH_MS);
@@ -141,6 +147,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
141
147
  throw err;
142
148
  }
143
149
 
150
+ manager.sweepReportTokens();
144
151
  writeRuntimeFile({
145
152
  pid: process.pid,
146
153
  host,
@@ -19,6 +19,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
19
19
  export const DEFAULTS_FILE = path.resolve(here, '../../config/providers.default.json');
20
20
  const ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
21
21
  const PLATFORM_KEYS = ['win32', 'darwin', 'linux'];
22
+ const REPORTING_MODES = new Set(['claude', 'codex', 'gemini', 'grok']);
22
23
  const VERSION_TTL_MS = 60 * 60 * 1000;
23
24
  const FAILED_PROBE_TTL_MS = 5 * 60 * 1000;
24
25
  const PATH_REFRESH_MS = 60 * 1000;
@@ -163,6 +164,7 @@ function normalize(raw, platform, warnings) {
163
164
  versionArgs: Array.isArray(merged.versionArgs) && merged.versionArgs.length ? merged.versionArgs.map(String) : null,
164
165
  usage: normalizeUsage(merged.usage),
165
166
  history: normalizeHistory(merged.history),
167
+ reporting: REPORTING_MODES.has(merged.reporting) ? merged.reporting : null,
166
168
  modelPattern: merged.modelPattern ? String(merged.modelPattern) : null,
167
169
  args: Array.isArray(merged.args) ? merged.args.map(String) : [],
168
170
  resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
@@ -251,6 +253,7 @@ export class ProviderRegistry extends EventEmitter {
251
253
  this.versions = new Map();
252
254
  this._refreshing = null;
253
255
  this._npmRegistry = null;
256
+ this.reportingEnabled = null;
254
257
  this.reload();
255
258
  }
256
259
 
@@ -623,7 +626,11 @@ export class ProviderRegistry extends EventEmitter {
623
626
  npmNote: provider.npmNote,
624
627
  usageSource: provider.usage === null ? null : typeof provider.usage === 'string' ? provider.usage : 'command',
625
628
  historySource: provider.history === null ? null : typeof provider.history === 'string' ? provider.history : 'command',
626
- accounts: provider.accounts.map(({ id, label }) => ({ id, label })),
629
+ reporting: provider.reporting,
630
+ accounts: provider.accounts.map((account) => {
631
+ const reportingEnabled = this.reportingEnabled?.(provider, this.accountFor(provider, account)) ?? null;
632
+ return { id: account.id, label: account.label, ...(reportingEnabled === null ? {} : { reportingEnabled }) };
633
+ }),
627
634
  modelPattern: provider.modelPattern,
628
635
  color: provider.color,
629
636
  monogram: provider.monogram,
@@ -642,7 +649,7 @@ export class ProviderRegistry extends EventEmitter {
642
649
  }
643
650
 
644
651
  /** Spawn spec for node-pty, or throws with a user-facing message. */
645
- spawnSpec(provider, extraArgs = [], resume = null) {
652
+ spawnSpec(provider, extraArgs = [], resume = null, leadArgs = []) {
646
653
  const resolved = this.resolve(provider);
647
654
  if (!resolved) {
648
655
  const hint = provider.install ? ` ${provider.install}` : '';
@@ -661,7 +668,7 @@ export class ProviderRegistry extends EventEmitter {
661
668
  }
662
669
  resumeArgs = provider.resumeArgs.map((arg) => arg.replaceAll('{id}', resume));
663
670
  }
664
- return buildSpawnSpec(resolved, [...provider.args, ...resumeArgs, ...extraArgs], this.env, this.platform);
671
+ return buildSpawnSpec(resolved, [...leadArgs, ...provider.args, ...resumeArgs, ...extraArgs], this.env, this.platform);
665
672
  }
666
673
 
667
674
  async updateSpec(provider) {
@@ -48,6 +48,7 @@ export function shimContents({ execPath, script, platform = process.platform })
48
48
  const files = {
49
49
  [SHIM_NAME]: [
50
50
  '#!/bin/sh',
51
+ '[ "$1" = --hook ] && [ -z "$AGENT_GUILD_SESSION_ID" ] && exec cat >/dev/null',
51
52
  `n=${shQuote(forSh(execPath))}`,
52
53
  '[ -x "$n" ] || n=node',
53
54
  `exec "$n" ${shQuote(forSh(script))} "$@"`,
@@ -58,6 +59,7 @@ export function shimContents({ execPath, script, platform = process.platform })
58
59
  files[LOADER_NAME] = `import ${JSON.stringify(fileUrl(script, platform))};\n`;
59
60
  files[`${SHIM_NAME}.cmd`] = [
60
61
  '@ECHO OFF',
62
+ 'IF "%~1"=="--hook" IF NOT DEFINED AGENT_GUILD_SESSION_ID EXIT /B 0',
61
63
  'IF EXIST "%AGENT_GUILD_NODE%" GOTO manager',
62
64
  `node "%~dp0${LOADER_NAME}" %*`,
63
65
  'EXIT /B %ERRORLEVEL%',
@@ -207,7 +207,7 @@ export function createManagerServer({
207
207
  // Agent, model and tool-session reports may authenticate with the
208
208
  // per-session report token that the manager injects into each tool's
209
209
  // environment.
210
- const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session)$/);
210
+ const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session|reporting|shells)$/);
211
211
  if (reportMatch && method === 'POST') {
212
212
  const [, id, kind] = reportMatch;
213
213
  const body = await readJsonBody(req);
@@ -217,6 +217,11 @@ export function createManagerServer({
217
217
  };
218
218
  if (kind === 'agents') return sendJson(res, 200, { agent: manager.reportAgent(id, body, auth) });
219
219
  if (kind === 'model') return sendJson(res, 200, { model: manager.reportModel(id, body, auth) });
220
+ if (kind === 'reporting') return sendJson(res, 200, { reporting: manager.reportHello(id, auth) });
221
+ if (kind === 'shells') {
222
+ manager.reportShell(id, body, auth);
223
+ return sendJson(res, 200, { ok: true });
224
+ }
220
225
  return sendJson(res, 200, { toolSessionId: manager.reportToolSession(id, body, auth) });
221
226
  }
222
227
 
@@ -269,6 +274,17 @@ export function createManagerServer({
269
274
  const account = registry.account(provider, url.searchParams.get('account'));
270
275
  return sendJson(res, 200, { history: await history.list(provider, account, { limit: url.searchParams.get('limit') }) });
271
276
  }
277
+ const reportingMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/reporting$/);
278
+ if (reportingMatch && method === 'POST' && manager.sessionHooks) {
279
+ const provider = registry.get(reportingMatch[1]);
280
+ if (!provider) throw new HttpError(404, `unknown provider "${reportingMatch[1]}"`, 'unknown_provider');
281
+ const body = await readJsonBody(req);
282
+ if (typeof body.enabled !== 'boolean') throw new HttpError(400, 'enabled must be true or false', 'bad_request');
283
+ const account = registry.account(provider, body.account ?? null);
284
+ await manager.sessionHooks.setEnabled(provider, account, body.enabled);
285
+ registry.emit('updated');
286
+ return sendJson(res, 200, { provider: registry.describe(provider) });
287
+ }
272
288
  const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
273
289
  if (installMatch && method === 'POST') {
274
290
  const body = await readJsonBody(req);
@@ -280,7 +296,7 @@ export function createManagerServer({
280
296
  }
281
297
  if (route === '/sessions' && method === 'POST') {
282
298
  const body = await readJsonBody(req);
283
- const session = manager.create(body);
299
+ const session = await manager.create(body);
284
300
  return sendJson(res, 201, { session: session.toJSON() });
285
301
  }
286
302
  if (route === '/shutdown' && method === 'POST') {
@@ -452,7 +468,7 @@ export function createManagerServer({
452
468
  if (isBinary) return;
453
469
  let msg;
454
470
  try { msg = JSON.parse(raw.toString('utf8')); } catch { return; }
455
- if (msg.type === 'input' && typeof msg.data === 'string') session.write(msg.data);
471
+ if (msg.type === 'input' && typeof msg.data === 'string') session.input(msg.data);
456
472
  else if (msg.type === 'resize') session.resize(msg.cols, msg.rows);
457
473
  });
458
474
  }