@oddessentials/agent-guild 0.11.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.
@@ -5,8 +5,8 @@ import { spawn } from 'node:child_process';
5
5
  import { buildSpawnSpec, runSpec, killWindowsTree } from './command-resolver.mjs';
6
6
 
7
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.';
8
+ export const PLUGIN_NAME = 'agent-guild';
9
+ const MANIFEST_DESCRIPTION = 'Reports to the Agent Guild session it runs in.';
10
10
  const PROBE_TIMEOUT_MS = 20000;
11
11
  const PROBE_RETRY_MS = 5 * 60 * 1000;
12
12
 
@@ -28,35 +28,32 @@ const claudeHooks = () => Object.fromEntries(CLAUDE_EVENTS.map((event) => [event
28
28
  }]]));
29
29
  const GROK_EVENTS = ['SessionStart', 'SubagentStart', 'SubagentStop', 'StopCancelled', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'Stop'];
30
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`;
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
+ };
36
46
  }
37
47
 
38
- export function bundleFiles(version, { shimDir = null, platform = process.platform } = {}) {
48
+ export function bundleFiles(version, { platform = process.platform } = {}) {
39
49
  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) })] });
50
+ const manifest = { name: PLUGIN_NAME, version, description: MANIFEST_DESCRIPTION };
42
51
  return {
43
52
  claude: {
44
53
  '.claude-plugin/plugin.json': json(manifest),
45
54
  'hooks/hooks.json': json({ hooks: claudeHooks() }),
46
55
  },
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
- },
56
+ antigravity: antigravityFiles({ name: PLUGIN_NAME, description: MANIFEST_DESCRIPTION }, platform),
60
57
  grok: {
61
58
  '.grok-plugin/plugin.json': json(manifest),
62
59
  'hooks/hooks.json': json({
@@ -188,50 +185,51 @@ export function helpLists(text, flag) {
188
185
  return new RegExp(`^\\s+(?:-\\w, )?${flag.replace(/[-]/g, '\\-')}\\b`, 'm').test(text);
189
186
  }
190
187
 
191
- export function geminiHome(env = {}) {
192
- return env.GEMINI_CLI_HOME || os.homedir();
193
- }
188
+ const geminiHome = (env, platform) => path.join((platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir(), '.gemini');
194
189
 
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
- }
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);
201
193
  }
202
194
 
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);
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');
206
198
  }
207
199
 
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;
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;
212
203
  try {
213
- return JSON.parse(fs.readFileSync(path.join(record.source, 'gemini-extension.json'), 'utf8')).description === MANIFEST_DESCRIPTION;
204
+ config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
214
205
  } catch (err) {
215
206
  return err.code === 'ENOENT';
216
207
  }
208
+ if (!config || typeof config !== 'object') return false;
209
+ return config.plugins?.[PLUGIN_NAME]?.enabled !== false;
217
210
  }
218
211
 
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));
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';
229
227
  }
230
228
 
231
229
  const pending = (tool, when) => ({ state: 'pending', reason: `Agent Guild added its reporting hooks to this ${tool} session. They report once ${tool} ${when}.` });
232
230
 
233
231
  export class SessionHooks {
234
- constructor({ registry, dir, version, shimDir = null, probeTimeoutMs = PROBE_TIMEOUT_MS, probeRetryMs = PROBE_RETRY_MS }) {
232
+ constructor({ registry, dir, version, probeTimeoutMs = PROBE_TIMEOUT_MS, probeRetryMs = PROBE_RETRY_MS }) {
235
233
  this.registry = registry;
236
234
  this.probeTimeoutMs = probeTimeoutMs;
237
235
  this.probeRetryMs = probeRetryMs;
@@ -240,7 +238,7 @@ export class SessionHooks {
240
238
  this.probes = new Map();
241
239
  if (dir) {
242
240
  try {
243
- this.bundles = writeBundles(dir, version, { shimDir, platform: registry.platform });
241
+ this.bundles = writeBundles(dir, version, { platform: registry.platform });
244
242
  } catch (err) {
245
243
  console.warn(`[reporting] could not write the reporting hooks to ${dir}: ${err.message}`);
246
244
  }
@@ -278,15 +276,15 @@ export class SessionHooks {
278
276
  return entry.promise;
279
277
  }
280
278
 
281
- async launch(provider, account) {
279
+ async launch(provider) {
282
280
  const mode = provider.reporting;
283
281
  if (!mode) return { args: [], reporting: null };
284
282
  const tool = provider.tool;
285
283
  if (!this.bundles) {
286
284
  return { args: [], reporting: { state: 'unavailable', reason: `Agent Guild could not write its reporting hooks, so ${tool} cannot report agents.` } };
287
285
  }
288
- if (mode === 'gemini') {
289
- if (this.enabled(provider, account)) return { args: [], reporting: pending(tool, 'starts its session') };
286
+ if (mode === 'antigravity') {
287
+ if (this.enabled(provider)) return { args: [], reporting: pending(tool, 'runs its first prompt') };
290
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.` } };
291
289
  }
292
290
  const probe = await this._probe(provider);
@@ -308,42 +306,45 @@ export class SessionHooks {
308
306
  };
309
307
  }
310
308
 
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);
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);
314
316
  }
315
317
 
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
+ _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' });
318
324
  if (!this.bundles) throw Object.assign(new Error('Agent Guild could not write its reporting hooks'), { status: 500, code: 'reporting_unavailable' });
319
325
  const resolved = this.registry.resolve(provider);
320
326
  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
+ 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' });
327
332
  }
333
+ const env = { ...this.registry.env, ...provider.env };
328
334
  const run = async (args, what) => {
329
335
  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 });
336
+ await runSpec(buildSpawnSpec(resolved, args, env, this.registry.platform), { env, timeoutMs: 60000, cwd: this.dir });
332
337
  } catch (err) {
333
338
  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' });
339
+ throw Object.assign(new Error(`${provider.tool} could not ${what} the Agent Guild plugin: ${detail}`), { status: 502, code: 'reporting_setup_failed' });
335
340
  }
336
341
  };
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);
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);
345
346
  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
+ 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' });
347
348
  }
348
349
  return now;
349
350
  }
@@ -54,7 +54,7 @@ export class SessionManager extends EventEmitter {
54
54
  * @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
55
55
  * @param {import('./github.mjs').GitHub|null} [opts.github]
56
56
  */
57
- constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null, reportTokenDir = null }) {
57
+ constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null }) {
58
58
  super();
59
59
  this.registry = registry;
60
60
  this.baseEnv = baseEnv;
@@ -64,7 +64,6 @@ export class SessionManager extends EventEmitter {
64
64
  this.selfUpdate = selfUpdate;
65
65
  this.github = github;
66
66
  this.sessionHooks = sessionHooks;
67
- this.reportTokenDir = reportTokenDir;
68
67
  this.sessions = new Map();
69
68
  /** Removed sessions whose process has not exited yet. */
70
69
  this.exiting = new Set();
@@ -104,7 +103,7 @@ export class SessionManager extends EventEmitter {
104
103
  const resumeId = cleanResumeId(resume);
105
104
  const workDir = this.resolveCwd(cwd);
106
105
  const signIn = this.registry.account(provider, account);
107
- const hooks = this.sessionHooks ? await this.sessionHooks.launch(provider, signIn) : { args: [], reporting: null };
106
+ const hooks = this.sessionHooks ? await this.sessionHooks.launch(provider) : { args: [], reporting: null };
108
107
  const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args);
109
108
  this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
110
109
  const sessionName = cleanName(name) || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null);
@@ -236,7 +235,6 @@ export class SessionManager extends EventEmitter {
236
235
  }
237
236
  const id = newId();
238
237
  const reportToken = crypto.randomBytes(16).toString('hex');
239
- const reportFile = this._writeReportToken(id, reportToken);
240
238
 
241
239
  // The tool's hooks run `agent-guild-report` by name, so the launchers
242
240
  // go first on PATH, after any provider PATH override.
@@ -245,9 +243,9 @@ 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
- AGENT_GUILD_REPORT_FILE: reportFile,
251
249
  AGENT_GUILD_NODE: process.execPath,
252
250
  }]), this.shimDir);
253
251
  // The tool runs in its own terminal, not in the terminal or multiplexer
@@ -278,10 +276,8 @@ export class SessionManager extends EventEmitter {
278
276
  reporting,
279
277
  });
280
278
  } catch (err) {
281
- this._removeReportToken(reportFile);
282
279
  throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
283
280
  }
284
- session.exited.then(() => this._removeReportToken(reportFile));
285
281
 
286
282
  session.on('changed', () => {
287
283
  if (this.sessions.has(id)) this.emit('event', { type: 'session.updated', session: session.toJSON() });
@@ -303,32 +299,6 @@ export class SessionManager extends EventEmitter {
303
299
  return session;
304
300
  }
305
301
 
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
-
332
302
  /** Remove a session from the list, ending its process if still running. */
333
303
  remove(id) {
334
304
  const session = this.get(id);
@@ -22,18 +22,8 @@ const MAX_TOOL_SESSION_ID = 200;
22
22
  const REPORTING_STATES = new Set(['pending', 'active', 'unavailable', 'setup_required', 'unsupported']);
23
23
  const SHELL_EVENTS = new Set(['start', 'waiting', 'asked', 'background', 'end', 'running', 'reset']);
24
24
  const MAX_SHELLS = 256;
25
- const MAX_SHELL_PIDS = 16;
26
25
  const MAX_ENDED_TASKS = 256;
27
26
 
28
- function processExists(pid) {
29
- try {
30
- process.kill(pid, 0);
31
- return true;
32
- } catch (err) {
33
- return err.code === 'EPERM';
34
- }
35
- }
36
-
37
27
  /**
38
28
  * Colours reported to programs that query them (OSC 10/11/12), matching the
39
29
  * web page's terminal theme. Tools use these to choose light or dark output.
@@ -99,11 +89,9 @@ export class Session extends EventEmitter {
99
89
  this.reporting = REPORTING_STATES.has(opts.reporting?.state) ? { state: opts.reporting.state, reason: opts.reporting.reason ?? null } : null;
100
90
  this._reportingTimer = null;
101
91
  this.shellDisplayDelayMs = opts.shellDisplayDelayMs ?? 600;
102
- this.shellPidCheckMs = opts.shellPidCheckMs ?? 2000;
103
92
  this.shells = new Map();
104
93
  this._shellSeq = 0;
105
94
  this._endedTasks = new Set();
106
- this._pidTimer = null;
107
95
  this.createdAt = new Date().toISOString();
108
96
  this.exitedAt = null;
109
97
  this.status = 'running';
@@ -381,11 +369,6 @@ export class Session extends EventEmitter {
381
369
  if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
382
370
  if (source === 'api') this._reportingHeard();
383
371
  if (report.finishForeground === true && report.agentId === undefined) {
384
- // The tool is between turns, so no foreground agent can still be
385
- // running; one that is never got its end event.
386
- for (const agent of [...this.agents.values()]) {
387
- if (agent.foreground && agent.status === 'working') this.reportAgent({ agentId: agent.id, status: 'done' }, source);
388
- }
389
372
  this._endForegroundShells(null);
390
373
  return null;
391
374
  }
@@ -402,11 +385,6 @@ export class Session extends EventEmitter {
402
385
  }
403
386
  const now = new Date().toISOString();
404
387
  const existing = this.agents.get(id);
405
- if (!existing && status === 'done' && report.foreground === true) {
406
- // Gemini CLI's invoke_agent is paired by its input, which a BeforeTool hook may rewrite before AfterTool.
407
- const working = [...this.agents.values()].filter((agent) => agent.foreground && agent.status === 'working');
408
- return working.length === 1 ? this.reportAgent({ agentId: working[0].id, status: 'done' }, source) : null;
409
- }
410
388
  if (status === 'done') this._endForegroundShells(id);
411
389
  // A first report that already says done would only flash an icon:
412
390
  // Claude Code's internal helpers (prompt suggestions, side questions)
@@ -424,9 +402,6 @@ export class Session extends EventEmitter {
424
402
  kind: String(report.kind ?? existing?.kind ?? 'agent').slice(0, 40),
425
403
  status,
426
404
  detail: report.detail === undefined ? existing?.detail ?? '' : String(report.detail).slice(0, 200),
427
- // The parent waits for a foreground agent, so a model reported while
428
- // it works belongs to the agent, not to the session.
429
- foreground: report.foreground === undefined ? existing?.foreground ?? false : report.foreground === true,
430
405
  startedAt: existing?.startedAt ?? now,
431
406
  updatedAt: now,
432
407
  source,
@@ -488,7 +463,6 @@ export class Session extends EventEmitter {
488
463
  if (!SHELL_EVENTS.has(report.shell)) throw badRequest(`shell must be one of ${[...SHELL_EVENTS].join(', ')}`);
489
464
  const id = (value) => (typeof value === 'string' && value ? value.slice(0, 128) : null);
490
465
  const key = id(report.key);
491
- const bucket = !key && typeof report.bucket === 'string' && /^[a-f0-9]{1,64}$/.test(report.bucket) ? report.bucket : null;
492
466
  const task = id(report.task);
493
467
  const match = typeof report.match === 'string' && /^[a-f0-9]{32}$/.test(report.match) ? report.match : null;
494
468
  const agentId = id(report.agentId);
@@ -525,34 +499,23 @@ export class Session extends EventEmitter {
525
499
  }
526
500
 
527
501
  if (report.shell === 'end') {
528
- if (!key && !bucket && !task) throw badRequest('a shell end needs a key, a bucket or a task');
502
+ if (!key && !task) throw badRequest('a shell end needs a key or a task');
529
503
  if (task) this._rememberEnded(task);
530
- const shell = (key && this._shellBy('key', key)) || (task && this._shellBy('task', task)) || (bucket && this._shellByBucket(bucket));
504
+ const shell = (key && this._shellBy('key', key)) || (task && this._shellBy('task', task));
531
505
  if (shell) this._endShell(shell);
532
506
  return null;
533
507
  }
534
508
 
535
- if (!key && !bucket) throw badRequest('a shell report needs a key or a bucket');
509
+ if (!key) throw badRequest('a shell report needs a key');
536
510
  if (report.shell === 'start') {
537
- if (!key || !this._shellBy('key', key)) this._addShell({ key, bucket, match, agentId, persist: report.persist === true });
511
+ if (!this._shellBy('key', key)) this._addShell({ key, match, agentId, persist: report.persist === true });
538
512
  return null;
539
513
  }
540
514
 
541
- const pids = Array.isArray(report.pids) ? report.pids.filter((pid) => Number.isInteger(pid) && pid > 0).slice(0, MAX_SHELL_PIDS) : [];
542
- if (!task && pids.length === 0) throw badRequest('a background report needs a task or pids');
543
- const shell = key ? this._shellBy('key', key) : this._shellByBucket(bucket);
515
+ if (!task) throw badRequest('a background report needs a task');
516
+ const shell = this._shellBy('key', key);
544
517
  if (!shell) return null;
545
- if (task) {
546
- Object.assign(shell, { task, endsWithAgent: report.endsWithAgent === true });
547
- } else {
548
- // Gemini CLI names a pid even for a command that ended at once.
549
- shell.pids = pids.filter(processExists);
550
- if (shell.pids.length === 0) {
551
- this._endShell(shell);
552
- return null;
553
- }
554
- this._checkPids();
555
- }
518
+ Object.assign(shell, { task, endsWithAgent: report.endsWithAgent === true });
556
519
  if (shell.waiting) {
557
520
  shell.waiting = false;
558
521
  this._showAfterDelay(shell);
@@ -566,7 +529,7 @@ export class Session extends EventEmitter {
566
529
  return;
567
530
  }
568
531
  const shell = {
569
- id: `shell-${++this._shellSeq}`, key: null, bucket: null, match: null, agentId: null, persist: false, task: null, pids: null,
532
+ id: `shell-${++this._shellSeq}`, key: null, match: null, agentId: null, persist: false, task: null,
570
533
  endsWithAgent: false, waiting: false, asked: false, visible: false, timer: null, ...fields,
571
534
  };
572
535
  this.shells.set(shell.id, shell);
@@ -587,7 +550,7 @@ export class Session extends EventEmitter {
587
550
  }
588
551
 
589
552
  _foreground(shell) {
590
- return shell.task === null && shell.pids === null;
553
+ return shell.task === null;
591
554
  }
592
555
 
593
556
  _shellBy(field, value) {
@@ -595,30 +558,6 @@ export class Session extends EventEmitter {
595
558
  return null;
596
559
  }
597
560
 
598
- // Gemini CLI's calls are paired by their input, which a BeforeTool hook may rewrite before AfterTool.
599
- _shellByBucket(bucket) {
600
- const open = [...this.shells.values()].filter((shell) => shell.bucket !== null && this._foreground(shell));
601
- return open.find((shell) => shell.bucket === bucket) ?? (open.length === 1 ? open[0] : null);
602
- }
603
-
604
- _checkPids() {
605
- if (this._pidTimer) return;
606
- this._pidTimer = setInterval(() => {
607
- let watching = false;
608
- for (const shell of [...this.shells.values()]) {
609
- if (!shell.pids) continue;
610
- shell.pids = shell.pids.filter(processExists);
611
- if (shell.pids.length) watching = true;
612
- else this._endShell(shell);
613
- }
614
- if (!watching) {
615
- clearInterval(this._pidTimer);
616
- this._pidTimer = null;
617
- }
618
- }, this.shellPidCheckMs);
619
- this._pidTimer.unref?.();
620
- }
621
-
622
561
  // A turn's foreground commands end with it; a background command, or one Codex CLI keeps running, does not.
623
562
  _endForegroundShells(agentId) {
624
563
  for (const shell of [...this.shells.values()]) {
@@ -642,8 +581,6 @@ export class Session extends EventEmitter {
642
581
 
643
582
  _clearShells() {
644
583
  for (const shell of this.shells.values()) clearTimeout(shell.timer);
645
- clearInterval(this._pidTimer);
646
- this._pidTimer = null;
647
584
  this.shells.clear();
648
585
  }
649
586
 
@@ -668,10 +605,6 @@ export class Session extends EventEmitter {
668
605
  const name = String(report.model ?? '').trim().slice(0, 120);
669
606
  if (!name) throw badRequest('model is required');
670
607
  const displayName = report.displayName === undefined || report.displayName === null ? null : String(report.displayName).trim().slice(0, 80) || null;
671
- // Gemini CLI fires BeforeModel for a sub-agent's requests too, with the
672
- // sub-agent's model and nothing to tell them apart. While a foreground
673
- // agent works its parent makes no request, so the report is the agent's.
674
- if (this._foregroundAgentWorking()) return this.model;
675
608
  this.setModel({ name, displayName }, 'report');
676
609
  return this.model;
677
610
  }
@@ -702,13 +635,6 @@ export class Session extends EventEmitter {
702
635
  this._changed();
703
636
  }
704
637
 
705
- _foregroundAgentWorking() {
706
- for (const agent of this.agents.values()) {
707
- if (agent.foreground && agent.status === 'working') return true;
708
- }
709
- return false;
710
- }
711
-
712
638
  /**
713
639
  * Scan shortly after output pauses, and at least every couple of seconds
714
640
  * while output keeps coming, so a busy tool still gets scanned.