@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.
@@ -12,10 +12,6 @@ const RATE_LIMITED_TTL_MS = 5 * 60 * 1000;
12
12
  const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
13
13
  const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
14
14
  const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials';
15
- const GEMINI_KEYCHAIN_SERVICE = 'gemini-cli-oauth';
16
- const GEMINI_KEYCHAIN_ACCOUNT = 'main-account';
17
- const GEMINI_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal';
18
- const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
19
15
 
20
16
  export class UsageError extends Error {}
21
17
 
@@ -258,232 +254,6 @@ function codexWindows(limits) {
258
254
  return windows;
259
255
  }
260
256
 
261
- // ---- Gemini CLI -----------------------------------------------------------
262
-
263
- export function geminiCredentialsFile(env = process.env) {
264
- return path.join(env.GEMINI_CLI_HOME || os.homedir(), '.gemini', 'oauth_creds.json');
265
- }
266
-
267
- export function geminiKeychainFile(env = process.env) {
268
- return path.join(env.GEMINI_CLI_HOME || os.homedir(), '.gemini', 'gemini-credentials.json');
269
- }
270
-
271
- export function geminiFileKey({ hostname = os.hostname(), username = os.userInfo().username } = {}) {
272
- return crypto.scryptSync(GEMINI_KEYCHAIN_SERVICE, `${hostname}-${username}-gemini-cli`, 32);
273
- }
274
-
275
- /**
276
- * The item Gemini CLI keeps in its own encrypted file when it does not use
277
- * the OS keychain: AES-256-GCM as iv:tag:ciphertext in hex, over a JSON
278
- * map of service to account to secret.
279
- */
280
- export async function readGeminiFileKeychain(file, { key = geminiFileKey() } = {}) {
281
- let raw;
282
- try {
283
- raw = await fs.promises.readFile(file, 'utf8');
284
- } catch {
285
- return null;
286
- }
287
- try {
288
- const [iv, tag, encrypted] = raw.trim().split(':').map((part) => Buffer.from(part, 'hex'));
289
- const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
290
- decipher.setAuthTag(tag);
291
- const json = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
292
- return JSON.parse(json)?.[GEMINI_KEYCHAIN_SERVICE]?.[GEMINI_KEYCHAIN_ACCOUNT] ?? null;
293
- } catch {
294
- throw new UsageError(`Gemini CLI credentials file ${shortPath(file)} could not be decrypted`);
295
- }
296
- }
297
-
298
- export function geminiKeychainLookup(platform) {
299
- if (platform === 'darwin') return { file: 'security', args: ['find-generic-password', '-s', GEMINI_KEYCHAIN_SERVICE, '-a', GEMINI_KEYCHAIN_ACCOUNT, '-w'] };
300
- // keytar stores libsecret items with the attributes "service" and "account".
301
- if (platform === 'linux') return { file: 'secret-tool', args: ['lookup', 'service', GEMINI_KEYCHAIN_SERVICE, 'account', GEMINI_KEYCHAIN_ACCOUNT] };
302
- return null;
303
- }
304
-
305
- /**
306
- * What the OS keychain holds for Gemini CLI: the item, or that it is
307
- * absent, that there is no keychain to ask (so the tool keeps its own
308
- * encrypted file instead), or that it is one this manager cannot read.
309
- */
310
- export async function readGeminiKeychainItem(platform) {
311
- const spec = geminiKeychainLookup(platform);
312
- if (!spec) return { status: platform === 'win32' ? 'unreadable' : 'unavailable' };
313
- try {
314
- const { stdout } = await runSpec(spec, { timeoutMs: 30000 });
315
- return stdout.trim() ? { status: 'found', item: stdout } : { status: 'absent' };
316
- } catch (err) {
317
- // security exits 44 for a missing item; secret-tool exits 1 for one and
318
- // says why on stderr when it has no Secret Service at all.
319
- if (platform === 'darwin' && err.code === 44) return { status: 'absent' };
320
- if (platform === 'linux' && err.code === 1 && !String(err.stderr || '').trim()) return { status: 'absent' };
321
- return { status: 'unavailable' };
322
- }
323
- }
324
-
325
- /** Gemini CLI's own storage choice, read from the environment the tool runs with. */
326
- export function geminiStorageMode(env = process.env) {
327
- return { encrypted: env.GEMINI_FORCE_ENCRYPTED_FILE_STORAGE === 'true', fileStorage: env.GEMINI_FORCE_FILE_STORAGE === 'true' };
328
- }
329
-
330
- /**
331
- * Reads the sign-in from where Gemini CLI keeps it: oauth_creds.json, or
332
- * with GEMINI_FORCE_ENCRYPTED_FILE_STORAGE the OS keychain item, or its
333
- * encrypted file when GEMINI_FORCE_FILE_STORAGE is set or there is no
334
- * keychain. An absent item falls back to oauth_creds.json, as the tool
335
- * itself migrates it from there.
336
- */
337
- export async function readGeminiCredentials({
338
- file = geminiCredentialsFile(),
339
- keychainFile = geminiKeychainFile(),
340
- platform = process.platform,
341
- encrypted = false,
342
- fileStorage = false,
343
- readKeychain = readGeminiKeychainItem,
344
- readFileKeychain = readGeminiFileKeychain,
345
- } = {}) {
346
- let raw = null;
347
- if (encrypted) {
348
- const keychain = fileStorage ? { status: 'unavailable' } : await readKeychain(platform);
349
- if (keychain.status === 'found') raw = keychain.item;
350
- else if (keychain.status === 'unavailable') raw = await readFileKeychain(keychainFile);
351
- else if (keychain.status === 'unreadable') throw new UsageError('Gemini CLI keeps its sign-in in the Windows Credential Manager, which cannot be read from here');
352
- }
353
- if (raw && raw.trim()) {
354
- let item;
355
- try { item = JSON.parse(raw); } catch { throw new UsageError('Gemini CLI credentials could not be parsed'); }
356
- const token = item?.token ?? item;
357
- if (typeof token?.accessToken !== 'string' || !token.accessToken) throw new UsageError('Gemini CLI credentials could not be parsed');
358
- return { accessToken: token.accessToken, refreshToken: token.refreshToken || null, expiresAt: Number(token.expiresAt) || null, client: null };
359
- }
360
- let legacy;
361
- try {
362
- legacy = JSON.parse(await fs.promises.readFile(file, 'utf8'));
363
- } catch {
364
- throw notSignedIn(`Gemini CLI is not signed in on this machine (no "${GEMINI_KEYCHAIN_SERVICE}" keychain item or ${shortPath(file)})`);
365
- }
366
- if (typeof legacy?.access_token !== 'string' || !legacy.access_token) throw new UsageError('Gemini CLI credentials could not be parsed');
367
- const client = typeof legacy.client_id === 'string' && typeof legacy.client_secret === 'string'
368
- ? { id: legacy.client_id, secret: legacy.client_secret }
369
- : null;
370
- return { accessToken: legacy.access_token, refreshToken: legacy.refresh_token || null, expiresAt: Number(legacy.expiry_date) || null, client };
371
- }
372
-
373
- const geminiClients = new Map();
374
-
375
- /**
376
- * The OAuth client Gemini CLI signed the user in with, read from the
377
- * installed copy that `commandPath` starts. A refresh token can only be
378
- * refreshed with the client that issued it, and the client belongs to
379
- * Gemini CLI, so it is not kept here.
380
- */
381
- export function geminiOAuthClientFromInstall(commandPath) {
382
- if (!commandPath) return null;
383
- if (geminiClients.has(commandPath)) return geminiClients.get(commandPath);
384
- let client = null;
385
- try {
386
- let start = fs.realpathSync(commandPath);
387
- if (/\.(cmd|bat|ps1)$/i.test(start)) {
388
- // npm's shims start node_modules/... relative to their own folder.
389
- const refersToGemini = /node_modules[\\/]@google[\\/]gemini-cli/.test(fs.readFileSync(start, 'utf8'));
390
- start = refersToGemini ? path.join(path.dirname(start), 'node_modules', '@google', 'gemini-cli') : null;
391
- }
392
- let dir = start && (fs.statSync(start).isDirectory() ? start : path.dirname(start));
393
- for (let depth = 0; dir && depth < 8; depth++) {
394
- let name = null;
395
- try { name = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name; } catch { /* keep climbing */ }
396
- if (name === '@google/gemini-cli') break;
397
- const parent = path.dirname(dir);
398
- dir = parent === dir ? null : parent;
399
- }
400
- for (const sub of dir ? ['bundle', 'dist'] : []) {
401
- let files = [];
402
- try { files = fs.readdirSync(path.join(dir, sub)).filter((f) => f.endsWith('.js')); } catch { continue; }
403
- for (const file of files) {
404
- const text = fs.readFileSync(path.join(dir, sub, file), 'latin1');
405
- const id = text.match(/OAUTH_CLIENT_ID\s*=\s*"([^"]+)"/)?.[1];
406
- const secret = text.match(/OAUTH_CLIENT_SECRET\s*=\s*"([^"]+)"/)?.[1];
407
- if (id && secret) { client = { id, secret }; break; }
408
- }
409
- if (client) break;
410
- }
411
- } catch {
412
- client = null;
413
- }
414
- geminiClients.set(commandPath, client);
415
- return client;
416
- }
417
-
418
- async function refreshGoogleToken(refreshToken, client, fetchImpl) {
419
- const body = new URLSearchParams({
420
- client_id: client.id,
421
- client_secret: client.secret,
422
- refresh_token: refreshToken,
423
- grant_type: 'refresh_token',
424
- });
425
- const res = await fetchImpl(GOOGLE_TOKEN_URL, {
426
- method: 'POST',
427
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
428
- body: body.toString(),
429
- signal: AbortSignal.timeout(10000),
430
- });
431
- if (!res.ok) throw new UsageError('Gemini CLI sign-in could not be refreshed; run gemini once to sign in again');
432
- const json = await res.json();
433
- if (typeof json?.access_token !== 'string') throw new UsageError('Gemini CLI sign-in could not be refreshed; run gemini once to sign in again');
434
- return { accessToken: json.access_token, expiresAt: Date.now() + (Number(json.expires_in) || 3600) * 1000 };
435
- }
436
-
437
- /**
438
- * Asks the Code Assist API the way Gemini CLI does: loadCodeAssist for the
439
- * account's project and tier (skipped when `project` is known), then
440
- * retrieveUserQuota for one bucket per model. Returns the refreshed token
441
- * and project too, so a caller can reuse them.
442
- */
443
- export async function fetchGeminiUsage({
444
- accessToken, refreshToken = null, expiresAt = null, client = null, project = null, version = null, fetchImpl = fetch,
445
- } = {}) {
446
- let token = { accessToken, expiresAt };
447
- if (expiresAt && expiresAt < Date.now() + 60000) {
448
- if (!refreshToken || !client) throw new UsageError('Gemini CLI sign-in has expired; run gemini once to refresh it');
449
- token = await refreshGoogleToken(refreshToken, client, fetchImpl);
450
- }
451
- const headers = {
452
- Authorization: `Bearer ${token.accessToken}`,
453
- 'Content-Type': 'application/json',
454
- Accept: 'application/json',
455
- 'User-Agent': `GeminiCLI/${version || '0.62.0'} (agent-guild)`,
456
- };
457
- const post = async (method, body) => {
458
- const res = await fetchImpl(`${GEMINI_CODE_ASSIST_URL}:${method}`, {
459
- method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(10000),
460
- });
461
- if (!res.ok) throw httpUsageError(res.status, 'sign in again in Gemini CLI');
462
- return res.json();
463
- };
464
- let plan = null;
465
- let projectId = project;
466
- if (!projectId) {
467
- const loaded = await post('loadCodeAssist', {
468
- metadata: { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' },
469
- });
470
- const found = loaded?.cloudaicompanionProject;
471
- projectId = typeof found === 'string' ? found : typeof found?.id === 'string' ? found.id : null;
472
- // A paid tier is the effective subscription, as Gemini CLI reads it.
473
- const tier = loaded?.paidTier?.id || loaded?.paidTier?.name ? loaded.paidTier : loaded?.currentTier;
474
- plan = tier?.name ?? tier?.id ?? null;
475
- if (!projectId) throw new UsageError('Gemini CLI has no Code Assist project yet; finish signing in with gemini first');
476
- }
477
- const quota = await post('retrieveUserQuota', { project: projectId });
478
- const windows = [];
479
- for (const bucket of Array.isArray(quota?.buckets) ? quota.buckets : []) {
480
- if (!bucket?.modelId || typeof bucket.remainingFraction !== 'number') continue;
481
- const used = clampPercent((1 - bucket.remainingFraction) * 100);
482
- if (used !== null) windows.push({ label: String(bucket.modelId).slice(0, 40), usedPercent: used, resetsAt: toIso(bucket.resetTime) });
483
- }
484
- return { plan, windows, project: projectId, token };
485
- }
486
-
487
257
  // ---- any command that prints JSON ------------------------------------------
488
258
 
489
259
  export async function commandUsage({ command, args = [] }, env, platform = process.platform) {
@@ -532,9 +302,8 @@ export class UsageMonitor {
532
302
  this.platform = platform;
533
303
  this.fetchImpl = fetchImpl;
534
304
  this.ttlMs = ttlMs;
535
- this.readers = { claude: readClaudeCredentials, codex: readCodexCredentials, gemini: readGeminiCredentials, ...readers };
305
+ this.readers = { claude: readClaudeCredentials, codex: readCodexCredentials, ...readers };
536
306
  this.cache = new Map();
537
- this.gemini = new Map();
538
307
  }
539
308
 
540
309
  /** Snapshots for every account of every provider that has a usage source. */
@@ -553,7 +322,7 @@ export class UsageMonitor {
553
322
  const now = Date.now();
554
323
  if (entry?.inflight) return entry.inflight;
555
324
  if (entry && now - entry.at < entry.ttl) return Promise.resolve(entry.snapshot);
556
- const inflight = this._fetch(provider, account, key).then((snapshot) => {
325
+ const inflight = this._fetch(provider, account).then((snapshot) => {
557
326
  const ttl = snapshot.rateLimited ? RATE_LIMITED_TTL_MS : this.ttlMs;
558
327
  this.cache.set(key, { snapshot, at: Date.now(), ttl });
559
328
  return snapshot;
@@ -562,7 +331,7 @@ export class UsageMonitor {
562
331
  return inflight;
563
332
  }
564
333
 
565
- async _fetch(provider, account, key) {
334
+ async _fetch(provider, account) {
566
335
  const base = { providerId: provider.id, accountId: account.id, plan: null, windows: [], credits: null, signedIn: null, fetchedAt: new Date().toISOString(), error: null };
567
336
  const env = { ...this.env, ...provider.env, ...account.env };
568
337
  let signedIn = null;
@@ -580,30 +349,6 @@ export class UsageMonitor {
580
349
  const creds = await this.readers.codex({ file: codexAuthFile(env) });
581
350
  signedIn = true;
582
351
  result = await fetchCodexUsage({ ...creds, fetchImpl: this.fetchImpl });
583
- } else if (provider.usage === 'gemini') {
584
- const creds = await this.readers.gemini({
585
- file: geminiCredentialsFile(env),
586
- keychainFile: geminiKeychainFile(env),
587
- platform: this.platform,
588
- ...geminiStorageMode(env),
589
- });
590
- signedIn = true;
591
- // The refreshed token, project and plan belong to one sign-in; a new
592
- // sign-in (another account, or the same one again) starts over.
593
- const signIn = creds.refreshToken || creds.accessToken;
594
- const cached = this.gemini.get(key);
595
- const known = cached?.signIn === signIn ? cached : {};
596
- const fresh = known.token && known.token.expiresAt > Date.now() + 60000 ? known.token : null;
597
- const { plan, windows, project, token } = await fetchGeminiUsage({
598
- ...creds,
599
- client: creds.client || geminiOAuthClientFromInstall(this.registry.resolve(provider)),
600
- ...(fresh || {}),
601
- project: env.GOOGLE_CLOUD_PROJECT || env.GOOGLE_CLOUD_PROJECT_ID || known.project || null,
602
- version: this.registry.versions.get(provider.id)?.installed,
603
- fetchImpl: this.fetchImpl,
604
- });
605
- this.gemini.set(key, { signIn, project, token, plan: plan ?? known.plan ?? null });
606
- result = { plan: plan ?? known.plan ?? null, windows };
607
352
  } else {
608
353
  result = await commandUsage(provider.usage, env, this.platform);
609
354
  }
@@ -1,21 +1,16 @@
1
1
  // Translate a coding tool's hook event into Agent Guild reports.
2
2
  //
3
- // Claude Code, Codex CLI, Gemini CLI and Grok Build all run hook commands
4
- // with one JSON event on stdin. They spell the event name and the sub-agent
5
- // and model fields differently, so every spelling is accepted here.
3
+ // Claude Code, Codex CLI, Antigravity CLI and Grok Build all run hook
4
+ // commands with one JSON event on stdin. They spell the event name and the
5
+ // sub-agent and model fields differently, so every spelling is accepted here.
6
6
  //
7
7
  // Sub-agents come from:
8
8
  // - SubagentStart / SubagentStop (Claude Code, Codex CLI, Grok Build).
9
- // - A tool call that runs a sub-agent: Gemini CLI's `invoke_agent`
10
- // (BeforeTool / AfterTool), which returns when the agent is finished, and
11
- // Claude Code's "Agent" tool (formerly "Task") on PreToolUse / PostToolUse
9
+ // - Claude Code's "Agent" tool (formerly "Task") on PreToolUse / PostToolUse
12
10
  // for versions without the sub-agent events. A launch marked as background
13
11
  // returns immediately, so its PostToolUse says nothing about when the
14
12
  // agent finishes; those launches are skipped. Configure one style, not
15
- // both, or each sub-agent appears twice. Gemini's call is a foreground
16
- // agent: the parent waits for it, and when its end event never comes (a
17
- // cancelled or denied call) it is closed at the next turn boundary of the
18
- // main session.
13
+ // both, or each sub-agent appears twice.
19
14
  // - Codex CLI ends every turn of a sub-agent with SubagentStop; a follow-up
20
15
  // turn of the same agent starts with a UserPromptSubmit inside it, or,
21
16
  // with Codex's multi_agent_v2 tools, straight with tool calls, which
@@ -27,11 +22,11 @@
27
22
  import path from 'node:path';
28
23
  import crypto from 'node:crypto';
29
24
 
30
- const SUBAGENT_TOOLS = new Set(['Task', 'Agent', 'invoke_agent']);
31
- const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'run_shell_command', 'run_terminal_command']);
32
- const TOOL_START_EVENTS = new Set(['PreToolUse', 'BeforeTool']);
33
- const TOOL_END_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure', 'AfterTool']);
34
- const TURN_BOUNDARY_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'UserPromptSubmit', 'Stop', 'Interrupt']);
25
+ const SUBAGENT_TOOLS = new Set(['Task', 'Agent']);
26
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'run_terminal_command']);
27
+ const TOOL_START_EVENTS = new Set(['PreToolUse']);
28
+ const TOOL_END_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
29
+ const TURN_BOUNDARY_EVENTS = new Set(['UserPromptSubmit', 'Stop', 'Interrupt']);
35
30
  const MAX_DETAIL = 200;
36
31
 
37
32
  const FINISHED_TASKS = new Set(['completed', 'failed', 'killed']);
@@ -44,32 +39,21 @@ function commandHash(command) {
44
39
  return crypto.createHash('sha256').update(command.replace(/\s+/g, ' ').trim()).digest('hex').slice(0, 32);
45
40
  }
46
41
 
47
- function geminiBackgroundPids(response) {
48
- const content = response?.llmContent;
49
- const body = typeof content === 'string' ? content
50
- : Array.isArray(content) ? content.map((part) => (typeof part === 'string' ? part : part?.text || '')).join('\n') : '';
51
- const own = body.match(/Command is running in background\. PID: (\d+)/) || body.match(/moved to background \(PID: (\d+)\)/);
52
- if (own) return [Number(own[1])];
53
- const left = body.match(/Background PIDs: (\d+(?:, \d+)*)/);
54
- return left ? left[1].split(', ').map(Number) : [];
55
- }
56
-
57
42
  function shellReport(event, input, subagentId) {
58
43
  const toolInput = input.tool_input || input.toolInput || {};
59
44
  const id = text(input.tool_use_id, input.toolUseId);
60
- const ref = id ? { key: id.slice(0, 128) } : { bucket: crypto.createHash('sha256').update(JSON.stringify(toolInput)).digest('hex').slice(0, 32) };
61
45
  const agent = subagentId ? { agentId: `hook-${subagentId}` } : {};
62
46
  const match = typeof toolInput.command === 'string' ? { match: commandHash(toolInput.command) } : {};
63
47
  // Codex CLI (its events carry turn_id) keeps a command running past its turn, and reports no end for one it refused.
64
48
  const codex = Boolean(text(input.turn_id));
65
49
  if (event === 'PermissionRequest') return { shell: codex ? 'asked' : 'waiting', ...agent, ...match };
66
- if (TOOL_START_EVENTS.has(event)) return { shell: 'start', ...ref, ...agent, ...match, ...(codex ? { persist: true } : {}) };
50
+ if (!id) return null;
51
+ const key = id.slice(0, 128);
52
+ if (TOOL_START_EVENTS.has(event)) return { shell: 'start', key, ...agent, ...match, ...(codex ? { persist: true } : {}) };
67
53
  const response = input.tool_response ?? input.toolResponse;
68
54
  const task = text(response?.backgroundTaskId);
69
- if (task) return { shell: 'background', ...ref, task: task.slice(0, 128), ...(response.backgroundEndsWithFinalResponse === true ? { endsWithAgent: true } : {}) };
70
- const pids = geminiBackgroundPids(response);
71
- if (pids.length) return { shell: 'background', ...ref, pids };
72
- return { shell: 'end', ...ref };
55
+ if (task) return { shell: 'background', key, task: task.slice(0, 128), ...(response.backgroundEndsWithFinalResponse === true ? { endsWithAgent: true } : {}) };
56
+ return { shell: 'end', key };
73
57
  }
74
58
 
75
59
  // Claude Code reports a background task ending as a prompt of <task-notification> blocks.
@@ -145,7 +129,8 @@ export function hookToReports(input) {
145
129
 
146
130
  const toolName = text(input.tool_name, input.toolName);
147
131
  const toolEvent = TOOL_START_EVENTS.has(event) || TOOL_END_EVENTS.has(event);
148
- if ((toolEvent || event === 'PermissionRequest') && SHELL_TOOLS.has(toolName)) reports.push(shellReport(event, input, subagentId));
132
+ const shell = (toolEvent || event === 'PermissionRequest') && SHELL_TOOLS.has(toolName) ? shellReport(event, input, subagentId) : null;
133
+ if (shell) reports.push(shell);
149
134
  if (event === 'PostToolUse' && toolName === 'TaskStop') {
150
135
  const toolInput = input.tool_input || input.toolInput || {};
151
136
  const task = text(toolInput.task_id, toolInput.shell_id);
@@ -153,20 +138,14 @@ export function hookToReports(input) {
153
138
  }
154
139
  if (toolEvent && SUBAGENT_TOOLS.has(toolName)) {
155
140
  const toolInput = input.tool_input || input.toolInput || {};
156
- if (toolInput.run_in_background !== true) {
157
- // tool_use_id links the start and end events; fall back to hashing the
158
- // identical tool_input both events carry (Gemini CLI sends no id).
159
- const key = text(input.tool_use_id, input.toolUseId) ||
160
- crypto.createHash('sha1').update(JSON.stringify(toolInput)).digest('hex').slice(0, 16);
141
+ const key = text(input.tool_use_id, input.toolUseId);
142
+ if (key && toolInput.run_in_background !== true) {
161
143
  reports.push({
162
144
  agentId: `hook-task-${key}`,
163
145
  name: text(toolInput.subagent_type, toolInput.agent_name) || 'subagent',
164
146
  kind: 'subagent',
165
147
  detail: (text(toolInput.description, toolInput.prompt) || '').slice(0, MAX_DETAIL),
166
148
  status: TOOL_START_EVENTS.has(event) ? 'working' : 'done',
167
- // Gemini CLI's call returns when the agent is finished, so the
168
- // parent waits: a model reported meanwhile is the sub-agent's.
169
- foreground: toolName === 'invoke_agent',
170
149
  });
171
150
  }
172
151
  }
@@ -183,26 +162,26 @@ export function hookToReports(input) {
183
162
  // agent_id, Grok Build by subagentType); its model is not the session's.
184
163
  const insideSubagent = Boolean(subagentId || text(input.subagent_type, input.subagentType));
185
164
 
186
- // Gemini CLI skips AfterTool for an invoke_agent call that was cancelled
187
- // or denied. A turn boundary of the main session (BeforeAgent and
188
- // AfterAgent in Gemini CLI; a main-thread prompt or Stop elsewhere) cannot
189
- // happen while the parent waits for a foreground agent, so one still
190
- // working then has been orphaned.
191
165
  if (event === 'Stop' && Array.isArray(input.background_tasks)) reports.push(runningShells(input.background_tasks));
192
166
  if (!insideSubagent && TURN_BOUNDARY_EVENTS.has(event)) reports.push({ finishForeground: true });
193
167
 
194
168
  const model = insideSubagent || toolEvent ? null : event === 'PostModelSwitch'
195
169
  ? text(input.to_model)
196
- : text(input.model, input.modelId, input.llm_request?.model);
170
+ : text(input.model, input.modelId, input.modelName);
197
171
  if (model) reports.push({ model });
198
172
 
199
173
  if (!insideSubagent && event === 'SessionStart') reports.push({ hello: true });
200
174
 
201
- const toolSessionId = insideSubagent ? null : text(input.session_id, input.sessionId);
202
- if (toolSessionId && (event === 'SessionStart' || event === 'BeforeAgent')) reports.push({ toolSessionId });
175
+ const toolSessionId = insideSubagent ? null : text(input.session_id, input.sessionId, input.conversationId);
176
+ if (toolSessionId && (event === 'SessionStart' || event === 'PreInvocation')) reports.push({ toolSessionId });
203
177
  return reports;
204
178
  }
205
179
 
180
+ /** Whether an Antigravity CLI conversation starts with the user's own request; a sub-agent's starts with its parent's message. */
181
+ export function antigravityUserConversation(firstRecord) {
182
+ return firstRecord?.type === 'USER_INPUT' && firstRecord.source === 'USER_EXPLICIT';
183
+ }
184
+
206
185
  /** Model report from the JSON Claude Code feeds to a status line command. */
207
186
  export function claudeStatuslineToReport(input) {
208
187
  const id = input?.model?.id;
package/web/app.js CHANGED
@@ -493,22 +493,20 @@ function renderAccounts(card, provider) {
493
493
 
494
494
  function renderReportingSetup(card, provider) {
495
495
  const row = card.querySelector('.reporting-row');
496
- const account = selectedAccount(provider);
497
- row.hidden = !provider.available || typeof account.reportingEnabled !== 'boolean';
496
+ row.hidden = !provider.available || typeof provider.reportingEnabled !== 'boolean';
498
497
  if (row.hidden) return;
499
- const on = account.reportingEnabled;
500
- const whose = (provider.accounts || []).length > 1 ? ` for the ${account.label} account` : '';
498
+ const on = provider.reportingEnabled;
501
499
  row.querySelector('.reporting-text').textContent = `Agent reporting ${on ? 'on' : 'off'}`;
502
500
  const button = row.querySelector('.reporting-toggle');
503
501
  button.textContent = on ? 'Turn off' : 'Turn on';
504
502
  button.title = on
505
- ? `Remove the Agent Guild extension from ${provider.tool}${whose}. New sessions stop reporting sub-agents.`
506
- : `Link the Agent Guild extension into ${provider.tool}${whose} with "${provider.command} extensions link", so new sessions show their sub-agents. It does nothing in sessions started outside Agent Guild.`;
503
+ ? `Remove the Agent Guild plugin from ${provider.tool}. New sessions stop reporting their model.`
504
+ : `Install the Agent Guild plugin into ${provider.tool} with "${provider.command} plugin install", so new sessions show their model and can be resumed. It does nothing in sessions started outside Agent Guild.`;
507
505
  button.onclick = async () => {
508
506
  button.disabled = true;
509
507
  try {
510
- await api('POST', `/providers/${provider.id}/reporting`, { account: account.id, enabled: !on });
511
- toast(on ? `Agent reporting is off for ${provider.tool}${whose}.` : `Agent reporting is on for ${provider.tool}${whose}. It applies to new sessions.`);
508
+ await api('POST', `/providers/${provider.id}/reporting`, { enabled: !on });
509
+ toast(on ? `Agent reporting is off for ${provider.tool}.` : `Agent reporting is on for ${provider.tool}. It applies to new sessions.`);
512
510
  } catch (err) {
513
511
  if (err instanceof AuthError) return showAuth(err.message);
514
512
  toast(err.message, 10000);
@@ -1694,7 +1692,7 @@ async function installProvider(provider, card, { force = false } = {}) {
1694
1692
 
1695
1693
  /**
1696
1694
  * Start a session. A resumed session starts in the folder its transcript
1697
- * names, since Claude Code and Gemini CLI only find a session from there;
1695
+ * names, since Claude Code only finds a session from there;
1698
1696
  * when that folder is gone, the working folder is used instead.
1699
1697
  */
1700
1698
  async function startSession(provider, card, { resume, cwd, account = selectedAccount(provider).id } = {}) {
@@ -1,61 +0,0 @@
1
- {
2
- "hooks": {
3
- "BeforeTool": [
4
- {
5
- "matcher": "invoke_agent",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "name": "Agent Guild agent start",
10
- "command": "agent-guild-report --hook"
11
- }
12
- ]
13
- }
14
- ],
15
- "AfterTool": [
16
- {
17
- "matcher": "invoke_agent",
18
- "hooks": [
19
- {
20
- "type": "command",
21
- "name": "Agent Guild agent stop",
22
- "command": "agent-guild-report --hook"
23
- }
24
- ]
25
- }
26
- ],
27
- "BeforeAgent": [
28
- {
29
- "hooks": [
30
- {
31
- "type": "command",
32
- "name": "Agent Guild turn start",
33
- "command": "agent-guild-report --hook"
34
- }
35
- ]
36
- }
37
- ],
38
- "AfterAgent": [
39
- {
40
- "hooks": [
41
- {
42
- "type": "command",
43
- "name": "Agent Guild turn end",
44
- "command": "agent-guild-report --hook"
45
- }
46
- ]
47
- }
48
- ],
49
- "BeforeModel": [
50
- {
51
- "hooks": [
52
- {
53
- "type": "command",
54
- "name": "Agent Guild model",
55
- "command": "agent-guild-report --hook"
56
- }
57
- ]
58
- }
59
- ]
60
- }
61
- }