@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.
@@ -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
- 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, 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 spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId);
105
- this.prepareAccount(provider, signIn);
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