@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.
- package/README.md +12 -14
- package/bin/agent-guild-report.mjs +42 -20
- package/config/providers.default.json +4 -0
- package/examples/grok-hooks.json +43 -0
- package/package.json +1 -1
- package/src/manager/command-resolver.mjs +6 -2
- package/src/manager/config.mjs +2 -0
- package/src/manager/main.mjs +9 -2
- package/src/manager/providers.mjs +10 -3
- package/src/manager/report-shims.mjs +2 -0
- package/src/manager/server.mjs +19 -3
- package/src/manager/session-hooks.mjs +350 -0
- package/src/manager/session-manager.mjs +57 -19
- package/src/manager/session.mjs +240 -8
- package/src/report/hooks.mjs +79 -9
- package/web/app.js +90 -12
- package/web/index.html +5 -0
- package/web/styles.css +10 -2
package/src/manager/session.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/report/hooks.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
+
const familiar = (id, name, hue) => {
|
|
361
383
|
const el = document.createElement('span');
|
|
362
|
-
el.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
el.
|
|
366
|
-
el.
|
|
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
|
-
|
|
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
|
}
|