@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 +12 -14
- package/bin/agent-guild-report.mjs +42 -20
- package/config/providers.default.json +4 -0
- 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 +2 -0
- package/src/manager/main.mjs +9 -2
- package/src/manager/providers.mjs +10 -3
- package/src/manager/report-shims.mjs +2 -0
- package/src/manager/server.mjs +19 -3
- package/src/manager/session-hooks.mjs +350 -0
- package/src/manager/session-manager.mjs +57 -19
- package/src/manager/session.mjs +240 -8
- package/src/report/hooks.mjs +79 -9
- package/web/app.js +90 -12
- package/web/index.html +5 -0
- package/web/styles.css +10 -2
|
@@ -0,0 +1,350 @@
|
|
|
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 EXTENSION_NAME = 'agent-guild';
|
|
9
|
+
const MANIFEST_DESCRIPTION = 'Reports sub-agents to the Agent Guild session they run 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
|
+
// Gemini's extension stays linked outside Agent Guild, where the launchers are not on PATH.
|
|
32
|
+
export function geminiCommand(shimDir, platform = process.platform) {
|
|
33
|
+
if (!shimDir) return REPORT_COMMAND;
|
|
34
|
+
if (platform === 'win32') return `& '${path.win32.join(shimDir, 'agent-guild-report.cmd').replace(/'/g, "''")}' --hook`;
|
|
35
|
+
return `'${path.posix.join(shimDir, 'agent-guild-report').replace(/'/g, `'\\''`)}' --hook`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function bundleFiles(version, { shimDir = null, platform = process.platform } = {}) {
|
|
39
|
+
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
40
|
+
const manifest = { name: EXTENSION_NAME, version, description: MANIFEST_DESCRIPTION };
|
|
41
|
+
const gemini = (name, matcher) => ({ ...(matcher ? { matcher } : {}), hooks: [handler({ name: `Agent Guild ${name}`, command: geminiCommand(shimDir, platform) })] });
|
|
42
|
+
return {
|
|
43
|
+
claude: {
|
|
44
|
+
'.claude-plugin/plugin.json': json(manifest),
|
|
45
|
+
'hooks/hooks.json': json({ hooks: claudeHooks() }),
|
|
46
|
+
},
|
|
47
|
+
gemini: {
|
|
48
|
+
'gemini-extension.json': json(manifest),
|
|
49
|
+
'hooks/hooks.json': json({
|
|
50
|
+
hooks: {
|
|
51
|
+
SessionStart: [gemini('session start')],
|
|
52
|
+
BeforeTool: [gemini('agent start', 'invoke_agent'), gemini('shell start', 'run_shell_command')],
|
|
53
|
+
AfterTool: [gemini('agent stop', 'invoke_agent'), gemini('shell stop', 'run_shell_command')],
|
|
54
|
+
BeforeAgent: [gemini('turn start')],
|
|
55
|
+
AfterAgent: [gemini('turn end')],
|
|
56
|
+
BeforeModel: [gemini('model')],
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
grok: {
|
|
61
|
+
'.grok-plugin/plugin.json': json(manifest),
|
|
62
|
+
'hooks/hooks.json': json({
|
|
63
|
+
hooks: groups(GROK_EVENTS, { SessionEnd: { handler: { timeout: 10 } }, ...shellEvents(['PreToolUse', 'PostToolUse', 'PostToolUseFailure'], 'run_terminal_command') }),
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function writeBundles(dir, version, opts) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const [name, files] of Object.entries(bundleFiles(version, opts))) {
|
|
72
|
+
const root = path.join(dir, name);
|
|
73
|
+
for (const [rel, contents] of Object.entries(files)) {
|
|
74
|
+
const file = path.join(root, ...rel.split('/'));
|
|
75
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
77
|
+
fs.writeFileSync(tmp, contents);
|
|
78
|
+
fs.renameSync(tmp, file);
|
|
79
|
+
}
|
|
80
|
+
out[name] = root;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tomlString = (value) => `'${value}'`;
|
|
86
|
+
|
|
87
|
+
export function codexHookArgs() {
|
|
88
|
+
const value = (event) => {
|
|
89
|
+
const matcher = CODEX_MATCHERS[event] ? `matcher=${tomlString(CODEX_MATCHERS[event])},` : '';
|
|
90
|
+
return `[{${matcher}hooks=[{type=${tomlString('command')},command=${tomlString(REPORT_COMMAND)}}]}]`;
|
|
91
|
+
};
|
|
92
|
+
return CODEX_EVENTS.flatMap((event) => ['-c', `hooks.${event}=${value(event)}`]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function codexTrustArgs(hooks) {
|
|
96
|
+
if (hooks.some(({ key, hash }) => /['\n]/.test(key) || /['\n]/.test(hash))) return null;
|
|
97
|
+
return ['-c', `hooks.state={${hooks.map(({ key, hash }) => `${tomlString(key)}={trusted_hash=${tomlString(hash)}}`).join(',')}}`];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function codexHooksFrom(result) {
|
|
101
|
+
const hooks = (result?.data || []).flatMap((entry) => entry.hooks || [])
|
|
102
|
+
.filter((h) => h.source === 'sessionFlags' && h.command === REPORT_COMMAND && h.enabled !== false);
|
|
103
|
+
const found = CODEX_EVENTS.map((event) => {
|
|
104
|
+
const name = event.charAt(0).toLowerCase() + event.slice(1);
|
|
105
|
+
return hooks.find((h) => h.eventName === name);
|
|
106
|
+
});
|
|
107
|
+
if (found.some((h) => !h || typeof h.key !== 'string' || typeof h.currentHash !== 'string')) return null;
|
|
108
|
+
return found.map((h) => ({ key: h.key, hash: h.currentHash, trusted: h.trustStatus === 'trusted' }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function codexHooksList(resolved, args, { env, platform = process.platform, timeoutMs = PROBE_TIMEOUT_MS, tmpDir = os.tmpdir() } = {}) {
|
|
112
|
+
const home = fs.mkdtempSync(path.join(tmpDir, 'agent-guild-codex-'));
|
|
113
|
+
const spec = buildSpawnSpec(resolved, [...args, 'app-server'], env, platform);
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
let child;
|
|
116
|
+
let settled = false;
|
|
117
|
+
const finish = (err, value) => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
endTree(child, platform);
|
|
122
|
+
setTimeout(() => fs.rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }, () => {}), 500).unref();
|
|
123
|
+
if (err) reject(err);
|
|
124
|
+
else resolve(value);
|
|
125
|
+
};
|
|
126
|
+
const timer = setTimeout(() => finish(new Error('Codex did not answer in time')), timeoutMs);
|
|
127
|
+
timer.unref?.();
|
|
128
|
+
try {
|
|
129
|
+
const verbatim = typeof spec.args === 'string';
|
|
130
|
+
child = spawn(spec.file, verbatim ? [spec.args] : spec.args, {
|
|
131
|
+
cwd: home, env: { ...env, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, windowsVerbatimArguments: verbatim,
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return finish(err);
|
|
135
|
+
}
|
|
136
|
+
let stderr = '';
|
|
137
|
+
let buffer = '';
|
|
138
|
+
const send = (msg) => child.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
139
|
+
child.on('error', (err) => finish(err));
|
|
140
|
+
child.on('exit', (code) => finish(new Error(`Codex exited with code ${code}: ${stderr.trim().split('\n').find((l) => /error|caused/i.test(l)) || ''}`.trim())));
|
|
141
|
+
child.stdin.on('error', () => {});
|
|
142
|
+
child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
|
|
143
|
+
child.stdout.on('data', (chunk) => {
|
|
144
|
+
buffer += chunk;
|
|
145
|
+
let index;
|
|
146
|
+
while ((index = buffer.indexOf('\n')) !== -1) {
|
|
147
|
+
const line = buffer.slice(0, index);
|
|
148
|
+
buffer = buffer.slice(index + 1);
|
|
149
|
+
let msg;
|
|
150
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
151
|
+
if (msg.id === 1) {
|
|
152
|
+
if (msg.error) return finish(new Error(msg.error.message || 'initialize failed'));
|
|
153
|
+
send({ method: 'initialized' });
|
|
154
|
+
send({ id: 2, method: 'hooks/list', params: { cwds: [home] } });
|
|
155
|
+
} else if (msg.id === 2) {
|
|
156
|
+
if (msg.error) return finish(new Error(msg.error.message || 'hooks/list failed'));
|
|
157
|
+
return finish(null, msg.result);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'agent-guild', title: null, version: '1' } } });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function endTree(child, platform) {
|
|
166
|
+
if (!child || child.exitCode !== null) return;
|
|
167
|
+
try { child.stdin.end(); } catch { /* closed */ }
|
|
168
|
+
if (platform === 'win32' && child.pid) return killWindowsTree(child.pid);
|
|
169
|
+
try { child.kill(); } catch { /* gone */ }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function probeCodex(resolved, opts) {
|
|
173
|
+
const hookArgs = codexHookArgs();
|
|
174
|
+
const listed = codexHooksFrom(await codexHooksList(resolved, hookArgs, opts));
|
|
175
|
+
if (!listed) return { args: [], trusted: false, error: 'Codex did not load the hooks' };
|
|
176
|
+
if (listed.every((h) => h.trusted)) return { args: hookArgs, trusted: true };
|
|
177
|
+
const trustArgs = codexTrustArgs(listed);
|
|
178
|
+
if (trustArgs) {
|
|
179
|
+
try {
|
|
180
|
+
const again = codexHooksFrom(await codexHooksList(resolved, [...hookArgs, ...trustArgs], opts));
|
|
181
|
+
if (again?.every((h) => h.trusted)) return { args: [...hookArgs, ...trustArgs], trusted: true };
|
|
182
|
+
} catch { /* fall back to untrusted hooks, which Codex asks the user about */ }
|
|
183
|
+
}
|
|
184
|
+
return { args: hookArgs, trusted: false };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function helpLists(text, flag) {
|
|
188
|
+
return new RegExp(`^\\s+(?:-\\w, )?${flag.replace(/[-]/g, '\\-')}\\b`, 'm').test(text);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function geminiHome(env = {}) {
|
|
192
|
+
return env.GEMINI_CLI_HOME || os.homedir();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function geminiRecord(home) {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(fs.readFileSync(path.join(home, '.gemini', 'extensions', EXTENSION_NAME, '.gemini-extension-install.json'), 'utf8'));
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function geminiLinked(home, bundle) {
|
|
204
|
+
const record = geminiRecord(home);
|
|
205
|
+
return record?.type === 'link' && typeof record.source === 'string' && path.resolve(record.source) === path.resolve(bundle);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function geminiStaleLink(home, bundle) {
|
|
209
|
+
const record = geminiRecord(home);
|
|
210
|
+
if (!record || geminiLinked(home, bundle)) return false;
|
|
211
|
+
if (record.type !== 'link' || typeof record.source !== 'string') return false;
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(fs.readFileSync(path.join(record.source, 'gemini-extension.json'), 'utf8')).description === MANIFEST_DESCRIPTION;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return err.code === 'ENOENT';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Gemini's uninstall finds only extensions that load, so a link to a folder without its manifest is removed here, as uninstall would.
|
|
220
|
+
function removeDanglingLink(home) {
|
|
221
|
+
const extensions = path.join(home, '.gemini', 'extensions');
|
|
222
|
+
fs.rmSync(path.join(extensions, EXTENSION_NAME), { recursive: true, force: true });
|
|
223
|
+
const enablement = path.join(extensions, 'extension-enablement.json');
|
|
224
|
+
let config;
|
|
225
|
+
try { config = JSON.parse(fs.readFileSync(enablement, 'utf8')); } catch { return; }
|
|
226
|
+
if (!config || typeof config !== 'object' || !(EXTENSION_NAME in config)) return;
|
|
227
|
+
delete config[EXTENSION_NAME];
|
|
228
|
+
fs.writeFileSync(enablement, JSON.stringify(config, null, 2));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const pending = (tool, when) => ({ state: 'pending', reason: `Agent Guild added its reporting hooks to this ${tool} session. They report once ${tool} ${when}.` });
|
|
232
|
+
|
|
233
|
+
export class SessionHooks {
|
|
234
|
+
constructor({ registry, dir, version, shimDir = null, probeTimeoutMs = PROBE_TIMEOUT_MS, probeRetryMs = PROBE_RETRY_MS }) {
|
|
235
|
+
this.registry = registry;
|
|
236
|
+
this.probeTimeoutMs = probeTimeoutMs;
|
|
237
|
+
this.probeRetryMs = probeRetryMs;
|
|
238
|
+
this.dir = dir;
|
|
239
|
+
this.bundles = null;
|
|
240
|
+
this.probes = new Map();
|
|
241
|
+
if (dir) {
|
|
242
|
+
try {
|
|
243
|
+
this.bundles = writeBundles(dir, version, { shimDir, platform: registry.platform });
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.warn(`[reporting] could not write the reporting hooks to ${dir}: ${err.message}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
warm() {
|
|
251
|
+
for (const provider of this.registry.providers) {
|
|
252
|
+
if (provider.reporting === 'claude' || provider.reporting === 'codex' || provider.reporting === 'grok') this._probe(provider).catch(() => {});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_probe(provider) {
|
|
257
|
+
const resolved = this.registry.resolve(provider);
|
|
258
|
+
if (!resolved) return Promise.resolve(null);
|
|
259
|
+
let mtime = null;
|
|
260
|
+
try { mtime = fs.statSync(resolved).mtimeMs; } catch { /* probe anyway */ }
|
|
261
|
+
const cached = this.probes.get(provider.id);
|
|
262
|
+
const fresh = cached && cached.resolved === resolved && cached.mtime === mtime && (cached.ok || Date.now() - cached.at < this.probeRetryMs);
|
|
263
|
+
if (fresh) return cached.promise;
|
|
264
|
+
const env = { ...this.registry.env, ...provider.env };
|
|
265
|
+
const platform = this.registry.platform;
|
|
266
|
+
const entry = { resolved, mtime, at: Date.now(), ok: false };
|
|
267
|
+
entry.promise = (async () => {
|
|
268
|
+
if (provider.reporting === 'codex') {
|
|
269
|
+
const result = await probeCodex(resolved, { env, platform, timeoutMs: this.probeTimeoutMs });
|
|
270
|
+
entry.ok = result.args.length > 0;
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
const { stdout, stderr } = await runSpec(buildSpawnSpec(resolved, ['--help'], env, platform), { env, timeoutMs: this.probeTimeoutMs });
|
|
274
|
+
entry.ok = true;
|
|
275
|
+
return { pluginDir: helpLists(`${stdout}\n${stderr}`, '--plugin-dir') };
|
|
276
|
+
})().catch((err) => ({ error: err.message }));
|
|
277
|
+
this.probes.set(provider.id, entry);
|
|
278
|
+
return entry.promise;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async launch(provider, account) {
|
|
282
|
+
const mode = provider.reporting;
|
|
283
|
+
if (!mode) return { args: [], reporting: null };
|
|
284
|
+
const tool = provider.tool;
|
|
285
|
+
if (!this.bundles) {
|
|
286
|
+
return { args: [], reporting: { state: 'unavailable', reason: `Agent Guild could not write its reporting hooks, so ${tool} cannot report agents.` } };
|
|
287
|
+
}
|
|
288
|
+
if (mode === 'gemini') {
|
|
289
|
+
if (this.enabled(provider, account)) return { args: [], reporting: pending(tool, 'starts its session') };
|
|
290
|
+
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.` } };
|
|
291
|
+
}
|
|
292
|
+
const probe = await this._probe(provider);
|
|
293
|
+
if (mode === 'codex') {
|
|
294
|
+
if (probe?.args?.length) return { args: probe.args, reporting: pending(tool, 'runs its first prompt') };
|
|
295
|
+
return { args: [], reporting: { state: 'unavailable', reason: `${tool} did not accept Agent Guild's reporting hooks${probe?.error ? ` (${probe.error})` : ''}.` } };
|
|
296
|
+
}
|
|
297
|
+
const dir = mode === 'claude' ? this.bundles.claude : this.bundles.grok;
|
|
298
|
+
if (probe?.pluginDir) return { args: ['--plugin-dir', dir], reporting: pending(tool, 'starts its session') };
|
|
299
|
+
if (probe?.error) {
|
|
300
|
+
return { args: [], reporting: { state: 'unavailable', reason: `Could not check whether ${tool} can load Agent Guild's reporting hooks (${probe.error}).` } };
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
args: [],
|
|
304
|
+
reporting: {
|
|
305
|
+
state: 'unsupported',
|
|
306
|
+
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.`,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
enabled(provider, account) {
|
|
312
|
+
if (provider.reporting !== 'gemini' || !this.bundles) return null;
|
|
313
|
+
return geminiLinked(geminiHome({ ...this.registry.env, ...provider.env, ...account?.env }), this.bundles.gemini);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async setEnabled(provider, account, enabled) {
|
|
317
|
+
if (provider.reporting !== 'gemini') throw Object.assign(new Error(`${provider.tool} needs no setup for agent reporting`), { status: 400, code: 'not_applicable' });
|
|
318
|
+
if (!this.bundles) throw Object.assign(new Error('Agent Guild could not write its reporting hooks'), { status: 500, code: 'reporting_unavailable' });
|
|
319
|
+
const resolved = this.registry.resolve(provider);
|
|
320
|
+
if (!resolved) throw Object.assign(new Error(`${provider.tool} is not installed`), { status: 409, code: 'provider_unavailable' });
|
|
321
|
+
const env = { ...this.registry.env, ...provider.env, ...account?.env };
|
|
322
|
+
const home = geminiHome(env);
|
|
323
|
+
const stale = geminiStaleLink(home, this.bundles.gemini);
|
|
324
|
+
if (enabled === this.enabled(provider, account) && !stale) return enabled;
|
|
325
|
+
if (enabled && !stale && geminiRecord(home)) {
|
|
326
|
+
throw Object.assign(new Error(`${provider.tool} already has another extension named "${EXTENSION_NAME}". Remove it with "${provider.command} extensions uninstall ${EXTENSION_NAME}" to turn on agent reporting.`), { status: 409, code: 'extension_conflict' });
|
|
327
|
+
}
|
|
328
|
+
const run = async (args, what) => {
|
|
329
|
+
try {
|
|
330
|
+
const commandEnv = { ...env, GEMINI_CLI_TRUST_WORKSPACE: 'true' };
|
|
331
|
+
await runSpec(buildSpawnSpec(resolved, args, commandEnv, this.registry.platform), { env: commandEnv, timeoutMs: 60000, cwd: this.dir });
|
|
332
|
+
} catch (err) {
|
|
333
|
+
const detail = `${err.stderr || ''}\n${err.stdout || ''}`.trim().split('\n').filter(Boolean).pop() || err.message;
|
|
334
|
+
throw Object.assign(new Error(`${provider.tool} could not ${what} the Agent Guild extension: ${detail}`), { status: 502, code: 'reporting_setup_failed' });
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
const record = geminiRecord(home);
|
|
338
|
+
if (stale && !fs.existsSync(path.join(record.source, 'gemini-extension.json'))) {
|
|
339
|
+
removeDanglingLink(home);
|
|
340
|
+
} else if (stale || !enabled) {
|
|
341
|
+
await run(['extensions', 'uninstall', EXTENSION_NAME], 'remove');
|
|
342
|
+
}
|
|
343
|
+
if (enabled) await run(['extensions', 'link', this.bundles.gemini, '--consent'], 'link');
|
|
344
|
+
const now = this.enabled(provider, account);
|
|
345
|
+
if (now !== enabled) {
|
|
346
|
+
throw Object.assign(new Error(`${provider.tool} reported success, but the Agent Guild extension is ${now ? 'still linked' : 'not linked'}`), { status: 502, code: 'reporting_setup_failed' });
|
|
347
|
+
}
|
|
348
|
+
return now;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -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, reportTokenDir = null }) {
|
|
57
58
|
super();
|
|
58
59
|
this.registry = registry;
|
|
59
60
|
this.baseEnv = baseEnv;
|
|
@@ -62,6 +63,8 @@ export class SessionManager extends EventEmitter {
|
|
|
62
63
|
this.shimDir = shimDir;
|
|
63
64
|
this.selfUpdate = selfUpdate;
|
|
64
65
|
this.github = github;
|
|
66
|
+
this.sessionHooks = sessionHooks;
|
|
67
|
+
this.reportTokenDir = reportTokenDir;
|
|
65
68
|
this.sessions = new Map();
|
|
66
69
|
/** Removed sessions whose process has not exited yet. */
|
|
67
70
|
this.exiting = new Set();
|
|
@@ -91,7 +94,7 @@ export class SessionManager extends EventEmitter {
|
|
|
91
94
|
return dir;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
create({ providerId, cwd, cols, rows, name, args, resume, account } = {}) {
|
|
97
|
+
async create({ providerId, cwd, cols, rows, name, args, resume, account } = {}) {
|
|
95
98
|
const provider = this.registry.get(String(providerId || ''));
|
|
96
99
|
if (!provider) throw httpError(404, `unknown provider "${providerId}"`, 'unknown_provider');
|
|
97
100
|
if (args !== undefined && (!Array.isArray(args) || args.some((a) => typeof a !== 'string'))) {
|
|
@@ -101,32 +104,28 @@ export class SessionManager extends EventEmitter {
|
|
|
101
104
|
const resumeId = cleanResumeId(resume);
|
|
102
105
|
const workDir = this.resolveCwd(cwd);
|
|
103
106
|
const signIn = this.registry.account(provider, account);
|
|
104
|
-
const
|
|
105
|
-
this.
|
|
107
|
+
const hooks = this.sessionHooks ? await this.sessionHooks.launch(provider, signIn) : { args: [], reporting: null };
|
|
108
|
+
const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args);
|
|
109
|
+
this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
|
|
106
110
|
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 });
|
|
111
|
+
const session = this._spawn({ provider, spawnSpec, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn, reporting: hooks.reporting });
|
|
108
112
|
const model = modelFromArgs([...provider.args, ...(args || [])]);
|
|
109
113
|
if (model) session.setModel({ name: model }, 'args');
|
|
110
114
|
return session;
|
|
111
115
|
}
|
|
112
116
|
|
|
113
|
-
prepareAccount(provider, account) {
|
|
117
|
+
prepareAccount(provider, account, { hooksSupplied = false } = {}) {
|
|
114
118
|
if (!account.dir) return;
|
|
115
119
|
try {
|
|
116
120
|
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
121
|
} catch (err) {
|
|
128
122
|
throw httpError(500, `could not prepare the ${account.label} account folder ${account.dir}: ${err.message}`, 'account_unavailable');
|
|
129
123
|
}
|
|
124
|
+
if (provider.reporting !== 'codex' || !hooksSupplied || !provider.hooks) return;
|
|
125
|
+
const target = path.join(account.dir, ...provider.hooks.path.split('/'));
|
|
126
|
+
try {
|
|
127
|
+
if (crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex') === SEEDED_CODEX_HOOKS) fs.unlinkSync(target);
|
|
128
|
+
} catch { /* absent or unreadable: nothing of ours to remove */ }
|
|
130
129
|
}
|
|
131
130
|
|
|
132
131
|
/**
|
|
@@ -229,7 +228,7 @@ export class SessionManager extends EventEmitter {
|
|
|
229
228
|
|
|
230
229
|
_spawn({
|
|
231
230
|
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,
|
|
231
|
+
extraEnv = null, dropEnv = null, clone = null, reporting = null,
|
|
233
232
|
}) {
|
|
234
233
|
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
235
234
|
if (this.sessions.size >= MAX_SESSIONS) {
|
|
@@ -237,6 +236,7 @@ export class SessionManager extends EventEmitter {
|
|
|
237
236
|
}
|
|
238
237
|
const id = newId();
|
|
239
238
|
const reportToken = crypto.randomBytes(16).toString('hex');
|
|
239
|
+
const reportFile = this._writeReportToken(id, reportToken);
|
|
240
240
|
|
|
241
241
|
// The tool's hooks run `agent-guild-report` by name, so the launchers
|
|
242
242
|
// go first on PATH, after any provider PATH override.
|
|
@@ -247,6 +247,7 @@ export class SessionManager extends EventEmitter {
|
|
|
247
247
|
AGENT_GUILD_PROVIDER: provider.id,
|
|
248
248
|
AGENT_GUILD_URL: this.getApiUrl(),
|
|
249
249
|
AGENT_GUILD_REPORT_TOKEN: reportToken,
|
|
250
|
+
AGENT_GUILD_REPORT_FILE: reportFile,
|
|
250
251
|
AGENT_GUILD_NODE: process.execPath,
|
|
251
252
|
}]), this.shimDir);
|
|
252
253
|
// The tool runs in its own terminal, not in the terminal or multiplexer
|
|
@@ -274,10 +275,13 @@ export class SessionManager extends EventEmitter {
|
|
|
274
275
|
task,
|
|
275
276
|
account: account ? { id: account.id, label: account.label } : null,
|
|
276
277
|
clone,
|
|
278
|
+
reporting,
|
|
277
279
|
});
|
|
278
280
|
} catch (err) {
|
|
281
|
+
this._removeReportToken(reportFile);
|
|
279
282
|
throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
|
|
280
283
|
}
|
|
284
|
+
session.exited.then(() => this._removeReportToken(reportFile));
|
|
281
285
|
|
|
282
286
|
session.on('changed', () => {
|
|
283
287
|
if (this.sessions.has(id)) this.emit('event', { type: 'session.updated', session: session.toJSON() });
|
|
@@ -299,6 +303,32 @@ export class SessionManager extends EventEmitter {
|
|
|
299
303
|
return session;
|
|
300
304
|
}
|
|
301
305
|
|
|
306
|
+
// Only once this manager owns the port, so a second one that fails to start leaves the running one's files.
|
|
307
|
+
sweepReportTokens() {
|
|
308
|
+
if (!this.reportTokenDir) return;
|
|
309
|
+
const live = new Set(this.sessions.keys());
|
|
310
|
+
let names = [];
|
|
311
|
+
try { names = fs.readdirSync(this.reportTokenDir); } catch { return; }
|
|
312
|
+
for (const name of names) if (!live.has(name)) fs.rm(path.join(this.reportTokenDir, name), { force: true }, () => {});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
_writeReportToken(id, token) {
|
|
316
|
+
if (!this.reportTokenDir) return null;
|
|
317
|
+
try {
|
|
318
|
+
fs.mkdirSync(this.reportTokenDir, { recursive: true, mode: 0o700 });
|
|
319
|
+
const file = path.join(this.reportTokenDir, id);
|
|
320
|
+
fs.writeFileSync(file, token, { mode: 0o600, flag: 'wx' });
|
|
321
|
+
return file;
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.warn(`[manager] could not write the report token file for session ${id}: ${err.message}`);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
_removeReportToken(file) {
|
|
329
|
+
if (file) fs.rm(file, { force: true }, () => {});
|
|
330
|
+
}
|
|
331
|
+
|
|
302
332
|
/** Remove a session from the list, ending its process if still running. */
|
|
303
333
|
remove(id) {
|
|
304
334
|
const session = this.get(id);
|
|
@@ -325,6 +355,14 @@ export class SessionManager extends EventEmitter {
|
|
|
325
355
|
return this._reportingSession(id, auth).reportToolSession(report);
|
|
326
356
|
}
|
|
327
357
|
|
|
358
|
+
reportHello(id, auth) {
|
|
359
|
+
return this._reportingSession(id, auth).reportHello();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
reportShell(id, report, auth) {
|
|
363
|
+
return this._reportingSession(id, auth).reportShell(report);
|
|
364
|
+
}
|
|
365
|
+
|
|
328
366
|
_reportingSession(id, { reportToken, trusted = false } = {}) {
|
|
329
367
|
const session = this.sessions.get(id);
|
|
330
368
|
// Without the API token, an unknown session and a wrong token look the
|