diff --git a/tools/agent-monitor/.gitignore b/tools/agent-monitor/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/tools/agent-monitor/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/tools/agent-monitor/README.md b/tools/agent-monitor/README.md new file mode 100644 index 00000000..1b9ce3a5 --- /dev/null +++ b/tools/agent-monitor/README.md @@ -0,0 +1,164 @@ +# Agent monitor + +Independent, read-only terminal viewer for **one explicitly selected local Copilot +CLI session**. It does not change the built-in `/tasks` interface, load the Copilot +SDK, resume the session, install hooks, call a model, or write to the source log. + +## Screenshots + +Captured from a fictional demo session (synthetic events, no real transcript). + +![Graph view: the spawn tree with launch modes and recorded wait durations](docs/graph.png) + +![Graph view: an agent blocked on the sync agent it launched](docs/graph-waiting.png) + +![Timeline view: swimlanes with waits, a later turn and the now cursor](docs/timeline.png) + +## Run on Windows + +Requires Node.js 22 or later. In PowerShell: + +```powershell +Set-Location "\tools\agent-monitor" +npm ci +npm run build +$session = Join-Path $env:USERPROFILE ".copilot\session-state\" +node .\dist\cli.js --session $session +``` + +Run this in a **separate interactive terminal tab** alongside Copilot. The viewer +replays the selected directory's `events.jsonl`, then follows newly appended +records. The directory can also belong to a historical session. There is no +automatic scan of other sessions. + +For development, use `npm start -- --session ""`. +Use `node .\dist\cli.js --help` for command-line help. Add `--view timeline` to +open on the Timeline. Add `--theme ` to +match the Copilot CLI `/theme` color mode: `default`, `light`, `dim`, +`high-contrast`, or `colorblind` (Primer semantic colors; needs a truecolor +terminal such as Windows Terminal). Minimum size: 80×24. + +## Open it from Copilot CLI (experimental) + +[`extension/`](extension/extension.mjs) is a +[Copilot CLI extension](https://docs.github.com/en/copilot/tutorials/create-an-extension) +that adds an `/agent-monitor` command to the session you are working in: + +- `/agent-monitor` prints the session's agent tree in the conversation and opens + the Graph view beside the CLI. +- `/agent-monitor timeline` does the same with the Timeline view. +- `/agent-monitor text` only prints the tree. + +The interactive view opens as a Windows Terminal split pane (or a tmux split); +in other terminals the command prints the line to run in another tab. +Extensions are an experimental Copilot CLI feature. After `npm run build`, link +this folder into your Copilot home once: + +```powershell +New-Item -ItemType Junction -Path "$HOME\.copilot\extensions\agent-monitor" -Target "$PWD\extension" +``` + +Then start Copilot CLI with `--experimental` (or run `/experimental on`) and use +`/agent-monitor`. Remove the junction to uninstall. Copilot CLI has no extension +API for adding tabs to its own tab bar, so the views open beside it. + +## Views and keys + +The layout follows Copilot CLI's own interface: a tab bar, blue query chips, a +`·`-separated key-hint footer, and a bordered detail dialog. Two views share +one selection: + +- **Graph** (default): a spawn tree. The main session sits on top; agents sit + under the agent or session that launched them, and a reused agent's later + turns sit side by side under its first turn. Edge labels show `bg` / `sync` + and any recorded wait with its duration (yellow while waiting, dim once + finished), naming the waiter when it is not the edge's parent. Cards narrow + to fit a wide fan-out; beyond that the graph scrolls horizontally to keep + the selection visible. +- **Timeline**: one swimlane per execution, with the main session's model + turns on the first lane. Bars are colored by outcome, `╍` marks time spent + waiting, `▾` connects a waiting lane to the execution it waited for, and the + `now` cursor follows the session. The default window covers the period in + which agents ran. + +The bottom dialog shows what the selected execution was asked (its task +prompt), its final message or error (the latest message while it runs), and its +recorded relationships. Tool calls stay hidden unless you press `t`. Long text +is loaded from the selected log on demand rather than copied to a new database. + +| Key | Action | +| --- | --- | +| Up/Down or `k`/`j` | Select the previous/next execution | +| `g` / `G` | Select the first/last execution | +| Left/Right or `h`/`l` | Graph: previous/next sibling. Timeline: pan earlier/later | +| `+` / `-` | Timeline: zoom in/out (beyond the agent window up to the whole session) | +| Tab or `v` | Switch between Graph and Timeline | +| `f` | Cycle all, active, finished; ancestors remain as dimmed context | +| `t` | Show tool calls in the detail dialog instead of relationships (below 100 columns: instead of the message) | +| Page Up / Page Down | Scroll the selected result | +| `q` or Escape | Quit and restore the previous terminal screen | + +`done` means an invocation has a recorded `subagent.completed` event or an +accepted agent-attributed `session.task_complete`. `cancelled` means +`subagent.completed` recorded `cancelled: true` (for example, a superseded run); +it is not a failure. `replied` means the agent's latest model turn ended with a +final answer, not progress commentary, but lifecycle completion was not +recorded. Lifecycle events name an agent's original launch call; they settle +the agent's open execution and never overwrite an earlier recorded outcome. +Follow-up executions on a reused agent remain separate from its earlier work. +If a follow-up arrives while the earlier execution has no recorded outcome, the +earlier one becomes `unknown` instead of staying `running`. +The finished filter includes replied executions without claiming the agent was +terminated. Running/unknown states are **last observed**, not a process-health +check; silence or end-of-file does not prove that an agent is dead or finished. + +## Relationships and compatibility + +Compatibility was verified against Copilot CLI **1.0.104** and **1.0.105**, using +a local parallel/nested-agent trace, a same-agent follow-up, and a replay of local +session logs. The on-disk event format +is version-sensitive, not a supported stable public attachment API. Other CLI +versions display a compatibility warning; malformed or unsupported relevant +records produce explicit diagnostics rather than fabricated results. + +- Invocation parents use verified launch-call ownership or + `subagent.started.data.parentId`. Envelope `parentId` is the event chain and + is never interpreted as an agent edge. +- Temporary waits use synchronous invocation or explicit `read_agent` + `wait: true` data. A nonblocking status read is not a dependency. +- The verified format has no general prerequisite/`depends_on` DAG. Its absence + is displayed explicitly. No graph edges are inferred from timing or prose. +- Background launch acknowledgement is not task completion. +- Missing outputs stay unavailable. Original text may already have been + truncated by Copilot; the viewer cannot reconstruct it. +- Ephemeral events and unrecorded cancellation/liveness signals cannot be + recovered from this file. Unknown agent-event types are diagnosed, not + interpreted as an outcome. +- Detected source replacement/truncation rebuilds the view. A trailing record + without a newline remains buffered until it is complete. Reading stops on a source + error, with a visible diagnostic and a nonzero exit status on quit; the clock + then stays at the last recorded event. +- While following a live log, open executions extend to the current time. A + recorded `session.shutdown` switches the header to `ended` and stops the clock; + a later `session.resume` makes the session live again. +- Below 100 columns the detail dialog uses one column and omits relationships. + +Text from the log is rendered without terminal control sequences. Session +content stays local: do not copy private transcripts into this repository. +This private package is a standalone local tool, not a modified distribution +of Copilot CLI or a claim that upstream accepts core CLI changes here. + +## Validate + +```powershell +npm run typecheck +npm test +npm run build +``` + +Tests use synthetic records and temporary files, covering replay/tailing, +UTF-8 boundaries, source reset, agent/result attribution, lifecycle routing and +cancellation, session resume, recorded wait history, reused agents and later +turns, graph layout and scrolling, the Graph/Timeline views, filtering, +hidden-by-default tool calls, narrow terminals, result scrolling, and the +text summary printed by the extension. diff --git a/tools/agent-monitor/docs/graph-waiting.png b/tools/agent-monitor/docs/graph-waiting.png new file mode 100644 index 00000000..9063154b Binary files /dev/null and b/tools/agent-monitor/docs/graph-waiting.png differ diff --git a/tools/agent-monitor/docs/graph.png b/tools/agent-monitor/docs/graph.png new file mode 100644 index 00000000..676ef101 Binary files /dev/null and b/tools/agent-monitor/docs/graph.png differ diff --git a/tools/agent-monitor/docs/mockup-agents-tab-graph.png b/tools/agent-monitor/docs/mockup-agents-tab-graph.png new file mode 100644 index 00000000..b4c31b58 Binary files /dev/null and b/tools/agent-monitor/docs/mockup-agents-tab-graph.png differ diff --git a/tools/agent-monitor/docs/mockup-agents-tab-timeline.png b/tools/agent-monitor/docs/mockup-agents-tab-timeline.png new file mode 100644 index 00000000..3fc1cb60 Binary files /dev/null and b/tools/agent-monitor/docs/mockup-agents-tab-timeline.png differ diff --git a/tools/agent-monitor/docs/timeline.png b/tools/agent-monitor/docs/timeline.png new file mode 100644 index 00000000..7331f730 Binary files /dev/null and b/tools/agent-monitor/docs/timeline.png differ diff --git a/tools/agent-monitor/extension/extension.mjs b/tools/agent-monitor/extension/extension.mjs new file mode 100644 index 00000000..fc8eeb61 --- /dev/null +++ b/tools/agent-monitor/extension/extension.mjs @@ -0,0 +1,56 @@ +// Copilot CLI extension (experimental): `/agent-monitor` prints the current session's agent tree in the conversation and +// opens the interactive Graph or Timeline view beside the CLI. Run `npm run build` in tools/agent-monitor first. +import {spawn} from 'node:child_process'; +import {realpathSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {basename, dirname, join} from 'node:path'; +import {fileURLToPath, pathToFileURL} from 'node:url'; +import {joinSession} from '@github/copilot-sdk/extension'; + +// Resolve the package through a linked extension directory, so the build next to this file is used. +const root = dirname(dirname(realpathSync(fileURLToPath(import.meta.url)))); +const cli = join(root, 'dist', 'cli.js'); +const {summarizeSession} = await import(pathToFileURL(join(root, 'dist', 'summary.js')).href); +const node = /^node(\.exe)?$/i.test(basename(process.execPath)) ? process.execPath : 'node'; +const CHOICES = ['graph', 'timeline', 'text']; + +const session = await joinSession({ + commands: [{ + name: 'agent-monitor', + description: 'Show this session\'s agents here and open the Graph or Timeline view beside the CLI: /agent-monitor [graph|timeline|text]', + handler: async ctx => { + const choice = ctx.args.trim().toLowerCase() || 'graph'; + if (!CHOICES.includes(choice)) { + await session.log('Usage: /agent-monitor [graph|timeline|text]', {level: 'warning', ephemeral: true}); + return; + } + const sessionDir = session.workspacePath ?? join(process.env.COPILOT_HOME ?? join(homedir(), '.copilot'), 'session-state', ctx.sessionId); + try { + await session.log(await summarizeSession(sessionDir), {ephemeral: true}); + } catch (error) { + await session.log(`agent-monitor: ${error instanceof Error ? error.message : String(error)}`, {level: 'error', ephemeral: true}); + return; + } + if (choice !== 'text') openBeside(sessionDir, choice); + }, + }], +}); + +/** Opens the viewer in a split pane where the terminal supports one; otherwise prints the command to run. */ +function openBeside(sessionDir, view) { + const args = [cli, '--session', sessionDir, '--view', view]; + const manual = () => session.log( + `Run this in another terminal tab for the interactive view:\n${[node, ...args].map(arg => JSON.stringify(arg)).join(' ')}`, + {ephemeral: true}, + ); + const pane = process.env.WT_SESSION + ? ['wt.exe', ['-w', '0', 'split-pane', '--vertical', '--title', 'Agent monitor', '--', node, ...args]] + : process.env.TMUX ? ['tmux', ['split-window', '-h', node, ...args]] : undefined; + if (!pane) { + void manual(); + return; + } + const child = spawn(pane[0], pane[1], {detached: true, stdio: 'ignore', windowsHide: true}); + child.on('error', () => void manual()); + child.unref(); +} diff --git a/tools/agent-monitor/package-lock.json b/tools/agent-monitor/package-lock.json new file mode 100644 index 00000000..c38cf931 --- /dev/null +++ b/tools/agent-monitor/package-lock.json @@ -0,0 +1,1155 @@ +{ + "name": "copilot-agent-monitor", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copilot-agent-monitor", + "version": "0.1.0", + "dependencies": { + "ink": "^8.0.0", + "react": "^19.3.1", + "string-width": "^8.3.0" + }, + "bin": { + "agent-monitor": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.3.0", + "ink-testing-library": "^4.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.1.tgz", + "integrity": "sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha1-v24QMDvPLnxoaXX6Uvk37Cco2Lw=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha1-LYTs5qTiaE2SvibuE9QnV9gxw4E=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha1-DGJGvI0sTRcqrC2z+xGQ1yvWVQQ=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha1-/DjU1jWNjcHPU/CfdYn+Q262SAE=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha1-+Dr+6sHX2sAcei/QErPkUaBZH8w=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha1-UQFHwFWnlViNu+FP1rG4rQovMN4=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha1-CTuSAOzwsRW6Tl4kinSFycX4vV4=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha1-C+Irbfkl0hPoQeqHEjr134Cw+vc=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha1-vrEq1yuE9y0oSIzBuO6ffrFB11M=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha1-G9vGUc2pupmVxT7ZxxzqplCUdi0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha1-uB+dVVKbRcIGpGoTghSxqmh5aWs=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha1-WYZnJBoEyZt27W75QKxQA4xBn5g=", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha1-HFHrnOqQP1PZe1rzsYQdtw9Vlso=", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha1-Y91h8XzrMagSJ/QT/qyKcbwsUfI=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha1-N2Owj95c8lqx+suOd1Lt/kX7/Cc=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha1-GhN/8pOoKQbrMXY4W9fo4OXPt8s=", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha1-Jos2IRwUbKVPj+EsV4qNbviXlIU=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha1-Ilca2VHWK7aszILY0frVyMGsC6E=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha1-QvzFcpfrCgyj9fxHUpH0waP3wN4=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha1-nrMq8QSsPaz07coB9ZZmSqsMc+8=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha1-/r7SQC1giCJekfIPtM4lIq0KTv0=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha1-hWQcPUZkKL+8zqXyHCaDZmP+9c4=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha1-pzb52JYkgQRfxMPlT1R58iyHD7Q=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha1-7lq0D60YYgG2UqM/il6xSenkJTI=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha1-xA0optmaEn2mcR8q/XSxHLY7Bqc=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha1-shr/uATMFnwTPZX0WzodwTI7moc=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react/-/react-19.3.0.tgz", + "integrity": "sha1-Ozzo2MFif7XM1NrdV+9EXFoVO3c=", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha1-U5W7dLIVCkodbjwlZfSuynjShic=", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "7.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-7.0.0.tgz", + "integrity": "sha1-hZCJpyVWDiNaQf7N6JmRlt9seMA=", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha1-UNjmPqWh3dy15eNkUcGoJm/7sq4=", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "6.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chalk/-/chalk-6.0.1.tgz", + "integrity": "sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha1-cmf0rjLsu1K1r3f+9IviGfZmxXw=", + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha1-POz+NzS/T+Aqg2HL3A9v4oxqV+o=", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha1-Na5moEDu81eu3P/9YUnYR796LrE=", + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha1-LefUbphRQ4XLAfezt0EyARX0yV4=", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha1-YabJj4qmJsFrKWuGKpFBKjO862s=", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha1-7EjA8+mT5QZIyG2lWeJhCZXPmJo=", + "devOptional": true, + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/environment/-/environment-1.1.0.tgz", + "integrity": "sha1-jobGaxgPNjx6sxF4fgJZZl9FqfE=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.53.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-toolkit/-/es-toolkit-1.53.0.tgz", + "integrity": "sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha1-D0O9G62VW3LSTiJh46vllXzPCBY=", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha1-owME6Z2qMuI7L9IPUbq9B8/8o0Q=", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha1-T9KYD8yvhiLRTGTWlPTPM8gZUaU=", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ink/-/ink-8.0.0.tgz", + "integrity": "sha512-M2aFwZOqmkFxCwedy6xrP4E+DGoK57iahAFnfTqNyOyJ54O7DZzu027z/NI8EGy44Sw9O9BcbTF4QEUS2GHAtg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.1", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^7.0.0", + "auto-bind": "^5.0.1", + "chalk": "^6.0.1", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.52.0", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "quick-lru": "^7.3.0", + "react-reconciler": "^0.34.0", + "signal-exit": "^3.0.7", + "stack-utils": "^2.0.6", + "string-width": "^8.3.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.10.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.2", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.3.0", + "react": ">=19.3.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha1-cHHayaPXg+e6su4F/fHQGizTuw0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink/node_modules/wrap-ansi": { + "version": "10.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-10.0.2.tgz", + "integrity": "sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ink/node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha1-BGsqbU9rFWsiM9MgfUtal4OZm5g=", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha1-5LNHHFVbR1CagxGGnDd8I0lnB58=", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha1-ftLCzMyvhNP/y3pptXcR/CCDQBs=", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha1-0Oluu1awdHbfHdnEgG5SN5hcpF4=", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha1-kCP0ZlhA5m9A6c53T5BKYxZ0M7s=", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha1-Kvfg/HK2a3SWJR9iJsxyNmLFBmU=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.34.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-reconciler/-/react-reconciler-0.34.0.tgz", + "integrity": "sha512-eVibvB1fc9j5Qf1KRkiM3GzRPBdjg2P6G6QV2f3P6Eb3OGMxRBhdg2rrUwjbV5JrQaJj8nOa8uajmmJA5tJWjQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha1-UZVgpDGJdQlt725gnUQQDtqkzLk=", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha1-A9PKNIrq+LdQL2RGjyF7J1I/lpU=", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha1-qaF2f4r4QVURTqq9c/mSc8j1mtk=", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "9.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/slice-ansi/-/slice-ansi-9.0.1.tgz", + "integrity": "sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha1-qvB0gWnAL8M8gjKrzPkz9Uocw08=", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha1-oLWRfChky6VIQUlav6P2sT7c9NY=", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha1-T+jLdIKq4lPwQrzQxnth8t3peQE=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha1-aoY4r4NwWVAdYcXTvfcsuTT2x5I=", + "license": "MIT", + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha1-0tG6BvDoHC62UMPlrYsLSt3h6EM=", + "license": "MIT" + } + } +} diff --git a/tools/agent-monitor/package.json b/tools/agent-monitor/package.json new file mode 100644 index 00000000..b34eee6b --- /dev/null +++ b/tools/agent-monitor/package.json @@ -0,0 +1,31 @@ +{ + "name": "copilot-agent-monitor", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Read-only terminal viewer for a selected local Copilot CLI session", + "bin": { + "agent-monitor": "dist/cli.js" + }, + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "tsx src/cli.tsx", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "tsx --test test/*.test.ts test/*.test.tsx" + }, + "dependencies": { + "ink": "^8.0.0", + "react": "^19.3.1", + "string-width": "^8.3.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.3.0", + "ink-testing-library": "^4.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + } +} diff --git a/tools/agent-monitor/src/App.tsx b/tools/agent-monitor/src/App.tsx new file mode 100644 index 00000000..e416f973 --- /dev/null +++ b/tools/agent-monitor/src/App.tsx @@ -0,0 +1,209 @@ +import {useEffect, useMemo, useRef, useState} from 'react'; +import {Box, Text, useApp, useInput, useWindowSize} from 'ink'; +import type {Key} from 'ink'; +import {drawChrome, drawInspector, frameLayout, promptRef, resultRef} from './chrome.js'; +import type {DetailTexts, Frame, ViewName, ViewState} from './chrome.js'; +import {errorMessage, JsonlReader} from './events.js'; +import type {ContentRef} from './events.js'; +import {drawGraph} from './graph.js'; +import {AgentModel} from './model.js'; +import type {Filter} from './model.js'; +import {Screen} from './screen.js'; +import {PALETTES} from './theme.js'; +import type {ColorMode} from './theme.js'; +import {drawTimeline} from './timeline.js'; +import {buildTree, MAIN, nowSeconds, sessionSeconds, visualOf} from './tree.js'; + +export const MIN_COLS = 80; +export const MIN_ROWS = 24; +const NEXT_FILTER: Record = {all: 'active', active: 'finished', finished: 'all'}; + +interface AppProps { + model: AgentModel; + revision?: number; + loadContent: (ref: ContentRef) => Promise; + source?: Frame['source']; + diagnostic?: string; + mode?: ColorMode; + view?: ViewName; + width?: number; + height?: number; + clock?: () => number; +} + +export function App({model, loadContent, source = 'following', diagnostic, mode = 'default', view = 'graph', width, height, clock = Date.now}: AppProps) { + const size = useWindowSize(); + const {exit} = useApp(); + const cols = width ?? size.columns; + const rows = height ?? size.rows; + const state = useRef({ + view, filter: 'all', selectedId: MAIN, showTools: false, zoom: 1, pan: 0, detailScroll: 0, graphX: 0, graphY: 0, laneTop: 0, span: 0, + }).current; + const [, setTick] = useState(0); + const [spin, setSpin] = useState(0); + const rerender = (): void => setTick(tick => tick + 1); + const tree = buildTree(model, state.filter); + if (!tree.byId.has(state.selectedId)) state.selectedId = (tree.order.find(node => !node.ctx) ?? tree.root).id; + const node = tree.byId.get(state.selectedId)!; + const result = resultRef(model, node); + const prompt = promptRef(model, node); + const refKey = (ref: ContentRef | undefined): string => ref ? `${ref.eventId}:${ref.field}:${ref.location?.generation ?? ''}` : ''; + // Loaded text is only reused for the same execution of the same source: a replaced log reuses execution IDs. + const nodeKey = `${model.epoch}|${node.id}`; + const textKey = `${nodeKey}|${refKey(result)}|${refKey(prompt)}`; + const [loaded, setLoaded] = useState<{nodeKey: string; texts: DetailTexts}>({nodeKey: '', texts: {pending: false}}); + + useEffect(() => { + let cancelled = false; + void Promise.allSettled([result ? loadContent(result) : undefined, prompt ? loadContent(prompt) : undefined]).then(([r, p]) => { + if (cancelled) return; + setLoaded({ + nodeKey, + texts: { + pending: false, + result: r.status === 'fulfilled' ? r.value : undefined, + error: r.status === 'rejected' ? errorMessage(r.reason) : undefined, + prompt: p.status === 'fulfilled' ? p.value : undefined, + promptError: p.status === 'rejected' ? errorMessage(p.reason) : undefined, + }, + }); + }); + return () => { cancelled = true; }; + }, [textKey, loadContent]); + + const animating = tree.order.some(n => visualOf(model, n) === 'running'); + useEffect(() => { + if (!animating) return; + const timer = setInterval(() => setSpin(value => value + 1), 250); + return () => clearInterval(timer); + }, [animating]); + useEffect(() => { + if (source !== 'following') return; + const timer = setInterval(rerender, 1000); + return () => clearInterval(timer); + }, [source]); + + // A stopped source records nothing new, so time stays at the last observed event instead of following the wall clock. + const now = source === 'stopped' ? sessionSeconds(model, model.lastEventAt) : nowSeconds(model, clock()); + /** Applies one key binding; returns false for an unbound key. */ + const applyKey = (input: string, key: Key): boolean => { + const current = buildTree(model, state.filter); + const order = current.order; + const selected = current.byId.get(state.selectedId) ?? current.root; + const index = order.indexOf(selected); + const select = (target: number): void => { + state.selectedId = order[clamp(target, 0, order.length - 1)]!.id; + state.detailScroll = 0; + }; + const siblings = selected.parent?.children ?? [selected]; + const sibling = siblings.indexOf(selected); + const pageStep = Math.max(1, frameLayout(cols, rows).box.h - 6); + const panStep = Math.max(1, (state.span || now) / 4); + if (key.tab || input === 'v') state.view = state.view === 'graph' ? 'timeline' : 'graph'; + else if (input === 'j' || key.downArrow) select(index + 1); + else if (input === 'k' || key.upArrow) select(index - 1); + else if (input === 'g' || key.home) select(0); + else if (input === 'G' || key.end) select(order.length - 1); + else if (input === 'f') { + state.filter = NEXT_FILTER[state.filter]; + state.laneTop = 0; + } else if (input === 't') state.showTools = !state.showTools; + else if (key.pageDown) state.detailScroll += pageStep; + else if (key.pageUp) state.detailScroll = Math.max(0, state.detailScroll - pageStep); + else if (state.view === 'timeline' && (input === '+' || input === '=')) state.zoom = Math.min(64, state.zoom * 2); + else if (state.view === 'timeline' && input === '-') { + state.zoom = Math.max(1 / 64, state.zoom / 2); + if (state.zoom === 1) state.pan = 0; + } else if (state.view === 'timeline' && (key.leftArrow || input === 'h')) state.pan += panStep; + else if (state.view === 'timeline' && (key.rightArrow || input === 'l')) state.pan -= panStep; + else if (state.view === 'graph' && (key.leftArrow || input === 'h') && sibling > 0) state.selectedId = siblings[sibling - 1]!.id; + else if (state.view === 'graph' && (key.rightArrow || input === 'l') && sibling < siblings.length - 1) state.selectedId = siblings[sibling + 1]!.id; + else return false; + return true; + }; + useInput((input, key) => { + // Ink delivers keys that arrive together (a held key during a slow render, buffered input) as one string. + let handled = false; + for (const char of input.length > 1 ? [...input] : [input]) { + if (char === 'q' || key.escape) { + exit(); + return; + } + handled = applyKey(char, key) || handled; + } + if (handled) rerender(); + }); + + if (cols < MIN_COLS || rows < MIN_ROWS) { + return {`Terminal too small for the agent monitor (need ${MIN_COLS}x${MIN_ROWS}, have ${cols}x${rows}). q: quit`}; + } + // A running agent's latest message changes often; keep showing its previous text until the new one is read. + const texts: DetailTexts = loaded.nodeKey === nodeKey ? loaded.texts : {pending: Boolean(result || prompt)}; + const warnings = [...model.warnings].join('; ') || undefined; + const frame: Frame = { + model, state, tree, now, spin, cols, rows, source, texts, + // A stopped source's error comes first; current warnings outrank an informational notice such as a reset. + diagnostic: source === 'stopped' ? diagnostic ?? warnings : warnings ?? diagnostic, + }; + const layout = frameLayout(cols, rows); + const screen = new Screen(cols, rows); + drawChrome(screen, frame, layout); + if (state.view === 'graph') drawGraph(screen, frame, layout.content, layout.legendY); + else drawTimeline(screen, frame, layout.content, layout.legendY); + drawInspector(screen, frame, layout.box); + const palette = PALETTES[mode]; + return ( + + {screen.segments().map((segments, y) => ( + + {segments.map((segment, i) => ( + + {segment.text} + + ))} + + ))} + + ); +} + +function clamp(value: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, value)); +} + +export function LiveApp({file, mode, view, onError}: {file: string; mode: ColorMode; view?: ViewName; onError: (message: string) => void}) { + const reader = useMemo(() => new JsonlReader(file), [file]); + const model = useMemo(() => new AgentModel(), [file]); + const [revision, setRevision] = useState(0); + const [diagnostic, setDiagnostic] = useState(); + const [stopped, setStopped] = useState(false); + const loadContent = useMemo(() => (ref: ContentRef) => reader.readContent(ref), [reader]); + useEffect(() => { + let active = true; + let timer: ReturnType; + const poll = async (): Promise => { + try { + const changed = await reader.poll(record => model.apply(record), () => { + model.reset(); + if (active) setDiagnostic('Session log was replaced/truncated; view rebuilt from the new source.'); + }); + if (!active) return; + if (changed) setRevision(value => value + 1); + timer = setTimeout(() => { void poll(); }, 400); + } catch (error) { + if (!active) return; + const message = `Source stopped: ${errorMessage(error)}`; + setDiagnostic(message); + setStopped(true); + onError(message); + } + }; + void poll(); + return () => { + active = false; + clearTimeout(timer); + }; + }, [reader, model, onError]); + const source = stopped ? 'stopped' : model.sessionEnded ? 'ended' : 'following'; + return ; +} diff --git a/tools/agent-monitor/src/chrome.ts b/tools/agent-monitor/src/chrome.ts new file mode 100644 index 00000000..dd536130 --- /dev/null +++ b/tools/agent-monitor/src/chrome.ts @@ -0,0 +1,268 @@ +import type {ContentRef} from './events.js'; +import type {AgentModel, Filter} from './model.js'; +import {fit, Screen, width, wrap} from './screen.js'; +import type {Part, Style} from './screen.js'; +import { + elapsed, formatDuration, glyphPart, isActiveVisual, nodeType, runLabel, sessionSeconds, + VISUALS, visualOf, waitLabel, waitSpan, +} from './tree.js'; +import type {Node, Tree} from './tree.js'; + +export type ViewName = 'graph' | 'timeline'; +export const VIEWS: ViewName[] = ['graph', 'timeline']; +const TITLES: Record = {graph: 'Graph', timeline: 'Timeline'}; + +export interface ViewState { + view: ViewName; + filter: Filter; + selectedId: string; + showTools: boolean; + zoom: number; + pan: number; + detailScroll: number; + graphX: number; + graphY: number; + laneTop: number; + span: number; +} + +export interface DetailTexts { + prompt?: string; + promptError?: string; + result?: string; + pending: boolean; + error?: string; +} + +export interface Frame { + model: AgentModel; + state: ViewState; + tree: Tree; + now: number; + spin: number; + cols: number; + rows: number; + source: 'following' | 'ended' | 'stopped'; + diagnostic?: string; + texts: DetailTexts; +} + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +export interface Layout { + content: Rect; + legendY: number; + box: Rect; + footerY: number; +} + +const MUTED: Style = {fg: 'muted'}; + +export function frameLayout(cols: number, rows: number): Layout { + const boxH = rows >= 34 ? 10 : rows >= 28 ? 9 : 7; + const box = {x: 1, y: rows - 2 - boxH, w: cols - 2, h: boxH}; + const legendY = box.y - 2; + return {content: {x: 1, y: 4, w: cols - 2, h: legendY - 4}, legendY, box, footerY: rows - 1}; +} + +export function selectedNode(frame: Frame): Node { + return frame.tree.byId.get(frame.state.selectedId) ?? frame.tree.root; +} + +export function resultRef(model: AgentModel, node: Node): ContentRef | undefined { + const run = node.run; + if (!run) return model.mainLatest; + if (run.status === 'failed') return run.error ?? run.result; + return run.status === 'completed' || run.status === 'replied' ? run.result : run.latest; +} + +export function promptRef(model: AgentModel, node: Node): ContentRef | undefined { + return node.run ? node.run.prompt : model.mainPrompt; +} + +/** The inspector shows relationships or tools beside the message once the dialog interior is this wide (100 columns). */ +const SPLIT_INNER = 94; + +export const isSplit = (box: Rect): boolean => box.w - 4 >= SPLIT_INNER; + +export function drawChrome(scr: Screen, frame: Frame, layout: Layout): void { + let x = 1; + for (const view of VIEWS) { + const on = view === frame.state.view; + x = scr.put(x, 0, ` ${TITLES[view]} `, on ? {fg: 'onEm', bg: 'accentEm', bold: true} : {fg: 'muted', bg: 'tab'}) + 1; + } + const zoom = frame.state.zoom; + const windowLabel = zoom === 1 && Math.abs(frame.state.pan) < 0.5 ? 'agent activity' : zoom >= 1 ? `${zoom}x` : `1/${Math.round(1 / zoom)}x`; + const chips: Array<[string, string]> = [ + ['status:', frame.state.filter], + frame.state.view === 'graph' ? ['edges:', 'spawn,wait'] : ['window:', windowLabel], + ['session:', (frame.model.sessionId ?? 'unknown').slice(0, 8)], + ]; + const index = frame.tree.order.findIndex(node => node.id === frame.state.selectedId); + const indicator: Part = frame.source === 'following' ? ['●', {fg: 'success'}] : frame.source === 'ended' ? ['○', MUTED] : ['✗', {fg: 'danger'}]; + const position: Part = [` ${index + 1} / ${frame.tree.order.length}`, MUTED]; + const clock: Part = [` ${formatDuration(frame.now)}`, MUTED]; + const source: Part = [` ${frame.source}`, {fg: 'fg'}]; + // Narrow terminals keep the chips readable: drop the position, then the clock, then trailing chips. + const chipsEnd = (list: typeof chips): number => list.reduce((end, [key, value]) => end + width(key) + width(value) + 4, 1); + const rightOptions: Part[][] = [[indicator, source, clock, position], [indicator, source, clock], [indicator, source]]; + const partsWidth = (parts: Part[]): number => parts.reduce((sum, [text]) => sum + width(text), 0); + const right = rightOptions.find(parts => frame.cols - 1 - partsWidth(parts) >= chipsEnd(chips)) ?? rightOptions.at(-1)!; + while (chips.length > 1 && chipsEnd(chips) > frame.cols - 1 - partsWidth(right)) chips.pop(); + x = 1; + for (const [key, value] of chips) { + x = scr.put(x, 2, key, {fg: 'fg'}); + x = scr.put(x, 2, ` ${value} `, {fg: 'accent', bg: 'chip'}) + 2; + } + scr.seq(frame.cols - 1 - partsWidth(right), 2, right); + if (frame.diagnostic) scr.put(1, 3, fit(frame.diagnostic, frame.cols - 2), {fg: 'attention'}); + const toolsHint = frame.state.showTools ? isSplit(layout.box) ? 'relationships' : 'message' : 'tools'; + const hints: Array<[string, string]> = frame.state.view === 'graph' + ? [['j/k', 'node'], ['tab', 'timeline'], ['f', 'filter'], ['t', toolsHint], ['pgup/pgdn', 'detail'], ['q', 'quit']] + : [['j/k', 'lane'], ['+/-', 'zoom'], ['←/→', 'pan'], ['tab', 'graph'], ['f', 'filter'], ['t', toolsHint], ['q', 'quit']]; + const parts: Part[] = []; + hints.forEach(([key, label], i) => { + if (i) parts.push([' · ', MUTED]); + parts.push([key, {fg: 'fg', bold: true}], [` ${label}`, MUTED]); + }); + scr.seqFit(1, layout.footerY, parts, frame.cols - 2); +} + +function metaLine(frame: Frame, node: Node): string { + const {model, now} = frame; + if (!node.run) { + const agents = frame.model.runs.size; + return [model.sessionModel, 'interactive session', formatDuration(now), `${agents} agent run${agents === 1 ? '' : 's'}`].filter(Boolean).join(' · '); + } + const run = node.run; + return [run.name, nodeType(node), run.model, run.mode, formatDuration(elapsed(model, node, now))].filter(Boolean).join(' · '); +} + +/** Heading, note, style and empty-state text for the inspector's message section. */ +function resultHeading(frame: Frame, node: Node): [string, string, Style, string] { + const visual = visualOf(frame.model, node); + const style: Style = {fg: 'fg', bold: true}; + if (visual === 'failed') return ['Error', 'from subagent.failed', {fg: 'danger', bold: true}, 'No error message recorded.']; + if (visual === 'replied') return ['Reply', 'turn ended · no completion event', style, 'No final message recorded.']; + if (visual === 'done') return ['Result', 'final message', style, 'No final message recorded.']; + if (isActiveVisual(visual)) return ['Latest message', 'last observed · still running', style, 'No message recorded yet.']; + if (visual === 'cancelled') return ['Latest message', 'cancelled · no final reply', style, 'No message recorded.']; + if (visual === 'unknown') return ['Latest message', 'status not recorded', style, 'No message recorded.']; + return ['Latest message', '', style, 'No message recorded.']; +} + +function relationships(frame: Frame, node: Node): Part[][] { + const {model, now} = frame; + const lines: Part[][] = []; + const key = node.run ? node.run.id : null; + if (!node.run) { + const launched = [...model.runs.values()].filter(run => run.parentRunId === null && !run.previousRunId); + const sync = launched.filter(run => run.mode === 'sync').length; + lines.push([['↳ ', MUTED], [`launched ${launched.length} agent${launched.length === 1 ? '' : 's'} · ${launched.length - sync} background, ${sync} sync`, {fg: 'fg'}]]); + } else if (node.edge === 'turn') lines.push([['┆ ', MUTED], [`later turn of ${node.parent?.run?.name ?? 'the same agent'}`, {fg: 'fg'}]]); + else if (node.edge === 'orphan') lines.push([['? ', MUTED], ['launcher not recorded', MUTED]]); + else lines.push([['↳ ', MUTED], [`spawned by ${runLabel(model, node.run.parentRunId ?? null)} · ${node.run.mode ?? 'unknown mode'} task`, {fg: 'fg'}]]); + const edges = [...model.waitLog.values()].sort((a, b) => Number(Boolean(a.end)) - Number(Boolean(b.end))); + for (const edge of edges) { + const [start, end] = waitSpan(model, edge, now); + const active = !edge.end; + const kind = edge.kind === 'sync-call' ? 'sync task' : 'read_agent'; + const tone: Style = {fg: 'attention', dim: !active}; + if (edge.fromRunId === key) lines.push([[active ? '◌ ' : '⇢ ', tone], [`${active ? 'waiting on' : 'waited on'} ${waitLabel(model, edge)} · ${kind} · ${formatDuration(end - start)}`, {fg: 'fg'}]]); + if (node.run && edge.toRunId === node.run.id) lines.push([[active ? '◌ ' : '⇠ ', tone], [`${active ? 'blocking' : 'waited on by'} ${runLabel(model, edge.fromRunId)} · ${kind} · ${formatDuration(end - start)}`, {fg: 'fg'}]]); + } + return lines; +} + +function toolLines(frame: Frame, node: Node, rows: number, w: number, scr: Screen, x: number, y: number): void { + const tools = node.run ? node.run.tools : frame.model.mainTools; + const shown = tools.slice(-rows); + if (!shown.length) scr.put(x, y, 'No tool calls recorded.', MUTED); + shown.forEach((tool, i) => { + const visual = tool.status === 'running' ? 'running' : tool.status === 'failed' ? 'failed' : 'done'; + const end = tool.endedAt ? sessionSeconds(frame.model, tool.endedAt) : frame.now; + const duration = formatDuration(end - sessionSeconds(frame.model, tool.startedAt)); + scr.seq(x, y + i, [glyphPart(visual, frame.spin)]); + scr.put(x + 2, y + i, fit(tool.name, 12), {fg: 'fg'}); + scr.rput(x + w - 1, y + i, duration, tool.status === 'running' ? {fg: 'fg'} : MUTED); + scr.put(x + 15, y + i, fit(tool.detail ?? '', w - 17 - width(duration)), MUTED); + }); +} + +function toolsHeader(frame: Frame, node: Node, scr: Screen, x: number, y: number, w: number, rows: number): void { + const shown = node.run ? node.run.tools.length : frame.model.mainTools.length; + const total = node.run ? shown : frame.model.mainToolCount; + scr.put(x, y, `Tools · ${total}`, {fg: 'fg', bold: true}); + scr.rput(x + w - 1, y, shown > rows ? `latest ${rows}` : 't to hide', MUTED); +} + +let wrapped: {text: string; w: number; lines: string[]} | undefined; + +/** Recorded messages can be long; the inspector redraws on every spinner tick, so keep the last wrap. */ +function wrapCached(text: string, w: number): string[] { + if (wrapped?.text !== text || wrapped.w !== w) wrapped = {text, w, lines: wrap(text, w)}; + return wrapped.lines; +} + +/** Bottom dialog: what the selected execution was asked, what it produced, and how it relates to others. */ +export function drawInspector(scr: Screen, frame: Frame, box: Layout['box']): void { + const node = selectedNode(frame); + const visual = visualOf(frame.model, node); + scr.box(box.x, box.y, box.w, box.h, {fg: 'border'}); + const x = box.x + 2; + const top = box.y + 1; + const rows = box.h - 2; + const inner = box.w - 4; + const split = isSplit(box); + const rightW = split ? Math.min(52, Math.floor(inner * 0.42)) : 0; + const leftW = split ? inner - rightW - 3 : inner; + const title = node.run ? node.run.task : 'Main session'; + scr.seq(x, top, [glyphPart(visual, frame.spin)]); + scr.rput(x + leftW - 1, top, VISUALS[visual].label, {fg: VISUALS[visual].tone}); + scr.put(x + 2, top, fit(title, leftW - 4 - width(VISUALS[visual].label)), {fg: 'fg', bold: true}); + scr.put(x, top + 1, fit(metaLine(frame, node), leftW), MUTED); + const {promptError} = frame.texts; + const prompt = promptError ? `Cannot read recorded prompt: ${promptError}` : frame.texts.prompt ?? (node.run ? node.run.task : undefined) ?? 'Not recorded'; + scr.seqFit(x, top + 2, [['Task ', {fg: 'fg', bold: true}], [prompt.replace(/\s+/g, ' '), promptError ? {fg: 'danger'} : MUTED]], leftW); + const bodyTop = top + 4; + const bodyRows = Math.max(0, rows - 4); + if (!split && frame.state.showTools) { + if (rows > 3) toolsHeader(frame, node, scr, x, top + 3, leftW, bodyRows); + toolLines(frame, node, bodyRows, leftW, scr, x, bodyTop); + return; + } + const [heading, note, headingStyle, empty] = resultHeading(frame, node); + let body: string[]; + if (frame.texts.pending) body = ['Loading recorded text…']; + else if (frame.texts.error) body = wrap(`Cannot read recorded text: ${frame.texts.error}`, leftW); + else body = frame.texts.result ? wrapCached(frame.texts.result, leftW) : [empty]; + const maxScroll = Math.max(0, body.length - bodyRows); + frame.state.detailScroll = Math.min(frame.state.detailScroll, maxScroll); + const scroll = frame.state.detailScroll; + const position = body.length > bodyRows ? `lines ${scroll + 1}-${Math.min(body.length, scroll + bodyRows)} of ${body.length}` : note; + if (rows > 3) { + scr.put(x, top + 3, heading, headingStyle); + if (position) scr.rput(x + leftW - 1, top + 3, fit(position, leftW - width(heading) - 2), MUTED); + } + body.slice(scroll, scroll + bodyRows).forEach((line, i) => scr.put(x, bodyTop + i, line, visual === 'failed' ? {fg: 'danger'} : {fg: 'fg'})); + if (!split) return; + const rx = x + leftW + 3; + scr.vline(x + leftW + 1, top, rows, '│', {fg: 'border'}); + if (frame.state.showTools) { + toolsHeader(frame, node, scr, rx, top, rightW, rows - 1); + toolLines(frame, node, rows - 1, rightW, scr, rx, top + 1); + return; + } + scr.put(rx, top, 'Relationships', {fg: 'fg', bold: true}); + scr.rput(rx + rightW - 1, top, 'other deps: not recorded', MUTED); + const lines = relationships(frame, node); + const room = rows - 1; + const shown = lines.length > room ? lines.slice(0, room - 1) : lines; + shown.forEach((parts, i) => scr.seqFit(rx, top + 1 + i, parts, rightW)); + if (shown.length < lines.length) scr.put(rx, top + 1 + shown.length, fit(`+${lines.length - shown.length} more · see Timeline`, rightW), MUTED); +} diff --git a/tools/agent-monitor/src/cli.tsx b/tools/agent-monitor/src/cli.tsx new file mode 100644 index 00000000..687aad71 --- /dev/null +++ b/tools/agent-monitor/src/cli.tsx @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import {join, resolve} from 'node:path'; +import {parseArgs} from 'node:util'; +import {render} from 'ink'; +import {LiveApp} from './App.js'; +import {VIEWS} from './chrome.js'; +import {errorMessage} from './events.js'; +import {clean} from './screen.js'; +import {COLOR_MODES, isColorMode} from './theme.js'; + +async function main(): Promise { + const {values} = parseArgs({ + options: { + session: {type: 'string'}, + theme: {type: 'string', default: 'default'}, + view: {type: 'string', default: 'graph'}, + help: {type: 'boolean', short: 'h'}, + }, + strict: true, + allowPositionals: false, + }); + if (values.help) { + console.log('Usage: agent-monitor --session [--theme ] [--view graph|timeline]\n' + + 'Read-only terminal viewer for that directory\'s events.jsonl.\n' + + `Themes: ${COLOR_MODES.join(', ')} (default: default).\n` + + 'Views: Graph (default) and Timeline; Tab or v switches.\n' + + 'Keys: j/k select, f filter, t tools, PgUp/PgDn detail, +/- zoom and ←/→ pan (Timeline), q quit.\n' + + 'No CLI configuration changes, network calls, or session writes.'); + return; + } + if (!values.session) throw new Error('--session is required (see --help)'); + if (!isColorMode(values.theme)) throw new Error(`--theme must be one of: ${COLOR_MODES.join(', ')}`); + const view = VIEWS.find(name => name === values.view); + if (!view) throw new Error(`--view must be one of: ${VIEWS.join(', ')}`); + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error('An interactive terminal is required. Run this command in a separate terminal tab.'); + } + let sourceError: string | undefined; + const onError = (message: string): void => { sourceError = message; }; + const instance = render( + , + {alternateScreen: true, patchConsole: false}, + ); + const terminate = (): void => { instance.unmount(); }; + process.once('SIGTERM', terminate); + try { + await instance.waitUntilExit(); + } finally { + process.off('SIGTERM', terminate); + instance.cleanup(); + } + if (sourceError) throw new Error(sourceError); +} + +void main().catch(error => { + // Source errors can quote log content; strip control sequences before they reach the restored terminal. + console.error(`agent-monitor: ${clean(errorMessage(error))}`); + process.exitCode = 1; +}); diff --git a/tools/agent-monitor/src/events.ts b/tools/agent-monitor/src/events.ts new file mode 100644 index 00000000..bb01fc5f --- /dev/null +++ b/tools/agent-monitor/src/events.ts @@ -0,0 +1,193 @@ +import {open} from 'node:fs/promises'; +import type {FileHandle} from 'node:fs/promises'; + +export interface SessionEvent { + id: string; + type: string; + timestamp: string; + agentId?: string; + data: Record; +} + +export interface RecordLocation { + offset: number; + length: number; + generation: number; +} + +export interface EventRecord { + event: SessionEvent; + location?: RecordLocation; +} + +type TextField = 'content' | 'summary' | 'result' | 'error' | 'prompt'; + +export interface ContentRef { + eventId: string; + field: TextField; + location?: RecordLocation; + inline?: string; +} + +export function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function optionalString(data: Record, key: string): string | undefined { + const value = data[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') throw new Error(`Expected string field "${key}"`); + return value; +} + +export function requiredString(data: Record, key: string): string { + const value = optionalString(data, key); + if (!value) throw new Error(`Missing string field "${key}"`); + return value; +} + +function decode(line: Buffer): SessionEvent { + const parsed: unknown = JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(line)); + if (!isObject(parsed) || !isObject(parsed.data)) throw new Error('Invalid event envelope'); + const timestamp = requiredString(parsed, 'timestamp'); + if (Number.isNaN(Date.parse(timestamp))) throw new Error('Invalid event timestamp'); + return { + id: requiredString(parsed, 'id'), + type: requiredString(parsed, 'type'), + timestamp, + agentId: optionalString(parsed, 'agentId'), + data: parsed.data, + }; +} + +function textAt(event: SessionEvent, field: TextField): string | undefined { + if (field === 'prompt') { + const args = event.data.arguments; + return isObject(args) && typeof args.prompt === 'string' ? args.prompt : undefined; + } + if (field === 'result') { + if (!isObject(event.data.result)) return undefined; + return optionalString(event.data.result, 'detailedContent') + ?? optionalString(event.data.result, 'content'); + } + if (field === 'error' && isObject(event.data.error)) { + return optionalString(event.data.error, 'message'); + } + return optionalString(event.data, field); +} + +export function capture(record: EventRecord, field: TextField): ContentRef | undefined { + const text = textAt(record.event, field); + if (!text) return undefined; + return { + eventId: record.event.id, + field, + location: record.location, + inline: record.location ? undefined : text, + }; +} + +export class JsonlReader { + private identity?: string; + private offset = 0; + private pending: Buffer = Buffer.alloc(0); + private pendingOffset = 0; + private line = 0; + private anchor: Buffer = Buffer.alloc(0); + private generation = 0; + + constructor(readonly file: string) {} + + async poll(accept: (record: EventRecord) => void, reset: () => void): Promise { + const handle = await open(this.file, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new Error('Session log is not a regular file'); + const identity = `${stat.dev}:${stat.ino}`; + let replaced = this.identity !== undefined && this.identity !== identity; + if (stat.size < this.offset) replaced = true; + if (!replaced && this.anchor.length) { + const previous = await this.readBytes(handle, this.offset - this.anchor.length, this.anchor.length); + replaced = !previous.equals(this.anchor); + } + if (replaced) { + this.offset = 0; + this.pending = Buffer.alloc(0); + this.pendingOffset = 0; + this.line = 0; + this.anchor = Buffer.alloc(0); + this.generation++; + reset(); + } + this.identity = identity; + const changed = replaced || stat.size > this.offset; + while (this.offset < stat.size) { + const chunk = await this.readBytes(handle, this.offset, Math.min(64 * 1024, stat.size - this.offset)); + if (!chunk.length) throw new Error('Session log changed during reading; reopen the viewer'); + const buffer = Buffer.concat([this.pending, chunk]); + let cursor = 0; + let end: number; + while ((end = buffer.indexOf(10, cursor)) !== -1) { + const line = buffer.subarray(cursor, end); + this.line++; + if (line.toString('utf8').trim()) { + try { + accept({ + event: decode(line), + location: { + offset: this.pendingOffset + cursor, + length: line.length, + generation: this.generation, + }, + }); + } catch (error) { + throw new Error(`Session log line ${this.line}: ${errorMessage(error)}`, {cause: error}); + } + } + cursor = end + 1; + } + // Copy the unconsumed tail: a subarray would keep a large, already-parsed record's buffer alive. + this.pending = cursor === 0 ? buffer : Buffer.from(buffer.subarray(cursor)); + this.pendingOffset += cursor; + this.offset += chunk.length; + } + this.anchor = await this.readBytes(handle, Math.max(0, this.offset - 64), Math.min(64, this.offset)); + return changed; + } finally { + await handle.close(); + } + } + + async readContent(ref: ContentRef): Promise { + if (ref.inline !== undefined) return ref.inline; + if (!ref.location || ref.location.generation !== this.generation) { + throw new Error('Result belongs to an outdated session-log generation'); + } + const handle = await open(this.file, 'r'); + try { + const bytes = await this.readBytes(handle, ref.location.offset, ref.location.length); + const event = decode(bytes); + if (event.id !== ref.eventId) throw new Error('Session log changed; result is no longer at this location'); + const text = textAt(event, ref.field); + if (text === undefined) throw new Error('Recorded text is unavailable'); + return text; + } finally { + await handle.close(); + } + } + + private async readBytes(handle: FileHandle, position: number, length: number): Promise { + const buffer = Buffer.alloc(length); + let total = 0; + while (total < length) { + const {bytesRead} = await handle.read(buffer, total, length - total, position + total); + if (!bytesRead) break; + total += bytesRead; + } + return buffer.subarray(0, total); + } +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/tools/agent-monitor/src/graph.ts b/tools/agent-monitor/src/graph.ts new file mode 100644 index 00000000..ecadc024 --- /dev/null +++ b/tools/agent-monitor/src/graph.ts @@ -0,0 +1,197 @@ +import type {Frame, Rect} from './chrome.js'; +import {fit, junction, Screen, width} from './screen.js'; +import type {Part, Style} from './screen.js'; +import {elapsed, formatDuration, glyphPart, MAIN, nodeType, runLabel, VISUALS, visualOf, waitSpan} from './tree.js'; +import type {Node, Visual} from './tree.js'; + +export const CARD_W = 26; +export const MIN_CARD_W = 18; +export const ROOT_W = 32; +export const CARD_H = 5; +export const ROOT_H = 4; +export const GAP = 2; +export const LEVEL = CARD_H + 2; + +export interface Placed { + node: Node; + x: number; + y: number; + w: number; + h: number; +} + +export interface GraphLayout { + placed: Map; + width: number; + height: number; +} + +const MUTED: Style = {fg: 'muted'}; +const centerOf = (p: Placed): number => p.x + Math.floor(p.w / 2); +const clamp = (value: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, value)); + +/** Row of a tree level: the root card is shorter, then each level adds a card plus a bus row and an arrow row. */ +export const levelY = (depth: number): number => depth === 0 ? 0 : ROOT_H + 2 + (depth - 1) * LEVEL; + +/** Top-down tidy layout: each subtree gets its own column span and parents sit centred over their children. */ +export function layoutGraph(root: Node, cardW = CARD_W): GraphLayout { + const cardWidth = (node: Node): number => node.id === MAIN ? ROOT_W : cardW; + const spans = new Map(); + const childSpan = (node: Node): number => + node.children.reduce((sum, child) => sum + spans.get(child.id)!, 0) + GAP * Math.max(0, node.children.length - 1); + const measure = (node: Node): void => { + node.children.forEach(measure); + spans.set(node.id, Math.max(cardWidth(node), childSpan(node))); + }; + measure(root); + const placed = new Map(); + let deepest = 0; + const place = (node: Node, left: number, depth: number): void => { + const total = spans.get(node.id)!; + const w = cardWidth(node); + deepest = Math.max(deepest, depth); + let cursor = left + Math.floor((total - childSpan(node)) / 2); + for (const child of node.children) { + place(child, cursor, depth + 1); + cursor += spans.get(child.id)! + GAP; + } + let x = left + Math.floor((total - w) / 2); + if (node.children.length) { + const mid = Math.floor((centerOf(placed.get(node.children[0]!.id)!) + centerOf(placed.get(node.children.at(-1)!.id)!)) / 2); + x = clamp(mid - Math.floor(w / 2), left, left + total - w); + } + placed.set(node.id, {node, x, y: levelY(depth), w, h: node.id === MAIN ? ROOT_H : CARD_H}); + }; + place(root, 0, 0); + return {placed, width: spans.get(root.id)!, height: deepest === 0 ? ROOT_H : levelY(deepest) + CARD_H}; +} + +/** Narrows the agent cards (down to MIN_CARD_W) before falling back to horizontal scrolling. */ +export function fitGraph(root: Node, areaW: number): GraphLayout { + let layout = layoutGraph(root); + for (let w = CARD_W - 2; layout.width > areaW && w >= MIN_CARD_W; w -= 2) layout = layoutGraph(root, w); + return layout; +} + +function edgeLabel(frame: Frame, parent: Node, child: Node): [Style, Part[]] { + const {model, now} = frame; + const parentKey = parent.run ? parent.run.id : null; + const waits = [...model.waitLog.values()].filter(edge => edge.toRunId === child.id); + const active = waits.some(edge => !edge.end); + const parts: Part[] = []; + if (child.edge === 'turn') parts.push(['later turn', MUTED]); + else if (child.edge === 'orphan') parts.push(['? launcher', MUTED]); + else parts.push([child.run?.mode === 'sync' ? 'sync' : child.run?.mode === 'background' ? 'bg' : '?', MUTED]); + for (const edge of waits) { + const [start, end] = waitSpan(model, edge, now); + const who = edge.fromRunId !== parentKey ? `${runLabel(model, edge.fromRunId)} ` : ''; + parts.push([' · ', MUTED], [`${who}${edge.end ? 'waited' : 'waiting'} ${formatDuration(end - start)}`, {fg: 'attention', dim: Boolean(edge.end)}]); + } + const tone: Style = waits.length ? {fg: 'attention', dim: !active} : {fg: child.edge === 'turn' ? 'muted' : 'border'}; + return [tone, parts]; +} + +function statusLine(frame: Frame, node: Node, visual: Visual): Part[] { + const duration = formatDuration(elapsed(frame.model, node, frame.now)); + if (visual === 'waiting') { + const edge = [...frame.model.waits.values()].find(wait => wait.fromRunId === node.run?.id); + const [start, end] = edge ? waitSpan(frame.model, edge, frame.now) : [0, 0]; + return [[`waiting · ${formatDuration(end - start)}`, {fg: 'attention'}]]; + } + if (visual === 'unknown') return [['status not recorded', MUTED]]; + return [[duration, visual === 'running' ? {fg: 'fg'} : MUTED], [' · ', MUTED], [VISUALS[visual].label, {fg: VISUALS[visual].tone}]]; +} + +function drawCard(view: Screen, frame: Frame, p: Placed, ox: number, oy: number): void { + const {node} = p; + const visual = visualOf(frame.model, node); + const selected = node.id === frame.state.selectedId; + const dim = node.ctx && !selected; + const x = p.x - ox; + const y = p.y - oy; + const inner = p.w - 6; + view.box(x, y, p.w, p.h, {fg: selected ? 'accent' : 'border', dim}); + if (selected) view.fill(x + 1, y + 1, p.w - 2, p.h - 2, 'sel'); + view.seq(x + 2, y + 1, [glyphPart(visual, frame.spin, dim)]); + const nameStyle: Style = {fg: selected ? 'accent' : 'fg', bold: true, dim}; + if (!node.run) { + const label = VISUALS[visual].label; + view.rput(x + p.w - 3, y + 1, label, {fg: VISUALS[visual].tone, dim}); + view.put(x + 4, y + 1, fit('main session', inner - width(label) - 1), nameStyle); + const model = frame.model.sessionModel ?? 'model not recorded'; + view.put(x + 4, y + 2, fit(`${model} · ${formatDuration(frame.now)}`, inner), {...MUTED, dim}); + return; + } + view.put(x + 4, y + 1, fit(node.run.name, inner), nameStyle); + view.put(x + 4, y + 2, fit(nodeType(node), inner), {...MUTED, dim}); + view.seqFit(x + 4, y + 3, statusLine(frame, node, visual), inner, dim ? {dim: true} : {}); +} + +export function drawGraph(scr: Screen, frame: Frame, area: Rect, legendY: number): void { + const layout = fitGraph(frame.tree.root, area.w); + const selected = layout.placed.get(frame.state.selectedId) ?? layout.placed.get(MAIN)!; + let ox: number; + if (layout.width <= area.w) ox = -Math.floor((area.w - layout.width) / 2); + else { + ox = clamp(frame.state.graphX, 0, layout.width - area.w); + if (selected.x < ox) ox = selected.x; + if (selected.x + selected.w > ox + area.w) ox = selected.x + selected.w - area.w; + frame.state.graphX = ox; + } + let oy = 0; + if (layout.height > area.h) { + oy = clamp(frame.state.graphY, 0, layout.height - area.h); + if (selected.y < oy) oy = selected.y; + if (selected.y + selected.h > oy + area.h) oy = selected.y + selected.h - area.h; + frame.state.graphY = oy; + } + const view = new Screen(area.w, area.h); + const border: Style = {fg: 'border'}; + // Edge labels may run right until the next arrow on the same row (children of every parent at one depth share it). + const arrows = new Map(); + for (const p of layout.placed.values()) { + if (p.node.id === MAIN) continue; + const row = arrows.get(p.y - 1) ?? []; + row.push(centerOf(p)); + arrows.set(p.y - 1, row); + } + for (const row of arrows.values()) row.sort((a, b) => a - b); + const right = Math.max(layout.width, ox + area.w); + for (const p of layout.placed.values()) { + const busY = p.y + p.h; + if (!p.node.children.length || busY + 1 < oy || busY >= oy + area.h) continue; + const pc = centerOf(p); + const centers = p.node.children.map(child => centerOf(layout.placed.get(child.id)!)); + const drops = new Set(centers); + const lo = Math.min(pc, ...centers); + const hi = Math.max(pc, ...centers); + for (let c = Math.max(lo, ox); c <= Math.min(hi, ox + area.w - 1); c++) { + view.put(c - ox, busY - oy, junction(c === pc, drops.has(c), c > lo, c < hi), border); + } + const row = arrows.get(busY + 1) ?? []; + p.node.children.forEach((child, i) => { + const cc = centers[i]!; + const next = row.find(x => x > cc); + const room = next === undefined ? right - cc - 2 : next - cc - 3; + const [tone, label] = edgeLabel(frame, p.node, child); + view.put(cc - ox, busY + 1 - oy, '▾', tone); + view.seqFit(cc + 2 - ox, busY + 1 - oy, label, Math.max(0, room)); + }); + } + const visible = (p: Placed): boolean => p.x + p.w > ox && p.x < ox + area.w && p.y + p.h > oy && p.y < oy + area.h; + for (const p of layout.placed.values()) { + if (!visible(p)) continue; + drawCard(view, frame, p, ox, oy); + if (p.node.children.length) view.put(centerOf(p) - ox, p.y + p.h - 1 - oy, '┬', {fg: p.node.id === frame.state.selectedId ? 'accent' : 'border'}); + } + scr.blit(view, area.x, area.y); + if (ox > 0) scr.put(area.x, area.y + Math.floor(area.h / 2), '◂', {fg: 'accent'}); + if (layout.width - ox > area.w) scr.put(area.x + area.w - 1, area.y + Math.floor(area.h / 2), '▸', {fg: 'accent'}); + const legend: Part[] = [ + ['─▾', border], [' spawned (bg / sync) ', MUTED], ['▾', {fg: 'attention'}], [' waiting now ', MUTED], + ['▾', {fg: 'attention', dim: true}], [' waited (recorded) ', MUTED], ['▾', MUTED], [' later turn', MUTED], + ]; + const note = 'other deps: not recorded'; + scr.seqFit(3, legendY, legend, frame.cols - width(note) - 8); + scr.rput(frame.cols - 2, legendY, note, MUTED); +} diff --git a/tools/agent-monitor/src/model.ts b/tools/agent-monitor/src/model.ts new file mode 100644 index 00000000..d3801cad --- /dev/null +++ b/tools/agent-monitor/src/model.ts @@ -0,0 +1,418 @@ +import {capture, isObject, optionalString, requiredString} from './events.js'; +import type {ContentRef, EventRecord, SessionEvent} from './events.js'; + +export type RunStatus = 'unknown' | 'running' | 'completed' | 'failed' | 'cancelled' | 'replied'; +export type Filter = 'all' | 'active' | 'finished'; + +export interface ToolActivity { + id: string; + name: string; + status: 'running' | 'succeeded' | 'failed'; + startedAt: string; + endedAt?: string; + detail?: string; +} + +export interface AgentRun { + id: string; + agentId: string; + name: string; + task: string; + agentType?: string; + mode?: 'sync' | 'background'; + parentRunId?: string | null; + parentAgentId?: string; + previousRunId?: string; + launchCallId?: string; + status: RunStatus; + startedAt?: string; + endedAt?: string; + lastActivity: string; + model?: string; + prompt?: ContentRef; + result?: ContentRef; + latest?: ContentRef; + error?: ContentRef; + tools: ToolActivity[]; + hasActivity: boolean; +} + +interface ToolCall { + ownerAgentId?: string; + ownerRunId: string | null; + name: string; + background: boolean; + targetAgentId?: string; + wait: boolean; + complete: boolean; + startedAt: string; + completedAt?: string; + prompt?: ContentRef; + activity?: ToolActivity; +} + +export interface WaitEdge { + callId: string; + fromRunId: string | null; + toAgentId: string; + toRunId?: string; + kind: 'sync-call' | 'wait'; + start: string; + end?: string; +} + +export interface MainTurn { + start: string; + end?: string; +} + +const MAIN_TOOL_LIMIT = 200; +/** CLI versions whose session logs this model was checked against. */ +const TESTED_VERSIONS = ['1.0.104', '1.0.105']; +const DETAIL_KEYS = ['path', 'pattern', 'command', 'query', 'url', 'name', 'agent_id', 'description']; + +export const isFinished = (run: AgentRun): boolean => + run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled' || run.status === 'replied'; + +const isOpen = (run: AgentRun): boolean => !isFinished(run) || run.status === 'replied'; + +/** Phased models also emit progress ("commentary") messages; only phase-less or final-answer replies count as results. */ +const isFinalAnswer = (d: Record): boolean => + (d.phase === undefined || d.phase === 'final_answer') && (!Array.isArray(d.toolRequests) || d.toolRequests.length === 0); + +export class AgentModel { + readonly runs = new Map(); + readonly warnings = new Set(); + readonly waits = new Map(); + readonly waitLog = new Map(); + readonly mainTurns: MainTurn[] = []; + readonly mainTools: ToolActivity[] = []; + mainToolCount = 0; + /** Counts `reset()` calls, so views can tell a rebuilt source from the one they last displayed. */ + epoch = 0; + version?: string; + sessionId?: string; + sessionModel?: string; + sessionStart?: string; + sessionEnded?: string; + lastEventAt?: string; + mainPrompt?: ContentRef; + mainLatest?: ContentRef; + private readonly latest = new Map(); + private readonly launches = new Map(); + private readonly calls = new Map(); + private readonly seen = new Set(); + private readonly repliedThisTurn = new Set(); + + reset(): void { + this.epoch++; + this.runs.clear(); + this.warnings.clear(); + this.waits.clear(); + this.waitLog.clear(); + this.mainTurns.length = 0; + this.mainTools.length = 0; + this.mainToolCount = 0; + this.latest.clear(); + this.launches.clear(); + this.calls.clear(); + this.seen.clear(); + this.repliedThisTurn.clear(); + this.version = undefined; + this.sessionId = undefined; + this.sessionModel = undefined; + this.sessionStart = undefined; + this.sessionEnded = undefined; + this.lastEventAt = undefined; + this.mainPrompt = undefined; + this.mainLatest = undefined; + } + + apply(record: EventRecord): void { + const e = record.event; + if (this.seen.has(e.id)) return; + this.seen.add(e.id); + if (!this.lastEventAt || Date.parse(e.timestamp) > Date.parse(this.lastEventAt)) this.lastEventAt = e.timestamp; + this.sessionStart ??= e.timestamp; + const d = e.data; + switch (e.type) { + case 'session.start': + this.version = optionalString(d, 'copilotVersion'); + this.sessionId = optionalString(d, 'sessionId'); + this.sessionModel = optionalString(d, 'selectedModel'); + this.sessionStart = e.timestamp; + if (!TESTED_VERSIONS.includes(this.version ?? '')) this.warnings.add(`Untested CLI event format: ${this.version ?? 'unknown'}`); + break; + case 'session.shutdown': { + this.sessionEnded = e.timestamp; + // Work after a later resume starts a new span instead of painting the offline gap as activity. + const last = this.mainTurns.at(-1); + if (last && !last.end) last.end = e.timestamp; + break; + } + case 'session.resume': + this.sessionEnded = undefined; + break; + case 'subagent.started': + this.start(e); + break; + case 'subagent.completed': + case 'subagent.failed': { + const launch = this.forLaunch(e, requiredString(d, 'toolCallId')); + // Lifecycle events name the original launch call even when a later turn of the same agent is the one + // still open. Recorded outcomes are kept; only the inferred 'replied' state can still be settled. + const run = [this.latest.get(launch.agentId), launch].find(candidate => candidate && isOpen(candidate)); + if (!run) break; + run.status = e.type === 'subagent.failed' ? 'failed' : d.cancelled === true ? 'cancelled' : 'completed'; + run.endedAt = e.timestamp; + run.lastActivity = e.timestamp; + run.model = optionalString(d, 'model') ?? run.model; + if (e.type === 'subagent.failed') run.error = capture(record, 'error'); + break; + } + case 'user.message': + if (e.agentId) { + const current = this.latest.get(e.agentId); + if (!current || isFinished(current) || (current.hasActivity && d.delivery === 'idle')) { + // An idle delivery means the previous execution's turn is over; its outcome was not recorded. + if (current && !isFinished(current)) current.status = 'unknown'; + const run = this.create(e, `message:${e.id}`, e.agentId); + run.name = current?.name ?? run.name; + run.agentType = current?.agentType; + run.parentRunId = current?.parentRunId; + run.parentAgentId = current?.parentAgentId; + run.previousRunId = current?.id; + run.model = current?.model; + run.task = firstLine(optionalString(d, 'content')) ?? 'Follow-up input not recorded'; + run.prompt = capture(record, 'content'); + run.startedAt = e.timestamp; + run.status = 'running'; + } + } else this.mainPrompt = capture(record, 'content') ?? this.mainPrompt; + break; + case 'assistant.turn_start': + if (e.agentId) { + const run = this.current(e); + run.hasActivity = true; + run.lastActivity = e.timestamp; + this.repliedThisTurn.delete(run.id); + if (run.status === 'replied') run.endedAt = undefined; + if (isOpen(run)) run.status = 'running'; + } else { + const last = this.mainTurns.at(-1); + if (!last || last.end) this.mainTurns.push({start: e.timestamp}); + } + break; + case 'assistant.message': { + const content = capture(record, 'content'); + if (e.agentId) { + const run = this.current(e); + run.hasActivity = true; + run.lastActivity = e.timestamp; + run.latest = content ?? run.latest; + if (content && isFinalAnswer(d)) { + run.result = content; + this.repliedThisTurn.add(run.id); + } + } else { + this.mainLatest = content ?? this.mainLatest; + this.sessionModel = optionalString(d, 'model') ?? this.sessionModel; + } + break; + } + case 'assistant.turn_end': + if (e.agentId) { + const run = this.current(e); + run.lastActivity = e.timestamp; + if (run.status === 'running' && this.repliedThisTurn.has(run.id) && !run.tools.some(tool => tool.status === 'running')) { + run.status = 'replied'; + run.endedAt = e.timestamp; + } + } else { + const last = this.mainTurns.at(-1); + if (last && !last.end) last.end = e.timestamp; + } + break; + case 'session.task_complete': + if (e.agentId) { + const run = this.current(e); + run.lastActivity = e.timestamp; + // Rejected ("continue") or blocked completion requests leave the agent's work open. + if (d.outcome === 'completed' || (d.outcome === undefined && d.success !== false)) { + run.result = capture(record, 'summary') ?? run.result; + run.status = 'completed'; + run.endedAt = e.timestamp; + } + } + break; + case 'tool.execution_start': + this.toolStart(record); + break; + case 'tool.execution_complete': + this.toolComplete(record); + break; + case 'subagent.configured': + case 'subagent.selected': + case 'subagent.deselected': + break; + default: + if (e.type.startsWith('subagent.')) this.warnings.add(`Unsupported agent event: ${e.type}`); + } + } + + status(run: AgentRun): string { + if (run.status === 'running' && [...this.waits.values()].some(edge => edge.fromRunId === run.id)) return 'waiting'; + return run.status; + } + + mainStatus(): 'running' | 'waiting' | 'idle' | 'ended' { + if (this.sessionEnded) return 'ended'; + if ([...this.waits.values()].some(edge => edge.fromRunId === null)) return 'waiting'; + const last = this.mainTurns.at(-1); + return !last || last.end ? 'idle' : 'running'; + } + + private create(e: SessionEvent, id: string, agentId: string): AgentRun { + const run: AgentRun = { + id, agentId, name: agentId, task: 'Task not recorded', status: 'unknown', + lastActivity: e.timestamp, tools: [], hasActivity: false, + }; + this.runs.set(id, run); + // Blocking reads recorded before any execution of this agent was known waited on its first execution. + if (!this.latest.has(agentId)) { + for (const edge of this.waitLog.values()) if (edge.toAgentId === agentId && !edge.toRunId) edge.toRunId = id; + } + this.latest.set(agentId, run); + return run; + } + + private current(e: SessionEvent): AgentRun { + const agentId = requiredString(eAsObject(e), 'agentId'); + return this.latest.get(agentId) ?? this.create(e, `unattributed:${agentId}`, agentId); + } + + private forLaunch(e: SessionEvent, callId: string): AgentRun { + let run = this.launches.get(callId); + if (run && e.agentId && run.agentId !== e.agentId) { + throw new Error(`Conflicting agent ID for launch call "${callId}"`); + } + if (!run) { + const agentId = requiredString(eAsObject(e), 'agentId'); + const orphan = this.latest.get(agentId); + run = orphan && !orphan.launchCallId && orphan.id.startsWith('unattributed:') + ? orphan : this.create(e, `launch:${callId}`, agentId); + this.launches.set(callId, run); + } + run.launchCallId = callId; + return run; + } + + private start(e: SessionEvent): void { + const callId = requiredString(e.data, 'toolCallId'); + requiredString(eAsObject(e), 'agentId'); + const run = this.forLaunch(e, callId); + run.name = requiredString(e.data, 'agentDisplayName'); + run.task = optionalString(e.data, 'agentDescription') ?? 'Task not recorded'; + run.agentType = optionalString(e.data, 'agentName') ?? run.agentType; + if (e.data.executionMode === 'sync' || e.data.executionMode === 'background') run.mode = e.data.executionMode; + run.model = optionalString(e.data, 'model') ?? run.model; + run.startedAt = e.timestamp; + if (run.status === 'unknown') run.status = 'running'; + run.parentAgentId = optionalString(e.data, 'parentId'); + const call = this.calls.get(callId); + if (call) this.link(run, call, e.data.executionMode === 'sync'); + else if (run.parentAgentId) run.parentRunId = this.latest.get(run.parentAgentId)?.id; + } + + private link(run: AgentRun, call: ToolCall, synchronous: boolean): void { + if (run.parentAgentId && run.parentAgentId !== call.ownerAgentId) { + run.parentRunId = undefined; + this.warnings.add(`Conflicting parent evidence for agent ${run.agentId}`); + return; + } + run.parentRunId = call.ownerRunId; + run.parentAgentId = call.ownerAgentId; + run.mode ??= synchronous ? 'sync' : 'background'; + run.prompt ??= call.prompt; + if (synchronous) { + const edge: WaitEdge = { + callId: run.launchCallId!, fromRunId: call.ownerRunId, toAgentId: run.agentId, toRunId: run.id, + kind: 'sync-call', start: call.startedAt, end: call.completedAt, + }; + this.waitLog.set(edge.callId, edge); + if (!call.complete) this.waits.set(edge.callId, edge); + } + } + + private toolStart(record: EventRecord): void { + const e = record.event; + const name = requiredString(e.data, 'toolName'); + const id = requiredString(e.data, 'toolCallId'); + const args = isObject(e.data.arguments) ? e.data.arguments : {}; + const owner = e.agentId ? this.current(e) : undefined; + const call: ToolCall = { + ownerAgentId: e.agentId, ownerRunId: owner?.id ?? null, + name, background: args.mode === 'background', + targetAgentId: name === 'read_agent' && typeof args.agent_id === 'string' ? args.agent_id : undefined, + wait: args.wait === true, complete: false, + startedAt: e.timestamp, prompt: name === 'task' ? capture(record, 'prompt') : undefined, + }; + call.activity = {id, name, status: 'running', startedAt: e.timestamp, detail: toolDetail(args)}; + if (owner) { + owner.hasActivity = true; + owner.lastActivity = e.timestamp; + owner.tools.push(call.activity); + } else { + this.mainToolCount++; + this.mainTools.push(call.activity); + if (this.mainTools.length > MAIN_TOOL_LIMIT) this.mainTools.shift(); + } + this.calls.set(id, call); + const run = this.launches.get(id); + if (run && name === 'task') this.link(run, call, !call.background); + if (name === 'read_agent' && call.wait && call.targetAgentId) { + const edge: WaitEdge = { + callId: id, fromRunId: call.ownerRunId, toAgentId: call.targetAgentId, + toRunId: this.latest.get(call.targetAgentId)?.id, kind: 'wait', start: e.timestamp, + }; + this.waits.set(id, edge); + this.waitLog.set(id, edge); + } + } + + private toolComplete(record: EventRecord): void { + const d = record.event.data; + const id = requiredString(d, 'toolCallId'); + const call = this.calls.get(id); + if (!call) return; + if (typeof d.success !== 'boolean') throw new Error('Tool completion lacks boolean success'); + call.complete = true; + call.completedAt = record.event.timestamp; + this.waits.delete(id); + const edge = this.waitLog.get(id); + if (edge) edge.end = record.event.timestamp; + if (call.activity) { + call.activity.status = d.success ? 'succeeded' : 'failed'; + call.activity.endedAt = record.event.timestamp; + } + // Only task calls are looked up again (by a late subagent.started); drop the rest. + if (call.name !== 'task') this.calls.delete(id); + } +} + +function eAsObject(e: SessionEvent): Record { + return {agentId: e.agentId}; +} + +function firstLine(text: string | undefined): string | undefined { + const line = text?.split(/\r?\n/).find(part => part.trim()); + return line?.trim(); +} + +function toolDetail(args: Record): string | undefined { + for (const key of DETAIL_KEYS) { + const value = args[key]; + if (typeof value === 'string' && value.trim()) return firstLine(value); + } + return undefined; +} diff --git a/tools/agent-monitor/src/screen.ts b/tools/agent-monitor/src/screen.ts new file mode 100644 index 00000000..a8dd1a9a --- /dev/null +++ b/tools/agent-monitor/src/screen.ts @@ -0,0 +1,214 @@ +import stringWidth from 'string-width'; +import {stripVTControlCharacters} from 'node:util'; +import type {Tone} from './theme.js'; + +export interface Style { + fg?: Tone; + bg?: Tone; + bold?: boolean; + dim?: boolean; +} + +export type Part = readonly [string, Style]; + +export interface Segment { + text: string; + fg?: Tone; + bg?: Tone; + bold: boolean; + dim: boolean; +} + +interface Cell { + ch: string; + fg?: Tone; + bg?: Tone; + bold: boolean; + dim: boolean; +} + +const graphemes = new Intl.Segmenter(undefined, {granularity: 'grapheme'}); +const UNSAFE = /[\u0000-\u001f\u007f-\u009f]/; +const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g; +const JUNCTIONS: Record = { + '1100': '│', '0011': '─', '0101': '┌', '0110': '┐', '1001': '└', '1010': '┘', '1101': '├', '1110': '┤', + '0111': '┬', '1011': '┴', '1111': '┼', '0100': '╷', '1000': '╵', '0001': '╶', '0010': '╴', +}; + +/** Removes terminal control sequences so recorded text cannot move the cursor or restyle the screen. */ +export function clean(text: string): string { + if (!UNSAFE.test(text)) return text; + return stripVTControlCharacters(text).replace(/\t/g, ' ').replace(CONTROL, ''); +} + +/** Display width of text as it will be drawn, i.e. after `clean`. */ +export const width = (text: string): number => stringWidth(clean(text)); + +export function fit(text: string, max: number): string { + if (max <= 0) return ''; + const safe = clean(text); + if (stringWidth(safe) <= max) return safe; + let out = ''; + let used = 0; + for (const {segment} of graphemes.segment(safe)) { + const w = stringWidth(segment); + if (used + w > max - 1) break; + out += segment; + used += w; + } + return out + '…'; +} + +function split(word: string, max: number): string[] { + const pieces: string[] = []; + let cur = ''; + for (const {segment} of graphemes.segment(word)) { + if (stringWidth(cur + segment) > max) { + pieces.push(cur); + cur = ''; + } + cur += segment; + } + if (cur) pieces.push(cur); + return pieces; +} + +/** Word-wraps recorded text to display columns, keeping paragraph breaks and splitting over-long tokens. */ +export function wrap(text: string, max: number): string[] { + const lines: string[] = []; + for (const paragraph of clean(text).split(/\r?\n/)) { + const words = paragraph.split(' ').filter(Boolean); + if (!words.length) { + if (lines.length && lines.at(-1) !== '') lines.push(''); + continue; + } + let cur = ''; + for (const word of words) { + const candidate = cur ? `${cur} ${word}` : word; + if (stringWidth(candidate) <= max) { + cur = candidate; + continue; + } + if (cur) lines.push(cur); + const pieces = stringWidth(word) > max ? split(word, max) : [word]; + lines.push(...pieces.slice(0, -1)); + cur = pieces.at(-1) ?? ''; + } + if (cur) lines.push(cur); + } + while (lines.at(-1) === '') lines.pop(); + return lines; +} + +export function junction(up: boolean, down: boolean, left: boolean, right: boolean): string { + return JUNCTIONS[`${+up}${+down}${+left}${+right}`] ?? ' '; +} + +/** Fixed-size grid of styled terminal cells; every write is cleaned of control sequences and clipped to the grid. */ +export class Screen { + private readonly cells: Cell[][]; + + constructor(readonly cols: number, readonly rows: number) { + this.cells = Array.from({length: rows}, () => Array.from({length: cols}, () => ({ch: ' ', bold: false, dim: false}))); + } + + charAt(x: number, y: number): string { + return this.cells[y]?.[x]?.ch ?? ''; + } + + put(x: number, y: number, text: string, style: Style = {}): number { + const row = this.cells[y]; + for (const {segment} of graphemes.segment(clean(text))) { + const w = stringWidth(segment); + if (w === 0) continue; + if (row && x >= 0 && x < this.cols) { + // Never leave half of an existing wide character behind. + if (row[x]!.ch === '' && x > 0) row[x - 1]!.ch = ' '; + if (x + w <= this.cols) { + const after = row[x + w]; + if (after && after.ch === '') after.ch = ' '; + row[x] = {ch: segment, fg: style.fg, bg: style.bg ?? row[x]!.bg, bold: Boolean(style.bold), dim: Boolean(style.dim)}; + for (let i = 1; i < w; i++) row[x + i] = {ch: '', fg: style.fg, bg: style.bg ?? row[x + i]!.bg, bold: false, dim: false}; + } else row[x] = {...row[x]!, ch: ' '}; + } + x += w; + } + return x; + } + + rput(right: number, y: number, text: string, style: Style = {}): number { + return this.put(right - width(text) + 1, y, text, style); + } + + seq(x: number, y: number, parts: readonly Part[]): number { + for (const [text, style] of parts) x = this.put(x, y, text, style); + return x; + } + + /** Writes parts left to right within `max` columns, ending with an ellipsis when truncated. */ + seqFit(x: number, y: number, parts: readonly Part[], max: number, extra: Style = {}): number { + let used = 0; + for (const [text, style] of parts) { + const room = max - used; + if (room <= 0) break; + const shown = fit(text, room); + this.put(x + used, y, shown, {...style, ...extra}); + used += stringWidth(shown); + } + return x + used; + } + + fill(x: number, y: number, w: number, h: number, bg: Tone): void { + for (let j = y; j < y + h; j++) { + for (let i = x; i < x + w; i++) { + const cell = this.cells[j]?.[i]; + if (cell) cell.bg = bg; + } + } + } + + hline(x: number, y: number, w: number, ch: string, style: Style = {}): void { + if (w > 0) this.put(x, y, ch.repeat(w), style); + } + + vline(x: number, y: number, h: number, ch: string, style: Style = {}): void { + for (let j = 0; j < h; j++) this.put(x, y + j, ch, style); + } + + box(x: number, y: number, w: number, h: number, style: Style = {}): void { + if (w < 2 || h < 2) return; + this.put(x, y, `╭${'─'.repeat(w - 2)}╮`, style); + for (let j = 1; j < h - 1; j++) { + this.put(x, y + j, '│', style); + this.put(x + w - 1, y + j, '│', style); + } + this.put(x, y + h - 1, `╰${'─'.repeat(w - 2)}╯`, style); + } + + /** Copies another screen onto this one at the given offset. */ + blit(src: Screen, x: number, y: number): void { + for (let j = 0; j < src.rows; j++) { + const target = this.cells[y + j]; + if (!target) continue; + for (let i = 0; i < src.cols; i++) { + if (x + i >= 0 && x + i < this.cols) target[x + i] = {...src.cells[j]![i]!}; + } + } + } + + segments(): Segment[][] { + return this.cells.map(row => { + const out: Segment[] = []; + for (const cell of row) { + const last = out.at(-1); + if (last && last.fg === cell.fg && last.bg === cell.bg && last.bold === cell.bold && last.dim === cell.dim) last.text += cell.ch; + else out.push({text: cell.ch, fg: cell.fg, bg: cell.bg, bold: cell.bold, dim: cell.dim}); + } + return out; + }); + } + + text(): string { + return this.cells.map(row => row.map(cell => cell.ch).join('').trimEnd()).join('\n'); + } +} diff --git a/tools/agent-monitor/src/summary.ts b/tools/agent-monitor/src/summary.ts new file mode 100644 index 00000000..1f049add --- /dev/null +++ b/tools/agent-monitor/src/summary.ts @@ -0,0 +1,49 @@ +import {join} from 'node:path'; +import {JsonlReader} from './events.js'; +import {AgentModel} from './model.js'; +import {clean} from './screen.js'; +import {buildTree, elapsed, formatDuration, nodeType, nowSeconds, VISUALS, visualOf, waitLabel, waitSpan} from './tree.js'; +import type {Node, Visual} from './tree.js'; + +const COUNT_ORDER: Visual[] = ['running', 'waiting', 'done', 'replied', 'failed', 'cancelled', 'unknown']; + +/** Plain-text snapshot of the agent tree, for one-off output such as the Copilot CLI extension's `/agents` command. */ +export function summarize(model: AgentModel, nowMs: number): string { + const tree = buildTree(model, 'all'); + const now = nowSeconds(model, nowMs); + const runs = tree.order.slice(1); + const counts = COUNT_ORDER + .map(visual => [visual, runs.filter(node => visualOf(model, node) === visual).length] as const) + .filter(([, count]) => count > 0) + .map(([visual, count]) => `${count} ${VISUALS[visual].label}`); + const lines = [runs.length + ? `Agents · ${runs.length} execution${runs.length === 1 ? '' : 's'}: ${counts.join(', ')}` + : 'Agents · no agent executions recorded yet']; + const describe = (node: Node): string => { + const visual = visualOf(model, node); + const {glyph, label} = VISUALS[visual]; + if (!node.run) return `${glyph} main session · ${label} · ${formatDuration(now)}`; + const parts = [`${glyph} ${clean(node.run.name)}`, nodeType(node)]; + const wait = visual === 'waiting' ? [...model.waits.values()].find(edge => edge.fromRunId === node.run!.id) : undefined; + if (wait) { + const [start, end] = waitSpan(model, wait, now); + parts.push(`waiting on ${clean(waitLabel(model, wait))} ${formatDuration(end - start)}`); + } else parts.push(formatDuration(elapsed(model, node, now)), label); + return parts.join(' · '); + }; + const visit = (node: Node, prefix: string, last: boolean): void => { + lines.push(node.run ? `${prefix}${last ? '└─ ' : '├─ '}${describe(node)}` : describe(node)); + const inner = node.run ? prefix + (last ? ' ' : '│ ') : ''; + node.children.forEach((child, i) => visit(child, inner, i === node.children.length - 1)); + }; + visit(tree.root, '', true); + for (const warning of model.warnings) lines.push(`! ${clean(warning)}`); + return lines.join('\n'); +} + +/** Reads a session directory's events.jsonl once and summarizes it. */ +export async function summarizeSession(directory: string, nowMs = Date.now()): Promise { + const model = new AgentModel(); + await new JsonlReader(join(directory, 'events.jsonl')).poll(record => model.apply(record), () => model.reset()); + return summarize(model, nowMs); +} diff --git a/tools/agent-monitor/src/theme.ts b/tools/agent-monitor/src/theme.ts new file mode 100644 index 00000000..5fe005d4 --- /dev/null +++ b/tools/agent-monitor/src/theme.ts @@ -0,0 +1,53 @@ +export const COLOR_MODES = ['default', 'light', 'dim', 'high-contrast', 'colorblind'] as const; +export type ColorMode = typeof COLOR_MODES[number]; + +export interface Palette { + fg: string; + muted: string; + border: string; + faint: string; + sel: string; + selbar: string; + tab: string; + accent: string; + accentEm: string; + chip: string; + onEm: string; + success: string; + attention: string; + danger: string; + done: string; +} + +export type Tone = keyof Palette; + +// Primer semantic tokens, matching the Copilot CLI color modes on dark and light terminals. +const DEFAULT: Palette = { + fg: '#f0f6fc', muted: '#9198a1', border: '#3d444d', faint: '#262c36', sel: '#151b23', selbar: '#656c76', + tab: '#212830', accent: '#4493f8', accentEm: '#1f6feb', chip: '#162945', onEm: '#ffffff', + success: '#3fb950', attention: '#d29922', danger: '#f85149', done: '#ab7df8', +}; + +export const PALETTES: Record = { + default: DEFAULT, + light: { + fg: '#1f2328', muted: '#59636e', border: '#d1d9e0', faint: '#eaeef2', sel: '#f6f8fa', selbar: '#818b98', + tab: '#eff2f5', accent: '#0969da', accentEm: '#0969da', chip: '#ddf4ff', onEm: '#ffffff', + success: '#1a7f37', attention: '#9a6700', danger: '#d1242f', done: '#8250df', + }, + dim: { + fg: '#d1d7e0', muted: '#9198a1', border: '#3d444d', faint: '#2f3742', sel: '#262c36', selbar: '#656c76', + tab: '#2a313c', accent: '#478be6', accentEm: '#316dca', chip: '#283c58', onEm: '#f0f6fc', + success: '#57ab5a', attention: '#c69026', danger: '#e5534b', done: '#986ee2', + }, + 'high-contrast': { + fg: '#ffffff', muted: '#b7bdc8', border: '#9ea7b3', faint: '#30363d', sel: '#1b2330', selbar: '#ffffff', + tab: '#262c36', accent: '#74b9ff', accentEm: '#409eff', chip: '#11294a', onEm: '#010409', + success: '#2bd853', attention: '#f0b72f', danger: '#ff6a69', done: '#b687f7', + }, + colorblind: {...DEFAULT, success: '#58a6ff', danger: '#f0883e'}, +}; + +export function isColorMode(value: string): value is ColorMode { + return (COLOR_MODES as readonly string[]).includes(value); +} diff --git a/tools/agent-monitor/src/timeline.ts b/tools/agent-monitor/src/timeline.ts new file mode 100644 index 00000000..53710319 --- /dev/null +++ b/tools/agent-monitor/src/timeline.ts @@ -0,0 +1,192 @@ +import type {Frame, Rect} from './chrome.js'; +import {isFinished} from './model.js'; +import {fit, Screen, width} from './screen.js'; +import type {Part, Style} from './screen.js'; +import {elapsed, formatDuration, glyphPart, isActiveVisual, mainSpans, MAIN, runSpan, VISUALS, visualOf, waitSpan} from './tree.js'; +import type {Node, Visual} from './tree.js'; +import type {Tone} from './theme.js'; + +const MUTED: Style = {fg: 'muted'}; +const DAY = 86400; +const STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 14400, 21600, 43200, DAY]; +const BAR: Record = { + running: 'accent', waiting: 'accent', done: 'success', failed: 'danger', cancelled: 'muted', replied: 'done', + idle: 'muted', ended: 'muted', unknown: 'muted', +}; +const END_MARKS: Visual[] = ['failed', 'cancelled', 'replied']; +const clamp = (value: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, value)); + +/** Tick spacing of at least `raw` seconds; beyond a day it grows in whole days so long windows keep few ticks. */ +export function niceStep(raw: number): number { + return STEPS.find(step => step >= raw) ?? Math.ceil(raw / DAY) * DAY; +} + +const pad2 = (n: number): string => String(n).padStart(2, '0'); + +/** Session-relative tick label whose precision follows the tick spacing. */ +export function tickLabel(seconds: number, step = 60): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (step < 60) { + if (seconds < 60) return `${s}s`; + return h ? `${h}:${pad2(m)}:${pad2(s)}` : `${m}:${pad2(s)}`; + } + if (step < 3600) return h ? `${h}h${pad2(m)}` : `${m}m`; + return step < DAY ? `${h}h` : `${Math.floor(seconds / DAY)}d`; +} + +const TYPE_SHORT: Record = {'general-purpose': 'general'}; + +/** Default window: the span where agents actually ran, padded slightly; falls back to the whole session. */ +function activityWindow(frame: Frame, total: number): [number, number] { + const runs = frame.tree.order.flatMap(node => node.run ? [runSpan(frame.model, node.run, frame.now)] : []); + if (!runs.length) return [0, total]; + let lo = Math.min(...runs.map(([a]) => a)); + let hi = Math.max(...runs.map(([, b]) => b)); + const pad = Math.max(2, (hi - lo) * 0.05); + lo = Math.max(0, lo - pad); + hi = Math.min(total, hi + pad); + return hi - lo < 1 ? [Math.max(0, hi - 1), hi] : [lo, hi]; +} + +/** Draws tree connectors left of a lane; deeper levels than `cap` share the last indentation column. */ +function treePrefix(scr: Screen, node: Node, y: number, span: number, base: number, cap: number): void { + const line: Style = {fg: 'border'}; + const isLast = (n: Node): boolean => n.parent?.children.at(-1) === n; + const chain: Node[] = []; + for (let p = node.parent; p?.parent; p = p.parent) chain.unshift(p); + chain.slice(0, cap - 1).forEach((ancestor, i) => { + if (!isLast(ancestor)) for (let r = 0; r < span; r++) scr.put(base + 3 * i, y + r, '│', line); + }); + const depth = Math.min(node.depth, cap); + if (depth > 0) { + const cx = base + 3 * (depth - 1); + const turn = node.edge === 'turn'; + scr.put(cx, y, isLast(node) ? (turn ? '╰' : '└') : '├', line); + scr.put(cx + 1, y, turn ? '┄' : '─', line); + if (!isLast(node)) for (let r = 1; r < span; r++) scr.put(cx, y + r, '│', line); + } + if (node.children.length && node.depth < cap) { + for (let r = 1; r < span; r++) scr.put(base + 3 * depth, y + r, node.children[0]!.edge === 'turn' ? '┆' : '│', line); + } +} + +export function drawTimeline(scr: Screen, frame: Frame, area: Rect, legendY: number): void { + const {model, state, now, cols} = frame; + const labelEnd = Math.min(43, Math.max(24, Math.floor(cols * 0.36))); + const cx = labelEnd + 3; + const durationRight = cols - 2; + const cw = Math.max(10, cols - cx - 10); + const total = Math.max(now, 1); + const [base0, base1] = activityWindow(frame, total); + const span = Math.min(total, Math.max(1, (base1 - base0) / state.zoom)); + const end = clamp(base1 - state.pan, Math.min(total, span), total); + const start = Math.max(0, end - span); + state.pan = base1 - end; + state.span = span; + const spc = Math.max(1e-6, (end - start) / (cw - 1)); + const col = (t: number): number => cx + clamp(Math.floor((t - start) / spc), 0, cw - 1); + const colEnd = (t: number): number => cx + clamp(Math.ceil((t - start) / spc) - 1, 0, cw - 1); + const axisY = area.y; + const laneY0 = area.y + 2; + scr.put(3, axisY, 'Agent', MUTED); + scr.rput(labelEnd, axisY, 'Type', MUTED); + scr.rput(durationRight, axisY, 'Time', MUTED); + scr.hline(cx, axisY + 1, cw, '─', {fg: 'border'}); + const nowC = now >= start && now <= end + 1e-9 ? cx + clamp(Math.round((now - start) / spc), 0, cw - 1) : -1; + const nowLabel: [number, number] | undefined = nowC >= 0 ? [nowC - 1, nowC + 1] : now > end ? [cx + cw - 5, cx + cw - 1] : undefined; + const grid: number[] = []; + const step = niceStep((end - start) / 6); + for (let t = Math.ceil(start / step) * step; t <= end; t += step) { + const c = col(t); + const label = tickLabel(t, step); + const lx = c === cx ? c : c - Math.floor(width(label) / 2); + // Keep a blank column between a tick label and the "now" marker. + if (nowLabel && lx <= nowLabel[1] + 1 && lx + width(label) - 1 >= nowLabel[0] - 1) continue; + grid.push(c); + scr.put(c, axisY + 1, '┬', {fg: 'border'}); + scr.put(lx, axisY, label, MUTED); + } + if (nowC >= 0) { + scr.put(nowC, axisY + 1, '┬', {fg: 'accent'}); + scr.put(nowC - 1, axisY, 'now', {fg: 'accent', bold: true}); + } else if (now > end) scr.rput(cx + cw - 1, axisY, 'now ▸', {fg: 'accent', bold: true}); + const lanes = frame.tree.order; + const capacity = Math.max(1, Math.floor((area.h - 2) / 2)); + const selectedIndex = Math.max(0, lanes.findIndex(node => node.id === state.selectedId)); + let top = clamp(state.laneTop, 0, Math.max(0, lanes.length - capacity)); + if (selectedIndex < top) top = selectedIndex; + if (selectedIndex >= top + capacity) top = selectedIndex - capacity + 1; + state.laneTop = top; + const shown = lanes.slice(top, top + capacity); + const laneY = new Map(shown.map((node, i) => [node.id, laneY0 + 2 * i])); + const bottom = laneY0 + 2 * shown.length - 1; + for (const c of grid) for (let y = laneY0; y <= bottom; y++) scr.put(c, y, '┊', {fg: 'faint'}); + if (nowC >= 0) for (let y = laneY0; y <= bottom; y++) scr.put(nowC, y, '│', {fg: 'accent', dim: true}); + const cap = Math.max(1, Math.floor((labelEnd - 19) / 3)); + const mainOpen = isActiveVisual(model.mainStatus()); + for (const node of shown) { + const y = laneY.get(node.id)!; + const visual = visualOf(model, node); + const selected = node.id === state.selectedId; + if (selected) { + scr.put(1, y, '▎', {fg: 'selbar'}); + scr.fill(2, y, cols - 3, 1, 'sel'); + } + treePrefix(scr, node, y, 2, 3, cap); + const gx = 3 + 3 * Math.min(node.depth, cap); + const type = node.run ? (node.edge === 'turn' ? 'turn' : TYPE_SHORT[node.run.agentType ?? ''] ?? node.run.agentType ?? 'agent') : 'session'; + scr.seq(gx, y, [glyphPart(visual, frame.spin, node.ctx)]); + scr.rput(labelEnd, y, type, {...MUTED, dim: node.ctx}); + scr.put(gx + 2, y, fit(node.run ? node.run.name : 'main', labelEnd - width(type) - gx - 3), {fg: selected ? 'accent' : 'fg', bold: true, dim: node.ctx}); + const spans = node.run ? [] : mainSpans(model, now); + const segments: Array<[number, number, Tone, boolean]> = node.run + ? [[...runSpan(model, node.run, now), BAR[visual], !isFinished(node.run)]] + : spans.map(([a, b], i) => [a, b, 'muted', mainOpen && i === spans.length - 1]); + for (const [a, b, tone, open] of segments) { + if (b < start || a > end) continue; + const c0 = col(Math.max(a, start)); + const c1 = Math.max(c0, colEnd(Math.min(b, end))); + for (let c = c0; c <= c1; c++) scr.put(c, y, c1 > c0 && c === c0 && a >= start ? '╺' : '━', {fg: tone, dim: node.ctx}); + if (c1 > c0 && b <= end && !open) scr.put(c1, y, '╸', {fg: tone, dim: node.ctx}); + } + const key = node.run ? node.run.id : null; + for (const edge of model.waitLog.values()) { + if (edge.fromRunId !== key) continue; + const [a, b] = waitSpan(model, edge, now); + if (b < start || a > end) continue; + const c0 = col(Math.max(a, start)); + for (let c = c0; c <= Math.max(c0, colEnd(Math.min(b, end))); c++) scr.put(c, y, '╍', {fg: 'attention', dim: node.ctx}); + } + if (node.run) { + const [, b] = runSpan(model, node.run, now); + if (visual === 'running' && nowC >= 0) scr.put(nowC, y, '●', {fg: 'accent', dim: node.ctx}); + if (END_MARKS.includes(visual) && b >= start && b <= end && colEnd(b) + 1 < cx + cw) { + scr.put(colEnd(b) + 1, y, VISUALS[visual].glyph, {fg: VISUALS[visual].tone, dim: node.ctx}); + } + } + scr.rput(durationRight, y, formatDuration(elapsed(model, node, now)), {fg: isActiveVisual(visual) ? 'fg' : 'muted', dim: node.ctx}); + } + for (const edge of model.waitLog.values()) { + const fromY = laneY.get(edge.fromRunId ?? MAIN); + const toY = edge.toRunId ? laneY.get(edge.toRunId) : undefined; + const [a] = waitSpan(model, edge, now); + if (fromY === undefined || toY === undefined || fromY === toY || a < start || a > end) continue; + const c = col(a); + const dir = toY > fromY ? 1 : -1; + const style: Style = {fg: 'attention', dim: Boolean(edge.end)}; + for (let y = fromY + dir; y !== toY - dir; y += dir) if ([' ', '┊'].includes(scr.charAt(c, y))) scr.put(c, y, '╎', style); + scr.put(c, toY - dir, dir > 0 ? '▾' : '▴', style); + } + const legend: Part[] = []; + const items: Array<[string, Tone, string]> = [ + ['━━', 'accent', 'running'], ['━━', 'success', 'done'], ['━━', 'danger', 'failed'], ['━■', 'muted', 'cancelled'], + ['━━', 'done', 'replied'], ['━━', 'muted', 'main turn'], ['╍╍', 'attention', 'waiting'], ['▾', 'attention', 'waits for'], + ]; + for (const [glyph, tone, label] of items) legend.push([glyph, {fg: tone}], [` ${label} `, MUTED]); + const scale = `${spc < 10 ? spc.toFixed(1) : Math.round(spc)}s / col`; + const range = lanes.length > capacity ? `lanes ${top + 1}-${top + shown.length} of ${lanes.length} ` : ''; + scr.seqFit(3, legendY, legend, cols - width(range + scale) - 6); + scr.rput(cols - 2, legendY, range + scale, MUTED); +} diff --git a/tools/agent-monitor/src/tree.ts b/tools/agent-monitor/src/tree.ts new file mode 100644 index 00000000..225e3e5c --- /dev/null +++ b/tools/agent-monitor/src/tree.ts @@ -0,0 +1,206 @@ +import {isFinished} from './model.js'; +import type {AgentModel, AgentRun, Filter, WaitEdge} from './model.js'; +import type {Part} from './screen.js'; +import type {Tone} from './theme.js'; + +export const MAIN = 'main'; + +export interface Node { + id: string; + run?: AgentRun; + parent?: Node; + children: Node[]; + edge: 'root' | 'spawn' | 'turn' | 'orphan'; + depth: number; + ctx: boolean; +} + +export interface Tree { + root: Node; + order: Node[]; + byId: Map; +} + +export type Visual = 'running' | 'waiting' | 'done' | 'failed' | 'cancelled' | 'replied' | 'idle' | 'ended' | 'unknown'; + +export const VISUALS: Record = { + running: {glyph: '◐', tone: 'accent', label: 'running'}, + waiting: {glyph: '◌', tone: 'attention', label: 'waiting'}, + done: {glyph: '✓', tone: 'success', label: 'done'}, + failed: {glyph: '✗', tone: 'danger', label: 'failed'}, + cancelled: {glyph: '■', tone: 'muted', label: 'cancelled'}, + replied: {glyph: '↩', tone: 'done', label: 'replied'}, + idle: {glyph: '○', tone: 'muted', label: 'idle'}, + ended: {glyph: '○', tone: 'muted', label: 'ended'}, + unknown: {glyph: '?', tone: 'muted', label: 'unknown'}, +}; + +const SPINNER = ['◐', '◓', '◑', '◒']; + +export function visualOf(model: AgentModel, node: Node): Visual { + if (!node.run) return model.mainStatus(); + const status = model.status(node.run); + if (status === 'completed') return 'done'; + return status === 'running' || status === 'waiting' || status === 'failed' || status === 'cancelled' || status === 'replied' + ? status : 'unknown'; +} + +export function glyphPart(visual: Visual, spin: number, dim = false): Part { + const glyph = visual === 'running' ? SPINNER[spin % SPINNER.length]! : VISUALS[visual].glyph; + return [glyph, {fg: VISUALS[visual].tone, dim}]; +} + +export const isActiveVisual = (visual: Visual): boolean => visual === 'running' || visual === 'waiting'; + +function matches(run: AgentRun, filter: Filter): boolean { + if (filter === 'all') return true; + return filter === 'finished' ? isFinished(run) : !isFinished(run); +} + +const startOf = (run: AgentRun): number => Date.parse(run.startedAt ?? run.lastActivity); + +/** + * Each execution's first turn: later turns hang off it as siblings so a long conversation does not nest deeper. + * Resolved once per build with shared results, because reused agents can have long predecessor chains. + */ +function firstTurns(model: AgentModel): Map { + const first = new Map(); + for (const run of model.runs.values()) { + const chain: AgentRun[] = []; + const onChain = new Set(); + let cursor = run; + let head = first.get(cursor.id); + while (!head) { + chain.push(cursor); + onChain.add(cursor.id); + const previous = cursor.previousRunId ? model.runs.get(cursor.previousRunId) : undefined; + // A missing predecessor or a cycle ends the chain at the last execution reached. + if (!previous || onChain.has(previous.id)) head = cursor; + else { + cursor = previous; + head = first.get(cursor.id); + } + } + for (const member of chain) first.set(member.id, head); + } + return first; +} + +/** Builds the display tree: spawned agents under their launcher, later turns under the agent's first turn. */ +export function buildTree(model: AgentModel, filter: Filter): Tree { + const root: Node = {id: MAIN, children: [], edge: 'root', depth: 0, ctx: filter !== 'all'}; + const parentOf = new Map(); + const first = firstTurns(model); + for (const run of model.runs.values()) { + const head = first.get(run.id)!; + if (head !== run) parentOf.set(run.id, {id: head.id, edge: 'turn'}); + else if (run.parentRunId === null) parentOf.set(run.id, {id: MAIN, edge: 'spawn'}); + else if (run.parentRunId && model.runs.has(run.parentRunId)) parentOf.set(run.id, {id: run.parentRunId, edge: 'spawn'}); + else parentOf.set(run.id, {id: MAIN, edge: 'orphan'}); + } + // Re-home any cyclic chain so rendering always terminates. + for (const run of model.runs.values()) { + const seen = new Set([run.id]); + let cursor = parentOf.get(run.id)!.id; + while (cursor !== MAIN) { + if (seen.has(cursor)) { + parentOf.set(run.id, {id: MAIN, edge: 'orphan'}); + break; + } + seen.add(cursor); + cursor = parentOf.get(cursor)?.id ?? MAIN; + } + } + const included = new Set(); + for (const run of model.runs.values()) { + if (!matches(run, filter)) continue; + for (let id: string = run.id; id !== MAIN && !included.has(id); id = parentOf.get(id)!.id) included.add(id); + } + const byId = new Map([[MAIN, root]]); + const runs = [...model.runs.values()].filter(run => included.has(run.id)).sort((a, b) => startOf(a) - startOf(b)); + for (const run of runs) byId.set(run.id, {id: run.id, run, children: [], edge: parentOf.get(run.id)!.edge, depth: 0, ctx: !matches(run, filter)}); + for (const run of runs) { + const node = byId.get(run.id)!; + const parent = byId.get(parentOf.get(run.id)!.id) ?? root; + node.parent = parent; + parent.children.push(node); + } + const order: Node[] = []; + const visit = (node: Node, depth: number): void => { + node.depth = depth; + order.push(node); + for (const child of node.children) visit(child, depth + 1); + }; + visit(root, 0); + return {root, order, byId}; +} + +/* ---------- Time ---------- */ + +export function sessionSeconds(model: AgentModel, iso: string | undefined): number { + if (!iso || !model.sessionStart) return 0; + return Math.max(0, (Date.parse(iso) - Date.parse(model.sessionStart)) / 1000); +} + +export function nowSeconds(model: AgentModel, nowMs: number): number { + if (model.sessionEnded) return sessionSeconds(model, model.sessionEnded); + const observed = sessionSeconds(model, model.lastEventAt); + const wall = model.sessionStart ? (nowMs - Date.parse(model.sessionStart)) / 1000 : 0; + return Math.max(observed, wall); +} + +export function runSpan(model: AgentModel, run: AgentRun, now: number): [number, number] { + const start = sessionSeconds(model, run.startedAt ?? run.lastActivity); + // Without a lifecycle status the run is only known to have lasted until its last recorded event. + const end = isFinished(run) ? sessionSeconds(model, run.endedAt ?? run.lastActivity) + : run.status === 'unknown' ? sessionSeconds(model, run.lastActivity) : now; + return [start, Math.max(start, end)]; +} + +export function elapsed(model: AgentModel, node: Node, now: number): number { + if (!node.run) return now; + const [start, end] = runSpan(model, node.run, now); + return end - start; +} + +/** Main-session activity: consecutive model turns separated by short tool gaps form one span. */ +export function mainSpans(model: AgentModel, now: number): Array<[number, number]> { + const spans: Array<[number, number]> = []; + for (const turn of model.mainTurns) { + const start = sessionSeconds(model, turn.start); + const end = turn.end ? sessionSeconds(model, turn.end) : now; + const last = spans.at(-1); + if (last && start - last[1] < 2) last[1] = Math.max(last[1], end); + else spans.push([start, end]); + } + return spans; +} + +export function waitSpan(model: AgentModel, edge: WaitEdge, now: number): [number, number] { + const start = sessionSeconds(model, edge.start); + return [start, edge.end ? sessionSeconds(model, edge.end) : now]; +} + +export function formatDuration(seconds: number): string { + const s = Math.max(0, seconds); + if (s < 60) return `${s.toFixed(1)}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${String(Math.floor(s % 60)).padStart(2, '0')}s`; + return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`; +} + +/* ---------- Labels ---------- */ + +export function nodeType(node: Node): string { + if (!node.run) return 'session'; + if (node.edge === 'turn') return `${node.run.agentType ?? 'agent'} · turn`; + return node.run.agentType ?? 'agent'; +} + +export function waitLabel(model: AgentModel, edge: WaitEdge): string { + const target = edge.toRunId ? model.runs.get(edge.toRunId)?.name : undefined; + return target ?? edge.toAgentId; +} + +export function runLabel(model: AgentModel, runId: string | null): string { + return runId === null ? 'main' : model.runs.get(runId)?.name ?? runId; +} diff --git a/tools/agent-monitor/test/App.test.tsx b/tools/agent-monitor/test/App.test.tsx new file mode 100644 index 00000000..07c47776 --- /dev/null +++ b/tools/agent-monitor/test/App.test.tsx @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict'; +import {setTimeout} from 'node:timers/promises'; +import {stripVTControlCharacters} from 'node:util'; +import {test} from 'node:test'; +import {render} from 'ink-testing-library'; +import {App} from '../src/App.js'; +import type {ContentRef, EventRecord} from '../src/events.js'; +import {AgentModel} from '../src/model.js'; +import {fixture, record, start} from './fixture.js'; + +const load = async (ref: ContentRef): Promise => { + if (ref.inline === undefined) throw new Error('Fixture text missing'); + return ref.inline; +}; + +function modelOf(events = fixture()): AgentModel { + const model = new AgentModel(); + for (const event of events) model.apply(event); + return model; +} + +function mount(model: AgentModel, width = 120, height = 36) { + const clock = (): number => Date.parse(model.lastEventAt!) + 5000; + return render(); +} + +async function frame(view: ReturnType, pattern: RegExp): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + const output = stripVTControlCharacters(view.lastFrame() ?? ''); + if (pattern.test(output)) return output; + await setTimeout(10); + } + assert.match(stripVTControlCharacters(view.lastFrame() ?? ''), pattern); + return ''; +} + +test('opens on the Graph tab, shows what the selected agent did, and keeps tool calls hidden until t', async t => { + const view = mount(modelOf()); + t.after(() => view.cleanup()); + let output = await frame(view, /Main session/); + assert.match(output, /Graph\s+Timeline/); + assert.match(output, /main session/); + for (const name of ['parent', 'child', 'control']) assert.match(output, new RegExp(name)); + assert.match(output, /sync · waited/); + view.stdin.write('j'); + output = await frame(view, /Parent result/); + assert.match(output, /Task for parent/); + assert.match(output, /Parent prompt/); + assert.match(output, /Relationships/); + assert.doesNotMatch(output, /Tools ·/); + view.stdin.write('t'); + output = await frame(view, /Tools · 1/); + assert.match(output, /task/); + assert.doesNotMatch(output, /Relationships/); +}); + +test('Tab switches to Timeline swimlanes: a wait sits on the waiting lane and points at the awaited lane', async t => { + const view = mount(modelOf()); + t.after(() => view.cleanup()); + await frame(view, /Main session/); + view.stdin.write('j'); + view.stdin.write('j'); + await frame(view, /Child result/); + view.stdin.write('\t'); + const output = await frame(view, /waits for/); + const lines = output.split('\n'); + const lane = (pattern: RegExp): number => lines.findIndex(line => pattern.test(line)); + const parent = lane(/ parent\s+general/); + assert.match(lines[parent]!, /╍/); + assert.match(lines[parent + 1]!, /▾/); + assert.equal(lane(/ child\s+general/), parent + 2); + assert.match(lines[lane(/ main\s+session/)]!, /━/); + assert.match(output, /Child result/); + const axis = (text: string): string => text.split('\n').find(line => /Agent\s+Type/.test(line))!; + const scale = (text: string): number => Number(/([\d.]+)s \/ col/.exec(text)![1]); + assert.doesNotMatch(axis(output), /\Snow/); + view.stdin.write('+'); + const zoomed = await frame(view, / 2x /); + assert.ok(scale(zoomed) < scale(output)); + assert.doesNotMatch(axis(zoomed), /\Snow/); +}); + +test('filtering keeps ancestors as context and narrow terminals show a resize message', async t => { + const view = mount(modelOf(fixture().slice(0, 15))); + t.after(() => view.cleanup()); + await frame(view, /status: all/); + view.stdin.write('f'); + const output = await frame(view, /status: active/); + assert.match(output, /parent/); + assert.doesNotMatch(output, /control/); + const small = mount(modelOf(), 70, 20); + t.after(() => small.cleanup()); + await frame(small, /Terminal too small/); +}); + +test('recorded control sequences are inert in every field and long results scroll inside the inspector', async t => { + const model = modelOf([ + record('session.start', {copilotVersion: '1.0.104', sessionId: 'scroll'}), + record('subagent.started', { + toolCallId: 'agent', agentName: 'general-purpose', agentDisplayName: '\u001b[31mred-agent\u001b[0m', + agentDescription: 'Task \u001b[1mbold\u001b[0m', executionMode: 'background', + }, 'agent'), + record('assistant.message', {content: '\u001b[2J' + Array.from({length: 40}, (_, i) => `RESULT-LINE-${i}`).join('\n')}, 'agent'), + record('subagent.completed', {toolCallId: 'agent'}, 'agent'), + ]); + const view = mount(model); + t.after(() => view.cleanup()); + await frame(view, /Main session/); + view.stdin.write('j'); + let output = await frame(view, /RESULT-LINE-0/); + assert.match(output, /red-agent/); + assert.match(output, /Task bold/); + assert.doesNotMatch(output, /\[(2J|31m|1m|0m)/); + assert.match(output, /lines 1-/); + view.stdin.write('\u001b[6~'); + output = await frame(view, /RESULT-LINE-6\b/); + assert.doesNotMatch(output, /RESULT-LINE-0\b/); +}); + +test('a main session blocked on a sync agent shows the agent by display name, never by agent ID', async t => { + const agentId = '0f8e2c1a-7b3d-4e5f-9a6b-1c2d3e4f5a6b'; + const model = modelOf([ + record('session.start', {copilotVersion: '1.0.104', sessionId: 'sync-wait'}), + record('assistant.turn_start', {turnId: '0'}), + record('tool.execution_start', {toolCallId: 'review-call', toolName: 'task', arguments: {mode: 'sync'}}), + record('subagent.started', { + toolCallId: 'review-call', agentName: 'code-review', agentDisplayName: 'Code Review', + agentDescription: 'Review the diff', executionMode: 'sync', + }, agentId), + ]); + const view = mount(model); + t.after(() => view.cleanup()); + const output = await frame(view, /waiting on Code Review/); + assert.match(output, /main session\s+waiting/); + assert.doesNotMatch(output, new RegExp(agentId)); +}); + +test('the graph narrows cards to fit a wide fan-out and scrolls to the selected card beyond that', async t => { + const fanOut = (count: number): AgentModel => modelOf([ + record('session.start', {copilotVersion: '1.0.104', sessionId: 'fan-out'}), + ...Array.from({length: count}, (_, i) => start(`call-${i + 1}`, `agent-${i + 1}`)), + ]); + const five = mount(fanOut(5)); + t.after(() => five.cleanup()); + assert.doesNotMatch(await frame(five, /agent-5/), /[◂▸]/); + const eight = mount(fanOut(8)); + t.after(() => eight.cleanup()); + await frame(eight, /▸/); + eight.stdin.write('G'); + assert.match(await frame(eight, /agent-8/), /◂/); +}); + +test('at 80 columns the header keeps its chips and t shows tool calls in the single-column inspector', async t => { + const view = mount(modelOf(), 80, 30); + t.after(() => view.cleanup()); + await frame(view, /Main session/); + view.stdin.write('\t'); + const output = await frame(view, /window:/); + assert.match(output, /session: syntheti /); + assert.match(output, /○ ended/); + view.stdin.write('j'); + view.stdin.write('t'); + await frame(view, /Tools · 1/); +}); + +test('keys that arrive in one input chunk are each applied', async t => { + const view = mount(modelOf()); + t.after(() => view.cleanup()); + await frame(view, /Main session/); + view.stdin.write('jj'); + await frame(view, /Child result/); +}); + +test('read failures for the prompt and the result are reported inside the message column', async t => { + const failing = async (): Promise => { throw new Error(`disk read failed ${'x'.repeat(120)}`); }; + const model = modelOf(); + const clock = (): number => Date.parse(model.lastEventAt!) + 5000; + const view = render(); + t.after(() => view.cleanup()); + await frame(view, /Main session/); + view.stdin.write('j'); + const output = await frame(view, /Task Cannot read recorded prompt: disk read failed/); + const error = output.split('\n').find(line => line.includes('Cannot read recorded text')); + assert.match(error ?? '', /Cannot read recorded text: disk read failed\s+│.*│/); +}); + +test('current warnings outrank an informational source notice', async t => { + const model = modelOf([record('session.start', {copilotVersion: '9.9.9', sessionId: 'notice'})]); + const clock = (): number => Date.parse(model.lastEventAt!) + 5000; + const notice = 'Session log was replaced/truncated; view rebuilt from the new source.'; + const view = render(); + t.after(() => view.cleanup()); + await frame(view, /Untested CLI event format: 9\.9\.9/); +}); + +test('text read from a replaced source is not shown for the rebuilt view', async t => { + const rebuilt = (): EventRecord[] => fixture().map(item => item.event.data.content === 'Parent result' + ? {event: {...item.event, data: {...item.event.data, content: 'Rebuilt result'}}} : item); + const loadContent = (ref: ContentRef): Promise => ref.inline === 'Rebuilt result' ? new Promise(() => {}) : load(ref); + const model = modelOf(); + const clock = (): number => Date.parse(model.lastEventAt!) + 5000; + const app = (revision: number) => ; + const view = render(app(0)); + t.after(() => view.cleanup()); + await frame(view, /Main session/); + view.stdin.write('j'); + await frame(view, /Parent result/); + model.reset(); + for (const event of rebuilt()) model.apply(event); + view.rerender(app(1)); + assert.doesNotMatch(await frame(view, /Loading recorded text/), /Parent result/); +}); + +test('a slow text load for an earlier selection does not replace the current one', async t => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const slowLoad = async (ref: ContentRef): Promise => { + if (ref.inline === 'Parent result') await gate; + return load(ref); + }; + const model = modelOf(); + const clock = (): number => Date.parse(model.lastEventAt!) + 5000; + const view = render(); + t.after(() => view.cleanup()); + await frame(view, /Main session/); + view.stdin.write('j'); + await frame(view, /Loading recorded text/); + view.stdin.write('j'); + await frame(view, /Child result/); + release(); + await setTimeout(50); + assert.match(stripVTControlCharacters(view.lastFrame() ?? ''), /Child result/); +}); diff --git a/tools/agent-monitor/test/events.test.ts b/tools/agent-monitor/test/events.test.ts new file mode 100644 index 00000000..6b35355a --- /dev/null +++ b/tools/agent-monitor/test/events.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import {appendFile, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; +import {capture, JsonlReader} from '../src/events.js'; +import type {EventRecord} from '../src/events.js'; +import {record} from './fixture.js'; + +test('incremental replay buffers partial UTF-8/JSONL and reads full results lazily', async t => { + const directory = await mkdtemp(join(tmpdir(), 'agent-monitor-reader-')); + t.after(() => rm(directory, {recursive: true})); + const file = join(directory, 'events.jsonl'); + const text = '\u5df2\u5b8c\u6210'.repeat(200); + const bytes = Buffer.from(JSON.stringify(record('assistant.message', {content: text}, 'agent').event) + '\n'); + const split = bytes.indexOf(Buffer.from('\u5df2')) + 1; + await writeFile(file, bytes.subarray(0, split)); + const reader = new JsonlReader(file); + const records: EventRecord[] = []; + const accept = (event: EventRecord): void => { records.push(event); }; + const unexpectedReset = (): never => { throw new Error('Unexpected reset'); }; + await reader.poll(accept, unexpectedReset); + assert.equal(records.length, 0); + await appendFile(file, bytes.subarray(split)); + await reader.poll(accept, unexpectedReset); + assert.equal(records.length, 1); + const ref = capture(records[0]!, 'content')!; + assert.equal(ref.inline, undefined); + assert.equal(await reader.readContent(ref), text); + assert.equal(await reader.poll(accept, unexpectedReset), false); + assert.equal(records.length, 1); + assert.deepEqual(await readFile(file), bytes); +}); + +test('recorded text is read back from later records, across polls and 64 KB read chunks', async t => { + const directory = await mkdtemp(join(tmpdir(), 'agent-monitor-offsets-')); + t.after(() => rm(directory, {recursive: true})); + const file = join(directory, 'events.jsonl'); + const line = (content: string): Buffer => Buffer.from(JSON.stringify(record('assistant.message', {content}, 'agent').event) + '\n'); + const big = 'x'.repeat(100 * 1024); + const head = Buffer.concat([line('first'), line(big)]); + const split = head.length - 10; + await writeFile(file, head.subarray(0, split)); + const reader = new JsonlReader(file); + const records: EventRecord[] = []; + const accept = (event: EventRecord): void => { records.push(event); }; + await reader.poll(accept, () => {}); + await appendFile(file, Buffer.concat([head.subarray(split), line('last')])); + await reader.poll(accept, () => {}); + const texts = await Promise.all(records.map(item => reader.readContent(capture(item, 'content')!))); + assert.deepEqual(texts, ['first', big, 'last']); +}); + +test('rewriting a log past its old size resets state and invalidates old result references', async t => { + const directory = await mkdtemp(join(tmpdir(), 'agent-monitor-reset-')); + t.after(() => rm(directory, {recursive: true})); + const file = join(directory, 'events.jsonl'); + await writeFile(file, JSON.stringify(record('assistant.message', {content: 'old'}, 'agent').event) + '\n'); + const reader = new JsonlReader(file); + let records: EventRecord[] = []; + let resets = 0; + const accept = (event: EventRecord): void => { records.push(event); }; + const reset = (): void => { records = []; resets++; }; + await reader.poll(accept, reset); + const old = capture(records[0]!, 'content')!; + await writeFile(file, JSON.stringify(record('assistant.message', {content: 'new'.repeat(500)}, 'agent').event) + '\n'); + await reader.poll(accept, reset); + assert.equal(resets, 1); + assert.equal(records.length, 1); + assert.equal(await reader.readContent(capture(records[0]!, 'content')!), 'new'.repeat(500)); + await assert.rejects(reader.readContent(old), /outdated.*generation/); +}); + +test('an unfinished record waits, but a malformed completed record is a visible error', async t => { + const directory = await mkdtemp(join(tmpdir(), 'agent-monitor-invalid-')); + t.after(() => rm(directory, {recursive: true})); + const file = join(directory, 'events.jsonl'); + await writeFile(file, '{"id":'); + const reader = new JsonlReader(file); + let count = 0; + await reader.poll(() => { count++; }, () => {}); + assert.equal(count, 0); + await appendFile(file, '\n'); + await assert.rejects(reader.poll(() => { count++; }, () => {}), /Session log line 1/); + assert.equal(count, 0); +}); diff --git a/tools/agent-monitor/test/fixture.ts b/tools/agent-monitor/test/fixture.ts new file mode 100644 index 00000000..f74a93fd --- /dev/null +++ b/tools/agent-monitor/test/fixture.ts @@ -0,0 +1,46 @@ +import type {EventRecord} from '../src/events.js'; + +let sequence = 0; + +export function record(type: string, data: Record, agentId?: string): EventRecord { + sequence++; + return {event: { + id: `event-${sequence}`, type, data, agentId, + timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, sequence)).toISOString(), + }}; +} + +export function start(callId: string, agentId: string, parentId?: string): EventRecord { + return record('subagent.started', { + toolCallId: callId, agentName: 'general-purpose', agentDisplayName: agentId, + agentDescription: `Task for ${agentId}`, parentId, + executionMode: parentId ? 'sync' : 'background', + }, agentId); +} + +export function fixture(): EventRecord[] { + return [ + record('session.start', {copilotVersion: '1.0.104', sessionId: 'synthetic-session', selectedModel: 'synthetic-model'}), + record('assistant.turn_start', {turnId: '0'}), + record('tool.execution_start', {toolCallId: 'parent-call', toolName: 'task', arguments: {mode: 'background', prompt: 'Parent prompt'}}), + start('parent-call', 'parent'), + record('tool.execution_complete', {toolCallId: 'parent-call', success: true, result: {content: 'Started in background'}}), + record('tool.execution_start', {toolCallId: 'control-call', toolName: 'task', arguments: {mode: 'background'}}), + start('control-call', 'control'), + record('assistant.message', {content: 'Control result', toolRequests: []}, 'control'), + record('subagent.completed', {toolCallId: 'control-call'}, 'control'), + record('tool.execution_start', {toolCallId: 'child-call', toolName: 'task', arguments: {mode: 'sync'}}, 'parent'), + start('child-call', 'child', 'parent'), + record('assistant.message', {content: 'Child result', toolRequests: []}, 'child'), + record('assistant.turn_end', {turnId: '0'}, 'child'), + record('subagent.completed', {toolCallId: 'child-call'}, 'child'), + record('tool.execution_complete', {toolCallId: 'child-call', success: true, result: {content: 'Child result'}}, 'parent'), + record('assistant.message', {content: 'Parent result', toolRequests: []}, 'parent'), + record('subagent.completed', {toolCallId: 'parent-call'}, 'parent'), + record('assistant.turn_end', {turnId: '0'}), + record('user.message', {content: 'Follow-up task', delivery: 'idle'}, 'control'), + record('assistant.turn_start', {turnId: '0'}, 'control'), + record('assistant.message', {content: 'Follow-up result', toolRequests: []}, 'control'), + record('assistant.turn_end', {turnId: '0'}, 'control'), + ]; +} diff --git a/tools/agent-monitor/test/model.test.ts b/tools/agent-monitor/test/model.test.ts new file mode 100644 index 00000000..0407ab63 --- /dev/null +++ b/tools/agent-monitor/test/model.test.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {AgentModel} from '../src/model.js'; +import {fixture, record, start} from './fixture.js'; + +test('parallel/nested agents retain their own results and same-agent follow-ups remain separate', () => { + const model = new AgentModel(); + const records = fixture(); + for (const event of records.slice(0, 4)) model.apply(event); + const parent = model.runs.get('launch:parent-call')!; + assert.equal(parent.status, 'running'); + assert.equal(model.runs.get('launch:parent-call')?.result, undefined); + for (const event of records.slice(4)) model.apply(event); + assert.equal(model.runs.size, 4); + assert.equal(parent.result?.inline, 'Parent result'); + assert.equal(parent.status, 'completed'); + assert.equal(model.runs.get('launch:child-call')?.parentRunId, parent.id); + assert.equal(model.runs.get('launch:child-call')?.result?.inline, 'Child result'); + const control = model.runs.get('launch:control-call')!; + assert.equal(control.result?.inline, 'Control result'); + assert.equal(control.status, 'completed'); + const followup = [...model.runs.values()].find(run => run.id.startsWith('message:'))!; + assert.equal(followup.agentId, control.agentId); + assert.equal(followup.status, 'replied'); + assert.equal(followup.result?.inline, 'Follow-up result'); +}); + +test('late start and launch records hydrate a completed invocation without regressing its outcome', () => { + const model = new AgentModel(); + model.apply(record('assistant.message', {content: 'Partial reply before lifecycle records'}, 'agent')); + model.apply(record('subagent.failed', {toolCallId: 'late', error: 'Recorded failure'}, 'agent')); + model.apply(start('late', 'agent')); + model.apply(record('tool.execution_start', {toolCallId: 'late', toolName: 'task', arguments: {mode: 'background'}})); + const run = [...model.runs.values()][0]!; + assert.equal(model.runs.size, 1); + assert.equal(run.status, 'failed'); + assert.equal(run.parentRunId, null); + assert.equal(run.error?.inline, 'Recorded failure'); + assert.equal(run.result?.inline, 'Partial reply before lifecycle records'); +}); + +test('event chain order is not dependency data; only explicit blocking waits create wait edges', () => { + const model = new AgentModel(); + const agent = start('a', 'a'); + Object.assign(agent.event, {parentId: 'previous-event-not-an-agent'}); + model.apply(agent); + const run = model.runs.get('launch:a')!; + assert.equal(run.parentRunId, undefined); + model.apply(record('tool.execution_start', {toolCallId: 'peek', toolName: 'read_agent', arguments: {agent_id: 'b', wait: false}}, 'a')); + assert.equal(model.waits.size, 0); + model.apply(record('tool.execution_start', {toolCallId: 'wait', toolName: 'read_agent', arguments: {agent_id: 'b', wait: true}}, 'a')); + assert.equal(model.status(run), 'waiting'); + assert.equal(model.waits.get('wait')?.toAgentId, 'b'); + model.apply(record('tool.execution_complete', {toolCallId: 'wait', success: true, result: {content: 'Still running'}}, 'a')); + assert.equal(model.waits.size, 0); + assert.equal(model.status(run), 'running'); + assert.equal(model.runs.size, 1); +}); + +test('waits keep recorded history; launch mode, agent type, prompts, follow-up links and main turns are tracked', () => { + const model = new AgentModel(); + for (const event of fixture()) model.apply(event); + const parent = model.runs.get('launch:parent-call')!; + const child = model.runs.get('launch:child-call')!; + const followup = [...model.runs.values()].find(run => run.id.startsWith('message:'))!; + assert.equal(parent.mode, 'background'); + assert.equal(child.mode, 'sync'); + assert.equal(child.agentType, 'general-purpose'); + assert.equal(parent.prompt?.inline, 'Parent prompt'); + assert.equal(followup.previousRunId, 'launch:control-call'); + assert.equal(followup.prompt?.inline, 'Follow-up task'); + const wait = model.waitLog.get('child-call')!; + assert.equal(wait.fromRunId, parent.id); + assert.equal(wait.toRunId, child.id); + assert.ok(wait.end && Date.parse(wait.end) > Date.parse(wait.start)); + assert.equal(model.waits.size, 0); + assert.equal(model.mainTurns.length, 1); + assert.ok(model.mainTurns[0]!.end); + assert.equal(model.mainStatus(), 'idle'); + assert.equal(model.sessionModel, 'synthetic-model'); +}); + +test('unsupported agent signals are diagnosed, not fabricated as an outcome', () => { + const model = new AgentModel(); + model.apply(start('a', 'a')); + model.apply(record('subagent.teleported', {toolCallId: 'a'}, 'a')); + assert.equal(model.runs.get('launch:a')?.status, 'running'); + assert.ok([...model.warnings].some(warning => warning.includes('subagent.teleported'))); + assert.throws(() => model.apply(start('a', 'different-agent')), /Conflicting agent ID/); + assert.throws(() => model.apply(record('subagent.started', {toolCallId: 'missing-agent'})), /agentId/); +}); + +test('lifecycle events settle the open turn of an agent, keep earlier outcomes, and report cancellation as its own status', () => { + const model = new AgentModel(); + model.apply(start('call', 'a')); + model.apply(record('assistant.message', {content: 'First answer'}, 'a')); + model.apply(record('assistant.turn_end', {turnId: '0'}, 'a')); + model.apply(record('subagent.completed', {toolCallId: 'call'}, 'a')); + const first = model.runs.get('launch:call')!; + const firstEnd = first.endedAt; + assert.equal(first.status, 'completed'); + model.apply(record('user.message', {content: 'Keep going', delivery: 'idle'}, 'a')); + model.apply(record('assistant.turn_start', {turnId: '0'}, 'a')); + model.apply(record('subagent.completed', {toolCallId: 'call', cancelled: true}, 'a')); + const second = [...model.runs.values()].find(run => run.previousRunId === first.id)!; + assert.equal(second.status, 'cancelled'); + assert.equal(first.status, 'completed'); + assert.equal(first.endedAt, firstEnd); +}); + +test('commentary is progress, not a result; a reply counts only when the current turn ends with a final answer', () => { + const model = new AgentModel(); + model.apply(start('call', 'a')); + model.apply(record('assistant.turn_start', {turnId: '0'}, 'a')); + model.apply(record('assistant.message', {content: 'Checking the logs', phase: 'commentary'}, 'a')); + model.apply(record('assistant.turn_end', {turnId: '0'}, 'a')); + const run = model.runs.get('launch:call')!; + assert.equal(run.status, 'running'); + assert.equal(run.result?.inline, undefined); + assert.equal(run.latest?.inline, 'Checking the logs'); + model.apply(record('assistant.turn_start', {turnId: '1'}, 'a')); + model.apply(record('assistant.message', {content: 'Done', phase: 'final_answer'}, 'a')); + model.apply(record('assistant.turn_end', {turnId: '1'}, 'a')); + assert.equal(run.status, 'replied'); + assert.equal(run.result?.inline, 'Done'); + model.apply(record('assistant.turn_start', {turnId: '2'}, 'a')); + assert.equal(run.status, 'running'); + assert.equal(run.endedAt, undefined); + model.apply(record('assistant.turn_end', {turnId: '2'}, 'a')); + assert.equal(run.status, 'running'); +}); + +test('tool arguments of unexpected types are ignored instead of stopping the viewer', () => { + const model = new AgentModel(); + model.apply(start('call', 'a')); + model.apply(record('tool.execution_start', {toolCallId: 'v', toolName: 'view', arguments: {description: 42, agent_id: {}}}, 'a')); + model.apply(record('tool.execution_start', {toolCallId: 'r', toolName: 'read_agent', arguments: {agent_id: 7, wait: true}}, 'a')); + assert.equal(model.runs.get('launch:call')!.tools.length, 2); + assert.equal(model.waits.size, 0); +}); + +test('a resumed session is live again after a shutdown, and the offline gap is not main-session activity', () => { + const model = new AgentModel(); + model.apply(record('session.start', {copilotVersion: '1.0.104', sessionId: 'resume'})); + model.apply(record('assistant.turn_start', {turnId: '0'})); + const shutdown = record('session.shutdown', {}); + model.apply(shutdown); + assert.equal(model.mainStatus(), 'ended'); + model.apply(record('session.resume', {})); + model.apply(record('assistant.turn_start', {turnId: '0'})); + assert.equal(model.mainStatus(), 'running'); + assert.deepEqual(model.mainTurns.map(turn => turn.end), [shutdown.event.timestamp, undefined]); +}); + +test('an execution left open by an idle follow-up becomes unknown instead of running forever', () => { + const model = new AgentModel(); + model.apply(start('call', 'a')); + model.apply(record('assistant.turn_start', {turnId: '0'}, 'a')); + model.apply(record('assistant.message', {content: 'Still checking', phase: 'commentary'}, 'a')); + model.apply(record('assistant.turn_end', {turnId: '0'}, 'a')); + model.apply(record('user.message', {content: 'Next step', delivery: 'idle'}, 'a')); + model.apply(record('subagent.completed', {toolCallId: 'call'}, 'a')); + const [first, followup] = [...model.runs.values()]; + assert.equal(first!.status, 'unknown'); + assert.equal(followup!.status, 'completed'); +}); + +test('a blocking read recorded before its target is known marks main waiting and attaches to that first execution', () => { + const model = new AgentModel(); + model.apply(record('tool.execution_start', {toolCallId: 'wait', toolName: 'read_agent', arguments: {agent_id: 'b', wait: true}})); + assert.equal(model.mainStatus(), 'waiting'); + model.apply(start('b-call', 'b')); + assert.equal(model.waitLog.get('wait')?.toRunId, 'launch:b-call'); +}); + +test('reset clears session state so a rewritten log replays from scratch', () => { + const model = new AgentModel(); + const events = fixture(); + for (const event of events) model.apply(event); + model.reset(); + for (const event of events) model.apply(event); + assert.equal(model.runs.size, 4); + assert.equal(model.mainTurns.length, 1); + assert.equal(model.waitLog.size, 1); +}); + +test('main-session completion does not complete children; only an accepted task summary completes its own execution', () => { + const model = new AgentModel(); + model.apply(start('a', 'a')); + model.apply(start('b', 'b')); + model.apply(record('session.task_complete', {summary: 'Main finished'})); + assert.equal(model.runs.get('launch:a')?.status, 'running'); + assert.equal(model.runs.get('launch:b')?.status, 'running'); + model.apply(record('session.task_complete', {summary: 'Not yet', success: false, outcome: 'continue'}, 'a')); + assert.equal(model.runs.get('launch:a')?.status, 'running'); + model.apply(record('session.task_complete', {summary: 'Agent A finished'}, 'a')); + assert.equal(model.runs.get('launch:a')?.status, 'completed'); + assert.equal(model.runs.get('launch:a')?.result?.inline, 'Agent A finished'); + assert.equal(model.runs.get('launch:b')?.status, 'running'); +}); diff --git a/tools/agent-monitor/test/summary.test.ts b/tools/agent-monitor/test/summary.test.ts new file mode 100644 index 00000000..a0406eba --- /dev/null +++ b/tools/agent-monitor/test/summary.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {AgentModel} from '../src/model.js'; +import {summarize} from '../src/summary.js'; +import {fixture} from './fixture.js'; + +test('the text summary nests executions under their launcher with status, duration and the wait in progress', () => { + const model = new AgentModel(); + const events = fixture(); + for (const event of events.slice(0, 11)) model.apply(event); + const now = Date.parse(model.lastEventAt!); + let lines = summarize(model, now).split('\n'); + assert.equal(lines[0], 'Agents · 3 executions: 1 running, 1 waiting, 1 done'); + assert.match(lines[1]!, /^◐ main session · running · /); + assert.match(lines[2]!, /^├─ ◌ parent · general-purpose · waiting on child \d/); + assert.match(lines[3]!, /^│ └─ ◐ child · general-purpose · [\d.]+s · running$/); + assert.match(lines[4]!, /^└─ ✓ control · general-purpose · [\d.]+s · done$/); + for (const event of events.slice(11)) model.apply(event); + lines = summarize(model, Date.parse(model.lastEventAt!)).split('\n'); + assert.match(lines.at(-1)!, /^ └─ ↩ control · general-purpose · turn · [\d.]+s · replied$/); +}); + +test('the text summary says when no agent has run yet', () => { + assert.equal(summarize(new AgentModel(), Date.now()).split('\n')[0], 'Agents · no agent executions recorded yet'); +}); diff --git a/tools/agent-monitor/test/tree.test.ts b/tools/agent-monitor/test/tree.test.ts new file mode 100644 index 00000000..9fe55464 --- /dev/null +++ b/tools/agent-monitor/test/tree.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {CARD_H, layoutGraph, levelY} from '../src/graph.js'; +import type {Placed} from '../src/graph.js'; +import {AgentModel} from '../src/model.js'; +import {Screen, width} from '../src/screen.js'; +import {niceStep, tickLabel} from '../src/timeline.js'; +import {buildTree, MAIN, mainSpans, nowSeconds} from '../src/tree.js'; +import {fixture, record, start} from './fixture.js'; + +test('display tree nests spawned agents under launchers, later turns under the first turn, and filters keep ancestors', () => { + const model = new AgentModel(); + const events = fixture(); + for (const event of events.slice(0, 15)) model.apply(event); + const partial = buildTree(model, 'finished'); + const child = partial.byId.get('launch:child-call')!; + assert.equal(child.parent?.id, 'launch:parent-call'); + assert.equal(child.parent?.ctx, true); + assert.equal(child.ctx, false); + assert.equal(buildTree(model, 'active').byId.has('launch:control-call'), false); + for (const event of events.slice(15)) model.apply(event); + model.apply(record('user.message', {content: 'Second follow-up', delivery: 'idle'}, 'control')); + model.apply(record('assistant.turn_start', {turnId: '0'}, 'control')); + const tree = buildTree(model, 'all'); + const followups = tree.order.filter(node => node.id.startsWith('message:')); + assert.equal(tree.byId.get('launch:parent-call')!.parent, tree.root); + assert.equal(followups.length, 2); + for (const followup of followups) { + assert.equal(followup.parent?.id, 'launch:control-call'); + assert.equal(followup.edge, 'turn'); + } + assert.deepEqual(tree.order.map(node => node.depth), [0, 1, 2, 1, 2, 2]); +}); + +test('cyclic launch evidence is re-homed instead of recursing forever', () => { + const model = new AgentModel(); + model.apply(record('tool.execution_start', {toolCallId: 'call-a', toolName: 'task', arguments: {mode: 'background'}}, 'b')); + model.apply(start('call-a', 'a')); + model.apply(record('tool.execution_start', {toolCallId: 'call-b', toolName: 'task', arguments: {mode: 'background'}}, 'a')); + model.apply(start('call-b', 'b')); + const tree = buildTree(model, 'all'); + assert.equal(tree.order.length, model.runs.size + 1); + assert.ok(tree.order.some(node => node.edge === 'orphan')); +}); + +test('a wide character cut off by the right edge never widens a screen row', () => { + const scr = new Screen(10, 1); + scr.put(0, 0, '一二三四五'); + scr.put(9, 0, '汉'); + assert.equal(scr.segments()[0]!.reduce((sum, segment) => sum + width(segment.text), 0), 10); +}); + +test('timeline ticks stay few for windows spanning several days', () => { + for (const days of [3, 30]) assert.ok(days * 86400 / niceStep(days * 86400 / 6) <= 6); + assert.equal(tickLabel(2 * 86400, 86400), '2d'); +}); + +test('graph layout centres parents over their children without overlapping cards', () => { + const model = new AgentModel(); + for (const event of fixture()) model.apply(event); + const tree = buildTree(model, 'all'); + const layout = layoutGraph(tree.root); + const cards = [...layout.placed.values()]; + for (const a of cards) for (const b of cards) if (a !== b && a.y === b.y) assert.ok(a.x + a.w <= b.x || b.x + b.w <= a.x); + const center = (p: Placed): number => p.x + Math.floor(p.w / 2); + const root = layout.placed.get(MAIN)!; + const kids = tree.root.children.map(node => center(layout.placed.get(node.id)!)); + assert.ok(center(root) >= Math.min(...kids) && center(root) <= Math.max(...kids)); + assert.equal(layout.placed.get('launch:child-call')!.y, levelY(2)); + assert.equal(layout.height, levelY(2) + CARD_H); + assert.ok(layout.height <= 18, 'a three-level graph fits the 120x36 content area'); +}); + +test('main-session spans merge model rounds split by short tool gaps but keep idle periods separate', () => { + const model = new AgentModel(); + model.apply(record('session.start', {copilotVersion: '1.0.104', sessionId: 'spans'})); + model.apply(record('assistant.turn_start', {turnId: '0'})); + model.apply(record('assistant.turn_end', {turnId: '0'})); + model.apply(record('assistant.turn_start', {turnId: '1'})); + model.apply(record('assistant.turn_end', {turnId: '1'})); + for (let i = 0; i < 3; i++) model.apply(record('session.usage_record', {})); + model.apply(record('assistant.turn_start', {turnId: '2'})); + const now = nowSeconds(model, Date.parse(model.lastEventAt!) + 2000); + const spans = mainSpans(model, now); + assert.equal(spans.length, 2); + assert.equal(spans[1]![1], now); + assert.equal(model.mainStatus(), 'running'); +}); diff --git a/tools/agent-monitor/tsconfig.build.json b/tools/agent-monitor/tsconfig.build.json new file mode 100644 index 00000000..3607fe11 --- /dev/null +++ b/tools/agent-monitor/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["test"] +} diff --git a/tools/agent-monitor/tsconfig.json b/tools/agent-monitor/tsconfig.json new file mode 100644 index 00000000..e1dc8723 --- /dev/null +++ b/tools/agent-monitor/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "test"] +}