@oddessentials/agent-guild 0.11.0 → 0.12.1

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
@@ -15,6 +15,7 @@ const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
15
15
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
16
16
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
17
17
  const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
18
+ const coarsePointer = window.matchMedia('(pointer: coarse)');
18
19
 
19
20
  const $ = (id) => document.getElementById(id);
20
21
  const state = {
@@ -493,22 +494,20 @@ function renderAccounts(card, provider) {
493
494
 
494
495
  function renderReportingSetup(card, provider) {
495
496
  const row = card.querySelector('.reporting-row');
496
- const account = selectedAccount(provider);
497
- row.hidden = !provider.available || typeof account.reportingEnabled !== 'boolean';
497
+ row.hidden = !provider.available || typeof provider.reportingEnabled !== 'boolean';
498
498
  if (row.hidden) return;
499
- const on = account.reportingEnabled;
500
- const whose = (provider.accounts || []).length > 1 ? ` for the ${account.label} account` : '';
499
+ const on = provider.reportingEnabled;
501
500
  row.querySelector('.reporting-text').textContent = `Agent reporting ${on ? 'on' : 'off'}`;
502
501
  const button = row.querySelector('.reporting-toggle');
503
502
  button.textContent = on ? 'Turn off' : 'Turn on';
504
503
  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.`;
504
+ ? `Remove the Agent Guild plugin from ${provider.tool}. New sessions stop reporting their model.`
505
+ : `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
506
  button.onclick = async () => {
508
507
  button.disabled = true;
509
508
  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.`);
509
+ await api('POST', `/providers/${provider.id}/reporting`, { enabled: !on });
510
+ toast(on ? `Agent reporting is off for ${provider.tool}.` : `Agent reporting is on for ${provider.tool}. It applies to new sessions.`);
512
511
  } catch (err) {
513
512
  if (err instanceof AuthError) return showAuth(err.message);
514
513
  toast(err.message, 10000);
@@ -1694,7 +1693,7 @@ async function installProvider(provider, card, { force = false } = {}) {
1694
1693
 
1695
1694
  /**
1696
1695
  * 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;
1696
+ * names, since Claude Code only finds a session from there;
1698
1697
  * when that folder is gone, the working folder is used instead.
1699
1698
  */
1700
1699
  async function startSession(provider, card, { resume, cwd, account = selectedAccount(provider).id } = {}) {
@@ -2749,7 +2748,7 @@ class TerminalView {
2749
2748
  this.term = new window.Terminal({
2750
2749
  cursorBlink: true,
2751
2750
  fontFamily: 'ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace',
2752
- fontSize: 13,
2751
+ fontSize: coarsePointer.matches ? 14 : 13,
2753
2752
  scrollback: 5000,
2754
2753
  macOptionIsMeta: true,
2755
2754
  theme: TERMINAL_THEME,
@@ -2830,12 +2829,51 @@ class TerminalView {
2830
2829
 
2831
2830
  mount(host) {
2832
2831
  host.replaceChildren(this.el);
2833
- if (!this.opened) { this.term.open(this.el); this.opened = true; }
2832
+ if (!this.opened) { this.term.open(this.el); this.enableTouchScroll(); this.opened = true; }
2834
2833
  this.resizeObserver.observe(host);
2835
2834
  this.refit();
2836
2835
  this.term.focus();
2837
2836
  }
2838
2837
 
2838
+ /**
2839
+ * xterm.js 6 scrolls only for the mouse wheel, so a one-finger drag would
2840
+ * scroll the page behind the panel instead. Turn the drag into scrolling.
2841
+ * Full-screen programs and programs that read the mouse get a wheel event,
2842
+ * which xterm turns into arrow keys or mouse reports as it does for a real
2843
+ * wheel. Scrollback uses scrollLines, because xterm's scrollback reads the
2844
+ * legacy wheelDeltaY that a constructed WheelEvent leaves at 0.
2845
+ */
2846
+ enableTouchScroll() {
2847
+ let lastY = null;
2848
+ let pending = 0;
2849
+ this.el.addEventListener('touchstart', (e) => {
2850
+ lastY = e.touches.length === 1 ? e.touches[0].clientY : null;
2851
+ pending = 0;
2852
+ }, { passive: true });
2853
+ this.el.addEventListener('touchmove', (e) => {
2854
+ if (lastY === null || e.touches.length !== 1) return;
2855
+ e.preventDefault();
2856
+ const touch = e.touches[0];
2857
+ const delta = lastY - touch.clientY;
2858
+ lastY = touch.clientY;
2859
+ if (this.term.buffer.active.type === 'alternate' || this.term.modes.mouseTrackingMode !== 'none') {
2860
+ const target = this.el.querySelector('.xterm-screen') || this.el;
2861
+ target.dispatchEvent(new WheelEvent('wheel', {
2862
+ deltaY: delta, deltaMode: WheelEvent.DOM_DELTA_PIXEL,
2863
+ clientX: touch.clientX, clientY: touch.clientY, bubbles: true, cancelable: true,
2864
+ }));
2865
+ return;
2866
+ }
2867
+ pending += delta;
2868
+ const lineHeight = this.el.querySelector('.xterm-rows')?.firstElementChild?.offsetHeight || 17;
2869
+ const lines = Math.trunc(pending / lineHeight);
2870
+ if (lines) { this.term.scrollLines(lines); pending -= lines * lineHeight; }
2871
+ }, { passive: false });
2872
+ const end = () => { lastY = null; };
2873
+ this.el.addEventListener('touchend', end);
2874
+ this.el.addEventListener('touchcancel', end);
2875
+ }
2876
+
2839
2877
  unmount() {
2840
2878
  this.resizeObserver.disconnect();
2841
2879
  this.el.remove();
package/web/styles.css CHANGED
@@ -354,6 +354,14 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
354
354
  .panel-actions { display: flex; gap: 8px; }
355
355
  .terminal-host { flex: 1; min-height: 0; padding: 8px 4px 4px 10px; }
356
356
  .terminal-host > div { height: 100%; }
357
+ @media (pointer: coarse) {
358
+ /* A drag on the panel would otherwise scroll the page behind it. */
359
+ body:has(#terminal-panel:not([hidden])) { overflow: hidden; }
360
+ }
361
+ @media (pointer: coarse) and (max-width: 900px) {
362
+ /* Tablets in portrait are wider than the phone layout but still want the whole width. */
363
+ .terminal-panel { inset: var(--topbar-h, 56px) 0 0; border-radius: 0; }
364
+ }
357
365
 
358
366
  .models {
359
367
  width: min(880px, calc(100vw - 32px)); max-height: calc(100vh - 48px); padding: 0; overflow: hidden;
@@ -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
- }