@oddessentials/agent-guild 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,11 +3,11 @@
3
3
  // byte replay), and the sub-agents and model the coding tool has reported.
4
4
 
5
5
  import { EventEmitter } from 'node:events';
6
- import { execFile } from 'node:child_process';
7
6
  import crypto from 'node:crypto';
8
7
  import pty from 'node-pty';
9
8
  import headless from '@xterm/headless';
10
9
  import serializeAddon from '@xterm/addon-serialize';
10
+ import { killWindowsTree } from './command-resolver.mjs';
11
11
 
12
12
  const { Terminal } = headless;
13
13
  const { SerializeAddon } = serializeAddon;
@@ -19,6 +19,20 @@ const MODEL_SOURCE_RANK = { args: 0, screen: 1, report: 2 };
19
19
  const SCREEN_SCAN_DELAY_MS = 400;
20
20
  const SCREEN_SCAN_MAX_DELAY_MS = 2000;
21
21
  const MAX_TOOL_SESSION_ID = 200;
22
+ const REPORTING_STATES = new Set(['pending', 'active', 'unavailable', 'setup_required', 'unsupported']);
23
+ const SHELL_EVENTS = new Set(['start', 'waiting', 'asked', 'background', 'end', 'running', 'reset']);
24
+ const MAX_SHELLS = 256;
25
+ const MAX_SHELL_PIDS = 16;
26
+ const MAX_ENDED_TASKS = 256;
27
+
28
+ function processExists(pid) {
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch (err) {
33
+ return err.code === 'EPERM';
34
+ }
35
+ }
22
36
 
23
37
  /**
24
38
  * Colours reported to programs that query them (OSC 10/11/12), matching the
@@ -81,6 +95,15 @@ export class Session extends EventEmitter {
81
95
  this.activityIdleMs = opts.activityIdleMs ?? 2500;
82
96
  this.doneAgentLingerMs = opts.doneAgentLingerMs ?? 15000;
83
97
  this.killGraceMs = opts.killGraceMs ?? 4000;
98
+ this.reportingTimeoutMs = opts.reportingTimeoutMs ?? 30000;
99
+ this.reporting = REPORTING_STATES.has(opts.reporting?.state) ? { state: opts.reporting.state, reason: opts.reporting.reason ?? null } : null;
100
+ this._reportingTimer = null;
101
+ this.shellDisplayDelayMs = opts.shellDisplayDelayMs ?? 600;
102
+ this.shellPidCheckMs = opts.shellPidCheckMs ?? 2000;
103
+ this.shells = new Map();
104
+ this._shellSeq = 0;
105
+ this._endedTasks = new Set();
106
+ this._pidTimer = null;
84
107
  this.createdAt = new Date().toISOString();
85
108
  this.exitedAt = null;
86
109
  this.status = 'running';
@@ -190,7 +213,9 @@ export class Session extends EventEmitter {
190
213
  clearTimeout(this._activityTimer);
191
214
  clearTimeout(this._killTimer);
192
215
  clearTimeout(this._scanTimer);
216
+ clearTimeout(this._reportingTimer);
193
217
  this._clearAgents();
218
+ this._clearShells();
194
219
  // Let the headless terminal finish parsing before announcing the exit so
195
220
  // any client attaching afterwards still sees the final screen.
196
221
  this.term.write('', () => {
@@ -260,6 +285,21 @@ export class Session extends EventEmitter {
260
285
  try { this.pty.write(data); } catch { /* process is exiting */ }
261
286
  }
262
287
 
288
+ input(data) {
289
+ this.write(data);
290
+ if (this.reporting?.state === 'pending' && !this._reportingTimer && typeof data === 'string' && /[\r\n]/.test(data)) {
291
+ this._reportingTimer = setTimeout(() => {
292
+ if (this.reporting?.state !== 'pending' || this.status !== 'running') return;
293
+ this.reporting = {
294
+ state: 'unavailable',
295
+ reason: `${this.provider.tool} has not run Agent Guild's reporting hooks yet. That is expected while it signs in or sets up; otherwise its hooks may be turned off, restricted by an administrator, or not trusted for this folder.`,
296
+ };
297
+ this._changed();
298
+ }, this.reportingTimeoutMs);
299
+ this._reportingTimer.unref?.();
300
+ }
301
+ }
302
+
263
303
  _reply(data) {
264
304
  this.write(data);
265
305
  }
@@ -302,9 +342,7 @@ export class Session extends EventEmitter {
302
342
  };
303
343
  const pid = this.pty.pid;
304
344
  if (!pid) return fallback();
305
- execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 5000 }, (err) => {
306
- if (err) fallback();
307
- });
345
+ killWindowsTree(pid, (err) => { if (err) fallback(); });
308
346
  }
309
347
 
310
348
  /**
@@ -317,6 +355,8 @@ export class Session extends EventEmitter {
317
355
  this.disposed = true;
318
356
  clearTimeout(this._activityTimer);
319
357
  clearTimeout(this._scanTimer);
358
+ clearTimeout(this._reportingTimer);
359
+ this._clearShells();
320
360
  for (const t of this._agentTimers.values()) clearTimeout(t);
321
361
  this.subscribers.clear();
322
362
  this.term.dispose();
@@ -339,12 +379,14 @@ export class Session extends EventEmitter {
339
379
  reportAgent(report, source = 'api') {
340
380
  if (!report || typeof report !== 'object') throw badRequest('agent report must be an object');
341
381
  if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
382
+ if (source === 'api') this._reportingHeard();
342
383
  if (report.finishForeground === true && report.agentId === undefined) {
343
384
  // The tool is between turns, so no foreground agent can still be
344
385
  // running; one that is never got its end event.
345
386
  for (const agent of [...this.agents.values()]) {
346
387
  if (agent.foreground && agent.status === 'working') this.reportAgent({ agentId: agent.id, status: 'done' }, source);
347
388
  }
389
+ this._endForegroundShells(null);
348
390
  return null;
349
391
  }
350
392
  const id = String(report.agentId ?? report.agent ?? report.id ?? '').trim().slice(0, 128);
@@ -360,6 +402,12 @@ export class Session extends EventEmitter {
360
402
  }
361
403
  const now = new Date().toISOString();
362
404
  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
+ if (status === 'done') this._endForegroundShells(id);
363
411
  // A first report that already says done would only flash an icon:
364
412
  // Claude Code's internal helpers (prompt suggestions, side questions)
365
413
  // stop without ever having started here.
@@ -425,13 +473,180 @@ export class Session extends EventEmitter {
425
473
  const isAgent = report && typeof report === 'object' &&
426
474
  ((report.agentId ?? report.agent ?? report.id) !== undefined || report.finishForeground === true);
427
475
  if (isAgent) this.reportAgent(report, 'terminal');
428
- else if (report?.toolSessionId !== undefined) this.reportToolSession(report);
429
- else this.reportModel(report);
476
+ else if (report?.toolSessionId !== undefined) this.reportToolSession(report, 'terminal');
477
+ else this.reportModel(report, 'terminal');
430
478
  } catch (err) {
431
479
  this.emit('warning', `ignored in-band report: ${err.message}`);
432
480
  }
433
481
  }
434
482
 
483
+ // ---- shells ------------------------------------------------------------
484
+
485
+ reportShell(report) {
486
+ if (!report || typeof report !== 'object') throw badRequest('shell report must be an object');
487
+ if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
488
+ if (!SHELL_EVENTS.has(report.shell)) throw badRequest(`shell must be one of ${[...SHELL_EVENTS].join(', ')}`);
489
+ const id = (value) => (typeof value === 'string' && value ? value.slice(0, 128) : null);
490
+ 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
+ const task = id(report.task);
493
+ const match = typeof report.match === 'string' && /^[a-f0-9]{32}$/.test(report.match) ? report.match : null;
494
+ const agentId = id(report.agentId);
495
+ this._reportingHeard();
496
+
497
+ if (report.shell === 'reset') {
498
+ for (const shell of [...this.shells.values()]) this._endShell(shell);
499
+ return null;
500
+ }
501
+
502
+ if (report.shell === 'running') {
503
+ if (!Array.isArray(report.tasks)) throw badRequest('a running report needs a tasks array');
504
+ const running = new Set(report.tasks.slice(0, MAX_SHELLS).map(id).filter(Boolean));
505
+ for (const shell of [...this.shells.values()]) if (shell.task && !running.has(shell.task)) this._endShell(shell);
506
+ const known = new Set([...this.shells.values()].map((shell) => shell.task));
507
+ for (const each of running) if (!known.has(each) && !this._endedTasks.has(each)) this._addShell({ task: each }, { now: true });
508
+ return null;
509
+ }
510
+
511
+ if (report.shell === 'waiting' || report.shell === 'asked') {
512
+ // A permission request carries no call id, and a PreToolUse hook may have rewritten its command: it belongs to the
513
+ // agent's one foreground command with that command, else to its only one.
514
+ const candidates = [...this.shells.values()].filter((shell) => shell.agentId === agentId && this._foreground(shell) && !shell.waiting && !shell.asked);
515
+ const exact = match ? candidates.filter((shell) => shell.match === match) : [];
516
+ const shell = exact.length === 1 ? exact[0] : exact.length === 0 && candidates.length === 1 ? candidates[0] : null;
517
+ if (shell && report.shell === 'asked') {
518
+ shell.asked = true;
519
+ } else if (shell) {
520
+ clearTimeout(shell.timer);
521
+ shell.waiting = true;
522
+ this._setVisible(shell, false);
523
+ }
524
+ return null;
525
+ }
526
+
527
+ if (report.shell === 'end') {
528
+ if (!key && !bucket && !task) throw badRequest('a shell end needs a key, a bucket or a task');
529
+ if (task) this._rememberEnded(task);
530
+ const shell = (key && this._shellBy('key', key)) || (task && this._shellBy('task', task)) || (bucket && this._shellByBucket(bucket));
531
+ if (shell) this._endShell(shell);
532
+ return null;
533
+ }
534
+
535
+ if (!key && !bucket) throw badRequest('a shell report needs a key or a bucket');
536
+ if (report.shell === 'start') {
537
+ if (!key || !this._shellBy('key', key)) this._addShell({ key, bucket, match, agentId, persist: report.persist === true });
538
+ return null;
539
+ }
540
+
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);
544
+ 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
+ }
556
+ if (shell.waiting) {
557
+ shell.waiting = false;
558
+ this._showAfterDelay(shell);
559
+ }
560
+ return null;
561
+ }
562
+
563
+ _addShell(fields, { now = false } = {}) {
564
+ if (this.shells.size >= MAX_SHELLS) {
565
+ this.emit('warning', `ignored a shell command: ${MAX_SHELLS} are already running`);
566
+ return;
567
+ }
568
+ const shell = {
569
+ id: `shell-${++this._shellSeq}`, key: null, bucket: null, match: null, agentId: null, persist: false, task: null, pids: null,
570
+ endsWithAgent: false, waiting: false, asked: false, visible: false, timer: null, ...fields,
571
+ };
572
+ this.shells.set(shell.id, shell);
573
+ if (now) this._setVisible(shell, true);
574
+ else this._showAfterDelay(shell);
575
+ }
576
+
577
+ _showAfterDelay(shell) {
578
+ clearTimeout(shell.timer);
579
+ shell.timer = setTimeout(() => this._setVisible(shell, true), this.shellDisplayDelayMs);
580
+ shell.timer.unref?.();
581
+ }
582
+
583
+ _setVisible(shell, visible) {
584
+ if (shell.visible === visible) return;
585
+ shell.visible = visible;
586
+ this._changed();
587
+ }
588
+
589
+ _foreground(shell) {
590
+ return shell.task === null && shell.pids === null;
591
+ }
592
+
593
+ _shellBy(field, value) {
594
+ for (const shell of this.shells.values()) if (shell[field] === value) return shell;
595
+ return null;
596
+ }
597
+
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
+ // A turn's foreground commands end with it; a background command, or one Codex CLI keeps running, does not.
623
+ _endForegroundShells(agentId) {
624
+ for (const shell of [...this.shells.values()]) {
625
+ if (shell.agentId !== agentId) continue;
626
+ const lasting = this._foreground(shell) ? shell.persist && !shell.asked : !(agentId && shell.endsWithAgent);
627
+ if (!lasting) this._endShell(shell);
628
+ }
629
+ }
630
+
631
+ _rememberEnded(task) {
632
+ this._endedTasks.delete(task);
633
+ this._endedTasks.add(task);
634
+ if (this._endedTasks.size > MAX_ENDED_TASKS) this._endedTasks.delete(this._endedTasks.values().next().value);
635
+ }
636
+
637
+ _endShell(shell) {
638
+ clearTimeout(shell.timer);
639
+ if (shell.task) this._rememberEnded(shell.task);
640
+ if (this.shells.delete(shell.id) && shell.visible) this._changed();
641
+ }
642
+
643
+ _clearShells() {
644
+ for (const shell of this.shells.values()) clearTimeout(shell.timer);
645
+ clearInterval(this._pidTimer);
646
+ this._pidTimer = null;
647
+ this.shells.clear();
648
+ }
649
+
435
650
  // ---- model -------------------------------------------------------------
436
651
 
437
652
  /**
@@ -446,9 +661,10 @@ export class Session extends EventEmitter {
446
661
  this._changed();
447
662
  }
448
663
 
449
- reportModel(report) {
664
+ reportModel(report, source = 'api') {
450
665
  if (!report || typeof report !== 'object') throw badRequest('model report must be an object');
451
666
  if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
667
+ if (source === 'api') this._reportingHeard();
452
668
  const name = String(report.model ?? '').trim().slice(0, 120);
453
669
  if (!name) throw badRequest('model is required');
454
670
  const displayName = report.displayName === undefined || report.displayName === null ? null : String(report.displayName).trim().slice(0, 80) || null;
@@ -460,9 +676,10 @@ export class Session extends EventEmitter {
460
676
  return this.model;
461
677
  }
462
678
 
463
- reportToolSession(report) {
679
+ reportToolSession(report, source = 'api') {
464
680
  if (!report || typeof report !== 'object') throw badRequest('tool session report must be an object');
465
681
  if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
682
+ if (source === 'api') this._reportingHeard();
466
683
  const id = String(report.toolSessionId ?? '').trim();
467
684
  if (!id || id.length > MAX_TOOL_SESSION_ID || /\p{Cc}/u.test(id)) throw badRequest(`toolSessionId must be a printable id of at most ${MAX_TOOL_SESSION_ID} characters`);
468
685
  if (this.toolSessionId !== id) {
@@ -472,6 +689,19 @@ export class Session extends EventEmitter {
472
689
  return this.toolSessionId;
473
690
  }
474
691
 
692
+ reportHello() {
693
+ if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
694
+ this._reportingHeard();
695
+ return this.reporting;
696
+ }
697
+
698
+ _reportingHeard() {
699
+ clearTimeout(this._reportingTimer);
700
+ if (!this.reporting || this.reporting.state === 'active') return;
701
+ this.reporting = { state: 'active', reason: null };
702
+ this._changed();
703
+ }
704
+
475
705
  _foregroundAgentWorking() {
476
706
  for (const agent of this.agents.values()) {
477
707
  if (agent.foreground && agent.status === 'working') return true;
@@ -539,7 +769,9 @@ export class Session extends EventEmitter {
539
769
  attachedClients: this.subscribers.size,
540
770
  model: this.model,
541
771
  toolSessionId: this.toolSessionId,
772
+ reporting: this.reporting,
542
773
  agents: [...this.agents.values()],
774
+ shells: [...this.shells.values()].filter((s) => s.visible).map((s) => ({ id: s.id })),
543
775
  };
544
776
  }
545
777
  }
@@ -28,24 +28,71 @@ import path from 'node:path';
28
28
  import crypto from 'node:crypto';
29
29
 
30
30
  const SUBAGENT_TOOLS = new Set(['Task', 'Agent', 'invoke_agent']);
31
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'run_shell_command', 'run_terminal_command']);
31
32
  const TOOL_START_EVENTS = new Set(['PreToolUse', 'BeforeTool']);
32
33
  const TOOL_END_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure', 'AfterTool']);
33
- const TURN_BOUNDARY_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'UserPromptSubmit', 'Stop']);
34
+ const TURN_BOUNDARY_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'UserPromptSubmit', 'Stop', 'Interrupt']);
34
35
  const MAX_DETAIL = 200;
35
36
 
37
+ const FINISHED_TASKS = new Set(['completed', 'failed', 'killed']);
38
+ const LIVE_TASKS = new Set(['pending', 'running', 'paused']);
39
+ const MAX_RUNNING_TASKS = 256;
40
+
36
41
  const text = (...values) => values.find((v) => typeof v === 'string' && v.trim())?.trim() ?? null;
37
42
 
43
+ function commandHash(command) {
44
+ return crypto.createHash('sha256').update(command.replace(/\s+/g, ' ').trim()).digest('hex').slice(0, 32);
45
+ }
46
+
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
+ function shellReport(event, input, subagentId) {
58
+ const toolInput = input.tool_input || input.toolInput || {};
59
+ 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
+ const agent = subagentId ? { agentId: `hook-${subagentId}` } : {};
62
+ const match = typeof toolInput.command === 'string' ? { match: commandHash(toolInput.command) } : {};
63
+ // Codex CLI (its events carry turn_id) keeps a command running past its turn, and reports no end for one it refused.
64
+ const codex = Boolean(text(input.turn_id));
65
+ 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 } : {}) };
67
+ const response = input.tool_response ?? input.toolResponse;
68
+ 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 };
73
+ }
74
+
75
+ // Claude Code reports a background task ending as a prompt of <task-notification> blocks.
76
+ function taskNotifications(prompt) {
77
+ if (typeof prompt !== 'string' || !/^\s*<task-notification>/.test(prompt)) return null;
78
+ return [...prompt.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].map(([, body]) => {
79
+ const tag = (name) => body.match(new RegExp(`<${name}>([^<]*)</${name}>`))?.[1].trim() || null;
80
+ return { taskId: tag('task-id'), toolUseId: tag('tool-use-id'), status: tag('status') };
81
+ });
82
+ }
83
+
84
+ function runningShells(tasks) {
85
+ const ids = tasks.filter((task) => task?.type === 'shell' && !FINISHED_TASKS.has(task.status)).map((task) => text(task.id)).filter(Boolean);
86
+ return { shell: 'running', tasks: ids.slice(0, MAX_RUNNING_TASKS).map((id) => id.slice(0, 128)) };
87
+ }
88
+
38
89
  /** "subagent_start", "subagentStart" and "SubagentStart" all become "SubagentStart". */
39
90
  function eventName(input) {
40
91
  const raw = text(input.hook_event_name, input.hookEventName) || '';
41
92
  return raw.replace(/(?:^|[_-])([a-z])/g, (_m, c) => c.toUpperCase());
42
93
  }
43
94
 
44
- /**
45
- * Reports for one hook event: an agent report (has agentId) when a
46
- * sub-agent starts or stops, and a model report (has model) when the event
47
- * names the main model. Empty for events that carry neither.
48
- */
95
+ /** Reports for one hook event: its sub-agents, shell commands, model and the tool's own session id. */
49
96
  export function hookToReports(input) {
50
97
  if (!input || typeof input !== 'object') return [];
51
98
  const event = eventName(input);
@@ -69,6 +116,19 @@ export function hookToReports(input) {
69
116
  if (detail) report.detail = detail.slice(0, MAX_DETAIL);
70
117
  reports.push(report);
71
118
  }
119
+ if (Array.isArray(input.background_tasks)) reports.push(runningShells(input.background_tasks));
120
+ return reports;
121
+ }
122
+
123
+ if (event === 'UserPromptSubmit' && !text(input.turn_id)) {
124
+ // Claude Code (turn_id is Codex's) fires this for a prompt typed while a turn runs, too, so it is no turn boundary.
125
+ // It also brings each background task's end; a sub-agent's task id is its agent id, and one stopped with TaskStop
126
+ // gets no SubagentStop.
127
+ for (const { taskId, toolUseId, status } of taskNotifications(input.prompt) ?? []) {
128
+ if (!taskId || LIVE_TASKS.has(status)) continue;
129
+ reports.push({ shell: 'end', task: taskId.slice(0, 128), ...(toolUseId ? { key: toolUseId.slice(0, 128) } : {}) });
130
+ if (FINISHED_TASKS.has(status)) reports.push({ agentId: `hook-${taskId}`, status: 'done' });
131
+ }
72
132
  return reports;
73
133
  }
74
134
 
@@ -79,11 +139,19 @@ export function hookToReports(input) {
79
139
  const childType = text(input.subagent_type, input.subagentType);
80
140
  const childId = text(input.session_id, input.sessionId);
81
141
  if (childType && childId) reports.push({ agentId: `hook-${childId}`, name: childType, kind: 'subagent', status: 'done' });
142
+ else if (event === 'SessionEnd') reports.push({ shell: 'reset' });
82
143
  return reports;
83
144
  }
84
145
 
85
146
  const toolName = text(input.tool_name, input.toolName);
86
- if ((TOOL_START_EVENTS.has(event) || TOOL_END_EVENTS.has(event)) && SUBAGENT_TOOLS.has(toolName)) {
147
+ 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));
149
+ if (event === 'PostToolUse' && toolName === 'TaskStop') {
150
+ const toolInput = input.tool_input || input.toolInput || {};
151
+ const task = text(toolInput.task_id, toolInput.shell_id);
152
+ if (task) reports.push({ shell: 'end', task: task.slice(0, 128) }, { agentId: `hook-${task}`, status: 'done' });
153
+ }
154
+ if (toolEvent && SUBAGENT_TOOLS.has(toolName)) {
87
155
  const toolInput = input.tool_input || input.toolInput || {};
88
156
  if (toolInput.run_in_background !== true) {
89
157
  // tool_use_id links the start and end events; fall back to hashing the
@@ -120,14 +188,16 @@ export function hookToReports(input) {
120
188
  // AfterAgent in Gemini CLI; a main-thread prompt or Stop elsewhere) cannot
121
189
  // happen while the parent waits for a foreground agent, so one still
122
190
  // working then has been orphaned.
191
+ if (event === 'Stop' && Array.isArray(input.background_tasks)) reports.push(runningShells(input.background_tasks));
123
192
  if (!insideSubagent && TURN_BOUNDARY_EVENTS.has(event)) reports.push({ finishForeground: true });
124
193
 
125
- const model = insideSubagent ? null : event === 'PostModelSwitch'
194
+ const model = insideSubagent || toolEvent ? null : event === 'PostModelSwitch'
126
195
  ? text(input.to_model)
127
196
  : text(input.model, input.modelId, input.llm_request?.model);
128
197
  if (model) reports.push({ model });
129
198
 
130
- // Gemini CLI has no session-start event; its turn start stands in.
199
+ if (!insideSubagent && event === 'SessionStart') reports.push({ hello: true });
200
+
131
201
  const toolSessionId = insideSubagent ? null : text(input.session_id, input.sessionId);
132
202
  if (toolSessionId && (event === 'SessionStart' || event === 'BeforeAgent')) reports.push({ toolSessionId });
133
203
  return reports;
package/web/app.js CHANGED
@@ -354,23 +354,69 @@ function paintProviderIcon(el, provider) {
354
354
 
355
355
  const FAMILIARS = ['flame', 'leaf', 'night', 'aether'];
356
356
 
357
- function renderAgents(container, agents) {
357
+ const REPORTING_TEXT = {
358
+ pending: 'waiting for hooks',
359
+ unavailable: 'no reports yet',
360
+ setup_required: 'reporting off',
361
+ unsupported: 'not supported',
362
+ };
363
+
364
+ function paintReporting(row, s) {
365
+ const reporting = s.status === 'running' ? s.reporting : null;
366
+ const agents = row.querySelector('.agents');
367
+ agents.dataset.empty = REPORTING_TEXT[reporting?.state] || 'none reported';
368
+ agents.classList.toggle('reporting-attention', ['unavailable', 'setup_required', 'unsupported'].includes(reporting?.state));
369
+ const why = row.querySelector('.reporting-why');
370
+ const reason = reporting?.state !== 'active' ? reporting?.reason || '' : '';
371
+ why.hidden = !reason;
372
+ why.title = reason;
373
+ why.setAttribute('aria-label', `Why agent reporting says ${agents.dataset.empty}: ${reason}`);
374
+ why.onclick = () => toast(reason, 12000);
375
+ }
376
+
377
+ const MAX_SHELLS_SHOWN = 16;
378
+
379
+ function renderAgents(container, agents, shells = []) {
358
380
  const known = container.dataset.rendered ? new Set([...container.children].map((el) => el.dataset.agent)) : null;
359
381
  container.dataset.rendered = 'true';
360
- container.replaceChildren(...agents.map((agent) => {
382
+ const familiar = (id, name, hue) => {
361
383
  const el = document.createElement('span');
362
- el.className = `agent ${agent.status}`;
363
- el.dataset.agent = agent.id;
364
- if (known && !known.has(agent.id)) el.classList.add('summon');
365
- el.style.setProperty('--c', `hsl(${hueFor(agent.name)} 65% 50%)`);
366
- el.dataset.familiar = FAMILIARS[hueFor(agent.name) % FAMILIARS.length];
384
+ el.dataset.agent = id;
385
+ if (known && !known.has(id)) el.classList.add('summon');
386
+ el.style.setProperty('--c', `hsl(${hue} 65% 50%)`);
387
+ el.dataset.familiar = FAMILIARS[hue % FAMILIARS.length];
388
+ el.setAttribute('role', 'img');
389
+ return el;
390
+ };
391
+ const agentEls = agents.map((agent) => {
392
+ const el = familiar(agent.id, agent.name, hueFor(agent.name));
393
+ el.classList.add('agent', agent.status);
367
394
  el.textContent = (agent.name || '?').charAt(0).toUpperCase();
368
395
  const detail = agent.detail ? ` — ${agent.detail}` : '';
369
396
  el.title = `${agent.name} (${agent.status})${detail}`;
370
- el.setAttribute('role', 'img');
371
397
  el.setAttribute('aria-label', el.title);
372
398
  return el;
373
- }));
399
+ });
400
+ const shellEls = shells.slice(0, MAX_SHELLS_SHOWN).map((shell) => {
401
+ const el = familiar(shell.id, 'Shell', hueFor(shell.id));
402
+ el.classList.add('agent', 'working', 'shell');
403
+ el.textContent = '>';
404
+ el.title = 'Shell command (running)';
405
+ el.setAttribute('aria-label', el.title);
406
+ return el;
407
+ });
408
+ if (shells.length > MAX_SHELLS_SHOWN) {
409
+ const more = document.createElement('span');
410
+ const hidden = shells.length - MAX_SHELLS_SHOWN;
411
+ more.className = 'agent-overflow';
412
+ more.dataset.agent = 'shell-overflow';
413
+ more.textContent = `+${hidden}`;
414
+ more.title = `${shells.length} shell commands running; ${hidden} more not drawn`;
415
+ more.setAttribute('role', 'img');
416
+ more.setAttribute('aria-label', more.title);
417
+ shellEls.push(more);
418
+ }
419
+ container.replaceChildren(...agentEls, ...shellEls);
374
420
  }
375
421
 
376
422
  // ---- API ------------------------------------------------------------------
@@ -431,6 +477,7 @@ function renderAccounts(card, provider) {
431
477
  selectAccount(provider, account.id);
432
478
  renderAccounts(card, provider);
433
479
  renderUsage(card, provider);
480
+ renderReportingSetup(card, provider);
434
481
  });
435
482
  return chip;
436
483
  }));
@@ -444,6 +491,33 @@ function renderAccounts(card, provider) {
444
491
  });
445
492
  }
446
493
 
494
+ function renderReportingSetup(card, provider) {
495
+ const row = card.querySelector('.reporting-row');
496
+ const account = selectedAccount(provider);
497
+ row.hidden = !provider.available || typeof account.reportingEnabled !== 'boolean';
498
+ if (row.hidden) return;
499
+ const on = account.reportingEnabled;
500
+ const whose = (provider.accounts || []).length > 1 ? ` for the ${account.label} account` : '';
501
+ row.querySelector('.reporting-text').textContent = `Agent reporting ${on ? 'on' : 'off'}`;
502
+ const button = row.querySelector('.reporting-toggle');
503
+ button.textContent = on ? 'Turn off' : 'Turn on';
504
+ 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.`;
507
+ button.onclick = async () => {
508
+ button.disabled = true;
509
+ 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.`);
512
+ } catch (err) {
513
+ if (err instanceof AuthError) return showAuth(err.message);
514
+ toast(err.message, 10000);
515
+ } finally {
516
+ button.disabled = false;
517
+ }
518
+ };
519
+ }
520
+
447
521
  let dealt = false;
448
522
 
449
523
  function renderProviders() {
@@ -487,6 +561,7 @@ function renderProviders() {
487
561
  renderConsoleLinks(node, provider);
488
562
  renderAccounts(node, provider);
489
563
  renderUsage(node, provider);
564
+ renderReportingSetup(node, provider);
490
565
  renderModelStats(node, provider);
491
566
  return node;
492
567
  }));
@@ -2541,7 +2616,8 @@ function updateCard(node, s) {
2541
2616
  // The LRM keeps a leading "/" in place under the right-to-left truncation style.
2542
2617
  cwd.textContent = `\u200E${s.cwd}`;
2543
2618
  cwd.title = s.cwd;
2544
- renderAgents(node.querySelector('.agents'), s.agents);
2619
+ renderAgents(node.querySelector('.agents'), s.agents, s.shells || []);
2620
+ paintReporting(node.querySelector('.agents-row'), s);
2545
2621
  node.classList.toggle('exited', s.status === 'exited');
2546
2622
  node.querySelector('.stop').hidden = s.status !== 'running';
2547
2623
  node.querySelector('.remove').hidden = s.status === 'running';
@@ -2553,7 +2629,9 @@ function updateCard(node, s) {
2553
2629
  resume.title = `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
2554
2630
  const modelLabel = s.model ? `, model ${modelText(s)}` : '';
2555
2631
  const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
2556
- node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
2632
+ const shellCount = (s.shells || []).length;
2633
+ const reportingNote = s.status === 'running' && REPORTING_TEXT[s.reporting?.state] ? `, agent reporting: ${REPORTING_TEXT[s.reporting.state]}` : '';
2634
+ node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents${shellCount ? `, ${shellCount} shell command${shellCount === 1 ? '' : 's'} running` : ''}${reportingNote}`);
2557
2635
  }
2558
2636
 
2559
2637
  function renderSessions() {
@@ -2810,7 +2888,7 @@ function updatePanel() {
2810
2888
  const id = toolSessionId(s);
2811
2889
  $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd, id && `session ${id}`].filter(Boolean).join(' · ');
2812
2890
  $('panel-sub').title = modelTitle(s);
2813
- renderAgents($('panel-agents'), s.agents);
2891
+ renderAgents($('panel-agents'), s.agents, s.shells || []);
2814
2892
  const stop = $('panel-stop');
2815
2893
  stop.textContent = s.status === 'running' ? 'Stop' : 'Remove';
2816
2894
  }