@oddessentials/agent-guild 0.10.0 → 0.12.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.
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import zlib from 'node:zlib';
9
9
  import { resolveCommand, buildSpawnSpec, runSpec } from './command-resolver.mjs';
10
10
  import { toIso } from './usage.mjs';
11
+ import { antigravityUserConversation } from '../report/hooks.mjs';
11
12
 
12
13
  export const HISTORY_TTL_MS = 5 * 1000;
13
14
  export const DEFAULT_LIMIT = 100;
@@ -255,88 +256,45 @@ export async function listCodexSessions(dir, memo = new FileMemo()) {
255
256
  return dedupe(entries);
256
257
  }
257
258
 
258
- // ---- Gemini CLI -----------------------------------------------------------
259
+ // ---- Antigravity CLI ------------------------------------------------------
259
260
 
260
- export function geminiDir(env = process.env) {
261
- return path.join(env.GEMINI_CLI_HOME || os.homedir(), '.gemini');
261
+ export function antigravityDir(env = process.env, platform = process.platform) {
262
+ return path.join((platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir(), '.gemini', 'antigravity-cli');
262
263
  }
263
264
 
264
- function geminiPrompt(record) {
265
- if (!record || typeof record !== 'object') return null;
266
- if (record.type === 'user' && record.id !== undefined) {
267
- const text = contentText(record.content);
268
- return /^[/?]/.test(text.trim()) ? null : promptTitle(text);
269
- }
270
- const checkpoint = record.$set?.messages;
271
- if (Array.isArray(checkpoint)) {
272
- for (const message of checkpoint) {
273
- const title = geminiPrompt(message);
274
- if (title) return title;
275
- }
276
- }
277
- return null;
278
- }
265
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
279
266
 
280
- function geminiEntry(text, stat, cwd, legacy) {
281
- let meta;
282
- let records;
283
- if (legacy) {
284
- try { meta = JSON.parse(text); } catch { return null; }
285
- records = Array.isArray(meta?.messages) ? meta.messages : [];
286
- } else {
287
- records = parseLines(text);
288
- meta = records.shift();
289
- }
290
- if (!meta || typeof meta !== 'object' || typeof meta.sessionId !== 'string') return null;
291
- if (meta.kind === 'subagent') return null;
292
- let title = typeof meta.summary === 'string' && meta.summary.trim() ? meta.summary : null;
293
- for (const record of records) {
294
- if (title) break;
295
- title = geminiPrompt(record);
296
- }
297
- return cleanEntry({ id: meta.sessionId, title, cwd, startedAt: meta.startTime, updatedAt: stat.mtime });
267
+ function antigravityEntry(id, text, stat) {
268
+ const [first] = parseLines(text ?? '');
269
+ if (!antigravityUserConversation(first) || typeof first.content !== 'string') return null;
270
+ const request = first.content.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/)?.[1];
271
+ return cleanEntry({ id, title: promptTitle(request), startedAt: first.created_at, updatedAt: stat.mtime });
298
272
  }
299
273
 
300
- /** The project folder a Gemini CLI temp folder stands for: its `.project_root` marker, else the registry in `projects.json`. */
301
- async function geminiProjectRoots(root, folders) {
302
- const roots = new Map();
303
- let registry = null;
304
- for (const folder of folders) {
305
- const marker = (await readText(path.join(root, folder, '.project_root')))?.trim();
306
- if (marker) {
307
- roots.set(folder, marker);
308
- continue;
309
- }
310
- if (registry === null) {
311
- let projects = {};
312
- try { projects = JSON.parse(await readText(path.join(path.dirname(root), 'projects.json')))?.projects ?? {}; } catch { /* no registry */ }
313
- registry = new Map(Object.entries(projects).map(([dir, slug]) => [slug, dir]));
314
- }
315
- roots.set(folder, registry.get(folder) ?? null);
316
- }
317
- return roots;
274
+ /** The folder of each folder's latest conversation; the CLI keeps no folder for the others. */
275
+ async function antigravityFolders(dir) {
276
+ const folders = new Map();
277
+ let latest = null;
278
+ try { latest = JSON.parse(await readText(path.join(dir, 'cache', 'last_conversations.json'))); } catch { /* none yet */ }
279
+ if (!latest || typeof latest !== 'object') return folders;
280
+ for (const [folder, id] of Object.entries(latest)) if (typeof id === 'string') folders.set(id, folder);
281
+ return folders;
318
282
  }
319
283
 
320
- /** Chats under `<dir>/tmp/<project>/chats/session-*.jsonl` (older versions: `.json`). Sub-agent chats sit in sub-folders. */
321
- export async function listGeminiSessions(dir, memo = new FileMemo()) {
322
- const root = path.join(dir, 'tmp');
284
+ /** Conversations under `<dir>/brain/<id>/.system_generated/logs/transcript.jsonl`. */
285
+ export async function listAntigravitySessions(dir, memo = new FileMemo()) {
286
+ const root = path.join(dir, 'brain');
287
+ const folders = await antigravityFolders(dir);
323
288
  const entries = [];
324
289
  const seen = new Set();
325
- const folders = (await readDir(root)).filter((item) => item.isDirectory()).map((item) => item.name);
326
- const roots = await geminiProjectRoots(root, folders);
327
- for (const folder of folders) {
328
- const chats = path.join(root, folder, 'chats');
329
- for (const item of await readDir(chats)) {
330
- const legacy = item.name.endsWith('.json');
331
- if (!item.isFile() || !item.name.startsWith('session-') || !(legacy || item.name.endsWith('.jsonl'))) continue;
332
- const file = path.join(chats, item.name);
333
- const stat = await statFile(file);
334
- if (!stat) continue;
335
- seen.add(file);
336
- const cwd = roots.get(folder);
337
- const entry = await memo.entry(file, stat, async () => geminiEntry(legacy ? await readText(file) : await readHead(file), stat, cwd, legacy));
338
- if (entry) entries.push(entry);
339
- }
290
+ for (const item of await readDir(root)) {
291
+ if (!item.isDirectory() || !UUID_RE.test(item.name)) continue;
292
+ const file = path.join(root, item.name, '.system_generated', 'logs', 'transcript.jsonl');
293
+ const stat = await statFile(file);
294
+ if (!stat) continue;
295
+ seen.add(file);
296
+ const entry = await memo.entry(file, stat, async () => antigravityEntry(item.name, await readHead(file), stat));
297
+ if (entry) entries.push({ ...entry, cwd: folders.get(entry.id) ?? null });
340
298
  }
341
299
  memo.prune(root, seen);
342
300
  return dedupe(entries);
@@ -447,7 +405,7 @@ export class SessionHistory {
447
405
  this.env = env;
448
406
  this.platform = platform;
449
407
  this.ttlMs = ttlMs;
450
- this.readers = { claude: listClaudeSessions, codex: listCodexSessions, gemini: listGeminiSessions, grok: listGrokSessions, ...readers };
408
+ this.readers = { claude: listClaudeSessions, codex: listCodexSessions, antigravity: listAntigravitySessions, grok: listGrokSessions, ...readers };
451
409
  this.memo = new FileMemo();
452
410
  this.cache = new Map();
453
411
  }
@@ -455,7 +413,7 @@ export class SessionHistory {
455
413
  static sourceDir(source, env) {
456
414
  if (source === 'claude') return claudeConfigDir(env);
457
415
  if (source === 'codex') return codexHome(env);
458
- if (source === 'gemini') return geminiDir(env);
416
+ if (source === 'antigravity') return antigravityDir(env);
459
417
  if (source === 'grok') return grokHome(env);
460
418
  return null;
461
419
  }
@@ -0,0 +1,351 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { buildSpawnSpec, runSpec, killWindowsTree } from './command-resolver.mjs';
6
+
7
+ export const REPORT_COMMAND = 'agent-guild-report --hook';
8
+ export const PLUGIN_NAME = 'agent-guild';
9
+ const MANIFEST_DESCRIPTION = 'Reports to the Agent Guild session it runs in.';
10
+ const PROBE_TIMEOUT_MS = 20000;
11
+ const PROBE_RETRY_MS = 5 * 60 * 1000;
12
+
13
+ const handler = (extra = {}) => ({ type: 'command', command: REPORT_COMMAND, ...extra });
14
+ const groups = (events, extra) => Object.fromEntries(events.map((event) => [event, [{ ...extra?.[event]?.group, hooks: [handler(extra?.[event]?.handler)] }]]));
15
+
16
+ const shellEvents = (events, matcher) => Object.fromEntries(events.map((event) => [event, { group: { matcher } }]));
17
+
18
+ const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'SubagentStart', 'SubagentStop', 'PostModelSwitch', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure', 'Stop'];
19
+ const CLAUDE_MATCHERS = { PreToolUse: 'Bash|PowerShell', PermissionRequest: 'Bash|PowerShell', PostToolUse: 'Bash|PowerShell|TaskStop', PostToolUseFailure: 'Bash|PowerShell' };
20
+ const CLAUDE_BLOCKING = new Set(['SubagentStart', 'PreToolUse']);
21
+ export const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'SubagentStart', 'SubagentStop', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'Stop', 'Interrupt', 'SessionEnd'];
22
+ const CODEX_MATCHERS = { PermissionRequest: 'Bash', PostToolUse: 'Bash' };
23
+
24
+ // Only the start of a sub-agent or a command holds Claude Code up, so the start reaches the manager before its end.
25
+ const claudeHooks = () => Object.fromEntries(CLAUDE_EVENTS.map((event) => [event, [{
26
+ ...(CLAUDE_MATCHERS[event] ? { matcher: CLAUDE_MATCHERS[event] } : {}),
27
+ hooks: [handler(CLAUDE_BLOCKING.has(event) ? {} : { async: true })],
28
+ }]]));
29
+ const GROK_EVENTS = ['SessionStart', 'SubagentStart', 'SubagentStop', 'StopCancelled', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'Stop'];
30
+
31
+ // Antigravity CLI loads an installed plugin into every session, also those started outside Agent Guild, and runs
32
+ // its hooks in the plugin's folder, on Windows through cmd /C, which cannot take a quoted path. So the hook is a
33
+ // script in that folder: it reports only inside an Agent Guild Antigravity session, and always prints the JSON object
34
+ // Antigravity reads.
35
+ function antigravityFiles(manifest, platform) {
36
+ const script = platform === 'win32'
37
+ ? ['@echo off', `if "%AGENT_GUILD_REPORTING%"=="antigravity" call ${REPORT_COMMAND} --event %1`, 'echo {}', ''].join('\r\n')
38
+ : ['#!/bin/sh', `if [ "$AGENT_GUILD_REPORTING" = antigravity ] && command -v agent-guild-report >/dev/null 2>&1; then ${REPORT_COMMAND} --event "$1"; else cat >/dev/null; fi`, "echo '{}'", ''].join('\n');
39
+ const name = platform === 'win32' ? 'agent-guild-hook.cmd' : 'agent-guild-hook.sh';
40
+ const command = platform === 'win32' ? `.\\${name} PreInvocation` : `sh ${name} PreInvocation`;
41
+ return {
42
+ 'plugin.json': `${JSON.stringify(manifest, null, 2)}\n`,
43
+ 'hooks.json': `${JSON.stringify({ [PLUGIN_NAME]: { PreInvocation: [{ type: 'command', command, timeout: 10 }] } }, null, 2)}\n`,
44
+ [name]: script,
45
+ };
46
+ }
47
+
48
+ export function bundleFiles(version, { platform = process.platform } = {}) {
49
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
50
+ const manifest = { name: PLUGIN_NAME, version, description: MANIFEST_DESCRIPTION };
51
+ return {
52
+ claude: {
53
+ '.claude-plugin/plugin.json': json(manifest),
54
+ 'hooks/hooks.json': json({ hooks: claudeHooks() }),
55
+ },
56
+ antigravity: antigravityFiles({ name: PLUGIN_NAME, description: MANIFEST_DESCRIPTION }, platform),
57
+ grok: {
58
+ '.grok-plugin/plugin.json': json(manifest),
59
+ 'hooks/hooks.json': json({
60
+ hooks: groups(GROK_EVENTS, { SessionEnd: { handler: { timeout: 10 } }, ...shellEvents(['PreToolUse', 'PostToolUse', 'PostToolUseFailure'], 'run_terminal_command') }),
61
+ }),
62
+ },
63
+ };
64
+ }
65
+
66
+ export function writeBundles(dir, version, opts) {
67
+ const out = {};
68
+ for (const [name, files] of Object.entries(bundleFiles(version, opts))) {
69
+ const root = path.join(dir, name);
70
+ for (const [rel, contents] of Object.entries(files)) {
71
+ const file = path.join(root, ...rel.split('/'));
72
+ fs.mkdirSync(path.dirname(file), { recursive: true });
73
+ const tmp = `${file}.${process.pid}.tmp`;
74
+ fs.writeFileSync(tmp, contents);
75
+ fs.renameSync(tmp, file);
76
+ }
77
+ out[name] = root;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ const tomlString = (value) => `'${value}'`;
83
+
84
+ export function codexHookArgs() {
85
+ const value = (event) => {
86
+ const matcher = CODEX_MATCHERS[event] ? `matcher=${tomlString(CODEX_MATCHERS[event])},` : '';
87
+ return `[{${matcher}hooks=[{type=${tomlString('command')},command=${tomlString(REPORT_COMMAND)}}]}]`;
88
+ };
89
+ return CODEX_EVENTS.flatMap((event) => ['-c', `hooks.${event}=${value(event)}`]);
90
+ }
91
+
92
+ export function codexTrustArgs(hooks) {
93
+ if (hooks.some(({ key, hash }) => /['\n]/.test(key) || /['\n]/.test(hash))) return null;
94
+ return ['-c', `hooks.state={${hooks.map(({ key, hash }) => `${tomlString(key)}={trusted_hash=${tomlString(hash)}}`).join(',')}}`];
95
+ }
96
+
97
+ export function codexHooksFrom(result) {
98
+ const hooks = (result?.data || []).flatMap((entry) => entry.hooks || [])
99
+ .filter((h) => h.source === 'sessionFlags' && h.command === REPORT_COMMAND && h.enabled !== false);
100
+ const found = CODEX_EVENTS.map((event) => {
101
+ const name = event.charAt(0).toLowerCase() + event.slice(1);
102
+ return hooks.find((h) => h.eventName === name);
103
+ });
104
+ if (found.some((h) => !h || typeof h.key !== 'string' || typeof h.currentHash !== 'string')) return null;
105
+ return found.map((h) => ({ key: h.key, hash: h.currentHash, trusted: h.trustStatus === 'trusted' }));
106
+ }
107
+
108
+ export function codexHooksList(resolved, args, { env, platform = process.platform, timeoutMs = PROBE_TIMEOUT_MS, tmpDir = os.tmpdir() } = {}) {
109
+ const home = fs.mkdtempSync(path.join(tmpDir, 'agent-guild-codex-'));
110
+ const spec = buildSpawnSpec(resolved, [...args, 'app-server'], env, platform);
111
+ return new Promise((resolve, reject) => {
112
+ let child;
113
+ let settled = false;
114
+ const finish = (err, value) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ clearTimeout(timer);
118
+ endTree(child, platform);
119
+ setTimeout(() => fs.rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }, () => {}), 500).unref();
120
+ if (err) reject(err);
121
+ else resolve(value);
122
+ };
123
+ const timer = setTimeout(() => finish(new Error('Codex did not answer in time')), timeoutMs);
124
+ timer.unref?.();
125
+ try {
126
+ const verbatim = typeof spec.args === 'string';
127
+ child = spawn(spec.file, verbatim ? [spec.args] : spec.args, {
128
+ cwd: home, env: { ...env, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, windowsVerbatimArguments: verbatim,
129
+ });
130
+ } catch (err) {
131
+ return finish(err);
132
+ }
133
+ let stderr = '';
134
+ let buffer = '';
135
+ const send = (msg) => child.stdin.write(`${JSON.stringify(msg)}\n`);
136
+ child.on('error', (err) => finish(err));
137
+ child.on('exit', (code) => finish(new Error(`Codex exited with code ${code}: ${stderr.trim().split('\n').find((l) => /error|caused/i.test(l)) || ''}`.trim())));
138
+ child.stdin.on('error', () => {});
139
+ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
140
+ child.stdout.on('data', (chunk) => {
141
+ buffer += chunk;
142
+ let index;
143
+ while ((index = buffer.indexOf('\n')) !== -1) {
144
+ const line = buffer.slice(0, index);
145
+ buffer = buffer.slice(index + 1);
146
+ let msg;
147
+ try { msg = JSON.parse(line); } catch { continue; }
148
+ if (msg.id === 1) {
149
+ if (msg.error) return finish(new Error(msg.error.message || 'initialize failed'));
150
+ send({ method: 'initialized' });
151
+ send({ id: 2, method: 'hooks/list', params: { cwds: [home] } });
152
+ } else if (msg.id === 2) {
153
+ if (msg.error) return finish(new Error(msg.error.message || 'hooks/list failed'));
154
+ return finish(null, msg.result);
155
+ }
156
+ }
157
+ });
158
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'agent-guild', title: null, version: '1' } } });
159
+ });
160
+ }
161
+
162
+ function endTree(child, platform) {
163
+ if (!child || child.exitCode !== null) return;
164
+ try { child.stdin.end(); } catch { /* closed */ }
165
+ if (platform === 'win32' && child.pid) return killWindowsTree(child.pid);
166
+ try { child.kill(); } catch { /* gone */ }
167
+ }
168
+
169
+ export async function probeCodex(resolved, opts) {
170
+ const hookArgs = codexHookArgs();
171
+ const listed = codexHooksFrom(await codexHooksList(resolved, hookArgs, opts));
172
+ if (!listed) return { args: [], trusted: false, error: 'Codex did not load the hooks' };
173
+ if (listed.every((h) => h.trusted)) return { args: hookArgs, trusted: true };
174
+ const trustArgs = codexTrustArgs(listed);
175
+ if (trustArgs) {
176
+ try {
177
+ const again = codexHooksFrom(await codexHooksList(resolved, [...hookArgs, ...trustArgs], opts));
178
+ if (again?.every((h) => h.trusted)) return { args: [...hookArgs, ...trustArgs], trusted: true };
179
+ } catch { /* fall back to untrusted hooks, which Codex asks the user about */ }
180
+ }
181
+ return { args: hookArgs, trusted: false };
182
+ }
183
+
184
+ export function helpLists(text, flag) {
185
+ return new RegExp(`^\\s+(?:-\\w, )?${flag.replace(/[-]/g, '\\-')}\\b`, 'm').test(text);
186
+ }
187
+
188
+ const geminiHome = (env, platform) => path.join((platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir(), '.gemini');
189
+
190
+ /** Where `agy plugin install` copies the plugin and where Antigravity CLI loads it from. */
191
+ export function antigravityPluginDir(env = {}, platform = process.platform) {
192
+ return path.join(geminiHome(env, platform), 'config', 'plugins', PLUGIN_NAME);
193
+ }
194
+
195
+ /** The shared Antigravity settings, where `agy plugin disable` turns a plugin off. */
196
+ export function antigravityConfigFile(env = {}, platform = process.platform) {
197
+ return path.join(geminiHome(env, platform), 'config', 'config.json');
198
+ }
199
+
200
+ /** True unless the settings turn the plugin off or cannot be read; no settings file means every plugin is on. */
201
+ export function antigravityPluginEnabled(configFile) {
202
+ let config;
203
+ try {
204
+ config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
205
+ } catch (err) {
206
+ return err.code === 'ENOENT';
207
+ }
208
+ if (!config || typeof config !== 'object') return false;
209
+ return config.plugins?.[PLUGIN_NAME]?.enabled !== false;
210
+ }
211
+
212
+ function readOr(file, fallback) {
213
+ try { return fs.readFileSync(file, 'utf8'); } catch { return fallback; }
214
+ }
215
+
216
+ /** "current" when the installed copy matches the bundle, "stale" when it is an older one of ours, "other" or null. */
217
+ export function antigravityInstalled(pluginDir, bundle) {
218
+ const manifest = readOr(path.join(pluginDir, 'plugin.json'), null);
219
+ if (manifest === null) return null;
220
+ let description = null;
221
+ try { description = JSON.parse(manifest)?.description; } catch { /* not ours */ }
222
+ if (description !== MANIFEST_DESCRIPTION) return 'other';
223
+ let names = [];
224
+ try { names = fs.readdirSync(bundle); } catch { return 'stale'; }
225
+ const same = names.every((name) => readOr(path.join(pluginDir, name), null) === readOr(path.join(bundle, name), ''));
226
+ return same ? 'current' : 'stale';
227
+ }
228
+
229
+ const pending = (tool, when) => ({ state: 'pending', reason: `Agent Guild added its reporting hooks to this ${tool} session. They report once ${tool} ${when}.` });
230
+
231
+ export class SessionHooks {
232
+ constructor({ registry, dir, version, probeTimeoutMs = PROBE_TIMEOUT_MS, probeRetryMs = PROBE_RETRY_MS }) {
233
+ this.registry = registry;
234
+ this.probeTimeoutMs = probeTimeoutMs;
235
+ this.probeRetryMs = probeRetryMs;
236
+ this.dir = dir;
237
+ this.bundles = null;
238
+ this.probes = new Map();
239
+ if (dir) {
240
+ try {
241
+ this.bundles = writeBundles(dir, version, { platform: registry.platform });
242
+ } catch (err) {
243
+ console.warn(`[reporting] could not write the reporting hooks to ${dir}: ${err.message}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ warm() {
249
+ for (const provider of this.registry.providers) {
250
+ if (provider.reporting === 'claude' || provider.reporting === 'codex' || provider.reporting === 'grok') this._probe(provider).catch(() => {});
251
+ }
252
+ }
253
+
254
+ _probe(provider) {
255
+ const resolved = this.registry.resolve(provider);
256
+ if (!resolved) return Promise.resolve(null);
257
+ let mtime = null;
258
+ try { mtime = fs.statSync(resolved).mtimeMs; } catch { /* probe anyway */ }
259
+ const cached = this.probes.get(provider.id);
260
+ const fresh = cached && cached.resolved === resolved && cached.mtime === mtime && (cached.ok || Date.now() - cached.at < this.probeRetryMs);
261
+ if (fresh) return cached.promise;
262
+ const env = { ...this.registry.env, ...provider.env };
263
+ const platform = this.registry.platform;
264
+ const entry = { resolved, mtime, at: Date.now(), ok: false };
265
+ entry.promise = (async () => {
266
+ if (provider.reporting === 'codex') {
267
+ const result = await probeCodex(resolved, { env, platform, timeoutMs: this.probeTimeoutMs });
268
+ entry.ok = result.args.length > 0;
269
+ return result;
270
+ }
271
+ const { stdout, stderr } = await runSpec(buildSpawnSpec(resolved, ['--help'], env, platform), { env, timeoutMs: this.probeTimeoutMs });
272
+ entry.ok = true;
273
+ return { pluginDir: helpLists(`${stdout}\n${stderr}`, '--plugin-dir') };
274
+ })().catch((err) => ({ error: err.message }));
275
+ this.probes.set(provider.id, entry);
276
+ return entry.promise;
277
+ }
278
+
279
+ async launch(provider) {
280
+ const mode = provider.reporting;
281
+ if (!mode) return { args: [], reporting: null };
282
+ const tool = provider.tool;
283
+ if (!this.bundles) {
284
+ return { args: [], reporting: { state: 'unavailable', reason: `Agent Guild could not write its reporting hooks, so ${tool} cannot report agents.` } };
285
+ }
286
+ if (mode === 'antigravity') {
287
+ if (this.enabled(provider)) return { args: [], reporting: pending(tool, 'runs its first prompt') };
288
+ return { args: [], reporting: { state: 'setup_required', reason: `Agent reporting is off for ${tool}. Turn it on from the ${tool} card; it applies to new sessions.` } };
289
+ }
290
+ const probe = await this._probe(provider);
291
+ if (mode === 'codex') {
292
+ if (probe?.args?.length) return { args: probe.args, reporting: pending(tool, 'runs its first prompt') };
293
+ return { args: [], reporting: { state: 'unavailable', reason: `${tool} did not accept Agent Guild's reporting hooks${probe?.error ? ` (${probe.error})` : ''}.` } };
294
+ }
295
+ const dir = mode === 'claude' ? this.bundles.claude : this.bundles.grok;
296
+ if (probe?.pluginDir) return { args: ['--plugin-dir', dir], reporting: pending(tool, 'starts its session') };
297
+ if (probe?.error) {
298
+ return { args: [], reporting: { state: 'unavailable', reason: `Could not check whether ${tool} can load Agent Guild's reporting hooks (${probe.error}).` } };
299
+ }
300
+ return {
301
+ args: [],
302
+ reporting: {
303
+ state: 'unsupported',
304
+ reason: `This version of ${tool} cannot load hooks for a single session, so Agent Guild cannot add its reporting hooks. Hooks you add to ${tool}'s own settings still report agents.`,
305
+ },
306
+ };
307
+ }
308
+
309
+ enabled(provider) {
310
+ if (provider.reporting !== 'antigravity' || !this.bundles) return null;
311
+ return this._installed(provider) === 'current' && this._pluginEnabled(provider);
312
+ }
313
+
314
+ _installed(provider) {
315
+ return antigravityInstalled(antigravityPluginDir({ ...this.registry.env, ...provider.env }, this.registry.platform), this.bundles.antigravity);
316
+ }
317
+
318
+ _pluginEnabled(provider) {
319
+ return antigravityPluginEnabled(antigravityConfigFile({ ...this.registry.env, ...provider.env }, this.registry.platform));
320
+ }
321
+
322
+ async setEnabled(provider, enabled) {
323
+ if (provider.reporting !== 'antigravity') throw Object.assign(new Error(`${provider.tool} needs no setup for agent reporting`), { status: 400, code: 'not_applicable' });
324
+ if (!this.bundles) throw Object.assign(new Error('Agent Guild could not write its reporting hooks'), { status: 500, code: 'reporting_unavailable' });
325
+ const resolved = this.registry.resolve(provider);
326
+ if (!resolved) throw Object.assign(new Error(`${provider.tool} is not installed`), { status: 409, code: 'provider_unavailable' });
327
+ const installed = this._installed(provider);
328
+ if (enabled ? installed === 'current' && this._pluginEnabled(provider) : !installed) return enabled;
329
+ if (installed === 'other') {
330
+ if (!enabled) return false;
331
+ throw Object.assign(new Error(`${provider.tool} already has another plugin named "${PLUGIN_NAME}". Remove it with "${provider.command} plugin uninstall ${PLUGIN_NAME}" to turn on agent reporting.`), { status: 409, code: 'plugin_conflict' });
332
+ }
333
+ const env = { ...this.registry.env, ...provider.env };
334
+ const run = async (args, what) => {
335
+ try {
336
+ await runSpec(buildSpawnSpec(resolved, args, env, this.registry.platform), { env, timeoutMs: 60000, cwd: this.dir });
337
+ } catch (err) {
338
+ const detail = `${err.stderr || ''}\n${err.stdout || ''}`.trim().split('\n').filter(Boolean).pop() || err.message;
339
+ throw Object.assign(new Error(`${provider.tool} could not ${what} the Agent Guild plugin: ${detail}`), { status: 502, code: 'reporting_setup_failed' });
340
+ }
341
+ };
342
+ if (installed && (!enabled || installed === 'stale')) await run(['plugin', 'uninstall', PLUGIN_NAME], 'remove');
343
+ if (enabled && installed !== 'current') await run(['plugin', 'install', this.bundles.antigravity], 'install');
344
+ if (enabled && !this._pluginEnabled(provider)) await run(['plugin', 'enable', PLUGIN_NAME], 'turn on');
345
+ const now = this.enabled(provider);
346
+ if (now !== enabled) {
347
+ throw Object.assign(new Error(`${provider.tool} reported success, but the Agent Guild plugin is ${now ? 'still on' : 'not on'}`), { status: 502, code: 'reporting_setup_failed' });
348
+ }
349
+ return now;
350
+ }
351
+ }
@@ -6,7 +6,6 @@ import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import crypto from 'node:crypto';
9
- import { fileURLToPath } from 'node:url';
10
9
  import { Session, newId, clampDimension, cleanName } from './session.mjs';
11
10
  import { prependPath } from './report-shims.mjs';
12
11
  import { CHANNEL_LABELS } from './install-channels.mjs';
@@ -14,7 +13,9 @@ import { SELF_PROVIDER } from './self-update.mjs';
14
13
  import { GITHUB_PROVIDER, dropsFromCloneEnv, parseRepo } from './github.mjs';
15
14
 
16
15
  export const MAX_SESSIONS = 32;
17
- const examplesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../examples');
16
+
17
+ // The hooks.json earlier versions copied into Codex CLI accounts; an untouched copy would run every hook twice.
18
+ const SEEDED_CODEX_HOOKS = '59d1cfb54cda5fd81add1edee7cf0cac56e4b2afec4c26ff207b675eeaaf70ce';
18
19
 
19
20
  function httpError(status, message, code) {
20
21
  return Object.assign(new Error(message), { status, code });
@@ -53,7 +54,7 @@ export class SessionManager extends EventEmitter {
53
54
  * @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
54
55
  * @param {import('./github.mjs').GitHub|null} [opts.github]
55
56
  */
56
- constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null }) {
57
+ constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null }) {
57
58
  super();
58
59
  this.registry = registry;
59
60
  this.baseEnv = baseEnv;
@@ -62,6 +63,7 @@ export class SessionManager extends EventEmitter {
62
63
  this.shimDir = shimDir;
63
64
  this.selfUpdate = selfUpdate;
64
65
  this.github = github;
66
+ this.sessionHooks = sessionHooks;
65
67
  this.sessions = new Map();
66
68
  /** Removed sessions whose process has not exited yet. */
67
69
  this.exiting = new Set();
@@ -91,7 +93,7 @@ export class SessionManager extends EventEmitter {
91
93
  return dir;
92
94
  }
93
95
 
94
- create({ providerId, cwd, cols, rows, name, args, resume, account } = {}) {
96
+ async create({ providerId, cwd, cols, rows, name, args, resume, account } = {}) {
95
97
  const provider = this.registry.get(String(providerId || ''));
96
98
  if (!provider) throw httpError(404, `unknown provider "${providerId}"`, 'unknown_provider');
97
99
  if (args !== undefined && (!Array.isArray(args) || args.some((a) => typeof a !== 'string'))) {
@@ -101,32 +103,28 @@ export class SessionManager extends EventEmitter {
101
103
  const resumeId = cleanResumeId(resume);
102
104
  const workDir = this.resolveCwd(cwd);
103
105
  const signIn = this.registry.account(provider, account);
104
- const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId);
105
- this.prepareAccount(provider, signIn);
106
+ const hooks = this.sessionHooks ? await this.sessionHooks.launch(provider) : { args: [], reporting: null };
107
+ const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args);
108
+ this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
106
109
  const sessionName = cleanName(name) || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null);
107
- const session = this._spawn({ provider, spawnSpec, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn });
110
+ const session = this._spawn({ provider, spawnSpec, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn, reporting: hooks.reporting });
108
111
  const model = modelFromArgs([...provider.args, ...(args || [])]);
109
112
  if (model) session.setModel({ name: model }, 'args');
110
113
  return session;
111
114
  }
112
115
 
113
- prepareAccount(provider, account) {
116
+ prepareAccount(provider, account, { hooksSupplied = false } = {}) {
114
117
  if (!account.dir) return;
115
118
  try {
116
119
  fs.mkdirSync(account.dir, { recursive: true, mode: 0o700 });
117
- if (!provider.hooks) return;
118
- const target = path.join(account.dir, ...provider.hooks.path.split('/'));
119
- const example = path.join(examplesDir, provider.hooks.example);
120
- if (fs.existsSync(target)) return;
121
- if (!fs.existsSync(example)) {
122
- console.warn(`[accounts] no hooks example ${example} for ${provider.tool}; ${target} was not written`);
123
- return;
124
- }
125
- fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
126
- fs.copyFileSync(example, target, fs.constants.COPYFILE_EXCL);
127
120
  } catch (err) {
128
121
  throw httpError(500, `could not prepare the ${account.label} account folder ${account.dir}: ${err.message}`, 'account_unavailable');
129
122
  }
123
+ if (provider.reporting !== 'codex' || !hooksSupplied || !provider.hooks) return;
124
+ const target = path.join(account.dir, ...provider.hooks.path.split('/'));
125
+ try {
126
+ if (crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex') === SEEDED_CODEX_HOOKS) fs.unlinkSync(target);
127
+ } catch { /* absent or unreadable: nothing of ours to remove */ }
130
128
  }
131
129
 
132
130
  /**
@@ -229,7 +227,7 @@ export class SessionManager extends EventEmitter {
229
227
 
230
228
  _spawn({
231
229
  provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, account = null,
232
- extraEnv = null, dropEnv = null, clone = null,
230
+ extraEnv = null, dropEnv = null, clone = null, reporting = null,
233
231
  }) {
234
232
  if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
235
233
  if (this.sessions.size >= MAX_SESSIONS) {
@@ -245,6 +243,7 @@ export class SessionManager extends EventEmitter {
245
243
  COLORTERM: 'truecolor',
246
244
  AGENT_GUILD_SESSION_ID: id,
247
245
  AGENT_GUILD_PROVIDER: provider.id,
246
+ AGENT_GUILD_REPORTING: provider.reporting || '',
248
247
  AGENT_GUILD_URL: this.getApiUrl(),
249
248
  AGENT_GUILD_REPORT_TOKEN: reportToken,
250
249
  AGENT_GUILD_NODE: process.execPath,
@@ -274,6 +273,7 @@ export class SessionManager extends EventEmitter {
274
273
  task,
275
274
  account: account ? { id: account.id, label: account.label } : null,
276
275
  clone,
276
+ reporting,
277
277
  });
278
278
  } catch (err) {
279
279
  throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
@@ -325,6 +325,14 @@ export class SessionManager extends EventEmitter {
325
325
  return this._reportingSession(id, auth).reportToolSession(report);
326
326
  }
327
327
 
328
+ reportHello(id, auth) {
329
+ return this._reportingSession(id, auth).reportHello();
330
+ }
331
+
332
+ reportShell(id, report, auth) {
333
+ return this._reportingSession(id, auth).reportShell(report);
334
+ }
335
+
328
336
  _reportingSession(id, { reportToken, trusted = false } = {}) {
329
337
  const session = this.sessions.get(id);
330
338
  // Without the API token, an unknown session and a wrong token look the