@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.
- package/README.md +22 -26
- package/bin/agent-guild-report.mjs +67 -25
- package/config/providers.default.json +18 -14
- package/examples/grok-hooks.json +43 -0
- package/package.json +1 -1
- package/src/manager/command-resolver.mjs +6 -2
- package/src/manager/config.mjs +1 -0
- package/src/manager/install-channels.mjs +7 -0
- package/src/manager/main.mjs +8 -2
- package/src/manager/news.mjs +1 -1
- package/src/manager/providers.mjs +14 -9
- package/src/manager/report-shims.mjs +6 -4
- package/src/manager/server.mjs +18 -3
- package/src/manager/session-history.mjs +32 -74
- package/src/manager/session-hooks.mjs +351 -0
- package/src/manager/session-manager.mjs +27 -19
- package/src/manager/session.mjs +183 -25
- package/src/manager/usage.mjs +3 -258
- package/src/report/hooks.mjs +87 -38
- package/web/app.js +89 -13
- package/web/index.html +5 -0
- package/web/styles.css +10 -2
- package/examples/gemini-settings.json +0 -61
|
@@ -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
|
-
// ----
|
|
259
|
+
// ---- Antigravity CLI ------------------------------------------------------
|
|
259
260
|
|
|
260
|
-
export function
|
|
261
|
-
return path.join(env.
|
|
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
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
301
|
-
async function
|
|
302
|
-
const
|
|
303
|
-
let
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
/**
|
|
321
|
-
export async function
|
|
322
|
-
const root = path.join(dir, '
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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,
|
|
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 === '
|
|
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
|
-
|
|
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
|
|
105
|
-
this.
|
|
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
|