From 259c1b14a4bc8eeb7f59827848a064439730ef7d Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Sun, 4 Oct 2026 20:34:58 -0700 Subject: [PATCH] chore: move the agent monitor to psf-monitor The monitor now lives in its own plugin, thisguymartin/psf-monitor. Remove the monitor skill and scripts/monitor, drop them from the scripts' test and typecheck runs, and point the docs at psf-monitor. pstack-runner keeps writing the lane journal, which psf-monitor reads. docs/reference.md now documents the journal as the contract between the two. --- CHANGES.md | 7 +- NOTICE.md | 1 - README.md | 11 +- UPSTREAM-FLEX.md | 1 - docs/USAGE.md | 59 +- docs/reference.md | 16 +- plugins/pstack/skills/monitor/SKILL.md | 27 - .../references/provider-dispatch.md | 2 +- .../poteto-mode/scripts/monitor/adapter.ts | 194 --- .../scripts/monitor/adapters/blocks.ts | 125 -- .../scripts/monitor/adapters/claude.test.ts | 242 --- .../scripts/monitor/adapters/claude.ts | 442 ----- .../scripts/monitor/adapters/codex.test.ts | 159 -- .../scripts/monitor/adapters/codex.ts | 329 ---- .../scripts/monitor/adapters/lane.test.ts | 195 --- .../scripts/monitor/adapters/lane.ts | 327 ---- .../scripts/monitor/assets.test.ts | 15 - .../poteto-mode/scripts/monitor/assets.ts | 27 - .../skills/poteto-mode/scripts/monitor/cli.ts | 161 -- .../poteto-mode/scripts/monitor/daemon.ts | 241 --- .../poteto-mode/scripts/monitor/doctor.ts | 63 - .../poteto-mode/scripts/monitor/domain.ts | 126 -- .../scripts/monitor/format.test.ts | 82 - .../poteto-mode/scripts/monitor/format.ts | 169 -- .../skills/poteto-mode/scripts/monitor/fs.ts | 63 - .../poteto-mode/scripts/monitor/graph.ts | 125 -- .../poteto-mode/scripts/monitor/index.test.ts | 169 -- .../poteto-mode/scripts/monitor/index.ts | 356 ---- .../scripts/monitor/journal.test.ts | 41 - .../poteto-mode/scripts/monitor/journal.ts | 49 - .../poteto-mode/scripts/monitor/json.ts | 84 - .../scripts/monitor/layout.test.ts | 115 -- .../poteto-mode/scripts/monitor/layout.ts | 156 -- .../poteto-mode/scripts/monitor/monitor.ts | 223 --- .../poteto-mode/scripts/monitor/probe.ts | 41 - .../scripts/monitor/pstack-monitor | 3 - .../poteto-mode/scripts/monitor/record.ts | 56 - .../scripts/monitor/server.test.ts | 152 -- .../poteto-mode/scripts/monitor/server.ts | 168 -- .../poteto-mode/scripts/monitor/sources.ts | 37 - .../poteto-mode/scripts/monitor/store.test.ts | 371 ---- .../poteto-mode/scripts/monitor/store.ts | 658 ------- .../poteto-mode/scripts/monitor/tail.test.ts | 82 - .../poteto-mode/scripts/monitor/tail.ts | 127 -- .../poteto-mode/scripts/monitor/tsconfig.json | 13 - .../poteto-mode/scripts/monitor/version.ts | 40 - .../poteto-mode/scripts/monitor/web/app.css | 1519 ----------------- .../poteto-mode/scripts/monitor/web/canvas.ts | 711 -------- .../poteto-mode/scripts/monitor/web/dom.ts | 142 -- .../scripts/monitor/web/index.html | 15 - .../poteto-mode/scripts/monitor/web/main.ts | 429 ----- .../poteto-mode/scripts/monitor/web/panel.ts | 566 ------ .../poteto-mode/scripts/monitor/web/rail.ts | 144 -- .../scripts/monitor/web/tsconfig.json | 14 - .../poteto-mode/scripts/monitor/wire.ts | 65 - .../skills/poteto-mode/scripts/package.json | 4 +- .../scripts/runner/flex-journal.ts | 2 +- 57 files changed, 15 insertions(+), 9746 deletions(-) delete mode 100644 plugins/pstack/skills/monitor/SKILL.md delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/blocks.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/assets.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/assets.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/cli.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/daemon.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/doctor.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/domain.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/format.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/fs.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/graph.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/index.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/index.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/journal.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/journal.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/json.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/layout.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/layout.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/monitor.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/probe.ts delete mode 100755 plugins/pstack/skills/poteto-mode/scripts/monitor/pstack-monitor delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/record.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/server.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/server.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/sources.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/store.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/store.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/tail.test.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/tail.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/tsconfig.json delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/version.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/web/app.css delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/web/canvas.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/web/dom.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/web/index.html delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/web/main.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/web/panel.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/web/rail.ts delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/web/tsconfig.json delete mode 100644 plugins/pstack/skills/poteto-mode/scripts/monitor/wire.ts diff --git a/CHANGES.md b/CHANGES.md index 360df52b..b5d39977 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,11 +1,8 @@ # CHANGES — applied substitutions -## Unreleased: agent monitor +## Unreleased: lane journal -- Add `/pstack:monitor` and `pstack-monitor`, a read-only local server that draws pstack work on this machine as a live node canvas: each session that runs pstack, every subagent it spawns, and external Codex, Grok, DeepSeek, and MiniMax lanes, with each agent's model and messages. `--all` or **Show all** shows every session. Tracked in [pstack-flex #23](https://github.com/thisguymartin/pstack-flex/issues/23). -- Status comes from process records, turn boundaries, parent results, and lane receipts, never file times. The page separates working, waiting for input, stalled, and finished, and shows each agent's task, the tool call it waits on, and its latest step. -- The agent panel resizes by dragging its edge or with a widen button, and remembers its width. -- `pstack-runner` writes an opt-in lane journal (start record with the head of the prompt, stdout as it arrives, receipt copy) under `~/.pstack-flex/lanes/` while that directory exists. `--label` names a lane. A journal failure never changes a lane's receipt, exit status, or output. +- `pstack-runner` writes an opt-in lane journal (start record with the head of the prompt, stdout as it arrives, receipt copy) under `~/.pstack-flex/lanes/` while that directory exists. `--label` names a lane. A journal failure never changes a lane's receipt, exit status, or output. [psf-monitor](https://github.com/thisguymartin/psf-monitor) reads the journal to show lanes while they run; the agent monitor that first shipped here moved there. Tracked in [pstack-flex #23](https://github.com/thisguymartin/pstack-flex/issues/23). ## Unreleased: merge open-pstack 1.5.0 (Cursor pstack 0.15.5) diff --git a/NOTICE.md b/NOTICE.md index 6f261609..a1c62890 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -58,7 +58,6 @@ Files authored for this port (not derived from upstream): - `plugins/pstack/skills/babysit/SKILL.md` (independently authored; workflow informed by Cursor's public `/babysit` behavior) - `plugins/pstack/agents/pstack-fable-*.md` and `plugins/pstack/agents/pstack-opus-*.md` (Claude-native frontier lanes at each selectable effort) - `plugins/pstack/hooks/hooks.json`, `plugins/pstack/hooks/session-start`, and `plugins/pstack/hooks/session-start-context.md` (the SessionStart hook and its opt-in gate) -- `plugins/pstack/skills/monitor/SKILL.md` and `plugins/pstack/skills/poteto-mode/scripts/monitor/` (the pstack-flex agent monitor) - `NOTICE.md` (this file) - `README.md` - `CHANGES.md` diff --git a/README.md b/README.md index 61a9ac08..bf413900 100644 --- a/README.md +++ b/README.md @@ -142,17 +142,11 @@ Use pstack:poteto-mode. Add saved filters to search. Keep the design simple, ver For that feature, poteto-mode should first understand how search works today. It should decide how the data should be represented before writing code, implement the smallest complete version, run the feature the way a user would, review the result, and prepare the pull request. -That is the main workflow. The other skills are there when poteto-mode needs them or when you want to call one directly. **[docs/USAGE.md](docs/USAGE.md)** is the longer walkthrough: three setup configurations (full frontier, hybrid saver, zero-subscription), copy-paste examples for the daily skills, how to read receipts, watching your agents, and troubleshooting. +That is the main workflow. The other skills are there when poteto-mode needs them or when you want to call one directly. **[docs/USAGE.md](docs/USAGE.md)** is the longer walkthrough: three setup configurations (full frontier, hybrid saver, zero-subscription), copy-paste examples for the daily skills, how to read receipts, and troubleshooting. ### 3. Watch your agents (optional) -The monitor does not start by itself. Start it when you want to watch a run: - -```text -/pstack:monitor -``` - -In Codex, ask for `pstack:monitor`. It prints a link to a local page that draws each session running pstack, the agents it spawned, the external lanes pstack launched, and what each one is doing now, live. It keeps running in the background until you say "pstack, kill the monitor" or restart the machine. See [Watching your agents](docs/USAGE.md#watching-your-agents-the-monitor) for the terminal commands and troubleshooting. +The agent monitor is a separate plugin, [psf-monitor](https://github.com/thisguymartin/psf-monitor). Install it next to pstack to watch each pstack session, the agents it spawned, and the external lanes pstack launched, live, and to cancel a running lane from the page. ## Useful skills @@ -167,7 +161,6 @@ In Codex, ask for `pstack:monitor`. It prints a link to a local page that draws | `maintain-verification-skill` | The project's verification instructions no longer match the product. | | `babysit` | A pull request needs CI failures and review comments handled until it is ready. | | `reflect` | A hard task is finished and its lessons should improve the next run. | -| `monitor` | You want to watch your agents work: a live graph of each pstack session, its subagents and model lanes, and what each is doing now. | Plugin skills include `pstack:` in their name. In Claude Code, invoke a native skill such as `/pstack:architect`. In Codex, ask for the skill, such as `Use pstack:architect for this design.` See the [technical reference](docs/reference.md) for the full list. diff --git a/UPSTREAM-FLEX.md b/UPSTREAM-FLEX.md index 891e5b18..86db01c6 100644 --- a/UPSTREAM-FLEX.md +++ b/UPSTREAM-FLEX.md @@ -37,7 +37,6 @@ All flex changes are additive and live in port-owned files so upstream merges st - The assignment-first restructure of `skills/setup-pstack/SKILL.md` - `docs/LANES.md`, this file, the README fork section, and the NOTICE/LICENSE/CHANGES additions - `plugins/pstack/hooks/session-start-context.md`, which the fork rewrote from open-pstack's auto-fire mandate into an opt-in gate, and the docs lines that describe it -- The agent monitor: `plugins/pstack/skills/monitor/`, `plugins/pstack/skills/poteto-mode/scripts/monitor/`, the `monitor` entries in the scripts `package.json`, and the "Agent monitor" section of `docs/reference.md`. Upstream has no equivalent, so none of these conflict on a sync. - The lane journal: `runner/flex-journal.ts` and its test (new), its call sites in `runner/{types,run,cli}.ts` (an optional stdout callback on the model run, the journal opened after output reservation and finished on both return paths, and the `--label` flag), the journal tests in `run.test.ts`, and the pstack-flex paragraph after the invocation block in `references/provider-dispatch.md`. On a sync, keep these call sites; they change no receipt or exit status. Every upstream skill body is byte-unchanged except for the default-descriptor mentions listed above. Since [#17](https://github.com/thisguymartin/pstack-flex/issues/17), the stock matrix carries three fork-owned GPT-6 rows and the first-run sheet uses them, so those two surfaces conflict on every upstream sync and are resolved by hand: keep the fork's rows and defaults, take upstream's wording for everything else. diff --git a/docs/USAGE.md b/docs/USAGE.md index 913acd12..189411e1 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -218,64 +218,11 @@ Every external lane writes a JSON receipt next to its output. The fields that ma | `usage` | real token counts — trust these | | `costUsd` | real for claude/grok subscription lanes; **always `null` on gateway lanes** (the CLI would price at Anthropic rates). Multiply `usage` by the [LANES.md](LANES.md) table instead | -## Watching your agents: the monitor +## Watching your agents -The monitor is a local web page that shows pstack work on this machine as a live graph. Each session that runs pstack appears with every agent it spawned: native Claude and Codex subagents, and external Codex, Grok, DeepSeek, and MiniMax lanes. Click an agent to see its task, what it is doing now, and its full activity. +The agent monitor moved to its own plugin, [psf-monitor](https://github.com/thisguymartin/psf-monitor). It draws each pstack session, the agents it spawned, and the external lanes pstack launched as a live graph, and can cancel a running lane. -A session counts as pstack work once it runs a `/pstack:` command, a pstack skill, or a pstack agent (in Codex, a prompt that names pstack or a call into pstack's skills or runner). **Show all** in the session list, or `start --all`, shows every session instead. - -Status comes from evidence: - -| Shown | Meaning | -| --- | --- | -| working | the process is busy, or the agent is mid-turn | -| waiting for input | the session's process is open and idle | -| stalled | a turn started and has been silent for 15 minutes with no live process behind it | -| quiet | still working, but nothing new for 90 seconds | -| done, failed, cancelled | the agent or lane recorded an outcome | -| ended · process gone | the process exited without an outcome | - -### Start and stop - -Nothing starts the monitor for you. Start it once; it runs in the background until you stop it or restart the machine. - -```text -/pstack:monitor # Claude Code -Use pstack:monitor. # Codex -``` - -Either prints a link such as `http://127.0.0.1:47317/?token=…`. The token changes on every start. From a terminal, run the launcher inside the installed plugin (it needs Bun): - -```shell -~/.claude/plugins/cache/open-pstack/pstack//skills/poteto-mode/scripts/monitor/pstack-monitor start --parent claude -~/.codex/plugins/cache/open-pstack/pstack//skills/poteto-mode/scripts/monitor/pstack-monitor start --parent codex -``` - -Options: `--all`, `--port ` (default 47317), `--hours ` (default 24), `--focus `. - -| Ask pstack | Or run | What happens | -| --- | --- | --- | -| "pstack, kill the monitor" | `pstack-monitor stop` | Stops the server. Agents keep running. | -| "is the monitor running?" | `pstack-monitor status` | Prints counts and the link. | -| | `pstack-monitor doctor` | Reports how well recent transcripts parsed. | -| | `pstack-monitor journal off` | Stops recording lanes and deletes the records. | - -### External lanes - -The first `start` turns on the lane journal in `~/.pstack-flex/lanes/`, kept for 7 days, so lanes show while they run. Codex and Grok lanes stream. Claude, DeepSeek, and MiniMax lanes show their reply when they exit. Lanes started while the journal was off do not appear. - -The monitor reads the transcripts in `~/.claude` and `~/.codex` (or `CLAUDE_CONFIG_DIR` and `CODEX_HOME`), writes only `~/.pstack-flex/monitor/` and the journal, listens on `127.0.0.1` only, and accepts no writes. - -### When something looks wrong - -| Symptom | Fix | -| --- | --- | -| `port 47317 is unavailable` | `pstack-monitor start --port 47400` | -| "Link expired" | the monitor restarted; run start again and open the new link | -| Start fails in a sandboxed session | run the start command in your own terminal (in Claude Code, after `!`) | -| A session is missing | it has not run pstack yet; click **Show all** | -| A banner says a source is degraded | run `pstack-monitor doctor` and open an issue with its output | -| An external lane never appears | `pstack-monitor journal status`, then `journal on` | +pstack's side is the lane journal. While `~/.pstack-flex/lanes/` exists, `pstack-runner` records each external lane's start, its output as it streams, and a copy of its receipt there. psf-monitor creates that directory when it starts. Delete the directory to stop journaling. A journal failure never changes a lane's receipt, exit status, or output. ## Cost playbook diff --git a/docs/reference.md b/docs/reference.md index 7cdf5a2f..2f23f3df 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -61,7 +61,7 @@ The marketplace install is the normal user path. Direct links are only for testi │ ├── .codex-plugin/plugin.json # Codex manifest (skills: ./skills/) │ ├── skills/ # 55 skills shared by Claude Code and Codex │ │ ├── poteto-mode/references/{codex-tools,provider-dispatch}.md # tool + provider routing -│ │ └── poteto-mode/scripts/ # bun/bash/node tooling: watch-pr, orch, runner, monitor, check-plan.mjs, worktree-audit.sh +│ │ └── poteto-mode/scripts/ # bun/bash/node tooling: watch-pr, orch, runner, check-plan.mjs, worktree-audit.sh │ ├── hooks/ # SessionStart opt-in gate: pstack runs only on request (Claude Code and Codex) │ └── agents/ # Claude subagents, including native Fable and Opus lanes at each selectable effort ├── tests/skill-collision-repro.sh # native-skill package invariants and Claude invocation checks @@ -151,20 +151,12 @@ The table uses the short upstream names. Claude Code exposes each native skill w | `/fix-merge-conflicts` | non-interactively resolve merge conflicts, validate, finalize | | `/get-pr-comments` | fetch and summarize review comments from the active PR | | `/what-did-i-get-done` | summarize authored commits over a user-chosen period | -| `/monitor` | open a live local view of running agents, their spawn tree, and each agent's activity (pstack-flex) | -## Agent monitor +## Lane journal -`/monitor` starts `skills/poteto-mode/scripts/monitor/pstack-monitor`, a read-only server on `127.0.0.1` (default port 47317), and prints a link with a per-start access token. The page draws one session's agents as a node canvas and streams any agent's timeline in a resizable side panel. +`pstack-runner` writes an opt-in journal under `~/.pstack-flex/lanes/` (or `PSTACK_FLEX_LANES_DIR`) while that directory exists. Each lane gets one directory holding `lane.json` (schema version 1: provider, model, effort, mode, label, the first 300 characters of the prompt, runner pid, parent harness and session), `stream.jsonl` (stdout as it arrives), and `receipt.json` (a copy of the receipt). Sending SIGTERM to the runner pid cancels the lane and writes a `cancelled` receipt. A journal failure never changes a lane's receipt, exit status, or output. -- **Scope.** The server indexes every Claude Code and Codex session and marks a spawn tree as pstack work when any member shows pstack evidence: `attributionPlugin`/`attributionSkill`/`attributionAgent` on Claude records, a `Skill` or `Agent` call naming `pstack:`, a `/pstack:` command, a `pstack:` subagent sidecar, a Codex root prompt naming pstack or a call into `pstack-runner` or pstack's skills, or any runner lane. The page shows marked trees by default; **Show all** or `?all=1` (`start --all`) shows everything. -- **Sources.** Claude Code session and subagent transcripts, `.meta.json` sidecars, and `~/.claude/sessions/.json` process records; Codex rollouts under `~/.codex/sessions/`. No hook, no harness setting. `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, and `PSTACK_FLEX_MONITOR_DIR` move them. -- **Status comes from evidence, never file times.** A terminal session follows its live process record (`busy` is working, `idle` is waiting for input). Sessions without one, and Claude subagents, follow turn boundaries: a human prompt opens a turn and an `end_turn` reply closes it. A subagent is done when its own turn ends or its parent records the result, whichever comes first. A Codex thread follows `task_started` and `task_complete`. The page adds two qualifiers: quiet after 90 s without stamped activity, and stalled once a turn-only run has been silent for 15 minutes. -- **Activity.** Each agent carries its latest prompt, the tool call still awaiting a result, and its latest step. Cards, the session list, and the panel's Task and Now lines show them. -- **External lanes.** `pstack-runner` writes an opt-in journal under `~/.pstack-flex/lanes/` (or `PSTACK_FLEX_LANES_DIR`) while that directory exists: `lane.json` (provider, model, effort, label, the first 300 characters of the prompt, runner pid, parent session), `stream.jsonl` (stdout as it arrives), and `receipt.json`. `pstack-monitor start` creates it, `journal off` deletes it, and the server prunes lanes older than 7 days. Codex and Grok lanes stream; Claude, DeepSeek, and MiniMax lanes print one result at exit. A journal failure never changes a lane's receipt, exit status, or output. -- **Messages between agents.** Arrowed arcs with a count, from Codex agent messages (addressed by agent path) and Claude Code `SendMessage` calls. Messages to another session's socket are left off. -- **Format drift is visible.** Unrecognized and malformed records are counted per source; the page shows a banner and `pstack-monitor doctor` prints counts and CLI versions, never content. -- **Lifecycle and security.** `start` reuses a running server of the same build and replaces another build. The server binds loopback, rejects foreign `Host` and `Origin` headers, accepts no writes, requires the token (exchanged for an `HttpOnly`, `SameSite=Strict` cookie) everywhere except a data-free health check, and renders transcript text only as text. Nothing times it out. +[psf-monitor](https://github.com/thisguymartin/psf-monitor) reads this journal to show lanes while they run. It keeps its own copy of the schema, so a change to `lane.json` needs a matching psf-monitor change. ## Subagents diff --git a/plugins/pstack/skills/monitor/SKILL.md b/plugins/pstack/skills/monitor/SKILL.md deleted file mode 100644 index ce1f2c5b..00000000 --- a/plugins/pstack/skills/monitor/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: monitor -description: "Open a live local view of pstack work on this machine: every session that runs pstack and every agent it spawns, including native Claude and Codex subagents and external Codex, Grok, DeepSeek, and MiniMax lanes, with what each is doing now. Also stops it. Use for /monitor, 'show me the agents', 'what is running', watching an arena, swarm, or interrogate fan-out, or 'stop / kill / shut down the monitor'." ---- - -# Monitor - -Run one command and relay its output. - -| The user asks to | Run | -| --- | --- | -| open, show, or start the monitor (the default) | `pstack-monitor start --parent ` | -| see every session, not only pstack's | `pstack-monitor start --parent --all` | -| stop, kill, close, or shut down the monitor | `pstack-monitor stop` | -| check whether it is running, or get the link again | `pstack-monitor status` | - -The launcher is `skills/poteto-mode/scripts/monitor/pstack-monitor` under the installed plugin. Pass the harness you are running in as `--parent`. - -`start` returns at once and prints one link. Give the user that link exactly as printed. The link carries an access token: never paste it anywhere else, and never open it with a fetch tool. - -The first `start` also turns on the lane journal and says so on stderr. Relay that line: lane output is kept on disk for 7 days, and `pstack-monitor journal off` stops it and deletes what it kept. - -`stop` ends the monitor only, never an agent. To stop an agent, the user interrupts it in its own session, or cancels a pstack lane through the background task that launched it. - -`pstack-monitor doctor` reports how well recent transcripts parsed. Run it when the page warns that a source is degraded, and relay the result. - -If `start` fails because the sandbox blocks a local port or `~/.pstack-flex`, do not raise permissions. Give the user the command to run; in Claude Code they can type it after `!`. diff --git a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md index afbffb64..af81c2ba 100644 --- a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md +++ b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md @@ -103,7 +103,7 @@ pstack-runner \ Pass arguments as an argv array or quote every path. Never interpolate prompt text into a shell command. The launcher preflights the assigned CLI and authentication, invokes the model exactly once, disables recursive agents and ambient skill dispatch where the CLI supports it, restricts the built-in tool surface, and records the exact provider/model/effort flags. External lanes do not receive the parent's MCP surface. Keep MCP-dependent Why and Reflect roles on `inherit-parent` or `auto`. The launcher never falls back. -pstack-flex: add `--label ` to name the lane in the agent monitor, using the role it fills, such as `--label "arena cross-judge"` or `--label "interrogate reviewer 2"`. The label is display text only; the launcher never routes on it. While the lane journal is on (`pstack-monitor start` turns it on), the launcher also records the lane's start, its output as it streams, and a copy of its receipt under `~/.pstack-flex/lanes/`, so the monitor can show the lane while it runs. A journal failure never changes the lane's receipt, exit status, or output. +pstack-flex: add `--label ` to name the lane in the agent monitor, using the role it fills, such as `--label "arena cross-judge"` or `--label "interrogate reviewer 2"`. The label is display text only; the launcher never routes on it. While the lane journal is on (starting [psf-monitor](https://github.com/thisguymartin/psf-monitor) turns it on), the launcher also records the lane's start, its output as it streams, and a copy of its receipt under `~/.pstack-flex/lanes/`, so psf-monitor can show the lane while it runs. A journal failure never changes the lane's receipt, exit status, or output. Gateway lanes (`deepseek`, `minimax`) run three checks before the model executes, all fail-closed. First, in-process: the lane's API key variable must be set, and the lane's isolated `CLAUDE_CONFIG_DIR` must be free of OAuth credentials — a `.credentials.json` carrying a claude.ai login, or one that cannot be parsed, refuses the lane with an `unauthenticated` receipt before any subprocess runs, so a claude.ai credential can never be sent to a third-party endpoint. Second, the spawned preflight is `claude --version`, which proves the binary executes; `claude auth status` is deliberately not used because its behavior under token auth is undocumented. Third, the one-shot invocation is the real authentication and model test; an endpoint authentication error classifies as `unauthenticated` like any other lane. diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts deleted file mode 100644 index 67392bf9..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { - Activity, - AgentId, - Flavor, - Harness, - NormalizedUsage, - SourceKind, - SpawnVia, - TimelineItem, -} from "./domain.ts"; - -// pstack-flex addition. Adapters are the only code that knows a transcript -// format. They turn raw records into facts; the store alone decides state. - -export interface AgentPatch { - readonly harness?: Harness; - readonly source?: SourceKind; - readonly flavor?: Flavor; - /** The session whose process decides this agent's liveness. Defaults to the agent itself. */ - readonly root?: AgentId; - /** Codex's address for the agent within its tree, such as `/root` or `/root/judge`. */ - readonly agentPath?: string; - /** The name a Claude Code teammate is addressed by. */ - readonly agentName?: string; - readonly cwd?: string; - readonly seenAt?: string; - readonly cliVersion?: string; - readonly entrypoint?: string; - readonly provider?: string; - readonly requestedModel?: string; - readonly reportedModel?: string; - readonly effort?: string; - /** Wins over any hint. */ - readonly title?: string; - /** Used only when nothing better is known; the first hint sticks. */ - readonly titleHint?: string; -} - -export type Outcome = "done" | "failed" | "cancelled"; -export type ChildOutcome = "launched" | Outcome; - -/** One end of an agent-to-agent message, as the transcript names it. The store resolves it. */ -export type Endpoint = - | { readonly kind: "agent"; readonly id: AgentId } - /** A Codex agent path, resolved within the tree that `near` belongs to. */ - | { readonly kind: "codex-path"; readonly near: AgentId; readonly path: string } - /** A Claude Code `SendMessage` target (an agent id, a name, or `main`), resolved within `session`. */ - | { readonly kind: "claude-target"; readonly session: AgentId; readonly target: string }; - -export interface ProcessRecord { - readonly pid: number; - /** When the recorded process started, so a reused pid is not mistaken for it. */ - readonly startedAtMs: number | null; - /** The harness's own state word, such as `busy` or `idle`. */ - readonly state: string | null; -} - -export type Fact = - | { readonly kind: "agent"; readonly id: AgentId; readonly patch: AgentPatch } - | { readonly kind: "link"; readonly id: AgentId; readonly parent: AgentId; readonly via: SpawnVia } - // The parent is whichever agent made `callId`; `fallback` until that call is seen. - | { - readonly kind: "link-by-call"; - readonly id: AgentId; - readonly callId: string; - readonly fallback: AgentId; - } - | { readonly kind: "spawn-call"; readonly by: AgentId; readonly callId: string } - // `key` is unique per message, so a message seen twice counts once. - | { - readonly kind: "message"; - readonly key: string; - readonly from: Endpoint; - readonly to: Endpoint; - readonly at: string | null; - } - | { readonly kind: "activity"; readonly id: AgentId; readonly activity: Activity } - | { readonly kind: "pstack"; readonly id: AgentId } - | { readonly kind: "prompt"; readonly id: AgentId; readonly text: string; readonly at: string | null } - | { - readonly kind: "call"; - readonly id: AgentId; - readonly callId: string; - readonly at: string | null; - readonly event: { readonly kind: "started"; readonly name: string; readonly snippet: string } | { readonly kind: "ended" }; - } - // `key: null` is a cumulative total that replaces earlier usage. - | { - readonly kind: "usage"; - readonly id: AgentId; - readonly key: string | null; - readonly usage: NormalizedUsage; - } - | { - readonly kind: "child-outcome"; - readonly callId: string; - readonly outcome: ChildOutcome; - readonly at: string | null; - readonly reason: string | null; - } - | { - readonly kind: "outcome"; - readonly id: AgentId; - readonly outcome: Outcome; - readonly at: string | null; - readonly reason: string | null; - } - | { - readonly kind: "turn"; - readonly id: AgentId; - readonly turnId: string; - readonly at: string | null; - readonly event: - | { readonly kind: "started" } - | { readonly kind: "ended"; readonly outcome: Outcome; readonly reason: string | null }; - } - // `key` names the record's file so a later removal can retract it. - | { - readonly kind: "process"; - readonly key: string; - readonly id: AgentId; - readonly process: ProcessRecord; - } - | { readonly kind: "process-gone"; readonly key: string }; - -export type Problem = - | { readonly kind: "not-json" } - | { readonly kind: "unknown-type"; readonly recordType: string } - | { readonly kind: "shape"; readonly recordType: string; readonly detail: string }; - -export interface Parsed { - readonly facts: readonly Fact[]; - readonly items: readonly TimelineItem[]; - readonly problem: Problem | null; - readonly cliVersion: string | null; -} - -export const NOTHING: Parsed = { facts: [], items: [], problem: null, cliVersion: null }; - -export type Claim = - | { - readonly kind: "stream"; - readonly agent: AgentId; - /** The session root whose liveness keeps this file indexed past the window. */ - readonly root: AgentId; - /** Prime the parser with line 0 before parsing from the middle of the file. */ - readonly header: boolean; - } - | { - readonly kind: "document"; - /** The agent this document describes; indexed whenever that agent is. */ - readonly agent: AgentId | null; - /** Index regardless of age, such as a live process record. */ - readonly always: boolean; - }; - -export interface LineParser { - line(text: string, offset: number): Parsed; -} - -export interface Root { - readonly dir: string; - readonly depth: number; - /** Return false to skip a directory, such as a Codex date folder outside the window. */ - readonly enter?: (dir: string, sinceMs: number) => boolean; - /** The directory's existence proves this harness keeps per-process records. */ - readonly processRecords?: Harness; -} - -export interface Adapter { - readonly source: SourceKind; - readonly roots: readonly Root[]; - /** False when every file is indexed regardless of age. */ - readonly windowed: boolean; - /** Newest CLI version the adapter was checked against; newer versions are flagged. */ - readonly checkedVersion: string | null; - claim(path: string): Claim | null; - open(path: string): LineParser; - document(path: string, text: string): Parsed; - /** Facts that retract a document that no longer exists. */ - removed(path: string): readonly Fact[]; -} - -export function parsed( - facts: readonly Fact[], - items: readonly TimelineItem[] = [], - cliVersion: string | null = null -): Parsed { - return { facts, items, problem: null, cliVersion }; -} - -export function problem(value: Problem, items: readonly TimelineItem[] = []): Parsed { - return { facts: [], items, problem: value, cliVersion: null }; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/blocks.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/blocks.ts deleted file mode 100644 index 9deac3eb..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/blocks.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { Fact } from "../adapter.ts"; -import type { AgentId, ItemId, TimelineItem } from "../domain.ts"; -import { array, clip, contentText, object, oneLine, pretty, text } from "../json.ts"; - -// Claude-style message content blocks. Claude Code transcripts, `claude -p` -// output, and Grok's streaming events all use this shape. - -export const BODY_LIMIT = 16_000; -export const SNIPPET_LIMIT = 140; -export const PROMPT_LIMIT = 300; -export const PSTACK_PREFIX = "pstack:"; - -const SPAWN_TOOLS = new Set(["Agent", "Task"]); - -export interface Collected { - readonly items: TimelineItem[]; - readonly facts: Fact[]; -} - -export function collected(): Collected { - return { items: [], facts: [] }; -} - -export function itemId(offset: number, index: number): ItemId { - return `${offset}.${index}` as ItemId; -} - -export function describeTool(name: string, input: unknown): string { - const record = object(input); - const detail = record === null - ? text(input) - : text(record.command) - ?? text(record.description) - ?? text(record.file_path) - ?? text(record.path) - ?? text(record.pattern) - ?? text(record.url) - ?? text(record.query) - ?? text(record.skill) - ?? text(record.prompt); - return detail === null ? name : `${name} · ${oneLine(detail, SNIPPET_LIMIT)}`; -} - -export function promptFact(agent: AgentId, body: string, at: string | null): Fact { - return { kind: "prompt", id: agent, text: oneLine(body, PROMPT_LIMIT), at }; -} - -export function callEnded(agent: AgentId, callId: string, at: string | null): Fact { - return { kind: "call", id: agent, callId, at, event: { kind: "ended" } }; -} - -/** A pstack skill or a pstack agent named by a Claude-style tool call. */ -export function namesPstack(name: string, input: unknown): boolean { - const record = object(input); - if (record === null) return false; - const target = name === "Skill" ? text(record.skill) : SPAWN_TOOLS.has(name) ? text(record.subagent_type) : null; - return target?.startsWith(PSTACK_PREFIX) === true; -} - -export function textActivity(agent: AgentId, body: string, at: string | null): Fact { - return { kind: "activity", id: agent, activity: { what: "text", snippet: oneLine(body, SNIPPET_LIMIT), at } }; -} - -export function assistantBlocks( - agent: AgentId, - offset: number, - at: string | null, - content: unknown, - out: Collected -): void { - array(content).forEach((raw, index) => { - const block = object(raw); - if (block === null) return; - const id = itemId(offset, index); - switch (block.type) { - case "text": { - const body = text(block.text); - if (body === null) return; - out.items.push({ id, at, kind: "text", body: clip(body, BODY_LIMIT) }); - out.facts.push(textActivity(agent, body, at)); - return; - } - case "thinking": - case "redacted_thinking": { - const body = text(block.thinking); - out.items.push({ id, at, kind: "thinking", body: body === null ? null : clip(body, BODY_LIMIT) }); - out.facts.push({ kind: "activity", id: agent, activity: { what: "thinking", snippet: "thinking", at } }); - return; - } - case "tool_use": - case "server_tool_use": - case "mcp_tool_use": { - const name = text(block.name) ?? "tool"; - const callId = text(block.id) ?? id; - const snippet = describeTool(name, block.input); - out.items.push({ id, at, kind: "tool-call", callId, name, input: clip(pretty(block.input), BODY_LIMIT) }); - out.facts.push( - { kind: "activity", id: agent, activity: { what: "tool", snippet, at } }, - { kind: "call", id: agent, callId, at, event: { kind: "started", name, snippet } }, - ); - if (SPAWN_TOOLS.has(name)) out.facts.push({ kind: "spawn-call", by: agent, callId }); - if (namesPstack(name, block.input)) out.facts.push({ kind: "pstack", id: agent }); - return; - } - } - }); -} - -export function toolResult( - offset: number, - index: number, - at: string | null, - block: Record -): TimelineItem | null { - const callId = text(block.tool_use_id); - if (callId === null) return null; - return { - id: itemId(offset, index), - at, - kind: "tool-result", - callId, - ok: block.is_error !== true, - output: clip(contentText(block.content), BODY_LIMIT), - }; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.test.ts deleted file mode 100644 index 11d3113f..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { join } from "node:path"; -import type { Fact } from "../adapter.ts"; -import { claudeAdapter, procStartMs } from "./claude.ts"; - -const home = "/home/u/.claude"; -const adapter = claudeAdapter(home); -const sessionPath = join(home, "projects", "-repo", "s1.jsonl"); -const subagentPath = join(home, "projects", "-repo", "s1", "subagents", "agent-a1.jsonl"); -const metaPath = join(home, "projects", "-repo", "s1", "subagents", "agent-a1.meta.json"); - -const base = { sessionId: "s1", cwd: "/repo", version: "2.1.287", entrypoint: "cli", gitBranch: "main", uuid: "u", parentUuid: null }; -const line = (record: Record): string => JSON.stringify({ ...base, timestamp: "2026-10-01T10:00:00.000Z", ...record }); - -function kinds(facts: readonly Fact[]): string[] { - return facts.map((fact) => fact.kind); -} - -describe("claim", () => { - it("recognizes sessions, subagents, sidecars, and process records", () => { - expect(adapter.claim(sessionPath)).toEqual({ kind: "stream", agent: "claude:s1", root: "claude:s1", header: false } as never); - expect(adapter.claim(subagentPath)).toEqual({ kind: "stream", agent: "claude:s1:a1", root: "claude:s1", header: false } as never); - expect(adapter.claim(metaPath)).toEqual({ kind: "document", agent: "claude:s1:a1", always: false } as never); - expect(adapter.claim(join(home, "sessions", "123.json"))).toEqual({ kind: "document", agent: null, always: true }); - expect(adapter.claim(join(home, "sessions", "123.key"))).toBeNull(); - expect(adapter.claim(join(home, "history.jsonl"))).toBeNull(); - }); -}); - -describe("session transcript", () => { - const parser = () => adapter.open(sessionPath); - - it("reads assistant text, usage keyed by message, and spawn calls", () => { - const parsed = parser().line(line({ - type: "assistant", - effort: "high", - message: { - id: "msg-1", - model: "claude-test-model", - usage: { input_tokens: 3, output_tokens: 7 }, - content: [ - { type: "text", text: "Looking now." }, - { type: "tool_use", id: "call-1", name: "Agent", input: { description: "Map the repo", subagent_type: "Explore" } }, - ], - }, - }), 120); - expect(parsed.problem).toBeNull(); - expect(parsed.cliVersion).toBe("2.1.287"); - expect(kinds(parsed.facts)).toContain("spawn-call"); - expect(parsed.facts).toContainEqual({ kind: "usage", id: "claude:s1", key: "msg-1", usage: { inputTokens: 3, outputTokens: 7 } } as never); - expect(parsed.facts).toContainEqual({ kind: "agent", id: "claude:s1", patch: { reportedModel: "claude-test-model", effort: "high" } } as never); - expect(parsed.items.map((item) => `${item.id} ${item.kind}`)).toEqual(["120.0 text", "120.1 tool-call"]); - const activity = parsed.facts.filter((fact) => fact.kind === "activity").at(-1); - expect(activity).toMatchObject({ activity: { what: "tool", snippet: "Agent · Map the repo" } }); - }); - - it("records sync and async Agent results as child outcomes", () => { - const result = (status: string) => parser().line(line({ - type: "user", - message: { role: "user", content: [{ type: "tool_result", tool_use_id: "call-1", content: [{ type: "text", text: "ok" }] }] }, - toolUseResult: { agentId: "a1", status }, - }), 0); - expect(result("completed").facts).toContainEqual({ kind: "child-outcome", callId: "call-1", outcome: "done", at: "2026-10-01T10:00:00.000Z", reason: null } as never); - expect(result("async_launched").facts).toContainEqual(expect.objectContaining({ kind: "child-outcome", outcome: "launched" })); - expect(result("completed").items[0]).toMatchObject({ kind: "tool-result", callId: "call-1", ok: true }); - }); - - it("reads background task notifications", () => { - const parsed = parser().line(line({ - type: "user", - origin: { kind: "task-notification" }, - message: { - role: "user", - content: "\na1\ncall-1\nkilled\nAgent stopped\n", - }, - }), 0); - expect(parsed.facts).toContainEqual(expect.objectContaining({ kind: "child-outcome", callId: "call-1", outcome: "cancelled" })); - expect(parsed.items[0]).toMatchObject({ kind: "notice", text: "Agent stopped" }); - }); - - it("turns human prompts into prompt items and title hints, but not injected context", () => { - const prompt = parser().line(line({ type: "user", message: { role: "user", content: "Build the monitor" } }), 0); - expect(prompt.items[0]).toMatchObject({ kind: "prompt", body: { text: "Build the monitor", omitted: 0 } }); - expect(prompt.facts).toContainEqual({ kind: "agent", id: "claude:s1", patch: { titleHint: "Build the monitor" } } as never); - const meta = parser().line(line({ type: "user", isMeta: true, message: { role: "user", content: "caveat" } }), 0); - expect(meta.items).toEqual([]); - const command = parser().line(line({ type: "user", message: { role: "user", content: "/model" } }), 0); - expect(command.items[0]).toMatchObject({ kind: "notice", text: "ran /model" }); - }); - - it("opens a turn on a human prompt and closes it only at the end of the reply", () => { - const turns = (record: Record) => parser().line(line(record), 0).facts.filter((fact) => fact.kind === "turn"); - const reply = (stop_reason: string | null) => turns({ type: "assistant", message: { id: "m1", stop_reason, content: [{ type: "text", text: "ok" }] } }); - expect(turns({ type: "user", message: { role: "user", content: "Build the monitor" } })).toEqual([ - { kind: "turn", id: "claude:s1", turnId: "turn", at: "2026-10-01T10:00:00.000Z", event: { kind: "started" } }, - ] as never); - expect(reply("tool_use")).toEqual([]); - expect(reply(null)).toEqual([]); - expect(reply("end_turn")).toMatchObject([{ event: { kind: "ended", outcome: "done" } }]); - expect(reply("stop_sequence")).toMatchObject([{ event: { kind: "ended", outcome: "done" } }]); - expect(turns({ type: "user", message: { role: "user", content: [{ type: "text", text: "[Request interrupted by user]" }] } })) - .toMatchObject([{ event: { kind: "ended", outcome: "cancelled" } }]); - expect(turns({ type: "user", isMeta: true, message: { role: "user", content: "caveat" } })).toEqual([]); - expect(turns({ type: "user", message: { role: "user", content: "/model" } })).toEqual([]); - }); - - it("records the prompt an agent is working on", () => { - const prompt = parser().line(line({ type: "user", message: { role: "user", content: "Build the\nmonitor" } }), 0); - expect(prompt.facts).toContainEqual({ kind: "prompt", id: "claude:s1", text: "Build the monitor", at: "2026-10-01T10:00:00.000Z" } as never); - const reminder = parser().line(line({ type: "user", message: { role: "user", content: "x" } }), 0); - expect(kinds(reminder.facts)).not.toContain("prompt"); - }); - - it("reports the tool call awaiting its result", () => { - const call = parser().line(line({ - type: "assistant", - message: { id: "m1", content: [{ type: "tool_use", id: "call-1", name: "Bash", input: { command: "bun test" } }] }, - }), 0); - expect(call.facts).toContainEqual({ - kind: "call", - id: "claude:s1", - callId: "call-1", - at: "2026-10-01T10:00:00.000Z", - event: { kind: "started", name: "Bash", snippet: "Bash · bun test" }, - } as never); - const result = (extra: Record) => parser().line(line({ - type: "user", - ...extra, - message: { role: "user", content: [{ type: "tool_result", tool_use_id: "call-1", content: "ok" }] }, - }), 0); - const ended = { kind: "call", id: "claude:s1", callId: "call-1", at: "2026-10-01T10:00:00.000Z", event: { kind: "ended" } }; - expect(result({}).facts).toContainEqual(ended as never); - expect(result({ isMeta: true }).facts).toContainEqual(ended as never); - }); - - it("flags pstack work from attribution, skills, agents, and commands", () => { - const flagged = (record: Record) => kinds(parser().line(line(record), 0).facts).includes("pstack"); - const assistant = (extra: Record, content: unknown[] = [{ type: "text", text: "ok" }]) => - ({ type: "assistant", ...extra, message: { id: "m1", content } }); - const tool = (name: string, input: Record) => assistant({}, [{ type: "tool_use", id: "c1", name, input }]); - expect(flagged(assistant({ attributionPlugin: "pstack" }))).toBe(true); - expect(flagged(assistant({ attributionSkill: "pstack:poteto-mode" }))).toBe(true); - expect(flagged(assistant({ attributionAgent: "pstack:pstack-opus-high" }))).toBe(true); - expect(flagged(assistant({ attributionPlugin: "caveman" }))).toBe(false); - expect(flagged(tool("Skill", { skill: "pstack:arena" }))).toBe(true); - expect(flagged(tool("Skill", { skill: "impeccable" }))).toBe(false); - expect(flagged(tool("Agent", { subagent_type: "pstack:pstack-fable-high", prompt: "x" }))).toBe(true); - expect(flagged(tool("Agent", { subagent_type: "Explore", prompt: "x" }))).toBe(false); - expect(flagged({ type: "user", message: { role: "user", content: "/pstack:monitor" } })).toBe(true); - expect(flagged({ type: "user", message: { role: "user", content: "/model" } })).toBe(false); - }); - - it("turns SendMessage calls into messages, leaving other sessions' sockets out", () => { - const send = (to: string) => parser().line(line({ - type: "assistant", - message: { id: "m9", content: [{ type: "tool_use", id: `call-${to.length}`, name: "SendMessage", input: { to, message: "status?" } }] }, - }), 0); - expect(send("a1").facts).toContainEqual({ - kind: "message", - key: "claude:call-2", - from: { kind: "agent", id: "claude:s1" }, - to: { kind: "claude-target", session: "claude:s1", target: "a1" }, - at: "2026-10-01T10:00:00.000Z", - } as never); - expect(send("uds:/tmp/cc-socks/1.sock").facts.some((fact) => fact.kind === "message")).toBe(false); - }); - - it("takes the AI title as the session title", () => { - const parsed = parser().line(JSON.stringify({ type: "ai-title", aiTitle: "Agent monitor", sessionId: "s1" }), 0); - expect(parsed.facts).toEqual([{ kind: "agent", id: "claude:s1", patch: { title: "Agent monitor" } }] as never); - }); - - it("degrades visibly on unknown or malformed records", () => { - const unknown = parser().line(line({ type: "brand-new-record" }), 40); - expect(unknown.problem).toEqual({ kind: "unknown-type", recordType: "brand-new-record" }); - expect(unknown.items[0]).toMatchObject({ kind: "unparsed", recordType: "brand-new-record" }); - expect(parser().line(line({ type: "assistant" }), 0).problem).toMatchObject({ kind: "shape", recordType: "assistant" }); - expect(parser().line("{not json", 0).problem).toEqual({ kind: "not-json" }); - expect(parser().line(line({ type: "attachment", attachment: {} }), 0).problem).toBeNull(); - }); -}); - -describe("subagent transcript", () => { - it("reports itself as a subagent of its session", () => { - const parsed = adapter.open(subagentPath).line(line({ type: "user", isSidechain: true, agentId: "a1", message: { role: "user", content: "Task brief" } }), 0); - expect(parsed.facts[0]).toMatchObject({ - kind: "agent", - id: "claude:s1:a1", - patch: { flavor: { kind: "subagent", agentType: null }, root: "claude:s1" }, - }); - }); -}); - -describe("documents", () => { - it("links a subagent to the call that spawned it", () => { - const parsed = adapter.document(metaPath, JSON.stringify({ agentType: "Explore", description: "Map the repo", toolUseId: "call-1", spawnDepth: 1 })); - expect(parsed.facts).toEqual([ - { - kind: "agent", - id: "claude:s1:a1", - patch: { - harness: "claude", - source: "claude-session", - flavor: { kind: "subagent", agentType: "Explore" }, - root: "claude:s1", - title: "Map the repo", - }, - }, - { kind: "link-by-call", id: "claude:s1:a1", callId: "call-1", fallback: "claude:s1" }, - ] as never); - const stopped = adapter.document(metaPath, JSON.stringify({ agentType: "Explore", toolUseId: "call-1", stoppedByUser: true })); - expect(kinds(stopped.facts)).toContain("outcome"); - const lane = adapter.document(metaPath, JSON.stringify({ agentType: "pstack:pstack-opus-high", toolUseId: "call-1" })); - expect(lane.facts).toContainEqual({ kind: "pstack", id: "claude:s1:a1" } as never); - }); - - it("turns a process record into a probe-able process", () => { - const path = join(home, "sessions", "29441.json"); - const parsed = adapter.document(path, JSON.stringify({ - pid: 29441, - sessionId: "s1", - cwd: "/repo", - procStart: "Fri Oct 2 04:15:58 2026", - status: "busy", - kind: "interactive", - entrypoint: "cli", - })); - expect(parsed.facts[1]).toEqual({ - kind: "process", - key: path, - id: "claude:s1", - process: { pid: 29441, startedAtMs: Date.UTC(2026, 9, 2, 4, 15, 58), state: "busy" }, - } as never); - expect(adapter.removed(path)).toEqual([{ kind: "process-gone", key: path }]); - expect(adapter.document(path, "{}").problem).toMatchObject({ kind: "shape" }); - }); - - it("parses ps-style start times as UTC", () => { - expect(procStartMs("Thu Oct 1 23:59:59 2026")).toBe(Date.UTC(2026, 9, 1, 23, 59, 59)); - expect(procStartMs("garbage")).toBeNull(); - }); -}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.ts deleted file mode 100644 index d59db787..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { join, relative, sep } from "node:path"; -import { - NOTHING, - parsed, - problem, - type Adapter, - type AgentPatch, - type ChildOutcome, - type Claim, - type Fact, - type LineParser, - type Parsed, -} from "../adapter.ts"; -import type { AgentId, TimelineItem } from "../domain.ts"; -import { - array, - clip, - contentText, - finite, - object, - oneLine, - parseJson, - text, - usageFrom, -} from "../json.ts"; -import { - assistantBlocks, - BODY_LIMIT, - callEnded, - collected, - itemId, - promptFact, - PSTACK_PREFIX, - toolResult, -} from "./blocks.ts"; - -// pstack-flex addition. Reads Claude Code's session transcripts, subagent -// transcripts and their sidecars, and per-process session records. - -const IGNORED = new Set([ - "attachment", - "mode", - "permission-mode", - "last-prompt", - "queue-operation", - "file-history-snapshot", - "file-history-delta", - "atis-latch", - "progress", - "summary", - "tag", - "agent-name", - "agent-color", - "agent-setting", - "pr-link", - "worktree-state", - "content-replacement", - "cost-state", -]); - -const NOTIFICATION_STATUS: Record = { - completed: "done", - failed: "failed", - error: "failed", - killed: "cancelled", - cancelled: "cancelled", - stopped: "cancelled", -}; - -const RESULT_STATUS: Record = { - completed: "done", - async_launched: "launched", - failed: "failed", - error: "failed", - killed: "cancelled", - cancelled: "cancelled", -}; - -/** Claude Code turns never overlap within one transcript, so one id serves them all. */ -const TURN = "turn"; - -const TURN_ENDS = new Set(["end_turn", "stop_sequence"]); - -function pstackAttribution(record: Record): boolean { - return text(record.attributionPlugin) === "pstack" - || text(record.attributionSkill)?.startsWith(PSTACK_PREFIX) === true - || text(record.attributionAgent)?.startsWith(PSTACK_PREFIX) === true; -} - -type Location = - | { readonly kind: "session"; readonly session: string } - | { readonly kind: "subagent"; readonly session: string; readonly agent: string } - | { readonly kind: "meta"; readonly session: string; readonly agent: string } - | { readonly kind: "process" }; - -export function sessionId(session: string): AgentId { - return `claude:${session}` as AgentId; -} - -export function subagentId(session: string, agent: string): AgentId { - return `claude:${session}:${agent}` as AgentId; -} - -function locate(home: string, path: string): Location | null { - const parts = relative(home, path).split(sep); - if (parts[0] === "sessions" && parts.length === 2 && /^\d+\.json$/.test(parts[1]!)) { - return { kind: "process" }; - } - if (parts[0] !== "projects") return null; - if (parts.length === 3 && parts[2]!.endsWith(".jsonl")) { - return { kind: "session", session: parts[2]!.slice(0, -".jsonl".length) }; - } - if (parts.length === 5 && parts[3] === "subagents") { - const name = parts[4]!; - const match = /^agent-(.+?)(\.meta\.json|\.jsonl)$/.exec(name); - if (match === null) return null; - return match[2] === ".jsonl" - ? { kind: "subagent", session: parts[2]!, agent: match[1]! } - : { kind: "meta", session: parts[2]!, agent: match[1]! }; - } - return null; -} - -function tag(body: string, name: string): string | null { - const match = new RegExp(`<${name}>([\\s\\S]*?)`).exec(body); - return match === null ? null : match[1]!.trim(); -} - -/** `ps -o lstart=` style text, which Claude Code records in UTC. */ -export function procStartMs(value: unknown): number | null { - const raw = text(value); - if (raw === null) return null; - const parsedMs = Date.parse(`${raw.replace(/\s+/g, " ")} UTC`); - return Number.isFinite(parsedMs) ? parsedMs : null; -} - -class TranscriptParser implements LineParser { - constructor( - private readonly agent: AgentId, - private readonly root: AgentId, - ) {} - - line(line: string, offset: number): Parsed { - const raw = parseJson(line); - if (raw === undefined) return problem({ kind: "not-json" }); - const record = object(raw); - if (record === null) return problem({ kind: "shape", recordType: "?", detail: "record is not an object" }); - const type = text(record.type) ?? "?"; - const at = text(record.timestamp); - const cliVersion = text(record.version); - const identity: AgentPatch = { - harness: "claude", - source: "claude-session", - flavor: this.agent === this.root ? { kind: "session" } : { kind: "subagent", agentType: null }, - root: this.root, - ...(text(record.cwd) !== null ? { cwd: text(record.cwd)! } : {}), - ...(at !== null ? { seenAt: at } : {}), - ...(cliVersion !== null ? { cliVersion } : {}), - ...(text(record.entrypoint) !== null ? { entrypoint: text(record.entrypoint)! } : {}), - }; - const seen: Fact = { kind: "agent", id: this.agent, patch: identity }; - - switch (type) { - case "assistant": - return this.assistant(record, offset, at, seen, cliVersion); - case "user": - return this.user(record, offset, at, seen, cliVersion); - case "ai-title": - case "custom-title": { - const title = text(record.aiTitle) ?? text(record.customTitle) ?? text(record.title); - return title === null ? NOTHING : parsed([{ kind: "agent", id: this.agent, patch: { title: oneLine(title, 120) } }]); - } - case "system": { - if (record.level !== "error") return parsed([seen], [], cliVersion); - const body = text(record.content) ?? text(record.subtype) ?? "error"; - return parsed([seen], [{ id: itemId(offset, 0), at, kind: "notice", level: "error", text: oneLine(body, 400) }], cliVersion); - } - default: - if (IGNORED.has(type)) return parsed(at === null ? [] : [seen], [], cliVersion); - return problem( - { kind: "unknown-type", recordType: type }, - [{ id: itemId(offset, 0), at, kind: "unparsed", recordType: type, bytes: line.length }], - ); - } - } - - private assistant( - record: Record, - offset: number, - at: string | null, - seen: Fact, - cliVersion: string | null, - ): Parsed { - const message = object(record.message); - if (message === null) return problem({ kind: "shape", recordType: "assistant", detail: "missing message" }); - const out = collected(); - out.facts.push(seen); - const model = text(message.model); - const effort = text(record.effort); - if ((model !== null && model !== "") || effort !== null) { - out.facts.push({ - kind: "agent", - id: this.agent, - patch: { - ...(model !== null && model !== "" ? { reportedModel: model } : {}), - ...(effort !== null ? { effort } : {}), - }, - }); - } - const usage = usageFrom(message.usage); - if (usage !== null) { - // One API message is split across several records that repeat its usage. - out.facts.push({ kind: "usage", id: this.agent, key: text(message.id) ?? `${offset}`, usage }); - } - assistantBlocks(this.agent, offset, at, message.content, out); - if (pstackAttribution(record) && !out.facts.some((fact) => fact.kind === "pstack")) { - out.facts.push({ kind: "pstack", id: this.agent }); - } - // Partial records carry a null stop reason and `tool_use` waits on results; only these end the turn. - if (TURN_ENDS.has(text(message.stop_reason) ?? "")) { - out.facts.push({ kind: "turn", id: this.agent, turnId: TURN, at, event: { kind: "ended", outcome: "done", reason: null } }); - } - for (const block of array(message.content)) { - const call = object(block); - if (call?.type !== "tool_use" || call.name !== "SendMessage") continue; - const target = text(object(call.input)?.to); - // A `uds:` target is another session's socket; that session is not on this canvas. - if (target === null || target.startsWith("uds:")) continue; - out.facts.push({ - kind: "message", - key: `claude:${text(call.id) ?? `${this.agent}:${offset}`}`, - from: { kind: "agent", id: this.agent }, - to: { kind: "claude-target", session: this.root, target }, - at, - }); - } - return parsed(out.facts, out.items, cliVersion); - } - - private user( - record: Record, - offset: number, - at: string | null, - seen: Fact, - cliVersion: string | null, - ): Parsed { - const message = object(record.message); - if (message === null) return problem({ kind: "shape", recordType: "user", detail: "missing message" }); - const facts: Fact[] = [seen]; - const items: TimelineItem[] = []; - const content = message.content; - - if (object(record.origin)?.kind === "task-notification") { - const body = contentText(content); - const callId = tag(body, "tool-use-id"); - const status = tag(body, "status"); - const outcome = status === null ? undefined : NOTIFICATION_STATUS[status]; - if (callId !== null && outcome !== undefined) { - facts.push({ kind: "child-outcome", callId, outcome, at, reason: outcome === "failed" ? status : null }); - } - const summary = tag(body, "summary") ?? `background task ${status ?? "update"}`; - items.push({ id: itemId(offset, 0), at, kind: "notice", level: outcome === "failed" ? "error" : "info", text: oneLine(summary, 400) }); - return parsed(facts, items, cliVersion); - } - - const agentResult = object(record.toolUseResult); - if (agentResult !== null && text(agentResult.agentId) !== null) { - const callId = array(content).map((block) => text(object(block)?.tool_use_id)).find((id) => id !== null); - const outcome = RESULT_STATUS[text(agentResult.status) ?? ""]; - if (callId !== undefined && callId !== null && outcome !== undefined) { - facts.push({ kind: "child-outcome", callId, outcome, at, reason: outcome === "failed" ? text(agentResult.status) : null }); - } - } - - if (record.isMeta === true) { - for (const block of array(content)) { - const callId = text(object(block)?.tool_use_id); - if (object(block)?.type === "tool_result" && callId !== null) facts.push(callEnded(this.agent, callId, at)); - } - return parsed(facts, items, cliVersion); - } - - if (typeof content === "string") { - this.prompt(content, offset, 0, at, facts, items); - return parsed(facts, items, cliVersion); - } - array(content).forEach((raw, index) => { - const block = object(raw); - if (block === null) return; - if (block.type === "tool_result") { - const item = toolResult(offset, index, at, block); - if (item !== null) items.push(item); - if (item?.kind === "tool-result") facts.push(callEnded(this.agent, item.callId, at)); - } else if (block.type === "text") { - const body = text(block.text); - if (body !== null) this.prompt(body, offset, index, at, facts, items); - } else if (block.type === "image") { - items.push({ id: itemId(offset, index), at, kind: "notice", level: "info", text: "image attached" }); - } - }); - return parsed(facts, items, cliVersion); - } - - private prompt( - body: string, - offset: number, - index: number, - at: string | null, - facts: Fact[], - items: TimelineItem[], - ): void { - const command = tag(body, "command-name"); - if (command !== null) { - items.push({ id: itemId(offset, index), at, kind: "notice", level: "info", text: `ran ${oneLine(command, 80)}` }); - // No turn: a built-in command such as /model gets no reply that would close it. - if (command.replace(/^\//, "").startsWith(PSTACK_PREFIX)) facts.push({ kind: "pstack", id: this.agent }); - return; - } - if (body.startsWith("")) return; - if (body.startsWith("[Request interrupted")) { - items.push({ id: itemId(offset, index), at, kind: "notice", level: "info", text: "interrupted by the user" }); - facts.push({ kind: "turn", id: this.agent, turnId: TURN, at, event: { kind: "ended", outcome: "cancelled", reason: "interrupted by the user" } }); - return; - } - items.push({ id: itemId(offset, index), at, kind: "prompt", body: clip(body, BODY_LIMIT) }); - facts.push( - { kind: "agent", id: this.agent, patch: { titleHint: oneLine(body, 80) } }, - { kind: "turn", id: this.agent, turnId: TURN, at, event: { kind: "started" } }, - promptFact(this.agent, body, at), - ); - } -} - -export function claudeAdapter(home: string): Adapter { - return { - source: "claude-session", - windowed: true, - checkedVersion: "2.1.287", - roots: [ - { dir: join(home, "sessions"), depth: 1, processRecords: "claude" }, - { dir: join(home, "projects"), depth: 4 }, - ], - - claim(path: string): Claim | null { - const location = locate(home, path); - if (location === null) return null; - switch (location.kind) { - case "process": - return { kind: "document", agent: null, always: true }; - case "meta": - return { kind: "document", agent: subagentId(location.session, location.agent), always: false }; - case "session": - return { kind: "stream", agent: sessionId(location.session), root: sessionId(location.session), header: false }; - case "subagent": - return { - kind: "stream", - agent: subagentId(location.session, location.agent), - root: sessionId(location.session), - header: false, - }; - } - }, - - open(path: string): LineParser { - const location = locate(home, path); - if (location?.kind === "subagent") { - return new TranscriptParser(subagentId(location.session, location.agent), sessionId(location.session)); - } - const session = location?.kind === "session" ? location.session : path; - return new TranscriptParser(sessionId(session), sessionId(session)); - }, - - document(path: string, body: string): Parsed { - const location = locate(home, path); - const record = object(parseJson(body)); - if (location === null) return NOTHING; - if (record === null) return problem({ kind: "shape", recordType: location.kind, detail: "not a JSON object" }); - - if (location.kind === "process") { - const session = text(record.sessionId); - const pid = finite(record.pid); - if (session === null || pid === undefined) { - return problem({ kind: "shape", recordType: "process", detail: "missing sessionId or pid" }); - } - const id = sessionId(session); - return parsed([ - { - kind: "agent", - id, - patch: { - harness: "claude", - source: "claude-session", - flavor: { kind: "session" }, - ...(text(record.cwd) !== null ? { cwd: text(record.cwd)! } : {}), - ...(text(record.entrypoint) !== null ? { entrypoint: text(record.entrypoint)! } : {}), - }, - }, - { - kind: "process", - key: path, - id, - process: { pid, startedAtMs: procStartMs(record.procStart), state: text(record.status) }, - }, - ], [], text(record.version)); - } - - if (location.kind !== "meta") return NOTHING; - const id = subagentId(location.session, location.agent); - const root = sessionId(location.session); - const title = text(record.description) ?? text(record.name); - const model = text(record.model); - const facts: Fact[] = [{ - kind: "agent", - id, - patch: { - harness: "claude", - source: "claude-session", - flavor: { kind: "subagent", agentType: text(record.agentType) }, - root, - ...(title !== null ? { title: oneLine(title, 120) } : {}), - ...(model !== null ? { requestedModel: model } : {}), - ...(text(record.name) !== null ? { agentName: text(record.name)! } : {}), - }, - }]; - const toolUseId = text(record.toolUseId); - if (toolUseId !== null) { - facts.push({ kind: "link-by-call", id, callId: toolUseId, fallback: root }); - } else if (text(record.teamName) !== null) { - facts.push({ kind: "link", id, parent: root, via: "team" }); - } - if (text(record.agentType)?.startsWith(PSTACK_PREFIX) === true) facts.push({ kind: "pstack", id }); - if (record.stoppedByUser === true) { - facts.push({ kind: "outcome", id, outcome: "cancelled", at: null, reason: "stopped by the user" }); - } - return parsed(facts); - }, - - removed(path: string) { - return locate(home, path)?.kind === "process" ? [{ kind: "process-gone", key: path }] : []; - }, - }; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.test.ts deleted file mode 100644 index 4ede0431..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { join } from "node:path"; -import { codexAdapter, enterDateFolder } from "./codex.ts"; - -const home = "/home/u/.codex"; -const adapter = codexAdapter(home); -const parentThread = "01a0aaaa-0000-7000-8000-000000000001"; -const childThread = "01a0bbbb-0000-7000-8000-000000000002"; -const rollout = (thread: string) => join(home, "sessions", "2026", "10", "01", `rollout-2026-10-01T10-00-00-${thread}.jsonl`); - -const record = (ordinal: number, type: string, payload: Record): string => - JSON.stringify({ timestamp: "2026-10-01T10:00:00.000Z", ordinal, type, payload }); - -const childMeta = (extra: Record = {}) => record(0, "session_meta", { - id: childThread, - timestamp: "2026-10-01T10:00:00.000Z", - cwd: "/repo", - originator: "codex-tui", - cli_version: "0.160.0", - parent_thread_id: parentThread, - source: { - subagent: { - thread_spawn: { parent_thread_id: parentThread, depth: 1, agent_path: "/root/cross_judge", agent_nickname: "Popper", agent_role: null }, - }, - }, - ...extra, -}); - -describe("claim", () => { - it("names the thread from the rollout file", () => { - expect(adapter.claim(rollout(childThread))).toEqual({ kind: "stream", agent: `codex:${childThread}`, root: `codex:${childThread}`, header: true } as never); - expect(adapter.claim(join(home, "sessions", "2026", "10", "01", "notes.jsonl"))).toBeNull(); - expect(adapter.claim(join(home, "history.jsonl"))).toBeNull(); - }); - - it("enters only date folders near the window", () => { - const sessions = join(home, "sessions"); - const since = new Date(2026, 9, 1, 12).getTime(); - expect(enterDateFolder(sessions, sessions, since)).toBe(true); - expect(enterDateFolder(sessions, join(sessions, "2026"), since)).toBe(true); - expect(enterDateFolder(sessions, join(sessions, "2026", "09"), since)).toBe(true); - expect(enterDateFolder(sessions, join(sessions, "2026", "09", "30"), since)).toBe(true); - expect(enterDateFolder(sessions, join(sessions, "2026", "09", "29"), since)).toBe(false); - expect(enterDateFolder(sessions, join(sessions, "2025"), since)).toBe(false); - }); -}); - -describe("rollout", () => { - it("links a spawned child to its parent thread and names it", () => { - const parsed = adapter.open(rollout(childThread)).line(childMeta(), 0); - expect(parsed.cliVersion).toBe("0.160.0"); - expect(parsed.facts).toContainEqual(expect.objectContaining({ - kind: "agent", - patch: expect.objectContaining({ flavor: { kind: "subagent", agentType: "cross_judge" }, title: "Popper · cross_judge" }), - })); - expect(parsed.facts).toContainEqual({ kind: "link", id: `codex:${childThread}`, parent: `codex:${parentThread}`, via: "thread-spawn" } as never); - }); - - it("skips the parent history a forked child copies", () => { - const parser = adapter.open(rollout(childThread)); - parser.line(childMeta({ subagent_history_start_ordinal: 3, forked_from_id: parentThread }), 0); - const copied = parser.line(record(1, "response_item", { type: "message", role: "assistant", content: [{ type: "output_text", text: "parent said this" }] }), 10); - expect(copied.items).toEqual([]); - const parentMeta = parser.line(record(2, "session_meta", { id: parentThread }), 20); - expect(parentMeta.facts).toEqual([]); - const own = parser.line(record(3, "response_item", { type: "message", role: "assistant", content: [{ type: "output_text", text: "child says this" }] }), 30); - expect(own.items[0]).toMatchObject({ kind: "text", body: { text: "child says this" } }); - }); - - it("tracks turns, usage, and settings", () => { - const parser = adapter.open(rollout(childThread)); - parser.line(childMeta(), 0); - expect(parser.line(record(1, "event_msg", { type: "task_started", turn_id: "u1" }), 1).facts[0]).toMatchObject({ kind: "turn", event: { kind: "started" } }); - const failed = parser.line(record(2, "event_msg", { type: "task_complete", turn_id: "u1", last_agent_message: null, error: { message: "quota" } }), 2); - expect(failed.facts[0]).toMatchObject({ kind: "turn", event: { kind: "ended", outcome: "failed", reason: "quota" } }); - expect(failed.items[0]).toMatchObject({ kind: "notice", level: "error" }); - const aborted = parser.line(record(3, "event_msg", { type: "turn_aborted", turn_id: "u2", reason: "interrupted" }), 3); - expect(aborted.facts[0]).toMatchObject({ event: { kind: "ended", outcome: "cancelled" } }); - const usage = parser.line(record(4, "event_msg", { type: "token_count", info: { total_token_usage: { input_tokens: 9, output_tokens: 4, reasoning_output_tokens: 2 } } }), 4); - expect(usage.facts[0]).toEqual({ kind: "usage", id: `codex:${childThread}`, key: null, usage: { inputTokens: 9, outputTokens: 4, reasoningTokens: 2 } } as never); - const context = parser.line(record(5, "turn_context", { model: "gpt-test", effort: "high", cwd: "/repo" }), 5); - expect(context.facts[0]).toMatchObject({ patch: { requestedModel: "gpt-test", effort: "high" } }); - }); - - it("pairs tool calls with outputs and hides injected context", () => { - const parser = adapter.open(rollout(parentThread)); - parser.line(record(0, "session_meta", { id: parentThread, cwd: "/repo", source: "cli", cli_version: "0.160.0" }), 0); - const call = parser.line(record(1, "response_item", { type: "function_call", name: "spawn_agent", arguments: '{"task_name":"judge"}', call_id: "c1" }), 1); - expect(call.items[0]).toMatchObject({ kind: "tool-call", callId: "c1", name: "spawn_agent" }); - const output = parser.line(record(2, "response_item", { type: "function_call_output", call_id: "c1", output: [{ type: "input_text", text: "spawned" }] }), 2); - expect(output.items[0]).toMatchObject({ kind: "tool-result", callId: "c1", output: { text: "spawned" } }); - const injected = parser.line(record(3, "response_item", { type: "message", role: "user", content: [{ type: "input_text", text: "…" }] }), 3); - expect(injected.items).toEqual([]); - const prompt = parser.line(record(4, "response_item", { type: "message", role: "user", content: [{ type: "input_text", text: "Run the arena" }] }), 4); - expect(prompt.items[0]).toMatchObject({ kind: "prompt" }); - expect(prompt.facts[0]).toMatchObject({ patch: { titleHint: "Run the arena" } }); - }); - - it("reports the call awaiting output and the prompt being worked on", () => { - const parser = adapter.open(rollout(parentThread)); - parser.line(record(0, "session_meta", { id: parentThread, cwd: "/repo", source: "cli", cli_version: "0.160.0" }), 0); - const call = parser.line(record(1, "response_item", { type: "function_call", name: "exec_command", arguments: '{"cmd":"bun test"}', call_id: "c1" }), 1); - expect(call.facts).toContainEqual(expect.objectContaining({ kind: "call", callId: "c1", event: expect.objectContaining({ kind: "started", name: "exec_command" }) })); - const output = parser.line(record(2, "response_item", { type: "function_call_output", call_id: "c1", output: "ok" }), 2); - expect(output.facts).toContainEqual(expect.objectContaining({ kind: "call", callId: "c1", event: { kind: "ended" } })); - const prompt = parser.line(record(3, "response_item", { type: "message", role: "user", content: [{ type: "input_text", text: "Run the arena" }] }), 3); - expect(prompt.facts).toContainEqual({ kind: "prompt", id: `codex:${parentThread}`, text: "Run the arena", at: "2026-10-01T10:00:00.000Z" } as never); - }); - - it("flags pstack from a root prompt or a call into pstack's skills or runner", () => { - const flagged = (thread: string, meta: string, line: string) => { - const parser = adapter.open(rollout(thread)); - parser.line(meta, 0); - return parser.line(line, 1).facts.some((fact) => fact.kind === "pstack"); - }; - const rootMeta = record(0, "session_meta", { id: parentThread, cwd: "/repo", source: "cli", cli_version: "0.160.0" }); - const ask = (text: string) => record(1, "response_item", { type: "message", role: "user", content: [{ type: "input_text", text }] }); - const shell = (cmd: string) => record(1, "response_item", { type: "function_call", name: "exec_command", arguments: JSON.stringify({ cmd }), call_id: "c1" }); - expect(flagged(parentThread, rootMeta, ask("Use pstack:monitor."))).toBe(true); - expect(flagged(parentThread, rootMeta, ask("Fix the parser"))).toBe(false); - expect(flagged(childThread, childMeta(), ask("Use pstack:monitor."))).toBe(false); - expect(flagged(parentThread, rootMeta, shell("pstack-runner --parent codex --provider minimax"))).toBe(true); - expect(flagged(parentThread, rootMeta, shell("cat ~/.codex/plugins/cache/open-pstack/pstack/1.5.0/skills/arena/SKILL.md"))).toBe(true); - expect(flagged(parentThread, rootMeta, shell("ls"))).toBe(false); - }); - - it("records each agent message once, addressed by path, and names the tree's root path", () => { - const parser = adapter.open(rollout(childThread)); - const meta = parser.line(childMeta(), 0); - expect(meta.facts[0]).toMatchObject({ patch: { agentPath: "/root/cross_judge" } }); - const message = parser.line(record(1, "response_item", { - type: "agent_message", - id: "msg-1", - author: "/root", - recipient: "/root/cross_judge", - content: [{ type: "input_text", text: "Compare candidates 2 and 3." }], - }), 10); - expect(message.facts).toEqual([{ - kind: "message", - key: "codex:msg-1", - from: { kind: "codex-path", near: `codex:${childThread}`, path: "/root" }, - to: { kind: "codex-path", near: `codex:${childThread}`, path: "/root/cross_judge" }, - at: "2026-10-01T10:00:00.000Z", - }] as never); - expect(message.items[0]).toMatchObject({ kind: "notice", text: "/root → /root/cross_judge: Compare candidates 2 and 3." }); - - const rootParser = adapter.open(rollout(parentThread)); - const rootMeta = rootParser.line(record(0, "session_meta", { id: parentThread, cwd: "/repo", source: "cli" }), 0); - expect(rootMeta.facts[0]).toMatchObject({ patch: { agentPath: "/root" } }); - }); - - it("reports unknown record types instead of dropping them silently", () => { - const parser = adapter.open(rollout(parentThread)); - const unknown = parser.line(record(1, "response_item", { type: "hologram" }), 7); - expect(unknown.problem).toEqual({ kind: "unknown-type", recordType: "response_item/hologram" }); - expect(parser.line(record(2, "world_state", { anything: true }), 8).problem).toBeNull(); - expect(parser.line(record(3, "brand_new", { anything: true }), 9).problem).toEqual({ kind: "unknown-type", recordType: "brand_new" }); - }); -}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.ts deleted file mode 100644 index 7e8320b4..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { join, relative, sep } from "node:path"; -import { - NOTHING, - parsed, - problem, - type Adapter, - type Claim, - type Fact, - type LineParser, - type Parsed, -} from "../adapter.ts"; -import type { AgentId, TimelineItem } from "../domain.ts"; -import { - array, - basename, - clip, - contentText, - finite, - object, - oneLine, - parseJson, - pretty, - text, - usageFrom, -} from "../json.ts"; -import { BODY_LIMIT, callEnded, describeTool, itemId, promptFact, textActivity } from "./blocks.ts"; - -// pstack-flex addition. Reads Codex rollouts. Each thread, including every -// spawned child, writes its own rollout; children name their parent thread. - -const ROLLOUT = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/; - -const IGNORED_TYPES = new Set([ - "world_state", - "token_usage_record", - "compacted", - "inter_agent_communication_metadata", -]); - -/** Paths and commands only pstack's skills and runner use. */ -const PSTACK_MARKERS = ["pstack-runner", "open-pstack/pstack/", "/pstack/skills/"]; -const PSTACK_WORD = /\bpstack\b/i; - -const IGNORED_ITEMS = new Set(["ghost_snapshot"]); - -export function threadId(thread: string): AgentId { - return `codex:${thread}` as AgentId; -} - -function threadOf(home: string, path: string): string | null { - const parts = relative(home, path).split(sep); - if (parts[0] !== "sessions" || parts.length !== 5) return null; - return ROLLOUT.exec(parts[4]!)?.[1] ?? null; -} - -/** Enter only date folders that can hold rollouts written inside the window. */ -export function enterDateFolder(sessionsDir: string, dir: string, sinceMs: number): boolean { - const parts = relative(sessionsDir, dir).split(sep).filter((part) => part.length > 0); - if (parts.length === 0) return true; - // A day folder holds threads that started that day and may still be writing; allow a day of slack. - const since = new Date(sinceMs - 86_400_000); - const floor = [ - String(since.getFullYear()), - String(since.getMonth() + 1).padStart(2, "0"), - String(since.getDate()).padStart(2, "0"), - ]; - const prefix = parts.join("-"); - return prefix >= floor.slice(0, parts.length).join("-"); -} - -// The CLI injects context as user messages wrapped in tags; they are not prompts. -function isInjectedContext(body: string): boolean { - const trimmed = body.trimStart(); - return trimmed.startsWith("<") || trimmed.startsWith("# AGENTS.md"); -} - -class RolloutParser implements LineParser { - private metaSeen = false; - private historyStart: number | null = null; - private isChild = false; - - constructor(private readonly agent: AgentId) {} - - line(line: string, offset: number): Parsed { - const raw = parseJson(line); - if (raw === undefined) return problem({ kind: "not-json" }); - const record = object(raw); - if (record === null) return problem({ kind: "shape", recordType: "?", detail: "record is not an object" }); - const type = text(record.type) ?? "?"; - const payload = object(record.payload); - const at = text(record.timestamp); - - if (type === "session_meta") { - // A forked child copies its parent's history, including the parent's meta, after its own. - if (this.metaSeen) return NOTHING; - this.metaSeen = true; - if (payload === null) return problem({ kind: "shape", recordType: type, detail: "missing payload" }); - return this.meta(payload, at); - } - const ordinal = finite(record.ordinal); - if (this.historyStart !== null && ordinal !== undefined && ordinal < this.historyStart) return NOTHING; - if (payload === null) { - return IGNORED_TYPES.has(type) ? NOTHING : problem({ kind: "shape", recordType: type, detail: "missing payload" }); - } - - switch (type) { - case "turn_context": - return parsed([this.settings(text(payload.model), text(payload.effort), text(payload.cwd))]); - case "event_msg": - return this.event(payload, offset, at); - case "response_item": - return this.item(payload, offset, at, line.length); - default: - if (IGNORED_TYPES.has(type)) return NOTHING; - return problem( - { kind: "unknown-type", recordType: type }, - [{ id: itemId(offset, 0), at, kind: "unparsed", recordType: type, bytes: line.length }], - ); - } - } - - private meta(payload: Record, at: string | null): Parsed { - const source = object(payload.source); - const subagent = object(source?.subagent); - const spawn = object(subagent?.thread_spawn); - const parent = text(payload.parent_thread_id) ?? text(spawn?.parent_thread_id); - this.isChild = parent !== null; - this.historyStart = finite(payload.subagent_history_start_ordinal) ?? null; - const nickname = text(payload.agent_nickname) ?? text(spawn?.agent_nickname); - const agentPath = text(payload.agent_path) ?? text(spawn?.agent_path); - const role = text(payload.agent_role) ?? text(spawn?.agent_role) ?? text(subagent?.other); - const task = agentPath === null ? null : basename(agentPath); - const named = [nickname, task].filter((part) => part !== null).join(" · "); - const title = role === "guardian" ? "guardian review" : named.length > 0 ? named : null; - const cliVersion = text(payload.cli_version); - const started = text(payload.timestamp) ?? at; - const facts: Fact[] = [{ - kind: "agent", - id: this.agent, - patch: { - harness: "codex", - source: "codex-rollout", - provider: "codex", - flavor: this.isChild ? { kind: "subagent", agentType: role ?? task } : { kind: "session" }, - ...(text(payload.cwd) !== null ? { cwd: text(payload.cwd)! } : {}), - ...(started !== null ? { seenAt: started } : {}), - ...(cliVersion !== null ? { cliVersion } : {}), - ...(text(payload.originator) !== null ? { entrypoint: text(payload.originator)! } : {}), - ...(title !== null ? { title } : {}), - // Codex addresses agents by path within a tree; the root thread is `/root`. - ...(agentPath !== null ? { agentPath } : this.isChild ? {} : { agentPath: "/root" }), - }, - }]; - if (parent !== null) facts.push({ kind: "link", id: this.agent, parent: threadId(parent), via: "thread-spawn" }); - return parsed(facts, [], cliVersion); - } - - private settings(model: string | null, effort: string | null, cwd: string | null): Fact { - return { - kind: "agent", - id: this.agent, - patch: { - ...(model !== null ? { requestedModel: model, reportedModel: model } : {}), - ...(effort !== null ? { effort } : {}), - ...(cwd !== null ? { cwd } : {}), - }, - }; - } - - private event(payload: Record, offset: number, at: string | null): Parsed { - const turnId = text(payload.turn_id) ?? "turn"; - switch (payload.type) { - case "task_started": - return parsed([{ kind: "turn", id: this.agent, turnId, at, event: { kind: "started" } }]); - case "task_complete": { - const error = text(object(payload.error)?.message); - const items: TimelineItem[] = error === null - ? [] - : [{ id: itemId(offset, 0), at, kind: "notice", level: "error", text: oneLine(error, 400) }]; - return parsed( - [{ kind: "turn", id: this.agent, turnId, at, event: { kind: "ended", outcome: error === null ? "done" : "failed", reason: error } }], - items, - ); - } - case "turn_aborted": { - const reason = text(payload.reason); - return parsed( - [{ kind: "turn", id: this.agent, turnId, at, event: { kind: "ended", outcome: "cancelled", reason } }], - [{ id: itemId(offset, 0), at, kind: "notice", level: "info", text: `turn aborted${reason === null ? "" : `: ${reason}`}` }], - ); - } - case "token_count": { - const usage = usageFrom(object(payload.info)?.total_token_usage); - return usage === null ? NOTHING : parsed([{ kind: "usage", id: this.agent, key: null, usage }]); - } - case "thread_settings_applied": { - const settings = object(payload.thread_settings); - return settings === null - ? NOTHING - : parsed([this.settings(text(settings.model), text(settings.reasoning_effort), text(settings.cwd))]); - } - default: - // event_msg is a wide family of UI events; the response items carry the content. - return NOTHING; - } - } - - private item(payload: Record, offset: number, at: string | null, bytes: number): Parsed { - const id = itemId(offset, 0); - const type = text(payload.type) ?? "?"; - switch (type) { - case "message": { - const role = text(payload.role); - const body = contentText(payload.content); - if (body.length === 0 || role === "developer" || role === "system") return NOTHING; - if (role === "assistant") { - return parsed([textActivity(this.agent, body, at)], [{ id, at, kind: "text", body: clip(body, BODY_LIMIT) }]); - } - if (isInjectedContext(body)) return NOTHING; - const facts: Fact[] = this.isChild ? [] : [{ kind: "agent", id: this.agent, patch: { titleHint: oneLine(body, 80) } }]; - facts.push(promptFact(this.agent, body, at)); - // A child's prompt can quote its parent's history, so only a root prompt counts as asking for pstack. - if (!this.isChild && PSTACK_WORD.test(body)) facts.push({ kind: "pstack", id: this.agent }); - return parsed(facts, [{ id, at, kind: "prompt", body: clip(body, BODY_LIMIT) }]); - } - case "reasoning": { - const summary = array(payload.summary) - .map((part) => text(object(part)?.text)) - .filter((part): part is string => part !== null) - .join("\n"); - return parsed( - [{ kind: "activity", id: this.agent, activity: { what: "thinking", snippet: "thinking", at } }], - [{ id, at, kind: "thinking", body: summary.length === 0 ? null : clip(summary, BODY_LIMIT) }], - ); - } - case "function_call": - case "custom_tool_call": - case "local_shell_call": - case "web_search_call": { - const name = text(payload.name) ?? (type === "web_search_call" ? "web_search" : "shell"); - const input = type === "function_call" - ? prettyArguments(payload.arguments) - : type === "custom_tool_call" - ? text(payload.input) ?? "" - : pretty(payload.action); - const callId = text(payload.call_id) ?? text(payload.id) ?? `${offset}`; - const snippet = describeTool(name, firstLine(input)); - const facts: Fact[] = [ - { kind: "activity", id: this.agent, activity: { what: "tool", snippet, at } }, - { kind: "call", id: this.agent, callId, at, event: { kind: "started", name, snippet } }, - ]; - if (PSTACK_MARKERS.some((marker) => input.includes(marker))) facts.push({ kind: "pstack", id: this.agent }); - return parsed(facts, [{ id, at, kind: "tool-call", callId, name, input: clip(input, BODY_LIMIT) }]); - } - case "function_call_output": - case "custom_tool_call_output": - case "local_shell_call_output": { - const callId = text(payload.call_id); - if (callId === null) return problem({ kind: "shape", recordType: `response_item/${type}`, detail: "missing call_id" }); - const output = typeof payload.output === "string" ? payload.output : contentText(payload.output); - return parsed([callEnded(this.agent, callId, at)], [{ id, at, kind: "tool-result", callId, ok: null, output: clip(output, BODY_LIMIT) }]); - } - case "agent_message": { - // Recorded once, in the recipient's rollout. - const author = text(payload.author); - const recipient = text(payload.recipient); - const body = contentText(payload.content); - const facts: Fact[] = author === null || recipient === null - ? [] - : [{ - kind: "message", - key: `codex:${text(payload.id) ?? `${this.agent}:${offset}`}`, - from: { kind: "codex-path", near: this.agent, path: author }, - to: { kind: "codex-path", near: this.agent, path: recipient }, - at, - }]; - return parsed(facts, [{ id, at, kind: "notice", level: "info", text: `${author ?? "agent"} → ${recipient ?? "agent"}: ${oneLine(body, 600)}` }]); - } - case "compaction": - return parsed([], [{ id, at, kind: "notice", level: "info", text: "context compacted" }]); - default: - if (IGNORED_ITEMS.has(type)) return NOTHING; - return problem( - { kind: "unknown-type", recordType: `response_item/${type}` }, - [{ id, at, kind: "unparsed", recordType: `response_item/${type}`, bytes }], - ); - } - } -} - -function prettyArguments(value: unknown): string { - if (typeof value !== "string") return pretty(value); - const parsedValue = parseJson(value); - return parsedValue === undefined ? value : pretty(parsedValue); -} - -function firstLine(value: string): string { - const trimmed = value.trim(); - const end = trimmed.indexOf("\n"); - return end === -1 ? trimmed : trimmed.slice(0, end); -} - -export function codexAdapter(home: string): Adapter { - const sessions = join(home, "sessions"); - return { - source: "codex-rollout", - windowed: true, - checkedVersion: "0.160.0", - roots: [{ dir: sessions, depth: 4, enter: (dir, sinceMs) => enterDateFolder(sessions, dir, sinceMs) }], - - claim(path: string): Claim | null { - const thread = threadOf(home, path); - if (thread === null) return null; - return { kind: "stream", agent: threadId(thread), root: threadId(thread), header: true }; - }, - - open(path: string): LineParser { - return new RolloutParser(threadId(threadOf(home, path) ?? path)); - }, - - document(): Parsed { - return NOTHING; - }, - - removed() { - return []; - }, - }; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.test.ts deleted file mode 100644 index 73bc4ce8..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { join } from "node:path"; -import type { Fact } from "../adapter.ts"; -import { laneAdapter } from "./lane.ts"; - -const root = "/home/u/.pstack-flex/lanes"; -const adapter = laneAdapter(root); -const dir = join(root, "lane-1"); -const id = "lane:lane-1"; - -const record = { - schemaVersion: 1, - laneId: "lane-1", - runnerPid: 4242, - startedAt: "2026-10-02T10:00:00.000Z", - parent: "claude", - parentSessionId: "s1", - provider: "codex", - model: "gpt-6-astra", - effort: "high", - mode: "read-only", - label: "arena cross-judge", - cwd: "/repo", - promptPath: "/tmp/p.md", - outputPath: "/tmp/o.md", - receiptPath: "/tmp/r.json", -}; - -function kinds(facts: readonly Fact[]): string[] { - return facts.map((fact) => fact.kind); -} - -describe("claim", () => { - it("recognizes the three journal files and nothing else", () => { - expect(adapter.claim(join(dir, "lane.json"))).toEqual({ kind: "document", agent: id, always: true } as never); - expect(adapter.claim(join(dir, "receipt.json"))).toEqual({ kind: "document", agent: id, always: true } as never); - expect(adapter.claim(join(dir, "stream.jsonl"))).toEqual({ kind: "stream", agent: id, root: id, header: false } as never); - expect(adapter.claim(join(dir, "lane.json.4242.tmp"))).toBeNull(); - expect(adapter.claim(join(root, "stray.json"))).toBeNull(); - }); -}); - -describe("lane record", () => { - it("names the lane, links it to the parent session, and records the runner process", () => { - const parsed = adapter.document(join(dir, "lane.json"), JSON.stringify(record)); - expect(parsed.problem).toBeNull(); - expect(parsed.facts[0]).toMatchObject({ - kind: "agent", - id, - patch: { - harness: "claude", - source: "runner-lane", - provider: "codex", - requestedModel: "gpt-6-astra", - effort: "high", - title: "arena cross-judge", - flavor: { kind: "lane", mode: "read-only", stream: "live", label: "arena cross-judge", receipt: null }, - }, - }); - expect(parsed.facts).toContainEqual({ - kind: "process", - key: join(dir, "lane.json"), - id, - process: { pid: 4242, startedAtMs: Date.parse(record.startedAt), state: "running" }, - } as never); - expect(parsed.facts).toContainEqual({ kind: "link", id, parent: "claude:s1", via: "runner" } as never); - }); - - it("marks lanes whose CLI prints only at exit", () => { - const parsed = adapter.document(join(dir, "lane.json"), JSON.stringify({ ...record, provider: "deepseek" })); - expect(parsed.facts[0]).toMatchObject({ patch: { flavor: { stream: "at-exit" } } }); - }); - - it("leaves a lane without a parent session as its own root", () => { - const parsed = adapter.document(join(dir, "lane.json"), JSON.stringify({ ...record, parentSessionId: null })); - expect(kinds(parsed.facts)).not.toContain("link"); - }); - - it("is always pstack work and carries what the lane was asked", () => { - for (const provider of ["codex", "claude", "grok", "deepseek", "minimax"]) { - const parsed = adapter.document(join(dir, "lane.json"), JSON.stringify({ ...record, provider, label: null, promptHead: "Judge the candidates." })); - expect(parsed.facts).toContainEqual({ kind: "pstack", id } as never); - expect(parsed.facts).toContainEqual({ kind: "prompt", id, text: "Judge the candidates.", at: record.startedAt } as never); - expect(parsed.facts[0]).toMatchObject({ patch: { provider, titleHint: "Judge the candidates." } }); - } - const older = adapter.document(join(dir, "lane.json"), JSON.stringify(record)); - expect(kinds(older.facts)).toContain("pstack"); - expect(kinds(older.facts)).not.toContain("prompt"); - }); - - it("streams Codex and Grok lanes and waits for exit on Claude, DeepSeek, and MiniMax", () => { - const stream = (provider: string) => - (adapter.document(join(dir, "lane.json"), JSON.stringify({ ...record, provider })).facts[0] as { patch: { flavor: { stream: string } } }).patch.flavor.stream; - expect(["codex", "grok", "claude", "deepseek", "minimax"].map(stream)).toEqual(["live", "live", "at-exit", "at-exit", "at-exit"]); - }); - - it("rejects a record without the fields it needs", () => { - expect(adapter.document(join(dir, "lane.json"), JSON.stringify({ provider: "codex" })).problem).toMatchObject({ kind: "shape" }); - }); -}); - -describe("receipt", () => { - const receipt = (status: string, extra: Record = {}) => - adapter.document(join(dir, "receipt.json"), JSON.stringify({ status, provider: "codex", mode: "read-only", completedAt: "2026-10-02T10:05:00.000Z", ...extra })); - - it("maps every receipt status to an outcome", () => { - const outcome = (status: string) => receipt(status).facts.find((fact) => fact.kind === "outcome"); - expect(outcome("complete")).toMatchObject({ outcome: "done", reason: null }); - expect(outcome("cancelled")).toMatchObject({ outcome: "cancelled" }); - for (const status of ["unavailable-cli", "unauthenticated", "unavailable-model", "timed-out", "child-failed", "malformed-output"]) { - expect(outcome(status)).toMatchObject({ outcome: "failed" }); - } - expect(receipt("exploded").problem).toMatchObject({ kind: "shape" }); - }); - - it("explains a failure and carries usage and the reported model", () => { - const parsed = receipt("child-failed", { - error: { message: "exit 1", evidence: "" }, - usage: { inputTokens: 20, outputTokens: 3 }, - reportedModel: "gpt-6-astra-2026", - }); - expect(parsed.facts).toContainEqual(expect.objectContaining({ kind: "outcome", reason: "child failed: exit 1" })); - expect(parsed.facts).toContainEqual({ kind: "usage", id, key: null, usage: { inputTokens: 20, outputTokens: 3 } } as never); - expect(parsed.facts[0]).toMatchObject({ patch: { reportedModel: "gpt-6-astra-2026", flavor: { receipt: "child-failed" } } }); - }); - - it("retracts the runner process when the lane record disappears", () => { - expect(adapter.removed(join(dir, "lane.json"))).toEqual([{ kind: "process-gone", key: join(dir, "lane.json") }]); - expect(adapter.removed(join(dir, "receipt.json"))).toEqual([]); - }); -}); - -describe("stream", () => { - it("reads Codex exec events, pairing a command with its output", () => { - const parser = adapter.open(join(dir, "stream.jsonl")); - expect(parser.line(JSON.stringify({ type: "thread.started", thread_id: "t" }), 0).items).toEqual([]); - const started = parser.line(JSON.stringify({ type: "item.started", item: { id: "i1", type: "command_execution", command: "git diff", status: "in_progress" } }), 10); - expect(started.items[0]).toMatchObject({ kind: "tool-call", callId: "i1", name: "shell", input: { text: "git diff" } }); - const done = parser.line(JSON.stringify({ type: "item.completed", item: { id: "i1", type: "command_execution", command: "git diff", aggregated_output: "+x", exit_code: 0 } }), 20); - expect(done.items).toEqual([{ id: "20.1", at: null, kind: "tool-result", callId: "i1", ok: true, output: { text: "+x", omitted: 0 } }] as never); - const message = parser.line(JSON.stringify({ type: "item.completed", item: { id: "i2", type: "agent_message", text: "Candidate 2 wins." } }), 30); - expect(message.items[0]).toMatchObject({ kind: "text", body: { text: "Candidate 2 wins." } }); - const usage = parser.line(JSON.stringify({ type: "turn.completed", usage: { input_tokens: 9, output_tokens: 2 } }), 40); - expect(usage.facts[0]).toMatchObject({ kind: "usage", usage: { inputTokens: 9, outputTokens: 2 } }); - const failed = parser.line(JSON.stringify({ type: "turn.failed", error: { message: "quota" } }), 50); - expect(failed.items[0]).toMatchObject({ kind: "notice", level: "error", text: "quota" }); - }); - - it("emits a call for a command seen only on completion", () => { - const parser = adapter.open(join(dir, "stream.jsonl")); - const done = parser.line(JSON.stringify({ type: "item.completed", item: { id: "i9", type: "command_execution", command: "ls", aggregated_output: "", exit_code: 2 } }), 0); - expect(done.items.map((item) => item.kind)).toEqual(["tool-call", "tool-result"]); - expect(done.items[1]).toMatchObject({ ok: false }); - }); - - it("reads the single result a Claude-style CLI prints at exit", () => { - const parser = adapter.open(join(dir, "stream.jsonl")); - const parsed = parser.line(JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "CLAUDE_OK", usage: { input_tokens: 10, output_tokens: 2 } }), 0); - expect(parsed.items[0]).toMatchObject({ kind: "text", body: { text: "CLAUDE_OK" } }); - expect(parsed.facts).toContainEqual({ kind: "usage", id, key: null, usage: { inputTokens: 10, outputTokens: 2 } } as never); - }); - - it("reads Grok's streamed assistant messages", () => { - const parser = adapter.open(join(dir, "stream.jsonl")); - const parsed = parser.line(JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "progress" }] } }), 0); - expect(parsed.items[0]).toMatchObject({ kind: "text", body: { text: "progress" } }); - }); - - it("tracks the call a Grok lane is waiting on", () => { - const parser = adapter.open(join(dir, "stream.jsonl")); - const call = parser.line(JSON.stringify({ - type: "assistant", - message: { content: [{ type: "tool_use", id: "g1", name: "read_file", input: { path: "src/a.ts" } }] }, - }), 0); - expect(call.facts).toContainEqual({ kind: "call", id, callId: "g1", at: null, event: { kind: "started", name: "read_file", snippet: "read_file · src/a.ts" } } as never); - const result = parser.line(JSON.stringify({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "g1", content: "x" }] } }), 10); - expect(result.facts).toEqual([{ kind: "call", id, callId: "g1", at: null, event: { kind: "ended" } }] as never); - }); - - it("tracks the item a Codex lane is waiting on", () => { - const parser = adapter.open(join(dir, "stream.jsonl")); - const started = parser.line(JSON.stringify({ type: "item.started", item: { id: "i1", type: "command_execution", command: "bun test" } }), 0); - expect(started.facts).toContainEqual(expect.objectContaining({ kind: "call", callId: "i1", event: expect.objectContaining({ kind: "started" }) })); - const done = parser.line(JSON.stringify({ type: "item.completed", item: { id: "i1", type: "command_execution", command: "bun test", exit_code: 0 } }), 10); - expect(done.facts).toEqual([{ kind: "call", id, callId: "i1", at: null, event: { kind: "ended" } }] as never); - const once = adapter.open(join(dir, "stream.jsonl")).line(JSON.stringify({ type: "item.completed", item: { id: "i2", type: "command_execution", command: "ls", exit_code: 0 } }), 0); - expect(once.facts.filter((fact) => fact.kind === "call").map((fact) => (fact as { event: { kind: string } }).event.kind)).toEqual(["started", "ended"]); - }); - - it("reports events it does not recognize", () => { - const parser = adapter.open(join(dir, "stream.jsonl")); - expect(parser.line(JSON.stringify({ type: "hologram" }), 0).problem).toEqual({ kind: "unknown-type", recordType: "hologram" }); - expect(parser.line(JSON.stringify({ type: "item.completed", item: { id: "x", type: "hologram" } }), 0).problem).toEqual({ kind: "unknown-type", recordType: "item/hologram" }); - }); -}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.ts deleted file mode 100644 index c1eaa560..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { relative, sep } from "node:path"; -import type { LaneRecord } from "../../runner/flex-journal.ts"; -import type { ReceiptStatus, RunnerReceipt } from "../../runner/types.ts"; -import { - NOTHING, - parsed, - problem, - type Adapter, - type Claim, - type Fact, - type LineParser, - type Outcome, - type Parsed, -} from "../adapter.ts"; -import type { AgentId, Flavor, TimelineItem } from "../domain.ts"; -import { array, clip, contentText, finite, object, oneLine, parseJson, pretty, text, usageFrom } from "../json.ts"; -import { assistantBlocks, BODY_LIMIT, callEnded, collected, describeTool, itemId, promptFact, textActivity, toolResult } from "./blocks.ts"; - -// pstack-flex addition. Reads the runner's lane journal: one directory per -// external lane holding its start record, its raw stdout, and its receipt. - -export function laneId(lane: string): AgentId { - return `lane:${lane}` as AgentId; -} - -/** Providers whose CLI streams events while it works; the rest print one result at exit. */ -function streams(provider: string): boolean { - return provider === "codex" || provider === "grok"; -} - -function outcomeOf(status: ReceiptStatus): Outcome { - switch (status) { - case "complete": - return "done"; - case "cancelled": - return "cancelled"; - case "unavailable-cli": - case "unauthenticated": - case "unavailable-model": - case "timed-out": - case "child-failed": - case "malformed-output": - return "failed"; - } -} - -function locate(root: string, path: string): { lane: string; file: string } | null { - const parts = relative(root, path).split(sep); - return parts.length === 2 && parts[0]!.length > 0 && !parts[0]!.startsWith(".") ? { lane: parts[0]!, file: parts[1]! } : null; -} - -function flavor(provider: string, mode: unknown, label: string | null, receipt: ReceiptStatus | null): Flavor { - return { - kind: "lane", - mode: mode === "isolated-write" ? "isolated-write" : "read-only", - stream: streams(provider) ? "live" : "at-exit", - label, - receipt, - }; -} - -/** Parses the lane's stdout whichever CLI wrote it: Codex exec events or Claude-style messages. */ -class StreamParser implements LineParser { - private readonly started = new Set(); - - constructor(private readonly agent: AgentId) {} - - line(line: string, offset: number): Parsed { - const raw = parseJson(line); - if (raw === undefined) return problem({ kind: "not-json" }); - const event = object(raw); - if (event === null) return problem({ kind: "shape", recordType: "?", detail: "event is not an object" }); - const type = text(event.type) ?? "?"; - if (type.startsWith("item.")) return this.item(type, object(event.item), offset); - switch (type) { - case "thread.started": - case "turn.started": - case "system": - return NOTHING; - case "turn.completed": { - const usage = usageFrom(event.usage); - return usage === null ? NOTHING : parsed([{ kind: "usage", id: this.agent, key: `turn:${offset}`, usage }]); - } - case "turn.failed": - case "error": { - const message = text(object(event.error)?.message) ?? text(event.message) ?? "the lane reported an error"; - return parsed([], [{ id: itemId(offset, 0), at: null, kind: "notice", level: "error", text: oneLine(message, 400) }]); - } - case "assistant": { - const out = collected(); - assistantBlocks(this.agent, offset, null, object(event.message)?.content, out); - return parsed(out.facts, out.items); - } - case "user": { - const items: TimelineItem[] = []; - const facts: Fact[] = []; - array(object(event.message)?.content).forEach((block, index) => { - const record = object(block); - if (record?.type !== "tool_result") return; - const item = toolResult(offset, index, null, record); - if (item?.kind !== "tool-result") return; - items.push(item); - facts.push(callEnded(this.agent, item.callId, null)); - }); - return parsed(facts, items); - } - case "result": { - const body = text(event.result); - const facts: Fact[] = []; - const items: TimelineItem[] = []; - const usage = usageFrom(event.usage); - if (usage !== null) facts.push({ kind: "usage", id: this.agent, key: null, usage }); - if (body !== null) { - items.push({ id: itemId(offset, 0), at: null, kind: "text", body: clip(body, BODY_LIMIT) }); - facts.push(textActivity(this.agent, body, null)); - } - if (event.is_error === true) { - items.push({ id: itemId(offset, 1), at: null, kind: "notice", level: "error", text: "the CLI reported an error result" }); - } - return parsed(facts, items); - } - default: - return problem({ kind: "unknown-type", recordType: type }, [ - { id: itemId(offset, 0), at: null, kind: "unparsed", recordType: type, bytes: line.length }, - ]); - } - } - - private item(type: string, item: Record | null, offset: number): Parsed { - if (item === null) return problem({ kind: "shape", recordType: type, detail: "missing item" }); - const kind = text(item.type) ?? "?"; - const callId = text(item.id) ?? `${offset}`; - const completed = type === "item.completed"; - const call = (name: string, input: string): Parsed => { - if (this.started.has(callId)) return NOTHING; - this.started.add(callId); - const snippet = describeTool(name, input); - return parsed( - [ - { kind: "activity", id: this.agent, activity: { what: "tool", snippet, at: null } }, - { kind: "call", id: this.agent, callId, at: null, event: { kind: "started", name, snippet } }, - ], - [{ id: itemId(offset, 0), at: null, kind: "tool-call", callId, name, input: clip(input, BODY_LIMIT) }], - ); - }; - const callAndResult = (name: string, input: string, ok: boolean | null, output: string): Parsed => { - const opened = call(name, input); - if (!completed) return opened; - return parsed([...opened.facts, callEnded(this.agent, callId, null)], [ - ...opened.items, - { id: itemId(offset, 1), at: null, kind: "tool-result", callId, ok, output: clip(output, BODY_LIMIT) }, - ]); - }; - switch (kind) { - case "agent_message": { - const body = text(item.text); - if (!completed || body === null) return NOTHING; - return parsed([textActivity(this.agent, body, null)], [{ id: itemId(offset, 0), at: null, kind: "text", body: clip(body, BODY_LIMIT) }]); - } - case "reasoning": { - if (!completed) return NOTHING; - const body = text(item.text); - return parsed( - [{ kind: "activity", id: this.agent, activity: { what: "thinking", snippet: "thinking", at: null } }], - [{ id: itemId(offset, 0), at: null, kind: "thinking", body: body === null ? null : clip(body, BODY_LIMIT) }], - ); - } - case "command_execution": { - const exit = finite(item.exit_code); - return callAndResult("shell", text(item.command) ?? "", exit === undefined ? null : exit === 0, text(item.aggregated_output) ?? ""); - } - case "file_change": - if (!completed) return NOTHING; - return callAndResult("apply_patch", pretty(item.changes), item.status === "completed" ? true : item.status === "failed" ? false : null, text(item.status) ?? ""); - case "mcp_tool_call": { - const name = [text(item.server), text(item.tool)].filter((part) => part !== null).join(".") || "mcp"; - const error = text(object(item.error)?.message); - return callAndResult(name, pretty(item.arguments), error === null ? (completed ? true : null) : false, error ?? contentText(object(item.result)?.content)); - } - case "web_search": - return call("web_search", text(item.query) ?? ""); - case "todo_list": { - if (!completed) return NOTHING; - const steps = array(item.items).map(object); - const doneCount = steps.filter((step) => step?.completed === true).length; - return parsed([], [{ id: itemId(offset, 0), at: null, kind: "notice", level: "info", text: `plan: ${doneCount} of ${steps.length} steps done` }]); - } - case "error": - return parsed([], [{ id: itemId(offset, 0), at: null, kind: "notice", level: "error", text: oneLine(text(item.message) ?? "error", 400) }]); - default: - return problem({ kind: "unknown-type", recordType: `item/${kind}` }, [ - { id: itemId(offset, 0), at: null, kind: "unparsed", recordType: `item/${kind}`, bytes: 0 }, - ]); - } - } -} - -export function laneAdapter(root: string): Adapter { - return { - source: "runner-lane", - windowed: false, - checkedVersion: null, - roots: [{ dir: root, depth: 2 }], - - claim(path: string): Claim | null { - const location = locate(root, path); - if (location === null) return null; - switch (location.file) { - case "lane.json": - case "receipt.json": - return { kind: "document", agent: laneId(location.lane), always: true }; - case "stream.jsonl": - return { kind: "stream", agent: laneId(location.lane), root: laneId(location.lane), header: false }; - default: - return null; - } - }, - - open(path: string): LineParser { - return new StreamParser(laneId(locate(root, path)?.lane ?? path)); - }, - - document(path: string, body: string): Parsed { - const location = locate(root, path); - const record = object(parseJson(body)); - if (location === null) return NOTHING; - if (record === null) return problem({ kind: "shape", recordType: location.file, detail: "not a JSON object" }); - const id = laneId(location.lane); - if (location.file === "lane.json") return laneRecord(path, id, record as Partial); - return laneReceipt(id, record as Partial); - }, - - removed(path: string) { - return locate(root, path)?.file === "lane.json" ? [{ kind: "process-gone", key: path }] : []; - }, - }; -} - -function laneRecord(path: string, id: AgentId, record: Partial): Parsed { - const provider = text(record.provider); - const parent = record.parent === "claude" || record.parent === "codex" ? record.parent : null; - const pid = finite(record.runnerPid); - if (provider === null || parent === null || pid === undefined) { - return problem({ kind: "shape", recordType: "lane.json", detail: "missing provider, parent, or runner pid" }); - } - const label = text(record.label); - const started = text(record.startedAt); - const head = text(record.promptHead); - const startedMs = started === null ? Number.NaN : Date.parse(started); - const facts: Fact[] = [ - { - kind: "agent", - id, - patch: { - harness: parent, - source: "runner-lane", - provider, - flavor: flavor(provider, record.mode, label, null), - ...(text(record.model) !== null ? { requestedModel: text(record.model)! } : {}), - ...(text(record.effort) !== null ? { effort: text(record.effort)! } : {}), - ...(text(record.cwd) !== null ? { cwd: text(record.cwd)! } : {}), - ...(started !== null ? { seenAt: started } : {}), - ...(label !== null ? { title: oneLine(label, 120) } : {}), - ...(head !== null ? { titleHint: oneLine(head, 80) } : {}), - }, - }, - // Only pstack's runner writes a lane record. - { kind: "pstack", id }, - { - kind: "process", - key: path, - id, - process: { pid, startedAtMs: Number.isFinite(startedMs) ? startedMs : null, state: "running" }, - }, - ]; - if (head !== null) facts.push(promptFact(id, head, started)); - const session = text(record.parentSessionId); - if (session !== null) facts.push({ kind: "link", id, parent: `${parent}:${session}` as AgentId, via: "runner" }); - return parsed(facts); -} - -const RECEIPT_STATUSES = new Set([ - "complete", - "cancelled", - "unavailable-cli", - "unauthenticated", - "unavailable-model", - "timed-out", - "child-failed", - "malformed-output", -]); - -function laneReceipt(id: AgentId, receipt: Partial): Parsed { - const status = text(receipt.status); - const provider = text(receipt.provider); - if (status === null || !RECEIPT_STATUSES.has(status) || provider === null) { - return problem({ kind: "shape", recordType: "receipt.json", detail: "missing or unknown status" }); - } - const known = status as ReceiptStatus; - const completedAt = text(receipt.completedAt); - const outcome = outcomeOf(known); - const message = text(receipt.error?.message); - const facts: Fact[] = [ - { - kind: "agent", - id, - patch: { - provider, - flavor: flavor(provider, receipt.mode, null, known), - ...(text(receipt.reportedModel) !== null ? { reportedModel: text(receipt.reportedModel)! } : {}), - ...(completedAt !== null ? { seenAt: completedAt } : {}), - }, - }, - { - kind: "outcome", - id, - outcome, - at: completedAt, - reason: outcome === "failed" ? `${known.replace(/-/g, " ")}${message === null ? "" : `: ${message}`}` : null, - }, - ]; - const usage = receipt.usage; - if (usage !== null && usage !== undefined && Object.values(usage).some((value) => typeof value === "number")) { - facts.push({ kind: "usage", id, key: null, usage }); - } - return parsed(facts); -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/assets.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/assets.test.ts deleted file mode 100644 index 1f52be66..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/assets.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { buildAssets } from "./assets.ts"; - -describe("buildAssets", () => { - it("bundles the page in memory with no inline script or style", async () => { - const assets = await buildAssets(); - expect(assets.js.length).toBeGreaterThan(10_000); - expect(assets.css).toContain("--accent"); - expect(assets.html).toContain(''); - expect(assets.html).toContain('data-harness="claude"'); - // The page's content security policy allows only same-origin scripts and styles. - expect(assets.html).not.toMatch(/]*\bsrc=)[^>]*>/); - expect(assets.html).not.toMatch(/ { - const result = await Bun.build({ - entrypoints: [join(WEB_DIR, "main.ts")], - target: "browser", - format: "esm", - minify: true, - }); - const bundle = result.outputs[0]; - if (!result.success || bundle === undefined) { - throw new Error(`could not bundle the monitor page:\n${result.logs.map(String).join("\n")}`); - } - return { - html: readFileSync(join(WEB_DIR, "index.html"), "utf8"), - css: readFileSync(join(WEB_DIR, "app.css"), "utf8"), - js: await bundle.text(), - }; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/cli.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/cli.ts deleted file mode 100644 index 5fd80b6b..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/cli.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { parseArgs as parseNodeArgs } from "node:util"; -import { buildAssets } from "./assets.ts"; -import { DEFAULT_PORT, serve, start, status, stop, type Io, type Scope } from "./daemon.ts"; -import { diagnose, renderReport } from "./doctor.ts"; -import type { Harness } from "./domain.ts"; -import { journalEnabled, journalOff, journalOn } from "./journal.ts"; -import { homes, type Homes } from "./sources.ts"; - -// pstack-flex addition. Entry point for `pstack-monitor`. - -const HELP = `Usage: pstack-monitor [options] - -Commands: - start Start the monitor, or reuse the running one, and print its link. - status Print a one-line summary and the link. - stop Stop the monitor. Agents keep running. - doctor Report how well recent transcripts parsed (counts only). - journal - Record external lanes for the monitor. \`off\` deletes the records. - serve Run the server in the foreground. - -Options: - --parent The harness asking: sets the theme and focuses its session. - --focus Session to select first. - --all Show every session, not only pstack's. - --port Port on 127.0.0.1 (default ${DEFAULT_PORT}). - --hours How far back to index (default 24). - -h, --help Show this help. - -The monitor runs until \`pstack-monitor stop\`. -`; - -const defaultIo: Io = { - stdout: (value) => process.stdout.write(value), - stderr: (value) => process.stderr.write(value), -}; - -class UsageError extends Error {} - -const COMMANDS = ["start", "status", "stop", "doctor", "journal", "serve"] as const; -type Command = (typeof COMMANDS)[number]; -const JOURNAL_ACTIONS = ["on", "off", "status"] as const; -type JournalAction = (typeof JOURNAL_ACTIONS)[number]; - -export interface Options { - readonly command: Command | "help"; - readonly journal: JournalAction; - readonly port: number; - readonly hours: number; - readonly harness: Harness | null; - readonly focus: string | null; - readonly scope: Scope; -} - -function positiveNumber(name: string, value: unknown, fallback: number): number { - if (value === undefined) return fallback; - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) throw new UsageError(`--${name} must be a number greater than zero`); - return parsed; -} - -/** The harness's own session id, read from the environment it gives its tools. */ -function currentSession(harness: Harness | null, env: NodeJS.ProcessEnv): string | null { - const value = harness === "claude" ? env.CLAUDE_CODE_SESSION_ID : harness === "codex" ? env.CODEX_THREAD_ID : undefined; - return value !== undefined && value.length > 0 ? value : null; -} - -export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): Options { - let parsed: ReturnType; - try { - parsed = parseNodeArgs({ - args: [...argv], - allowPositionals: true, - strict: true, - options: { - parent: { type: "string" }, - focus: { type: "string" }, - port: { type: "string" }, - hours: { type: "string" }, - all: { type: "boolean", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - }); - } catch (error) { - throw new UsageError(error instanceof Error ? error.message : String(error)); - } - const command = parsed.positionals[0]; - const port = positiveNumber("port", parsed.values.port, DEFAULT_PORT); - if (!Number.isInteger(port) || port > 65_535) throw new UsageError("--port must be an integer port number"); - const hours = positiveNumber("hours", parsed.values.hours, 24); - const parent = parsed.values.parent; - if (parent !== undefined && parent !== "claude" && parent !== "codex") { - throw new UsageError("--parent must be claude or codex"); - } - const harness = parent ?? null; - const focusValue = typeof parsed.values.focus === "string" && parsed.values.focus.length > 0 ? parsed.values.focus : null; - const session = focusValue ?? currentSession(harness, env); - const focus = session === null || harness === null || session.includes(":") ? session : `${harness}:${session}`; - const scope: Scope = parsed.values.all === true ? "all" : "pstack"; - if (parsed.values.help === true || command === undefined || command === "help") { - return { command: "help", journal: "status", port, hours, harness, focus, scope }; - } - if (!(COMMANDS as readonly string[]).includes(command)) throw new UsageError(`unknown command: ${command}`); - const action = parsed.positionals[1] ?? "status"; - if (command === "journal" && !(JOURNAL_ACTIONS as readonly string[]).includes(action)) { - throw new UsageError("journal takes on, off, or status"); - } - return { command: command as Command, journal: action as JournalAction, port, hours, harness, focus, scope }; -} - -function journal(where: Homes, action: JournalAction, io: Io): number { - switch (action) { - case "on": - io.stdout(journalOn(where.lanes) === "enabled" - ? `lane journal on: external lanes are recorded in ${where.lanes} and kept 7 days\n` - : `lane journal is already on (${where.lanes})\n`); - return 0; - case "off": - io.stdout(journalOff(where.lanes) === "disabled" - ? `lane journal off: deleted ${where.lanes}\n` - : "lane journal is already off\n"); - return 0; - case "status": - io.stdout(journalEnabled(where.lanes) ? `lane journal on (${where.lanes})\n` : "lane journal off\n"); - return 0; - } -} - -export async function main( - argv: readonly string[], - io: Io = defaultIo, - where: Homes = homes(), -): Promise { - let options: Options; - try { - options = parseArgs(argv); - } catch (error) { - io.stderr(`error: ${error instanceof Error ? error.message : String(error)}\n${HELP}`); - return 64; - } - switch (options.command) { - case "help": - io.stdout(HELP); - return 0; - case "start": - return start(where, { port: options.port, windowHours: options.hours, harness: options.harness, focus: options.focus, scope: options.scope }, io); - case "status": - return status(where, io, options.scope); - case "stop": - return stop(where, io); - case "journal": - return journal(where, options.journal, io); - case "serve": - return serve(where, { port: options.port, windowHours: options.hours, assets: buildAssets }, io); - case "doctor": { - const report = await diagnose(where, Date.now() - options.hours * 3_600_000); - io.stdout(renderReport(report, options.hours)); - return report.health.some((source) => source.state !== "ok") ? 1 : 0; - } - } -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/daemon.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/daemon.ts deleted file mode 100644 index d7533e1c..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/daemon.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { AgentNode, Harness } from "./domain.ts"; -import { diskFileSystem } from "./fs.ts"; -import { journalOn, pruneLanes } from "./journal.ts"; -import { Monitor } from "./monitor.ts"; -import { psTable } from "./probe.ts"; -import { clearRecord, readRecord, serverUrl, writeRecord, type ServerRecord } from "./record.ts"; -import { createHandler, type Assets } from "./server.ts"; -import { adapters, type Homes } from "./sources.ts"; -import { monitorVersion } from "./version.ts"; -import type { Snapshot } from "./wire.ts"; - -// pstack-flex addition. Process lifecycle for the monitor server: a detached -// daemon that outlives the session that started it, one per user. - -export const DEFAULT_PORT = 47317; -const LAUNCHER = fileURLToPath(new URL("./pstack-monitor", import.meta.url)); -const POLL_INTERVAL_MS = 100; - -export interface Io { - readonly stdout: (value: string) => void; - readonly stderr: (value: string) => void; -} - -export interface ServeOptions { - readonly port: number; - readonly windowHours: number; - readonly assets: () => Promise; -} - -export type Scope = "pstack" | "all"; - -export interface StartOptions { - readonly port: number; - readonly windowHours: number; - readonly harness: Harness | null; - readonly focus: string | null; - readonly scope: Scope; -} - -interface Health { - readonly app: string; - readonly version: string; - readonly instance: string; - readonly pid: number; -} - -async function health(port: number): Promise { - try { - const response = await fetch(`http://127.0.0.1:${port}/api/health`); - if (!response.ok) return null; - const body = (await response.json()) as Partial; - return body.app === "pstack-monitor" && typeof body.instance === "string" ? (body as Health) : null; - } catch { - return null; - } -} - -function processExists(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function waitForExit(pid: number): Promise { - while (processExists(pid)) await Bun.sleep(POLL_INTERVAL_MS); -} - -export function launchUrl(record: ServerRecord, harness: Harness | null, focus: string | null, scope: Scope = "pstack"): string { - const query = new URLSearchParams({ token: record.token }); - if (harness !== null) query.set("harness", harness); - if (focus !== null) query.set("focus", focus); - if (scope === "all") query.set("all", "1"); - return `${serverUrl(record)}/?${query.toString()}`; -} - -function logTail(path: string): string { - try { - const lines = readFileSync(path, "utf8").trimEnd().split("\n"); - return lines.slice(-8).join("\n"); - } catch { - return ""; - } -} - -/** Runs the server in the foreground until SIGINT or SIGTERM. */ -export async function serve(where: Homes, options: ServeOptions, io: Io): Promise { - const pruned = pruneLanes(where.lanes, Date.now()); - if (pruned > 0) io.stdout(`removed ${pruned} lane ${pruned === 1 ? "journal" : "journals"} older than 7 days\n`); - const version = monitorVersion(); - const instance = randomBytes(8).toString("hex"); - const token = randomBytes(24).toString("base64url"); - const monitor = new Monitor({ - adapters: adapters(where), - fs: diskFileSystem, - table: psTable, - windowHours: options.windowHours, - version, - instance, - }); - const handler = createHandler(monitor, { port: options.port, token, assets: await options.assets() }); - let server: ReturnType; - try { - server = Bun.serve({ hostname: "127.0.0.1", port: options.port, fetch: (request, bun) => handler(request, bun) }); - } catch (error) { - io.stderr(`port ${options.port} is unavailable: ${error instanceof Error ? error.message : String(error)}\n`); - return 69; - } - writeRecord(where.state, { - pid: process.pid, - port: options.port, - token, - version, - instance, - startedAt: new Date().toISOString(), - }); - io.stdout(`pstack-monitor ${version} serving ${serverUrl({ port: options.port })}\n`); - const stopped = new Promise((resolve) => { - process.once("SIGTERM", () => resolve()); - process.once("SIGINT", () => resolve()); - }); - await monitor.start(); - await stopped; - monitor.stop(); - server.stop(true); - clearRecord(where.state, instance); - return 0; -} - -/** Starts the daemon, or reuses one that runs this exact build, and prints its link. */ -export async function start(where: Homes, options: StartOptions, io: Io): Promise { - // Watching agents means wanting external lanes too; say so the first time, since lane output is kept on disk. - if (journalOn(where.lanes) === "enabled") { - io.stderr( - `lane journal on: external pstack lanes are recorded in ${where.lanes} and kept 7 days. ` + - "`pstack-monitor journal off` stops it and deletes them.\n", - ); - } - const version = monitorVersion(); - const existing = readRecord(where.state); - if (existing !== null) { - const running = await health(existing.port); - if (running?.instance === existing.instance) { - if (running.version === version) { - io.stdout(`${launchUrl(existing, options.harness, options.focus, options.scope)}\n`); - return 0; - } - io.stderr(`replacing pstack-monitor ${running.version} with ${version}\n`); - process.kill(running.pid, "SIGTERM"); - await waitForExit(running.pid); - } - } - - mkdirSync(where.state, { recursive: true, mode: 0o700 }); - const logPath = join(where.state, "server.log"); - const log = openSync(logPath, "a", 0o600); - const child = spawn( - process.execPath, - [LAUNCHER, "serve", "--port", String(options.port), "--hours", String(options.windowHours)], - // Detached with its output in a file, so the caller's shell or tool call returns at once. - { detached: true, stdio: ["ignore", log, log], env: process.env }, - ); - closeSync(log); - let exitCode: number | null = null; - child.once("exit", (code) => { - exitCode = code ?? 1; - }); - child.unref(); - - // The server either becomes healthy or exits; there is no deadline to invent. - for (;;) { - if (exitCode !== null) { - io.stderr(`pstack-monitor exited before it was ready (status ${exitCode}). Log: ${logPath}\n${logTail(logPath)}\n`); - return 69; - } - const record = readRecord(where.state); - if (record !== null && record.pid === child.pid && (await health(record.port))?.instance === record.instance) { - io.stdout(`${launchUrl(record, options.harness, options.focus, options.scope)}\n`); - return 0; - } - await Bun.sleep(POLL_INTERVAL_MS); - } -} - -export async function stop(where: Homes, io: Io): Promise { - const record = readRecord(where.state); - if (record === null) { - io.stdout("pstack-monitor is not running\n"); - return 0; - } - const running = await health(record.port); - if (running?.instance !== record.instance) { - clearRecord(where.state, record.instance); - io.stdout("pstack-monitor is not running (removed a stale record)\n"); - return 0; - } - process.kill(running.pid, "SIGTERM"); - await waitForExit(running.pid); - io.stdout("pstack-monitor stopped\n"); - return 0; -} - -export function summarize(agents: readonly AgentNode[]): string { - const counts = new Map(); - for (const agent of agents) counts.set(agent.status.kind, (counts.get(agent.status.kind) ?? 0) + 1); - const parts = [`${agents.length} agents`]; - for (const kind of ["running", "idle", "failed", "done", "cancelled", "ended", "unknown"]) { - const count = counts.get(kind); - if (count !== undefined) parts.push(`${count} ${kind}`); - } - return parts.join(" · "); -} - -export async function status(where: Homes, io: Io, scope: Scope = "pstack"): Promise { - const record = readRecord(where.state); - const running = record === null ? null : await health(record.port); - if (record === null || running?.instance !== record.instance) { - io.stdout("pstack-monitor is not running\n"); - return 1; - } - try { - const response = await fetch(`${serverUrl(record)}/api/snapshot`, { - headers: { Authorization: `Bearer ${record.token}` }, - }); - const snapshot = (await response.json()) as Snapshot; - const indexing = snapshot.server.indexing ? " · indexing" : ""; - const agents = snapshot.agents.filter((agent) => scope === "all" || agent.pstack); - io.stdout(`pstack-monitor ${running.version} · ${summarize(agents)}${indexing}\n${launchUrl(record, null, null, scope)}\n`); - return 0; - } catch (error) { - io.stderr(`pstack-monitor did not answer: ${error instanceof Error ? error.message : String(error)}\n`); - return 1; - } -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/doctor.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/doctor.ts deleted file mode 100644 index 14683719..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/doctor.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { AgentStatus } from "./domain.ts"; -import { diskFileSystem } from "./fs.ts"; -import { Index } from "./index.ts"; -import { alive, psTable, type ProcessTable } from "./probe.ts"; -import { adapters, type Homes } from "./sources.ts"; -import { Store } from "./store.ts"; -import type { SourceHealth } from "./wire.ts"; - -// pstack-flex addition. Indexes the window headlessly and reports how well -// each source parsed. Prints counts and record types only, never content. - -export interface DoctorReport { - readonly health: readonly SourceHealth[]; - readonly statuses: Readonly>>; - readonly agents: number; -} - -export async function diagnose( - where: Homes, - sinceMs: number, - table: ProcessTable = psTable, -): Promise { - const store = new Store(); - const index = new Index(adapters(where), store, diskFileSystem, { sinceMs }); - await index.refresh(true); - const targets = store.probeTargets(); - const live = await table(targets.map((target) => target.pid)); - for (const target of targets) store.setAlive(target.key, alive(target, live)); - const statuses: Partial> = {}; - const nodes = store.nodes(); - for (const node of nodes) statuses[node.status.kind] = (statuses[node.status.kind] ?? 0) + 1; - return { health: index.health(), statuses, agents: nodes.length }; -} - -export function renderReport(report: DoctorReport, windowHours: number): string { - const lines: string[] = [`pstack-monitor doctor · last ${windowHours}h · ${report.agents} agents`]; - const statuses = Object.entries(report.statuses).map(([kind, count]) => `${count} ${kind}`); - if (statuses.length > 0) lines.push(` status: ${statuses.join(", ")}`); - for (const source of report.health) { - lines.push(""); - if (!source.present) { - lines.push(`${source.source}: not present on this machine`); - continue; - } - lines.push(`${source.source}: ${source.state}`); - lines.push(` files ${source.files} · lines ${source.lines} · parsed ${source.parsed}`); - const issues = [ - source.shape > 0 ? `${source.shape} malformed` : null, - source.notJson > 0 ? `${source.notJson} not JSON` : null, - source.oversized > 0 ? `${source.oversized} oversized` : null, - ].filter((issue) => issue !== null); - if (issues.length > 0) lines.push(` problems: ${issues.join(", ")}`); - const unknown = Object.entries(source.unknownTypes).sort((a, b) => b[1] - a[1]); - if (unknown.length > 0) { - lines.push(` unrecognized record types: ${unknown.map(([type, count]) => `${type} ×${count}`).join(", ")}`); - } - if (source.cliVersions.length > 0) lines.push(` CLI versions: ${source.cliVersions.join(", ")}`); - if (source.unchecked.length > 0) { - lines.push(` newer than the adapter was checked against: ${source.unchecked.join(", ")}`); - } - } - return `${lines.join("\n")}\n`; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/domain.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/domain.ts deleted file mode 100644 index 8654817f..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/domain.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { AccessMode, NormalizedUsage, ReceiptStatus } from "../runner/types.ts"; - -// pstack-flex addition. Shared by the monitor server and the browser bundle, -// so this file imports nothing from Bun, Node, or the DOM. - -declare const brand: unique symbol; -type Brand = T & { readonly [brand]: B }; - -/** `claude:`, `claude::`, `codex:`, or `lane:`. */ -export type AgentId = Brand; -/** `.` of the record an item came from, in its agent's file. */ -export type ItemId = Brand; - -export type Harness = "claude" | "codex"; -export type SourceKind = "claude-session" | "codex-rollout" | "runner-lane"; -export type Health = "ok" | "degraded"; - -export type AgentStatus = - | { readonly kind: "running"; readonly evidence: "pid" | "lifecycle" | "parent" } - | { readonly kind: "idle"; readonly evidence: "pid" | "lifecycle"; readonly detail: string | null } - | { readonly kind: "done"; readonly at: string | null } - | { readonly kind: "failed"; readonly at: string | null; readonly reason: string } - | { readonly kind: "cancelled"; readonly at: string | null } - // The owning process is gone and no outcome was recorded. - | { readonly kind: "ended"; readonly at: string | null } - | { readonly kind: "unknown"; readonly why: string }; - -export type Flavor = - | { readonly kind: "session" } - | { readonly kind: "subagent"; readonly agentType: string | null } - | { - readonly kind: "lane"; - readonly mode: AccessMode; - // "at-exit" lanes print one blob when the child exits; there is nothing to stream. - readonly stream: "live" | "at-exit"; - readonly label: string | null; - readonly receipt: ReceiptStatus | null; - }; - -export type SpawnVia = "tool-use" | "thread-spawn" | "runner" | "team"; - -export interface ModelInfo { - readonly provider: string; - readonly requested: string | null; - readonly reported: string | null; - readonly effort: string | null; -} - -export interface Activity { - readonly what: "text" | "tool" | "thinking"; - readonly snippet: string; - readonly at: string | null; -} - -export interface Prompt { - readonly text: string; - readonly at: string | null; -} - -/** A tool call still waiting for its result. */ -export interface PendingCall { - readonly name: string; - readonly snippet: string; - readonly since: string | null; -} - -export interface AgentNode { - readonly id: AgentId; - // May name an agent outside the indexed window; the client then draws this node as a root. - readonly parent: AgentId | null; - readonly via: SpawnVia | null; - /** The parent's tool call that spawned this agent, when the harness records one. */ - readonly spawnCall: string | null; - readonly harness: Harness; - readonly source: SourceKind; - readonly flavor: Flavor; - readonly title: string; - readonly cwd: string | null; - readonly model: ModelInfo; - readonly status: AgentStatus; - readonly startedAt: string | null; - readonly lastActivityAt: string | null; - readonly activity: Activity | null; - /** True when any agent in this node's spawn tree is pstack work. */ - readonly pstack: boolean; - readonly prompt: Prompt | null; - readonly pending: PendingCall | null; - readonly usage: NormalizedUsage | null; - readonly health: Health; -} - -/** Messages one agent sent another, summed. Direction matters: a reply is its own link. */ -export interface MessageLink { - readonly from: AgentId; - readonly to: AgentId; - readonly count: number; - readonly lastAt: string | null; -} - -export interface Clipped { - readonly text: string; - /** Characters dropped from the end. */ - readonly omitted: number; -} - -export type TimelineItem = { readonly id: ItemId; readonly at: string | null } & ( - | { readonly kind: "prompt" | "text"; readonly body: Clipped } - // `body: null` means the CLI recorded that thinking happened but not its text. - | { readonly kind: "thinking"; readonly body: Clipped | null } - | { - readonly kind: "tool-call"; - readonly callId: string; - readonly name: string; - readonly input: Clipped; - } - | { - readonly kind: "tool-result"; - readonly callId: string; - readonly ok: boolean | null; - readonly output: Clipped; - } - | { readonly kind: "notice"; readonly level: "info" | "error"; readonly text: string } - | { readonly kind: "unparsed"; readonly recordType: string | null; readonly bytes: number } -); - -export type { AccessMode, NormalizedUsage, ReceiptStatus }; diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/format.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/format.test.ts deleted file mode 100644 index 2a7611a5..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/format.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { AgentNode, AgentStatus, Flavor } from "./domain.ts"; -import { activityLine, ago, compactNumber, duration, prettyModel, shortPath, statusLine, summarizeInput } from "./format.ts"; - -const now = Date.parse("2026-10-01T12:00:00Z"); -const node = (status: AgentStatus, flavor: Flavor = { kind: "session" }, extra: Partial = {}): AgentNode => ({ - status, - flavor, - startedAt: "2026-10-01T11:57:46Z", - lastActivityAt: "2026-10-01T11:57:00Z", - activity: null, - pending: null, - ...extra, -}) as AgentNode; - -describe("format", () => { - it("compacts token counts", () => { - expect([999, 1_000, 1_250, 12_400, 412_000, 1_200_000].map(compactNumber)).toEqual(["999", "1k", "1.3k", "12k", "412k", "1.2M"]); - }); - - it("formats durations and ages", () => { - expect(duration(8_000)).toBe("8s"); - expect(duration(134_000)).toBe("2m 14s"); - expect(duration(3 * 3_600_000 + 5 * 60_000)).toBe("3h 05m"); - const now = Date.parse("2026-10-01T12:00:00Z"); - expect(ago("2026-10-01T11:59:55Z", now)).toBe("just now"); - expect(ago("2026-10-01T11:56:00Z", now)).toBe("4m ago"); - expect(ago(null, now)).toBe(""); - }); - - it("shortens Claude model slugs and leaves others alone", () => { - expect(prettyModel(["claude", "opus", "5", "5"].join("-"))).toBe("opus 5.5"); - expect(prettyModel("gpt-6.1-sol")).toBe("gpt-6.1-sol"); - expect(prettyModel(null)).toBeNull(); - }); - - it("summarizes tool input by its most telling field", () => { - expect(summarizeInput('{\n "command": "git status --short",\n "description": "x"\n}')).toBe("git status --short"); - expect(summarizeInput('{"file_path": "/repo/a.ts"')).toBe('{"file_path": "/repo/a.ts"'); - expect(summarizeInput("ls -la\nmore")).toBe("ls -la"); - }); - - it("keeps the last two path segments", () => { - expect(shortPath("/Users/me/workspace/pstack-flex")).toBe("…/workspace/pstack-flex"); - expect(shortPath("/repo")).toBe("/repo"); - }); - - it("says whether an agent works, waits for input, or is gone", () => { - const subagent: Flavor = { kind: "subagent", agentType: null }; - const fresh = { activity: { what: "tool", snippet: "Bash · ls", at: "2026-10-01T11:59:50Z" }, lastActivityAt: "2026-10-01T11:59:50Z" } as const; - expect(statusLine(node({ kind: "running", evidence: "pid" }, { kind: "session" }, fresh), now)).toBe("working · 2m 14s"); - expect(statusLine(node({ kind: "running", evidence: "lifecycle" }, subagent, fresh), now)).toBe("in turn · 2m 14s"); - expect(statusLine(node({ kind: "running", evidence: "parent" }, subagent, fresh), now)).toBe("running · 2m 14s"); - expect(statusLine(node({ kind: "idle", evidence: "pid", detail: null }), now)).toBe("waiting for input · 3m ago"); - expect(statusLine(node({ kind: "idle", evidence: "lifecycle", detail: null }), now)).toBe("between turns · 3m ago"); - expect(statusLine(node({ kind: "ended", at: null }), now)).toBe("ended · process gone"); - expect(statusLine(node({ kind: "ended", at: null }, subagent), now)).toBe("ended · 3m ago"); - }); - - it("flags a running agent as quiet only when its source stamps activity", () => { - const stale = { activity: { what: "text", snippet: "x", at: "2026-10-01T11:57:00Z" } } as const; - expect(statusLine(node({ kind: "running", evidence: "lifecycle" }, { kind: "session" }, stale), now)).toBe("in turn · 2m 14s · quiet 3m 00s"); - const lane: Flavor = { kind: "lane", mode: "read-only", stream: "at-exit", label: null, receipt: null }; - expect(statusLine(node({ kind: "running", evidence: "pid" }, lane), now)).toBe("working · 2m 14s"); - }); - - it("calls a turn stalled once it has been silent for 15 minutes, but never a live process", () => { - const old = { lastActivityAt: "2026-10-01T11:40:00Z" } as const; - expect(statusLine(node({ kind: "running", evidence: "lifecycle" }, { kind: "session" }, old), now)).toBe("stalled · 20m ago"); - expect(statusLine(node({ kind: "running", evidence: "pid" }, { kind: "session" }, old), now)).toBe("working · 2m 14s"); - }); - - it("names the call an agent waits on before its latest step", () => { - const lane: Flavor = { kind: "lane", mode: "read-only", stream: "at-exit", label: null, receipt: null }; - const pending = { name: "Bash", snippet: "Bash · bun test", since: null }; - const activity = { what: "text", snippet: "Looking", at: null } as const; - expect(activityLine(node({ kind: "running", evidence: "pid" }, { kind: "session" }, { pending, activity }))).toBe("Bash · bun test"); - expect(activityLine(node({ kind: "running", evidence: "pid" }, { kind: "session" }, { activity }))).toBe("Looking"); - expect(activityLine(node({ kind: "running", evidence: "pid" }, lane))).toBe("reply arrives at exit"); - expect(activityLine(node({ kind: "done", at: null }, lane))).toBeNull(); - }); -}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts deleted file mode 100644 index 8fec16ab..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { AgentNode, AgentStatus } from "./domain.ts"; - -// pstack-flex addition. Pure display formatting shared by the browser and tests. - -export function compactNumber(value: number): string { - if (value < 1_000) return String(value); - if (value < 10_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`; - if (value < 1_000_000) return `${Math.round(value / 1_000)}k`; - return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; -} - -export function duration(ms: number): string { - const seconds = Math.max(0, Math.floor(ms / 1_000)); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; - return `${Math.floor(hours / 24)}d ${hours % 24}h`; -} - -export function ago(iso: string | null, now: number): string { - if (iso === null) return ""; - const ms = now - Date.parse(iso); - if (!Number.isFinite(ms)) return ""; - if (ms < 10_000) return "just now"; - const seconds = Math.floor(ms / 1_000); - if (seconds < 60) return `${seconds}s ago`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - -export function clockTime(iso: string | null): string { - if (iso === null) return ""; - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return ""; - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} - -/** `claude-opus-5-5` reads as `opus 5.5`; other providers' slugs are already short. */ -export function prettyModel(model: string | null): string | null { - if (model === null) return null; - const match = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?$/.exec(model); - return match === null ? model : `${match[1]} ${match[2]}.${match[3]}`; -} - -export function modelOf(node: AgentNode): string | null { - return prettyModel(node.model.reported ?? node.model.requested); -} - -export const STATUS_WORD: Record = { - running: "running", - idle: "idle", - done: "done", - failed: "failed", - cancelled: "cancelled", - ended: "ended", - unknown: "unknown", -}; - -export const QUIET_MS = 90_000; -export const STALE_MS = 15 * 60_000; - -const RUNNING_VERB = { pid: "working", lifecycle: "in turn", parent: "running" } as const; - -/** How long a running agent has gone without stamped activity, once past `QUIET_MS`. Lanes stamp none. */ -export function quietFor(node: AgentNode, now: number): number | null { - if (node.status.kind !== "running" || node.activity?.at == null || node.lastActivityAt === null) return null; - const silent = now - Date.parse(node.lastActivityAt); - return Number.isFinite(silent) && silent > QUIET_MS ? silent : null; -} - -/** - * A turn that opened and then went silent. Only a missing end-of-turn record says it still runs, - * which is what a process killed mid-turn leaves behind; a live process record is never stalled. - */ -export function stalled(node: AgentNode, now: number): boolean { - if (node.status.kind !== "running" || node.status.evidence !== "lifecycle" || node.lastActivityAt === null) return false; - const silent = now - Date.parse(node.lastActivityAt); - return Number.isFinite(silent) && silent >= STALE_MS; -} - -/** Doing work now, as far as the evidence shows. */ -export function working(node: AgentNode, now: number): boolean { - return node.status.kind === "running" && !stalled(node, now); -} - -/** What the agent is doing now: the call it waits on, else its latest step. */ -export function activityLine(node: AgentNode): string | null { - if (node.pending !== null) return node.pending.snippet; - if (node.activity !== null) return node.activity.snippet; - if (node.status.kind === "running" && node.flavor.kind === "lane" && node.flavor.stream === "at-exit") return "reply arrives at exit"; - return null; -} - -/** One line of status for a card, e.g. `working · 2m 14s` or `done · 4m ago`. */ -export function statusLine(node: AgentNode, now: number): string { - const status = node.status; - const session = node.flavor.kind === "session"; - switch (status.kind) { - case "running": { - if (stalled(node, now)) return `stalled · ${ago(node.lastActivityAt, now)}`; - const since = node.startedAt === null ? null : now - Date.parse(node.startedAt); - const verb = RUNNING_VERB[status.evidence]; - const line = since === null || !Number.isFinite(since) ? verb : `${verb} · ${duration(since)}`; - const quiet = quietFor(node, now); - return quiet === null ? line : `${line} · quiet ${duration(quiet)}`; - } - case "idle": { - if (status.detail !== null) return status.detail.replace(/_/g, " "); - const since = ago(node.lastActivityAt, now); - if (session && status.evidence === "pid") return `waiting for input · ${since}`; - return `${session ? "between turns" : "idle"} · ${since}`; - } - case "done": - return `done · ${ago(status.at ?? node.lastActivityAt, now)}`; - case "failed": - return `failed · ${status.reason}`; - case "cancelled": - return `cancelled · ${ago(status.at ?? node.lastActivityAt, now)}`; - case "ended": - return session ? "ended · process gone" : `ended · ${ago(status.at ?? node.lastActivityAt, now)}`; - case "unknown": - return "status unknown"; - } -} - -export function kindLabel(node: AgentNode): string { - switch (node.flavor.kind) { - case "session": - return node.harness === "claude" ? "Claude Code session" : "Codex session"; - case "subagent": { - // Plugin agent types are namespaced, e.g. `pstack:poteto-agent`; the namespace adds nothing here. - const type = node.flavor.agentType; - return type === null ? "subagent" : type.slice(type.lastIndexOf(":") + 1); - } - case "lane": - return `${node.model.provider} lane · ${node.flavor.mode}`; - } -} - -const INPUT_FIELDS = ["command", "description", "file_path", "path", "pattern", "url", "query", "skill", "prompt", "task_name", "message"]; - -/** A one-line summary of a tool call's input, from its (possibly clipped) JSON. */ -export function summarizeInput(input: string): string { - try { - const parsed = JSON.parse(input) as unknown; - if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { - const record = parsed as Record; - for (const field of INPUT_FIELDS) { - const value = record[field]; - if (typeof value === "string" && value.trim().length > 0) return value.replace(/\s+/g, " ").trim(); - } - } - } catch { - // Clipped or not JSON; fall back to the first line. - } - const line = input.trim().split("\n", 1)[0] ?? ""; - return line.replace(/\s+/g, " "); -} - -export function shortPath(path: string | null): string { - if (path === null) return ""; - const parts = path.split("/").filter((part) => part.length > 0); - return parts.length <= 2 ? path : `…/${parts.slice(-2).join("/")}`; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/fs.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/fs.ts deleted file mode 100644 index 5ca7d64b..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/fs.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs"; -import type { Reader } from "./tail.ts"; - -// pstack-flex addition. The monitor's only view of the disk, injectable for tests. - -export interface FileStat { - readonly size: number; - readonly mtimeMs: number; - readonly ino: number; - readonly directory: boolean; -} - -export interface Entry { - readonly name: string; - readonly directory: boolean; -} - -export interface FileSystem extends Reader { - stat(path: string): FileStat | null; - list(dir: string): readonly Entry[] | null; - readText(path: string, limit: number): string | null; -} - -export const diskFileSystem: FileSystem = { - stat(path) { - try { - const stat = statSync(path); - return { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino, directory: stat.isDirectory() }; - } catch { - return null; - } - }, - - list(dir) { - try { - return readdirSync(dir, { withFileTypes: true }).map((entry) => ({ - name: entry.name, - directory: entry.isDirectory(), - })); - } catch { - return null; - } - }, - - read(path, position, length) { - let descriptor: number | null = null; - try { - descriptor = openSync(path, "r"); - const buffer = new Uint8Array(length); - const read = readSync(descriptor, buffer, 0, length, position); - return read === length ? buffer : buffer.subarray(0, read); - } catch { - return new Uint8Array(0); - } finally { - if (descriptor !== null) closeSync(descriptor); - } - }, - - readText(path, limit) { - const bytes = this.read(path, 0, limit); - return bytes.length === 0 && this.stat(path) === null ? null : new TextDecoder().decode(bytes); - }, -}; diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/graph.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/graph.ts deleted file mode 100644 index 59060ef0..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/graph.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { AgentId, AgentNode, NormalizedUsage } from "./domain.ts"; -import { working } from "./format.ts"; - -// pstack-flex addition. Pure tree helpers shared by the browser and tests. - -export interface Tree { - readonly root: AgentNode; - /** Root first, then depth-first in spawn order. */ - readonly nodes: readonly AgentNode[]; - readonly children: ReadonlyMap; - readonly depth: ReadonlyMap; -} - -export interface Counts { - readonly spawned: number; - readonly running: number; - readonly waiting: number; - readonly done: number; - readonly failed: number; - readonly tokens: number; -} - -/** Working now, or idle in a process known to be alive. A turn boundary alone proves nothing. */ -export function isLive(node: AgentNode, now = Date.now()): boolean { - return working(node, now) || (node.status.kind === "idle" && node.status.evidence === "pid"); -} - -function byStart(a: AgentNode, b: AgentNode): number { - const left = a.startedAt ?? "￿"; - const right = b.startedAt ?? "￿"; - return left === right ? (a.id < b.id ? -1 : 1) : left < right ? -1 : 1; -} - -function recency(node: AgentNode): string { - return node.lastActivityAt ?? node.startedAt ?? ""; -} - -/** The parent a node is drawn under: null when it has none or its parent is not indexed. */ -export function parentOf(node: AgentNode, nodes: ReadonlyMap): AgentId | null { - return node.parent !== null && nodes.has(node.parent) ? node.parent : null; -} - -/** Sessions to list: live ones first, then most recently active. */ -export function rootsOf(nodes: ReadonlyMap, now = Date.now()): AgentNode[] { - return [...nodes.values()] - .filter((node) => parentOf(node, nodes) === null) - .sort((a, b) => { - const live = Number(isLive(b, now)) - Number(isLive(a, now)); - if (live !== 0) return live; - const left = recency(a); - const right = recency(b); - return left === right ? 0 : left < right ? 1 : -1; - }); -} - -export function rootOf(id: AgentId, nodes: ReadonlyMap): AgentId { - let current = id; - const seen = new Set(); - for (;;) { - const node = nodes.get(current); - if (node === undefined || seen.has(current)) return current; - seen.add(current); - const parent = parentOf(node, nodes); - if (parent === null) return current; - current = parent; - } -} - -export function treeOf(rootId: AgentId, nodes: ReadonlyMap): Tree | null { - const root = nodes.get(rootId); - if (root === undefined) return null; - const children = new Map(); - for (const node of nodes.values()) { - const parent = parentOf(node, nodes); - if (parent === null || node.id === rootId) continue; - const siblings = children.get(parent); - if (siblings === undefined) children.set(parent, [node]); - else siblings.push(node); - } - for (const siblings of children.values()) siblings.sort(byStart); - const ordered: AgentNode[] = []; - const depth = new Map(); - const visit = (node: AgentNode, level: number): void => { - if (depth.has(node.id)) return; - depth.set(node.id, level); - ordered.push(node); - for (const child of children.get(node.id) ?? []) visit(child, level + 1); - }; - visit(root, 0); - return { root, nodes: ordered, children, depth }; -} - -export function tokenTotal(usage: NormalizedUsage | null): number { - if (usage === null) return 0; - return (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); -} - -export function countsOf(tree: Tree, now = Date.now()): Counts { - let running = 0; - let waiting = 0; - let done = 0; - let failed = 0; - let tokens = 0; - for (const node of tree.nodes) { - tokens += tokenTotal(node.usage); - if (node === tree.root) continue; - switch (node.status.kind) { - case "running": - if (working(node, now)) running += 1; - break; - case "idle": - waiting += 1; - break; - case "done": - done += 1; - break; - case "failed": - failed += 1; - break; - default: - break; - } - } - return { spawned: tree.nodes.length - 1, running, waiting, done, failed, tokens }; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/index.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/index.test.ts deleted file mode 100644 index 723b15ef..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/index.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { appendFileSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { claudeAdapter } from "./adapters/claude.ts"; -import { codexAdapter } from "./adapters/codex.ts"; -import type { AgentId, TimelineItem } from "./domain.ts"; -import { diskFileSystem } from "./fs.ts"; -import { Index, newerVersion } from "./index.ts"; -import { Store } from "./store.ts"; -import type { TimelinePage } from "./wire.ts"; - -let scratch: string; -let claudeHome: string; -let codexHome: string; - -const now = Date.now(); -const iso = (offsetMs = 0) => new Date(now + offsetMs).toISOString(); -const jsonl = (...records: Record[]) => records.map((record) => `${JSON.stringify(record)}\n`).join(""); - -function claudeRecord(type: string, extra: Record): Record { - return { type, sessionId: "s1", cwd: "/repo", version: "2.1.287", entrypoint: "cli", timestamp: iso(), uuid: crypto.randomUUID(), ...extra }; -} - -function assistant(text: string, extra: Record = {}): Record { - return claudeRecord("assistant", { message: { id: crypto.randomUUID(), model: "claude-test-model", content: [{ type: "text", text }], usage: { output_tokens: 1 } }, ...extra }); -} - -function setup(): { store: Store; index: Index; appended: Map } { - const store = new Store(); - const appended = new Map(); - const index = new Index([claudeAdapter(claudeHome), codexAdapter(codexHome)], store, diskFileSystem, { - sinceMs: now - 24 * 3_600_000, - watched: () => true, - onItems: (agent, items) => appended.set(agent, [...(appended.get(agent) ?? []), ...items]), - }); - return { store, index, appended }; -} - -beforeEach(() => { - scratch = mkdtempSync(join(tmpdir(), "pstack-monitor-")); - claudeHome = join(scratch, "claude"); - codexHome = join(scratch, "codex"); - mkdirSync(join(claudeHome, "projects", "-repo", "s1", "subagents"), { recursive: true }); - mkdirSync(join(claudeHome, "sessions"), { recursive: true }); - const day = new Date(now); - const dayDir = join(codexHome, "sessions", String(day.getFullYear()), String(day.getMonth() + 1).padStart(2, "0"), String(day.getDate()).padStart(2, "0")); - mkdirSync(dayDir, { recursive: true }); - - writeFileSync(join(claudeHome, "projects", "-repo", "s1.jsonl"), jsonl( - claudeRecord("user", { message: { role: "user", content: "Build the monitor" } }), - claudeRecord("assistant", { - message: { id: "m1", model: "claude-test-model", usage: { output_tokens: 5 }, content: [{ type: "tool_use", id: "call-1", name: "Agent", input: { description: "Explore" } }] }, - }), - )); - writeFileSync(join(claudeHome, "projects", "-repo", "s1", "subagents", "agent-a1.jsonl"), jsonl( - claudeRecord("user", { isSidechain: true, agentId: "a1", message: { role: "user", content: "Explore the repo" } }), - assistant("Reading files", { isSidechain: true, agentId: "a1" }), - )); - writeFileSync(join(claudeHome, "projects", "-repo", "s1", "subagents", "agent-a1.meta.json"), JSON.stringify({ agentType: "Explore", description: "Explore", toolUseId: "call-1" })); - writeFileSync(join(dayDir, "rollout-2026-10-01T10-00-00-01a0aaaa-0000-7000-8000-000000000001.jsonl"), jsonl( - { timestamp: iso(), ordinal: 0, type: "session_meta", payload: { id: "01a0aaaa-0000-7000-8000-000000000001", cwd: "/repo", source: "cli", cli_version: "0.999.0" } }, - { timestamp: iso(), ordinal: 1, type: "event_msg", payload: { type: "task_started", turn_id: "u1" } }, - )); -}); - -afterEach(() => { - rmSync(scratch, { recursive: true, force: true }); -}); - -describe("Index", () => { - it("builds the agent tree from both harnesses", async () => { - const { store, index } = setup(); - await index.refresh(true); - const nodes = new Map(store.nodes().map((node) => [node.id, node])); - expect(nodes.get("claude:s1" as AgentId)).toMatchObject({ title: "Build the monitor", harness: "claude" }); - expect(nodes.get("claude:s1:a1" as AgentId)).toMatchObject({ parent: "claude:s1", title: "Explore", flavor: { kind: "subagent", agentType: "Explore" } }); - expect(nodes.get("codex:01a0aaaa-0000-7000-8000-000000000001" as AgentId)?.status).toEqual({ kind: "running", evidence: "lifecycle" }); - const health = index.health(); - expect(health.find((source) => source.source === "claude-session")).toMatchObject({ state: "ok", files: 3, unchecked: [] }); - expect(health.find((source) => source.source === "codex-rollout")?.unchecked).toEqual(["0.999.0"]); - }); - - it("tails appended lines and reports them to watchers", async () => { - const { store, index, appended } = setup(); - await index.refresh(true); - appended.clear(); - appendFileSync(join(claudeHome, "projects", "-repo", "s1", "subagents", "agent-a1.jsonl"), jsonl(assistant("Found the runner", { agentId: "a1" }))); - await index.refresh(false); - expect(store.node("claude:s1:a1" as AgentId)?.activity).toMatchObject({ what: "text", snippet: "Found the runner" }); - expect(appended.get("claude:s1:a1" as AgentId)?.map((item) => item.kind)).toEqual(["text"]); - }); - - it("does not consume a line that is still being written", async () => { - const { store, index } = setup(); - await index.refresh(true); - const path = join(claudeHome, "projects", "-repo", "s1", "subagents", "agent-a1.jsonl"); - const line = JSON.stringify(assistant("half", { agentId: "a1" })); - appendFileSync(path, line.slice(0, 20)); - await index.refresh(false); - expect(store.node("claude:s1:a1" as AgentId)?.activity?.snippet).toBe("Reading files"); - appendFileSync(path, `${line.slice(20)}\n`); - await index.refresh(false); - expect(store.node("claude:s1:a1" as AgentId)?.activity?.snippet).toBe("half"); - }); - - it("pages a timeline backwards", async () => { - const path = join(claudeHome, "projects", "-repo", "s1", "subagents", "agent-a1.jsonl"); - appendFileSync(path, jsonl(...Array.from({ length: 30 }, (_, i) => assistant(`step ${i}`, { agentId: "a1" })))); - const { index } = setup(); - await index.refresh(true); - const agent = "claude:s1:a1" as AgentId; - const texts: string[] = []; - let before: number | null = null; - for (let guard = 0; guard < 50; guard++) { - const page: TimelinePage = index.timeline(agent, before, 4)!; - texts.unshift(...page.items.flatMap((item) => (item.kind === "text" || item.kind === "prompt" ? [item.body.text] : []))); - if (page.older === null) break; - before = page.older; - } - expect(texts).toEqual(["Explore the repo", "Reading files", ...Array.from({ length: 30 }, (_, i) => `step ${i}`)]); - expect(index.timeline("claude:nope" as AgentId, null, 4)).toBeNull(); - }); - - it("leaves old transcripts out unless their session is live", async () => { - const old = join(claudeHome, "projects", "-repo", "s0.jsonl"); - writeFileSync(old, jsonl(claudeRecord("user", { sessionId: "s0", message: { role: "user", content: "old work" } }))); - const twoDaysAgo = (now - 48 * 3_600_000) / 1000; - utimesSync(old, twoDaysAgo, twoDaysAgo); - - const first = setup(); - await first.index.refresh(true); - expect(first.store.has("claude:s0" as AgentId)).toBe(false); - - writeFileSync(join(claudeHome, "sessions", "4242.json"), JSON.stringify({ pid: 4242, sessionId: "s0", status: "idle", entrypoint: "cli" })); - const second = setup(); - await second.index.refresh(true); - expect(second.store.has("claude:s0" as AgentId)).toBe(true); - }); - - it("retracts a process record when its file disappears", async () => { - const pidFile = join(claudeHome, "sessions", "4243.json"); - writeFileSync(pidFile, JSON.stringify({ pid: 4243, sessionId: "s1", status: "busy", entrypoint: "cli" })); - const { store, index } = setup(); - await index.refresh(true); - store.setAlive(pidFile, true); - expect(store.node("claude:s1" as AgentId)?.status.kind).toBe("running"); - unlinkSync(pidFile); - await index.refresh(true); - expect(store.node("claude:s1" as AgentId)?.status.kind).toBe("ended"); - }); - - it("counts shape problems against the source and the agent", async () => { - appendFileSync(join(claudeHome, "projects", "-repo", "s1.jsonl"), jsonl({ type: "assistant", timestamp: iso() })); - const { store, index } = setup(); - await index.refresh(true); - expect(index.health().find((source) => source.source === "claude-session")?.state).toBe("degraded"); - expect(store.node("claude:s1" as AgentId)?.health).toBe("degraded"); - }); -}); - -describe("newerVersion", () => { - it("compares dotted versions numerically", () => { - expect(newerVersion("0.160.1", "0.160.0")).toBe(true); - expect(newerVersion("2.1.300", "2.1.287")).toBe(true); - expect(newerVersion("2.1.287", "2.1.287")).toBe(false); - expect(newerVersion("2.0.999", "2.1.0")).toBe(false); - }); -}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/index.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/index.ts deleted file mode 100644 index baa62932..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/index.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { join } from "node:path"; -import type { Adapter, Claim, LineParser, Parsed } from "./adapter.ts"; -import type { AgentId, SourceKind, TimelineItem } from "./domain.ts"; -import type { FileStat, FileSystem } from "./fs.ts"; -import type { Store } from "./store.ts"; -import { MAX_LINE_BYTES, readBackward, readForward } from "./tail.ts"; -import type { SourceHealth, TimelinePage } from "./wire.ts"; - -// pstack-flex addition. Finds transcript files, tails them, and feeds what -// the adapters parse into the store. Polling is the source of truth; file -// events only make it sooner. - -const DOCUMENT_LIMIT = 1024 * 1024; -const SLICE_BYTES = 2 * MAX_LINE_BYTES; - -type StreamClaim = Extract; -type DocumentClaim = Extract; - -interface TrackedStream { - readonly kind: "stream"; - readonly path: string; - readonly adapter: Adapter; - readonly claim: StreamClaim; - ino: number; - offset: number; - midLine: boolean; - parser: LineParser; -} - -interface TrackedDocument { - readonly kind: "document"; - readonly path: string; - readonly adapter: Adapter; - readonly claim: DocumentClaim; - stamp: string; -} - -type Tracked = TrackedStream | TrackedDocument; - -interface Candidate { - readonly path: string; - readonly adapter: Adapter; - readonly claim: Claim; - readonly stat: FileStat; -} - -interface Counters { - present: boolean; - files: number; - lines: number; - parsed: number; - notJson: number; - shape: number; - oversized: number; - readonly unknownTypes: Map; - readonly cliVersions: Set; -} - -export interface IndexOptions { - readonly sinceMs: number; - /** Receives timeline items parsed while tailing, for agents someone is watching. */ - readonly onItems?: (agent: AgentId, items: readonly TimelineItem[]) => void; - readonly watched?: (agent: AgentId) => boolean; - /** Lets other work run between slices of a large initial index. */ - readonly yieldTurn?: () => Promise; -} - -function stampOf(stat: FileStat): string { - return `${stat.ino}:${stat.size}:${stat.mtimeMs}`; -} - -/** Numeric comparison of dotted versions; missing parts count as zero. */ -export function newerVersion(candidate: string, baseline: string): boolean { - const a = candidate.split(/[.+-]/).map((part) => Number.parseInt(part, 10)); - const b = baseline.split(/[.+-]/).map((part) => Number.parseInt(part, 10)); - for (let i = 0; i < Math.max(a.length, b.length); i++) { - const left = Number.isFinite(a[i]) ? a[i]! : 0; - const right = Number.isFinite(b[i]) ? b[i]! : 0; - if (left !== right) return left > right; - } - return false; -} - -export class Index { - private readonly tracked = new Map(); - private readonly streams = new Map(); - private readonly counters = new Map(); - private busy = false; - private indexing = true; - - constructor( - private readonly adapters: readonly Adapter[], - private readonly store: Store, - private readonly fs: FileSystem, - private readonly options: IndexOptions, - ) { - for (const adapter of adapters) { - this.counters.set(adapter.source, { - present: false, - files: 0, - lines: 0, - parsed: 0, - notJson: 0, - shape: 0, - oversized: 0, - unknownTypes: new Map(), - cliVersions: new Set(), - }); - } - } - - get isIndexing(): boolean { - return this.indexing; - } - - /** Discover new files, retire vanished ones, and tail everything that grew. */ - async refresh(full: boolean): Promise { - if (this.busy) return; - this.busy = true; - try { - if (full) await this.scan(); - else await this.poll(); - } finally { - this.busy = false; - this.indexing = false; - } - } - - /** A file event hint: tail the file now if it is tracked. */ - async touch(path: string): Promise { - const tracked = this.tracked.get(path); - if (tracked === undefined || this.busy) return; - this.busy = true; - try { - await this.update(tracked); - } finally { - this.busy = false; - } - } - - timeline(agent: AgentId, before: number | null, limit: number): TimelinePage | null { - const stream = this.streams.get(agent); - if (stream === undefined) return null; - const end = before === null ? stream.offset : Math.min(before, stream.offset); - const parser = stream.adapter.open(stream.path); - if (stream.claim.header && end > 0) this.primeHeader(stream, parser); - const window = readBackward(this.fs, stream.path, end, limit); - const items: TimelineItem[] = []; - let older: number | null = null; - // Keep whole lines, newest first, until the page holds `limit` items. - const parsedLines = window.lines.map((line) => ({ line, items: parser.line(line.text, line.offset).items })); - let taken = 0; - let first = parsedLines.length; - for (let i = parsedLines.length - 1; i >= 0 && taken < limit; i--) { - taken += parsedLines[i]!.items.length; - first = i; - } - for (let i = first; i < parsedLines.length; i++) items.push(...parsedLines[i]!.items); - const start = parsedLines[first]?.line.offset ?? window.start; - if (start > 0) older = start; - return { agent, items, older }; - } - - health(): SourceHealth[] { - return this.adapters.map((adapter) => { - const counters = this.counters.get(adapter.source)!; - const versions = [...counters.cliVersions].sort(); - return { - source: adapter.source, - state: counters.shape > 0 ? "degraded" : "ok", - present: counters.present, - files: counters.files, - lines: counters.lines, - parsed: counters.parsed, - notJson: counters.notJson, - shape: counters.shape, - oversized: counters.oversized, - unknownTypes: Object.fromEntries(counters.unknownTypes), - cliVersions: versions, - unchecked: adapter.checkedVersion === null - ? [] - : versions.filter((version) => newerVersion(version, adapter.checkedVersion!)), - }; - }); - } - - /** Every tracked file; used to route file events. */ - get paths(): readonly string[] { - return [...this.tracked.keys()]; - } - - private async scan(): Promise { - const found = new Map(); - for (const adapter of this.adapters) { - const counters = this.counters.get(adapter.source)!; - counters.present = false; - for (const root of adapter.roots) { - const stat = this.fs.stat(root.dir); - if (stat === null || !stat.directory) continue; - counters.present = true; - if (root.processRecords !== undefined) this.store.noteProcessRecords(root.processRecords); - this.walk(root.dir, root.depth, root.enter, adapter, found); - } - } - - // Process records decide which old transcripts stay indexed, and streams decide which sidecars do. - const order = (candidate: Candidate): number => - candidate.claim.kind === "document" && candidate.claim.always ? 0 : candidate.claim.kind === "stream" ? 1 : 2; - const candidates = [...found.values()].sort((a, b) => order(a) - order(b)); - for (const candidate of candidates) { - const existing = this.tracked.get(candidate.path); - if (existing !== undefined) { - await this.update(existing, candidate.stat); - } else if (this.include(candidate)) { - await this.track(candidate); - } - } - - for (const [path, tracked] of this.tracked) { - if (found.has(path)) continue; - this.tracked.delete(path); - this.counters.get(tracked.adapter.source)!.files -= 1; - if (tracked.kind === "stream") { - if (this.streams.get(tracked.claim.agent) === tracked) this.streams.delete(tracked.claim.agent); - } else { - for (const fact of tracked.adapter.removed(path)) this.store.apply(fact); - } - } - } - - private async poll(): Promise { - for (const tracked of [...this.tracked.values()]) await this.update(tracked); - } - - private walk( - dir: string, - depth: number, - enter: ((dir: string, sinceMs: number) => boolean) | undefined, - adapter: Adapter, - found: Map, - ): void { - if (enter !== undefined && !enter(dir, this.options.sinceMs)) return; - const entries = this.fs.list(dir); - if (entries === null) return; - for (const entry of entries) { - const path = join(dir, entry.name); - if (entry.directory) { - if (depth > 1) this.walk(path, depth - 1, enter, adapter, found); - continue; - } - const claim = adapter.claim(path); - if (claim === null) continue; - const stat = this.fs.stat(path); - if (stat === null || stat.directory) continue; - found.set(path, { path, adapter, claim, stat }); - } - } - - private include(candidate: Candidate): boolean { - const { adapter, claim, stat } = candidate; - if (!adapter.windowed || stat.mtimeMs >= this.options.sinceMs) return true; - if (claim.kind === "document") return claim.always || (claim.agent !== null && this.store.has(claim.agent)); - return this.store.isLive(claim.root); - } - - private async track(candidate: Candidate): Promise { - const { path, adapter, claim, stat } = candidate; - this.counters.get(adapter.source)!.files += 1; - if (claim.kind === "document") { - const tracked: TrackedDocument = { kind: "document", path, adapter, claim, stamp: "" }; - this.tracked.set(path, tracked); - this.load(tracked, stat); - return; - } - const tracked: TrackedStream = { - kind: "stream", - path, - adapter, - claim, - ino: stat.ino, - offset: 0, - midLine: false, - parser: adapter.open(path), - }; - this.tracked.set(path, tracked); - this.streams.set(claim.agent, tracked); - await this.tail(tracked, stat.size); - } - - private async update(tracked: Tracked, known?: FileStat): Promise { - const stat = known ?? this.fs.stat(tracked.path); - if (stat === null) return; - if (tracked.kind === "document") { - if (stampOf(stat) !== tracked.stamp) this.load(tracked, stat); - return; - } - if (stat.ino !== tracked.ino || stat.size < tracked.offset) { - // Replaced or truncated: start over with a fresh parser. - tracked.ino = stat.ino; - tracked.offset = 0; - tracked.midLine = false; - tracked.parser = tracked.adapter.open(tracked.path); - } - if (stat.size > tracked.offset) await this.tail(tracked, stat.size); - } - - private load(tracked: TrackedDocument, stat: FileStat): void { - tracked.stamp = stampOf(stat); - const body = this.fs.readText(tracked.path, DOCUMENT_LIMIT); - if (body === null) return; - this.record(tracked.adapter.source, tracked.claim.agent, tracked.adapter.document(tracked.path, body)); - } - - private async tail(tracked: TrackedStream, size: number): Promise { - const watching = this.options.watched?.(tracked.claim.agent) === true; - const appended: TimelineItem[] = []; - while (tracked.offset < size) { - const to = Math.min(size, tracked.offset + SLICE_BYTES); - const result = readForward(this.fs, tracked.path, tracked.offset, to, { discardFirst: tracked.midLine }); - const counters = this.counters.get(tracked.adapter.source)!; - counters.oversized += result.oversized; - for (const line of result.lines) { - const parsedLine = tracked.parser.line(line.text, line.offset); - this.record(tracked.adapter.source, tracked.claim.agent, parsedLine); - if (watching) appended.push(...parsedLine.items); - } - if (result.next === tracked.offset && !result.midLine) break; - tracked.offset = result.next; - tracked.midLine = result.midLine; - if (to < size && this.options.yieldTurn !== undefined) await this.options.yieldTurn(); - } - if (appended.length > 0) this.options.onItems?.(tracked.claim.agent, appended); - } - - private primeHeader(stream: TrackedStream, parser: LineParser): void { - const head = readForward(this.fs, stream.path, 0, Math.min(stream.offset, MAX_LINE_BYTES + 1)); - const first = head.lines[0]; - if (first !== undefined && first.offset === 0) parser.line(first.text, 0); - } - - private record(source: SourceKind, agent: AgentId | null, parsedLine: Parsed): void { - const counters = this.counters.get(source)!; - counters.lines += 1; - if (parsedLine.cliVersion !== null) counters.cliVersions.add(parsedLine.cliVersion); - const issue = parsedLine.problem; - if (issue === null) counters.parsed += 1; - else if (issue.kind === "not-json") counters.notJson += 1; - else if (issue.kind === "unknown-type") { - counters.unknownTypes.set(issue.recordType, (counters.unknownTypes.get(issue.recordType) ?? 0) + 1); - } else { - counters.shape += 1; - if (agent !== null) this.store.noteShapeError(agent); - } - for (const fact of parsedLine.facts) this.store.apply(fact); - } -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/journal.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/journal.test.ts deleted file mode 100644 index f1574056..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/journal.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, utimesSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { journalEnabled, journalOff, journalOn, LANE_RETENTION_MS, pruneLanes } from "./journal.ts"; - -let scratch = ""; - -beforeEach(() => { - scratch = mkdtempSync(join(tmpdir(), "pstack-journal-switch-")); -}); - -afterEach(() => { - rmSync(scratch, { recursive: true, force: true }); -}); - -describe("journal switch", () => { - it("turns on by creating a private directory and off by deleting it", () => { - const root = join(scratch, "lanes"); - expect(journalEnabled(root)).toBe(false); - expect(journalOn(root)).toBe("enabled"); - expect(statSync(root).mode & 0o777).toBe(0o700); - expect(journalOn(root)).toBe("already-on"); - mkdirSync(join(root, "lane-1")); - expect(journalOff(root)).toBe("disabled"); - expect(existsSync(root)).toBe(false); - expect(journalOff(root)).toBe("already-off"); - }); - - it("prunes lanes older than the retention window", () => { - const root = join(scratch, "lanes"); - mkdirSync(join(root, "old"), { recursive: true }); - mkdirSync(join(root, "new")); - const now = Date.now(); - const old = (now - LANE_RETENTION_MS - 60_000) / 1000; - utimesSync(join(root, "old"), old, old); - expect(pruneLanes(root, now)).toBe(1); - expect(readdirSync(root)).toEqual(["new"]); - expect(pruneLanes(join(scratch, "missing"), now)).toBe(0); - }); -}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/journal.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/journal.ts deleted file mode 100644 index 190baf56..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/journal.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; -import { join } from "node:path"; - -// pstack-flex addition. The lane journal's on/off switch is the existence of -// its directory: the runner writes there only when it already exists. - -export const LANE_RETENTION_MS = 7 * 24 * 3_600_000; - -export type JournalChange = "enabled" | "already-on" | "disabled" | "already-off"; - -export function journalOn(root: string): JournalChange { - if (existsSync(root)) return "already-on"; - mkdirSync(root, { recursive: true, mode: 0o700 }); - return "enabled"; -} - -/** Turning the journal off deletes what it recorded; that is the point of the switch. */ -export function journalOff(root: string): JournalChange { - if (!existsSync(root)) return "already-off"; - rmSync(root, { recursive: true, force: true }); - return "disabled"; -} - -export function journalEnabled(root: string): boolean { - return existsSync(root); -} - -/** Removes lane directories older than the retention window. Returns how many went. */ -export function pruneLanes(root: string, now: number, retentionMs: number = LANE_RETENTION_MS): number { - let entries: string[]; - try { - entries = readdirSync(root); - } catch { - return 0; - } - let removed = 0; - for (const name of entries) { - const path = join(root, name); - try { - const stat = statSync(path); - if (!stat.isDirectory() || now - stat.mtimeMs < retentionMs) continue; - rmSync(path, { recursive: true, force: true }); - removed += 1; - } catch { - // Another process removed it first. - } - } - return removed; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/json.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/json.ts deleted file mode 100644 index 73521180..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/json.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Clipped, NormalizedUsage } from "./domain.ts"; - -// Narrow accessors for untrusted transcript JSON. Nothing here throws. - -export type JsonObject = Record; - -export function object(value: unknown): JsonObject | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as JsonObject) - : null; -} - -export function array(value: unknown): readonly unknown[] { - return Array.isArray(value) ? value : []; -} - -export function text(value: unknown): string | null { - return typeof value === "string" && value.length > 0 ? value : null; -} - -export function finite(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -export function parseJson(line: string): unknown { - try { - return JSON.parse(line); - } catch { - return undefined; - } -} - -export function clip(value: string, limit: number): Clipped { - return value.length <= limit - ? { text: value, omitted: 0 } - : { text: value.slice(0, limit), omitted: value.length - limit }; -} - -export function oneLine(value: string, limit: number): string { - const flat = value.replace(/\s+/g, " ").trim(); - return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`; -} - -export function pretty(value: unknown): string { - if (typeof value === "string") return value; - try { - return JSON.stringify(value, null, 2) ?? ""; - } catch { - return ""; - } -} - -/** Text of a Claude- or Codex-style content field: a string, or blocks carrying `text`. */ -export function contentText(value: unknown): string { - if (typeof value === "string") return value; - const parts: string[] = []; - for (const block of array(value)) { - const part = text(object(block)?.text); - if (part !== null) parts.push(part); - } - return parts.join("\n"); -} - -export function usageFrom(value: unknown): NormalizedUsage | null { - const usage = object(value); - if (usage === null) return null; - const result: NormalizedUsage = { - inputTokens: finite(usage.input_tokens), - cachedInputTokens: finite(usage.cached_input_tokens ?? usage.cache_read_input_tokens), - cacheCreationInputTokens: finite( - usage.cache_creation_input_tokens ?? usage.cache_write_input_tokens - ), - outputTokens: finite(usage.output_tokens), - reasoningTokens: finite(usage.reasoning_tokens ?? usage.reasoning_output_tokens), - totalTokens: finite(usage.total_tokens), - }; - return Object.values(result).some((entry) => entry !== undefined) ? result : null; -} - -export function basename(path: string): string { - const trimmed = path.replace(/\/+$/, ""); - const index = trimmed.lastIndexOf("/"); - return index === -1 ? trimmed : trimmed.slice(index + 1); -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.test.ts deleted file mode 100644 index 08049150..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { AgentId, AgentNode, AgentStatus } from "./domain.ts"; -import { countsOf, rootOf, rootsOf, treeOf } from "./graph.ts"; -import { CARD_HEIGHT, COLUMN_GAP, connector, layout, messageArc, ROOT_HEIGHT, ROOT_WIDTH, SATELLITE_SPACE } from "./layout.ts"; - -function node(id: string, parent: string | null, status: AgentStatus["kind"] = "done", startedAt = "2026-10-01T10:00:00Z"): AgentNode { - const statuses: Record = { - running: { kind: "running", evidence: "pid" }, - idle: { kind: "idle", evidence: "pid", detail: null }, - done: { kind: "done", at: null }, - failed: { kind: "failed", at: null, reason: "x" }, - cancelled: { kind: "cancelled", at: null }, - ended: { kind: "ended", at: null }, - unknown: { kind: "unknown", why: "x" }, - }; - return { - id: id as AgentId, - parent: parent as AgentId | null, - via: null, - spawnCall: null, - harness: "claude", - source: "claude-session", - flavor: parent === null ? { kind: "session" } : { kind: "subagent", agentType: null }, - title: id, - cwd: null, - model: { provider: "claude", requested: null, reported: null, effort: null }, - status: statuses[status], - startedAt, - lastActivityAt: startedAt, - activity: null, - pstack: false, - prompt: null, - pending: null, - usage: { inputTokens: 10, outputTokens: 5 }, - health: "ok", - }; -} - -function index(...nodes: AgentNode[]): Map { - return new Map(nodes.map((entry) => [entry.id, entry])); -} - -describe("graph", () => { - const nodes = index( - node("root", null, "running"), - node("b", "root", "failed", "2026-10-01T10:00:02Z"), - node("a", "root", "running", "2026-10-01T10:00:01Z"), - node("a1", "a", "done"), - node("orphan", "missing-parent", "done", "2026-10-01T09:00:00Z"), - ); - - it("treats nodes whose parent is not indexed as roots, live first", () => { - expect(rootsOf(nodes).map((entry) => entry.id)).toEqual(["root", "orphan"] as never); - expect(rootOf("a1" as AgentId, nodes)).toBe("root" as AgentId); - }); - - it("orders children by start time and counts the tree", () => { - const tree = treeOf("root" as AgentId, nodes)!; - expect(tree.nodes.map((entry) => entry.id)).toEqual(["root", "a", "a1", "b"] as never); - expect(countsOf(tree)).toEqual({ spawned: 3, running: 1, waiting: 0, done: 1, failed: 1, tokens: 60 }); - }); - - it("survives a parent cycle", () => { - const cyclic = index(node("x", "y"), node("y", "x")); - expect(rootOf("x" as AgentId, cyclic)).toBeDefined(); - expect(rootsOf(cyclic)).toEqual([]); - }); -}); - -describe("layout", () => { - it("places depth in columns and centers parents on their children", () => { - const tree = treeOf("root" as AgentId, index(node("root", null), node("a", "root"), node("b", "root")))!; - const result = layout(tree, () => false); - const root = result.placed.get("root" as AgentId)!; - const a = result.placed.get("a" as AgentId)!; - const b = result.placed.get("b" as AgentId)!; - expect(a.x).toBe(ROOT_WIDTH + COLUMN_GAP); - expect(root.width).toBe(ROOT_WIDTH); - expect(b.y).toBeGreaterThan(a.y + CARD_HEIGHT); - expect(root.y + ROOT_HEIGHT / 2).toBeCloseTo((a.y + b.y + CARD_HEIGHT) / 2, 0); - expect(result.edges).toEqual([{ from: "root", to: "a" }, { from: "root", to: "b" }] as never); - }); - - it("reserves room for model nodes so cards never overlap", () => { - const tree = treeOf("root" as AgentId, index(node("root", null), node("a", "root"), node("b", "root"), node("c", "root")))!; - const result = layout(tree, () => true); - const ys = ["a", "b", "c"].map((id) => result.placed.get(id as AgentId)!.y); - for (let i = 1; i < ys.length; i++) expect(ys[i]! - ys[i - 1]!).toBeGreaterThanOrEqual(CARD_HEIGHT + SATELLITE_SPACE); - const root = result.placed.get("root" as AgentId)!; - expect(root.y).toBeGreaterThanOrEqual(0); - }); - - it("keeps a lone root inside its bounds", () => { - const tree = treeOf("root" as AgentId, index(node("root", null)))!; - expect(layout(tree, () => true).bounds).toEqual({ x: 0, y: 0, width: ROOT_WIDTH, height: ROOT_HEIGHT + SATELLITE_SPACE }); - }); - - it("draws a message between card edges, bowing opposite ways for each direction", () => { - const parent = { x: 0, y: 0, width: 100, height: 40 }; - const child = { x: 300, y: 0, width: 100, height: 40 }; - const down = messageArc(parent, child); - const up = messageArc(child, parent); - const start = down.d.split(" ").slice(1, 3).map(Number); - // The arc leaves from the parent's edge, not its center. - expect(start[0]! >= 0 && start[0]! <= 100 && (start[1] === 0 || start[0] === 100)).toBe(true); - expect(down.midY).toBeLessThan(20); - expect(up.midY).toBeGreaterThan(20); - }); - - it("draws connectors from output port to input port", () => { - const path = connector({ x: 0, y: 0, width: 100, height: 40 }, { x: 200, y: 100, width: 100, height: 40 }); - expect(path.startsWith("M 100 20 C")).toBe(true); - expect(path.endsWith("200 120")).toBe(true); - }); -}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.ts deleted file mode 100644 index 5a382477..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { AgentId, AgentNode } from "./domain.ts"; -import type { Tree } from "./graph.ts"; - -// pstack-flex addition. Left-to-right tree layout. Columns are spawn depth; -// leaves take consecutive rows and parents center on their children, so a -// new child appends below its siblings instead of reshuffling the canvas. - -export const CARD_WIDTH = 264; -export const CARD_HEIGHT = 84; -export const ROOT_WIDTH = 304; -export const ROOT_HEIGHT = 100; -export const COLUMN_GAP = 112; -export const ROW_GAP = 24; -/** Room under a card for its model node and label. */ -export const SATELLITE_SPACE = 76; - -export interface Placed { - readonly id: AgentId; - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; - readonly satellite: boolean; -} - -export interface Edge { - readonly from: AgentId; - readonly to: AgentId; -} - -export interface Bounds { - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; -} - -export interface Layout { - readonly placed: ReadonlyMap; - readonly edges: readonly Edge[]; - readonly bounds: Bounds; -} - -/** The root column is wider than the rest. */ -export function columnX(depth: number): number { - return depth === 0 ? 0 : ROOT_WIDTH + COLUMN_GAP + (depth - 1) * (CARD_WIDTH + COLUMN_GAP); -} - -export function layout(tree: Tree, hasSatellite: (node: AgentNode) => boolean): Layout { - const placed = new Map(); - const edges: Edge[] = []; - let cursor = 0; - - // Returns the vertical center of the node's card. - const place = (node: AgentNode, depth: number): number => { - const height = node === tree.root ? ROOT_HEIGHT : CARD_HEIGHT; - const satellite = hasSatellite(node); - const slot = height + (satellite ? SATELLITE_SPACE : 0); - const children = (tree.children.get(node.id) ?? []).filter((child) => tree.depth.get(child.id) === depth + 1); - let center: number; - if (children.length === 0) { - center = cursor + height / 2; - cursor += slot + ROW_GAP; - } else { - const start = cursor; - const centers = children.map((child) => { - edges.push({ from: node.id, to: child.id }); - return place(child, depth + 1); - }); - center = (centers[0]! + centers[centers.length - 1]!) / 2; - // A parent's own card and model node must not reach past its subtree. - const top = center - height / 2; - if (top < start) center += start - top; - cursor = Math.max(cursor, center - height / 2 + slot + ROW_GAP); - } - placed.set(node.id, { - id: node.id, - x: columnX(depth), - y: Math.round(center - height / 2), - width: node === tree.root ? ROOT_WIDTH : CARD_WIDTH, - height, - satellite, - }); - return center; - }; - - place(tree.root, 0); - let maxX = 0; - let maxY = 0; - for (const node of placed.values()) { - maxX = Math.max(maxX, node.x + node.width); - maxY = Math.max(maxY, node.y + node.height + (node.satellite ? SATELLITE_SPACE : 0)); - } - return { placed, edges, bounds: { x: 0, y: 0, width: maxX, height: maxY } }; -} - -export interface Box { - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; -} - -export interface Arc { - readonly d: string; - readonly midX: number; - readonly midY: number; -} - -/** Where a ray from the box's center toward a point leaves the box. */ -function exitPoint(box: Box, towardX: number, towardY: number): { x: number; y: number } { - const cx = box.x + box.width / 2; - const cy = box.y + box.height / 2; - const dx = towardX - cx; - const dy = towardY - cy; - if (dx === 0 && dy === 0) return { x: cx, y: cy }; - const t = Math.min( - dx === 0 ? Number.POSITIVE_INFINITY : box.width / 2 / Math.abs(dx), - dy === 0 ? Number.POSITIVE_INFINITY : box.height / 2 / Math.abs(dy), - ); - return { x: cx + dx * t, y: cy + dy * t }; -} - -/** - * A message from one agent to another: a curve between their edges that bows - * to the left of travel, so a reply between the same pair takes the other side. - */ -export function messageArc(from: Box, to: Box): Arc { - const ax = from.x + from.width / 2; - const ay = from.y + from.height / 2; - const bx = to.x + to.width / 2; - const by = to.y + to.height / 2; - const dx = bx - ax; - const dy = by - ay; - const length = Math.hypot(dx, dy) || 1; - const bend = Math.min(160, Math.max(56, length * 0.28)); - const controlX = (ax + bx) / 2 + (dy / length) * bend; - const controlY = (ay + by) / 2 - (dx / length) * bend; - const start = exitPoint(from, controlX, controlY); - const end = exitPoint(to, controlX, controlY); - return { - d: `M ${start.x} ${start.y} Q ${controlX} ${controlY} ${end.x} ${end.y}`, - midX: 0.25 * start.x + 0.5 * controlX + 0.25 * end.x, - midY: 0.25 * start.y + 0.5 * controlY + 0.25 * end.y, - }; -} - -/** A cubic connector from a parent's output port to a child's input port. */ -export function connector(from: Box, to: Box): string { - const x1 = from.x + from.width; - const y1 = from.y + from.height / 2; - const x2 = to.x; - const y2 = to.y + to.height / 2; - const bend = Math.max(48, (x2 - x1) * 0.5); - return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/monitor.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/monitor.ts deleted file mode 100644 index ddd7eccb..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/monitor.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { watch, type FSWatcher } from "node:fs"; -import { join } from "node:path"; -import type { Adapter } from "./adapter.ts"; -import type { AgentId, TimelineItem } from "./domain.ts"; -import type { FileSystem } from "./fs.ts"; -import { Index } from "./index.ts"; -import { alive, type ProcessTable } from "./probe.ts"; -import { Store } from "./store.ts"; -import type { ServerEvent, ServerInfo, Snapshot, SourceHealth, TimelinePage } from "./wire.ts"; - -// pstack-flex addition. The live model behind the server: it keeps the index -// current, probes recorded processes, and pushes changes to subscribers. - -const POLL_MS = 2_000; -const SCAN_MS = 10_000; -const FLUSH_MS = 250; -const HEARTBEAT_MS = 15_000; -const HINT_DELAY_MS = 75; -export const FIRST_PAGE = 80; - -export interface Subscriber { - readonly watch: AgentId | null; - send(event: ServerEvent): void; - ping(): void; -} - -export interface MonitorOptions { - readonly adapters: readonly Adapter[]; - readonly fs: FileSystem; - readonly table: ProcessTable; - readonly windowHours: number; - readonly version: string; - readonly instance: string; - readonly now?: () => number; -} - -export class Monitor { - readonly store = new Store(); - private readonly index: Index; - private readonly subscribers = new Set(); - private readonly timers: ReturnType[] = []; - private readonly watchers: FSWatcher[] = []; - private readonly pendingHints = new Set(); - private hintTimer: ReturnType | null = null; - private lastHealth = ""; - private watching: ServerInfo["watching"] = "poll"; - private readonly startedAt: string; - - constructor(private readonly options: MonitorOptions) { - const now = options.now ?? Date.now; - this.startedAt = new Date(now()).toISOString(); - this.index = new Index(options.adapters, this.store, options.fs, { - sinceMs: now() - options.windowHours * 3_600_000, - watched: (agent) => this.isWatched(agent), - onItems: (agent, items) => this.append(agent, items), - yieldTurn: () => new Promise((resolve) => setImmediate(resolve)), - }); - } - - info(): ServerInfo { - return { - app: "pstack-monitor", - version: this.options.version, - instance: this.options.instance, - pid: process.pid, - startedAt: this.startedAt, - watching: this.watching, - indexing: this.index.isIndexing, - windowHours: this.options.windowHours, - }; - } - - snapshot(): Snapshot { - return { - rev: this.store.rev, - agents: this.store.nodes(), - links: this.store.links(), - health: this.index.health(), - server: this.info(), - }; - } - - health(): readonly SourceHealth[] { - return this.index.health(); - } - - timeline(agent: AgentId, before: number | null, limit: number): TimelinePage | null { - return this.index.timeline(agent, before, limit); - } - - /** Indexes the window, probes processes once, then keeps everything current. */ - async start(): Promise { - await this.index.refresh(true); - await this.probe(); - this.flush(); - this.watchRoots(); - this.timers.push( - setInterval(() => void this.cycle(false), POLL_MS), - setInterval(() => void this.cycle(true), SCAN_MS), - setInterval(() => this.flush(), FLUSH_MS), - setInterval(() => this.heartbeat(), HEARTBEAT_MS), - ); - } - - stop(): void { - for (const timer of this.timers) clearInterval(timer); - this.timers.length = 0; - for (const watcher of this.watchers) watcher.close(); - this.watchers.length = 0; - if (this.hintTimer !== null) clearTimeout(this.hintTimer); - } - - subscribe(subscriber: Subscriber): () => void { - this.subscribers.add(subscriber); - subscriber.send({ event: "snapshot", data: this.snapshot() }); - if (subscriber.watch !== null) { - const page = this.index.timeline(subscriber.watch, null, FIRST_PAGE); - if (page !== null) subscriber.send({ event: "timeline", data: page }); - } - return () => { - this.subscribers.delete(subscriber); - }; - } - - private async cycle(full: boolean): Promise { - await this.index.refresh(full); - await this.probe(); - } - - private async probe(): Promise { - const targets = this.store.probeTargets(); - if (targets.length === 0) return; - const table = await this.options.table([...new Set(targets.map((target) => target.pid))]); - for (const target of targets) this.store.setAlive(target.key, alive(target, table)); - } - - private flush(): void { - const changes = this.store.flush(); - const health = this.index.health(); - const serializedHealth = JSON.stringify(health); - const healthChanged = serializedHealth !== this.lastHealth; - this.lastHealth = serializedHealth; - if (changes === null && !healthChanged) return; - this.broadcast({ - event: "delta", - data: { - rev: changes?.rev ?? this.store.rev, - upserts: changes?.upserts ?? [], - links: changes?.links ?? null, - health: healthChanged ? health : null, - indexing: this.index.isIndexing, - }, - }); - } - - private append(agent: AgentId, items: readonly TimelineItem[]): void { - for (const subscriber of this.subscribers) { - if (subscriber.watch === agent) this.deliver(subscriber, { event: "append", data: { agent, items } }); - } - } - - private broadcast(event: ServerEvent): void { - for (const subscriber of this.subscribers) this.deliver(subscriber, event); - } - - private heartbeat(): void { - for (const subscriber of [...this.subscribers]) { - try { - subscriber.ping(); - } catch { - this.subscribers.delete(subscriber); - } - } - } - - private deliver(subscriber: Subscriber, event: ServerEvent): void { - try { - subscriber.send(event); - } catch { - this.subscribers.delete(subscriber); - } - } - - private isWatched(agent: AgentId): boolean { - for (const subscriber of this.subscribers) if (subscriber.watch === agent) return true; - return false; - } - - // File events only make polling sooner; when they are unavailable polling still covers everything. - private watchRoots(): void { - for (const adapter of this.options.adapters) { - for (const root of adapter.roots) { - if (this.options.fs.stat(root.dir)?.directory !== true) continue; - try { - this.watchers.push(watch(root.dir, { recursive: true }, (_event, name) => { - if (typeof name === "string") this.hint(join(root.dir, name)); - })); - this.watching = "events"; - } catch { - // Recursive watching is unsupported here; the poll loop covers it. - } - } - } - } - - private hint(path: string): void { - this.pendingHints.add(path); - if (this.hintTimer !== null) return; - this.hintTimer = setTimeout(() => { - this.hintTimer = null; - const paths = [...this.pendingHints]; - this.pendingHints.clear(); - void (async () => { - const tracked = new Set(this.index.paths); - const unknown = paths.some((path) => !tracked.has(path)); - for (const path of paths) if (tracked.has(path)) await this.index.touch(path); - // A new file appeared: find it now rather than at the next scan. - if (unknown) await this.index.refresh(true); - this.flush(); - })(); - }, HINT_DELAY_MS); - } -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/probe.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/probe.ts deleted file mode 100644 index afbaf5db..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/probe.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ProbeTarget } from "./store.ts"; - -// pstack-flex addition. Decides whether a recorded process is still the one -// that wrote the record: the pid must exist and must have started when the -// record says it did, so a reused pid is not mistaken for a live agent. - -/** Live pids and their start times in epoch milliseconds. */ -export type ProcessTable = (pids: readonly number[]) => Promise>; - -// `ps -o lstart=` has one-second resolution, and a launcher records its start a moment after exec. -const START_TOLERANCE_MS = 10_000; - -export function parseProcessTable(output: string): Map { - const table = new Map(); - for (const line of output.split("\n")) { - const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line); - if (match === null) continue; - const started = Date.parse(`${match[2]!.replace(/\s+/g, " ")} UTC`); - if (Number.isFinite(started)) table.set(Number(match[1]), started); - } - return table; -} - -export const psTable: ProcessTable = async (pids) => { - if (pids.length === 0) return new Map(); - const child = Bun.spawn(["ps", "-o", "pid=,lstart=", "-p", pids.join(",")], { - env: { ...process.env, TZ: "UTC", LC_ALL: "C" }, - stdout: "pipe", - stderr: "ignore", - }); - const output = await new Response(child.stdout).text(); - await child.exited; - return parseProcessTable(output); -}; - -export function alive(target: ProbeTarget, table: ReadonlyMap): boolean { - const started = table.get(target.pid); - if (started === undefined) return false; - if (target.startedAtMs === null) return true; - return Math.abs(started - target.startedAtMs) <= START_TOLERANCE_MS; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/pstack-monitor b/plugins/pstack/skills/poteto-mode/scripts/monitor/pstack-monitor deleted file mode 100755 index c6d66997..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/pstack-monitor +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bun -const { main } = await import("./cli.ts"); -process.exitCode = await main(process.argv.slice(2)); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/record.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/record.ts deleted file mode 100644 index 3f3f0355..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/record.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -// pstack-flex addition. The running server's address and access token, kept -// where only this user can read it. - -export interface ServerRecord { - readonly pid: number; - readonly port: number; - readonly token: string; - readonly version: string; - readonly instance: string; - readonly startedAt: string; -} - -const FILE = "server.json"; - -export function readRecord(dir: string): ServerRecord | null { - try { - const parsed = JSON.parse(readFileSync(join(dir, FILE), "utf8")) as Partial; - if ( - typeof parsed.pid !== "number" - || typeof parsed.port !== "number" - || typeof parsed.token !== "string" - || typeof parsed.version !== "string" - || typeof parsed.instance !== "string" - || typeof parsed.startedAt !== "string" - ) { - return null; - } - return parsed as ServerRecord; - } catch { - return null; - } -} - -export function writeRecord(dir: string, record: ServerRecord): void { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - const temporary = join(dir, `${FILE}.${process.pid}.tmp`); - writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); - renameSync(temporary, join(dir, FILE)); -} - -/** Removes the record only if it still describes `instance`. */ -export function clearRecord(dir: string, instance: string): void { - if (readRecord(dir)?.instance !== instance) return; - try { - unlinkSync(join(dir, FILE)); - } catch { - // Already gone. - } -} - -export function serverUrl(record: Pick): string { - return `http://127.0.0.1:${record.port}`; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/server.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/server.test.ts deleted file mode 100644 index 249d682c..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/server.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { claudeAdapter } from "./adapters/claude.ts"; -import { parseArgs } from "./cli.ts"; -import { launchUrl, summarize } from "./daemon.ts"; -import { diskFileSystem } from "./fs.ts"; -import { Monitor } from "./monitor.ts"; -import { createHandler } from "./server.ts"; -import type { Snapshot, TimelinePage } from "./wire.ts"; - -const PORT = 47001; -const TOKEN = "secret-token"; -let scratch: string; -let monitor: Monitor; -let handle: ReturnType; - -function request(path: string, headers: Record = {}, method = "GET"): Request { - return new Request(`http://127.0.0.1:${PORT}${path}`, { - method, - headers: { host: `127.0.0.1:${PORT}`, ...headers }, - }); -} - -const authed = { authorization: `Bearer ${TOKEN}` }; - -beforeAll(async () => { - scratch = mkdtempSync(join(tmpdir(), "pstack-monitor-server-")); - const projects = join(scratch, "projects", "-repo"); - mkdirSync(projects, { recursive: true }); - const at = new Date().toISOString(); - writeFileSync(join(projects, "s1.jsonl"), [ - { type: "user", sessionId: "s1", cwd: "/repo", entrypoint: "cli", timestamp: at, message: { role: "user", content: "hello" } }, - { type: "assistant", sessionId: "s1", cwd: "/repo", timestamp: at, message: { id: "m1", content: [{ type: "text", text: "hi there" }] } }, - ].map((record) => `${JSON.stringify(record)}\n`).join("")); - monitor = new Monitor({ - adapters: [claudeAdapter(scratch)], - fs: diskFileSystem, - table: async () => new Map(), - windowHours: 24, - version: "test", - instance: "instance-1", - }); - await monitor.start(); - handle = createHandler(monitor, { port: PORT, token: TOKEN, assets: { html: "", css: "", js: "" } }); -}); - -afterAll(() => { - monitor.stop(); - rmSync(scratch, { recursive: true, force: true }); -}); - -describe("access control", () => { - it("answers the health check without a token and reveals no data", async () => { - const response = await handle(request("/api/health")); - expect(await response.json()).toEqual({ app: "pstack-monitor", version: "test", instance: "instance-1", pid: process.pid }); - }); - - it("requires the token everywhere else", async () => { - expect((await handle(request("/api/snapshot"))).status).toBe(401); - expect((await handle(request("/"))).status).toBe(401); - expect((await handle(request("/api/snapshot", { authorization: "Bearer wrong" }))).status).toBe(401); - expect((await handle(request("/api/snapshot", authed))).status).toBe(200); - expect((await handle(request("/api/snapshot", { cookie: `pstack_monitor_${PORT}=${TOKEN}` }))).status).toBe(200); - }); - - it("rejects foreign hosts and origins even with the token", async () => { - expect((await handle(request("/api/snapshot", { ...authed, host: "evil.example" }))).status).toBe(403); - expect((await handle(request("/api/snapshot", { ...authed, origin: "http://evil.example" }))).status).toBe(403); - expect((await handle(request("/api/snapshot", { ...authed, origin: `http://localhost:${PORT}` }))).status).toBe(200); - }); - - it("is read-only", async () => { - expect((await handle(request("/api/snapshot", authed, "POST"))).status).toBe(405); - }); - - it("trades the link's token for a strict cookie and drops it from the URL", async () => { - const response = await handle(request(`/?token=${TOKEN}&harness=codex`)); - expect(response.status).toBe(303); - expect(response.headers.get("location")).toBe("/?harness=codex"); - expect(response.headers.get("set-cookie")).toBe(`pstack_monitor_${PORT}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/`); - expect((await handle(request("/?token=nope"))).status).toBe(401); - }); - - it("sends a strict content security policy", async () => { - const response = await handle(request("/", authed)); - expect(response.headers.get("content-security-policy")).toContain("script-src 'self'"); - expect(response.headers.get("x-content-type-options")).toBe("nosniff"); - }); - - it("themes the first paint from the link and accepts only known harnesses", async () => { - const themed = createHandler(monitor, { port: PORT, token: TOKEN, assets: { html: '', css: "", js: "" } }); - expect(await (await themed(request("/?harness=codex", authed))).text()).toBe(''); - expect(await (await themed(request('/?harness="> - - - - - diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/main.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/main.ts deleted file mode 100644 index 029656dd..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/main.ts +++ /dev/null @@ -1,429 +0,0 @@ -import type { AgentId, AgentNode, Harness, MessageLink, SourceKind } from "../domain.ts"; -import { compactNumber, shortPath, working } from "../format.ts"; -import { countsOf, isLive, rootOf, rootsOf, treeOf } from "../graph.ts"; -import type { Delta, ServerInfo, Snapshot, SourceHealth, TimelineAppend, TimelinePage } from "../wire.ts"; -import { Canvas } from "./canvas.ts"; -import { h, icon, logo } from "./dom.ts"; -import { Panel, PANEL_MIN } from "./panel.ts"; -import { Rail, type Descendants, type Scope } from "./rail.ts"; - -// pstack-flex addition. The page controller: one event stream, one state, -// and a render that every view reads from. - -const SOURCE_NAME: Record = { - "claude-session": "Claude Code", - "codex-rollout": "Codex", - "runner-lane": "pstack lanes", -}; - -const RETRY_MS = 3_000; -/** Canvas kept visible beside the panel; narrower than this and the panel overlays it. */ -const CANVAS_MIN = 360; -const PANEL_GUTTER = 240; -const SCOPE_KEY = "pstack-monitor.scope"; - -type Connection = "connecting" | "live" | "retrying" | "expired"; - -interface State { - nodes: Map; - links: readonly MessageLink[]; - health: readonly SourceHealth[]; - server: ServerInfo | null; - session: AgentId | null; - agent: AgentId | null; - connection: Connection; - dismissed: string; - scope: Scope; -} - -const params = new URLSearchParams(location.search); -const defaultHarness: Harness = params.get("harness") === "codex" ? "codex" : "claude"; -let pendingFocus = params.get("focus") as AgentId | null; - -function initialScope(): Scope { - if (params.get("all") === "1") return "all"; - try { - return localStorage.getItem(SCOPE_KEY) === "all" ? "all" : "pstack"; - } catch { - return "pstack"; - } -} - -const state: State = { - nodes: new Map(), - links: [], - health: [], - server: null, - session: null, - agent: null, - connection: "connecting", - dismissed: "", - scope: initialScope(), -}; - -/** The agents the current scope shows: pstack's trees, or everything. */ -function visible(): Map { - if (state.scope === "all") return state.nodes; - return new Map([...state.nodes].filter(([, node]) => node.pstack)); -} - -const canvas = new Canvas({ select: (id) => selectAgent(id) }); -const panel = new Panel({ - close: () => selectAgent(null), - select: (id) => selectAgent(id), - loadOlder: (agent, before) => fetchTimeline(agent, before), - resized: (width, settled) => canvas.setRightInset(insetFor(width), settled), -}); -const rail = new Rail({ select: (root) => selectSession(root), toggleScope: () => setScope(state.scope === "pstack" ? "all" : "pstack") }); - -const sessionTitle = h("h1", { class: "bar-title" }); -const sessionPath = h("span", { class: "bar-path mono" }); -const stats = h("div", { class: "bar-stats", attrs: { "aria-label": "This session" } }); -const liveText = h("span", { class: "live-text" }); -const live = h("div", { class: "live", attrs: { role: "status" } }, h("span", { class: "live-dot", attrs: { "aria-hidden": "true" } }), liveText); -const railToggle = h("button", { class: "icon-button rail-toggle", title: "Sessions", attrs: { type: "button", "aria-label": "Show sessions" } }, icon("sessions")); -railToggle.addEventListener("click", () => toggleRail()); -const banner = h("div", { class: "banner", attrs: { hidden: "", role: "note" } }); - -const bar = h( - "header", - { class: "bar" }, - railToggle, - h("div", { class: "brand" }, logo(), h("span", { class: "brand-name", text: "pstack monitor" })), - h("div", { class: "bar-session" }, sessionTitle, sessionPath), - stats, - live, -); - -const stage = h("main", { class: "stage" }, canvas.element, banner, panel.element); -const scrim = h("div", { class: "scrim", attrs: { "aria-hidden": "true" } }); -scrim.addEventListener("click", () => toggleRail(false)); -document.body.append(bar, rail.element, stage, scrim); -document.documentElement.dataset.harness = defaultHarness; - -// --- data ------------------------------------------------------------------ - -let source: EventSource | null = null; -let retryTimer: number | null = null; - -function connect(): void { - source?.close(); - if (retryTimer !== null) window.clearTimeout(retryTimer); - retryTimer = null; - const url = state.agent === null ? "/api/events" : `/api/events?watch=${encodeURIComponent(state.agent)}`; - const stream = new EventSource(url); - source = stream; - stream.addEventListener("open", () => setConnection("live")); - stream.addEventListener("snapshot", (event) => onSnapshot(JSON.parse((event as MessageEvent).data) as Snapshot)); - stream.addEventListener("delta", (event) => onDelta(JSON.parse((event as MessageEvent).data) as Delta)); - stream.addEventListener("timeline", (event) => panel.setPage(JSON.parse((event as MessageEvent).data) as TimelinePage)); - stream.addEventListener("append", (event) => { - const data = JSON.parse((event as MessageEvent).data) as TimelineAppend; - panel.append(data.agent, data.items); - }); - stream.addEventListener("error", () => { - if (stream !== source) return; - if (stream.readyState === EventSource.CLOSED) void diagnoseClosed(); - else setConnection("retrying"); - }); -} - -// A closed stream is either a server that restarted with a new token or one that is down. -async function diagnoseClosed(): Promise { - try { - const response = await fetch("/api/snapshot"); - if (response.status === 401) { - setConnection("expired"); - return; - } - } catch { - // The server is down; keep trying. - } - setConnection("retrying"); - retryTimer = window.setTimeout(connect, RETRY_MS); -} - -async function fetchTimeline(agent: AgentId, before: number): Promise { - try { - const response = await fetch(`/api/timeline?agent=${encodeURIComponent(agent)}&before=${before}`); - return response.ok ? ((await response.json()) as TimelinePage) : null; - } catch { - return null; - } -} - -function onSnapshot(snapshot: Snapshot): void { - state.nodes = new Map(snapshot.agents.map((node) => [node.id, node])); - state.links = snapshot.links; - state.health = snapshot.health; - state.server = snapshot.server; - const shown = visible(); - if (state.session === null || !shown.has(state.session)) state.session = chooseSession(); - if (state.agent !== null && !shown.has(state.agent)) state.agent = null; - setConnection("live"); - render(); -} - -function onDelta(delta: Delta): void { - const pulses: AgentId[] = []; - for (const node of delta.upserts) { - const previous = state.nodes.get(node.id); - if (previous !== undefined && node.activity !== null && (previous.activity?.at !== node.activity.at || previous.activity?.snippet !== node.activity.snippet)) { - pulses.push(node.id); - } - state.nodes.set(node.id, node); - } - const talks: MessageLink[] = []; - if (delta.links !== null) { - const before = new Map(state.links.map((link) => [`${link.from}>${link.to}`, link.count])); - for (const link of delta.links) { - if (link.count > (before.get(`${link.from}>${link.to}`) ?? 0)) talks.push(link); - } - state.links = delta.links; - } - if (delta.health !== null) state.health = delta.health; - if (state.server !== null) state.server = { ...state.server, indexing: delta.indexing }; - if (state.session === null || !visible().has(state.session)) state.session = chooseSession(); - render(); - for (const id of pulses) canvas.pulse(id); - for (const link of talks) canvas.pulseMessage(link.from, link.to); -} - -function chooseSession(): AgentId | null { - const shown = visible(); - if (pendingFocus !== null) { - const focus = pendingFocus; - if (shown.has(focus)) { - pendingFocus = null; - return rootOf(focus, shown); - } - } - const now = Date.now(); - const roots = rootsOf(shown, now); - return (roots.find((root) => isLive(root, now)) ?? roots[0])?.id ?? null; -} - -function setScope(scope: Scope): void { - state.scope = scope; - try { - localStorage.setItem(SCOPE_KEY, scope); - } catch { - // Storage is unavailable; the choice lasts for this page only. - } - const shown = visible(); - if (state.agent !== null && !shown.has(state.agent)) selectAgent(null); - if (state.session === null || !shown.has(state.session)) state.session = chooseSession(); - render(); -} - -// --- selection ------------------------------------------------------------- - -function selectSession(root: AgentId): void { - toggleRail(false); - if (state.session === root) return; - state.session = root; - if (state.agent !== null) { - state.agent = null; - panel.close(); - canvas.setRightInset(0); - } - render(); -} - -function selectAgent(id: AgentId | null): void { - if (id === state.agent) return; - state.agent = id; - if (id === null) { - panel.close(); - canvas.setRightInset(0); - render(); - return; - } - const shown = visible(); - const node = shown.get(id); - if (node === undefined) return; - const root = rootOf(id, shown); - if (root !== state.session) state.session = root; - panel.open(node, shown, Date.now()); - render(); - canvas.setRightInset(insetFor(panel.width)); - canvas.reveal(id); - // The stream carries the watched agent's timeline, so a new selection reconnects. - connect(); -} - -/** The panel overlays the canvas when keeping both side by side would leave the canvas too narrow. */ -function insetFor(width: number): number { - return stage.clientWidth - width >= CANVAS_MIN ? width : 0; -} - -function fitPanel(): void { - panel.setMaxWidth(Math.max(PANEL_MIN, stage.clientWidth - PANEL_GUTTER)); - canvas.setRightInset(state.agent === null ? 0 : insetFor(panel.width), false); -} - -function toggleRail(open?: boolean): void { - const next = open ?? document.body.dataset.rail !== "open"; - document.body.dataset.rail = next ? "open" : "closed"; - railToggle.setAttribute("aria-expanded", String(next)); -} - -// --- rendering ------------------------------------------------------------- - -function render(): void { - const now = Date.now(); - const shown = visible(); - const roots = rootsOf(shown, now); - const descendants = new Map(); - for (const node of shown.values()) { - const root = rootOf(node.id, shown); - if (root === node.id) continue; - const entry = descendants.get(root) ?? { total: 0, running: 0 }; - descendants.set(root, { total: entry.total + 1, running: entry.running + (working(node, now) ? 1 : 0) }); - } - const hours = state.server?.windowHours ?? 24; - rail.render(roots, descendants, state.session, now, state.scope, `Last ${hours} hours`); - - const tree = state.session === null ? null : treeOf(state.session, shown); - canvas.render(tree, state.agent, now, state.links); - document.documentElement.dataset.harness = tree?.root.harness ?? defaultHarness; - - if (state.connection === "expired") { - canvas.setEmpty( - h( - "div", - { class: "empty" }, - logo(), - h("h2", { text: "Link expired" }), - h("p", {}, "Run ", h("code", { text: "pstack-monitor start" }), " and open the new link."), - ), - ); - } else if (shown.size === 0) { - canvas.setEmpty(emptyState(state.server?.indexing === true, state.nodes.size > 0)); - } else if (tree !== null && tree.nodes.length === 1) { - canvas.setEmpty(h("p", { class: "canvas-hint", text: "No agents spawned yet." })); - } else { - canvas.setEmpty(null); - } - - if (tree === null) { - sessionTitle.textContent = "No session selected"; - sessionPath.textContent = ""; - stats.replaceChildren(); - } else { - sessionTitle.textContent = tree.root.title; - sessionPath.textContent = shortPath(tree.root.cwd); - sessionPath.title = tree.root.cwd ?? ""; - const counts = countsOf(tree, now); - const members = new Set(tree.nodes.map((node) => node.id)); - const messages = state.links - .filter((link) => members.has(link.from) && members.has(link.to)) - .reduce((sum, link) => sum + link.count, 0); - stats.replaceChildren( - stat("running", counts.running, "running"), - stat("waiting", counts.waiting, "waiting"), - stat("spawned", counts.spawned, counts.spawned === 1 ? "agent" : "agents"), - stat("done", counts.done, "done"), - stat("failed", counts.failed, "failed"), - ...(messages > 0 ? [stat("messages", messages, messages === 1 ? "message" : "messages")] : []), - stat("tokens", counts.tokens, "tokens", compactNumber(counts.tokens)), - ); - document.title = counts.running > 0 ? `(${counts.running}) pstack monitor` : "pstack monitor"; - } - - if (state.agent !== null) panel.update(shown, now, state.links); - renderBanner(); - renderLive(); -} - -function stat(kind: string, value: number, label: string, shown = String(value)): HTMLElement { - return h("span", { class: "stat", attrs: { "data-kind": kind, "data-zero": String(value === 0) } }, h("b", { text: shown }), h("span", { text: ` ${label}` })); -} - -function emptyState(indexing: boolean, hidden: boolean): HTMLElement { - const hours = state.server?.windowHours ?? 24; - if (indexing) return h("div", { class: "empty" }, logo(), h("h2", { text: "Reading recent transcripts…" })); - const scoped = state.scope === "pstack"; - const showAll = scoped && hidden - ? (() => { - const button = h("button", { class: "empty-action", text: "Show every session", attrs: { type: "button" } }); - button.addEventListener("click", () => setScope("all")); - return button; - })() - : null; - return h( - "div", - { class: "empty" }, - logo(), - h("h2", { text: scoped ? `No pstack sessions in the last ${hours} hours` : `No sessions in the last ${hours} hours` }), - h("p", { text: scoped ? "Run a pstack skill in Claude Code or Codex and it appears here." : "Start a Claude Code or Codex session and it appears here." }), - showAll, - ); -} - -function renderBanner(): void { - const messages: string[] = []; - for (const source of state.health) { - if (!source.present) continue; - const name = SOURCE_NAME[source.source]; - if (source.state === "degraded") { - messages.push(`${name}: ${source.shape} unrecognized ${source.shape === 1 ? "record" : "records"}; some activity may be missing.`); - } else if (source.unchecked.length > 0) { - messages.push(`${name} ${source.unchecked.join(", ")} is newer than this monitor was checked against.`); - } - } - const text = messages.join(" "); - if (text.length === 0 || text === state.dismissed) { - banner.hidden = true; - return; - } - const dismiss = h("button", { class: "icon-button", title: "Dismiss", attrs: { type: "button", "aria-label": "Dismiss" } }, icon("close")); - dismiss.addEventListener("click", () => { - state.dismissed = text; - banner.hidden = true; - }); - banner.replaceChildren(icon("alert"), h("p", {}, text, " Run ", h("code", { text: "pstack-monitor doctor" }), " for details."), dismiss); - banner.hidden = false; -} - -function setConnection(next: Connection): void { - if (state.connection === next) return; - const wasExpired = state.connection === "expired"; - state.connection = next; - if (wasExpired || next === "expired") render(); - else renderLive(); -} - -function renderLive(): void { - const indexing = state.server?.indexing === true; - const shown: Connection | "indexing" = state.connection === "live" && indexing ? "indexing" : state.connection; - live.dataset.state = shown; - liveText.textContent = { - connecting: "Connecting…", - live: "Live", - indexing: "Indexing…", - retrying: "Reconnecting…", - expired: "Link expired", - }[shown]; - live.title = shown === "expired" ? "Run `pstack-monitor start` and open the new link." : ""; -} - -// --- clocks and keys ------------------------------------------------------- - -window.setInterval(() => { - const now = Date.now(); - canvas.tick(now); - panel.tick(now); -}, 1_000); - -window.setInterval(() => render(), 30_000); - -document.addEventListener("keydown", (event) => { - if (event.key !== "Escape") return; - if (document.body.dataset.rail === "open") toggleRail(false); - else if (state.agent !== null) selectAgent(null); -}); - -new ResizeObserver(() => fitPanel()).observe(stage); - -connect(); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/panel.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/panel.ts deleted file mode 100644 index 1ccd4ed5..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/panel.ts +++ /dev/null @@ -1,566 +0,0 @@ -import type { AgentId, AgentNode, Clipped, MessageLink, TimelineItem } from "../domain.ts"; -import { activityLine, ago, stalled, clockTime, compactNumber, duration, kindLabel, modelOf, prettyModel, shortPath, statusLine, summarizeInput } from "../format.ts"; -import type { TimelinePage } from "../wire.ts"; -import { h, icon, providerIcon, toolIcon, type IconName } from "./dom.ts"; - -// pstack-flex addition. The drill-down: one agent's facts and its timeline, -// streaming while the agent works. - -const STICK_THRESHOLD = 40; -const LOAD_OLDER_THRESHOLD = 160; -const CLAMP_CHARS = 700; -export const PANEL_MIN = 360; -export const PANEL_DEFAULT = 460; -const PANEL_WIDE = 760; -const KEY_STEP = 24; -const WIDTH_KEY = "pstack-monitor.panel-width"; - -export interface PanelEvents { - close(): void; - select(id: AgentId): void; - loadOlder(agent: AgentId, before: number): Promise; - /** `settled` is false while a drag is still moving. */ - resized(width: number, settled: boolean): void; -} - -function storedWidth(): number { - try { - const value = Number(localStorage.getItem(WIDTH_KEY)); - return Number.isFinite(value) && value >= PANEL_MIN ? value : PANEL_DEFAULT; - } catch { - return PANEL_DEFAULT; - } -} - -function storeWidth(width: number | null): void { - try { - if (width === null) localStorage.removeItem(WIDTH_KEY); - else localStorage.setItem(WIDTH_KEY, String(Math.round(width))); - } catch { - // Storage is unavailable; the width lasts for this page only. - } -} - -type ToolCall = Extract; -type ToolResult = Extract; - -function itemOrder(item: TimelineItem): [number, number] { - const [offset, index] = item.id.split("."); - return [Number(offset), Number(index ?? 0)]; -} - -function compareItems(a: TimelineItem, b: TimelineItem): number { - const [ao, ai] = itemOrder(a); - const [bo, bi] = itemOrder(b); - return ao === bo ? ai - bi : ao - bo; -} - -function clippedNote(body: Clipped): HTMLElement | null { - return body.omitted > 0 - ? h("p", { class: "t-clipped", text: `${body.omitted.toLocaleString()} more characters not shown` }) - : null; -} - -export class Panel { - readonly element: HTMLElement; - private readonly glyph: HTMLElement; - private readonly title: HTMLElement; - private readonly kind: HTMLElement; - private readonly chip: HTMLElement; - private readonly facts: HTMLElement; - private readonly task: HTMLElement; - private readonly taskText: HTMLElement; - private readonly now: HTMLElement; - private readonly nowLabel: HTMLElement; - private readonly nowText: HTMLElement; - private readonly note: HTMLElement; - private readonly widen: HTMLButtonElement; - private preferred = storedWidth(); - private maxWidth = Number.POSITIVE_INFINITY; - private readonly scroller: HTMLElement; - private readonly list: HTMLOListElement; - private readonly olderButton: HTMLButtonElement; - private readonly jump: HTMLButtonElement; - private readonly emptyNote: HTMLElement; - private agent: AgentNode | null = null; - private nodes: ReadonlyMap = new Map(); - private links: readonly MessageLink[] = []; - private readonly items = new Map(); - private readonly expanded = new Set(); - private older: number | null = null; - private loaded = false; - private loadingOlder = false; - private stuck = true; - - constructor(private readonly events: PanelEvents) { - this.glyph = h("span", { class: "panel-glyph" }); - this.title = h("h2", { class: "panel-title", attrs: { id: "panel-title" } }); - this.kind = h("p", { class: "panel-kind" }); - this.chip = h("p", { class: "status-chip" }); - this.facts = h("dl", { class: "facts" }); - this.taskText = h("p", { class: "panel-task-text" }); - this.task = h("div", { class: "panel-task", attrs: { hidden: "" } }, h("span", { class: "panel-label", text: "Task" }), this.taskText); - this.nowLabel = h("span", { class: "panel-label" }); - this.nowText = h("span", { class: "panel-now-text" }); - this.now = h("div", { class: "panel-now", attrs: { hidden: "" } }, this.nowLabel, this.nowText); - this.note = h("p", { class: "panel-note", attrs: { hidden: "" } }); - const close = h("button", { class: "icon-button", title: "Close", attrs: { type: "button", "aria-label": "Close agent details" } }, icon("close")); - close.addEventListener("click", () => this.events.close()); - this.widen = h("button", { class: "icon-button panel-widen", title: "Widen", attrs: { type: "button", "aria-label": "Widen panel", "aria-pressed": "false" } }, icon("widen")); - this.widen.addEventListener("click", () => this.setWidth(this.width >= PANEL_WIDE ? PANEL_DEFAULT : PANEL_WIDE, true)); - const resizer = h("div", { - class: "panel-resizer", - title: "Drag to resize; double-click to reset", - attrs: { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize panel", tabindex: "0" }, - }); - this.bindResizer(resizer); - this.olderButton = h("button", { class: "older", text: "Load earlier activity", attrs: { type: "button", hidden: "" } }); - this.olderButton.addEventListener("click", () => void this.loadOlder()); - this.list = h("ol", { class: "timeline-items" }); - this.emptyNote = h("p", { class: "timeline-empty", attrs: { hidden: "" } }); - this.scroller = h("div", { class: "timeline", attrs: { tabindex: "0", "aria-label": "Activity" } }, this.olderButton, this.list, this.emptyNote); - this.scroller.addEventListener("scroll", () => this.onScroll(), { passive: true }); - this.jump = h("button", { class: "jump", attrs: { type: "button", hidden: "" } }, icon("arrowDown"), h("span", { text: "New activity" })); - this.jump.addEventListener("click", () => this.scrollToEnd(true)); - this.element = h( - "aside", - { class: "panel", attrs: { "aria-labelledby": "panel-title", "data-open": "false" } }, - resizer, - h( - "header", - { class: "panel-head" }, - h("div", { class: "panel-identity" }, this.glyph, h("div", { class: "panel-names" }, this.title, this.kind), h("div", { class: "panel-actions" }, this.widen, close)), - this.chip, - this.task, - this.now, - this.facts, - this.note, - ), - this.scroller, - this.jump, - ); - } - - get current(): AgentId | null { - return this.agent?.id ?? null; - } - - get width(): number { - return Math.max(PANEL_MIN, Math.min(this.preferred, this.maxWidth)); - } - - /** Keeps the panel from covering the whole stage; called when the window resizes. */ - setMaxWidth(pixels: number): void { - this.maxWidth = Math.max(PANEL_MIN, pixels); - this.applyWidth(); - } - - private setWidth(pixels: number | null, settled: boolean): void { - this.preferred = pixels ?? PANEL_DEFAULT; - if (settled) storeWidth(pixels === null ? null : this.width); - this.applyWidth(); - this.events.resized(this.width, settled); - } - - private applyWidth(): void { - this.element.style.setProperty("--panel-width", `${this.width}px`); - this.widen.setAttribute("aria-pressed", String(this.width >= PANEL_WIDE)); - } - - private bindResizer(resizer: HTMLElement): void { - let start: { x: number; width: number } | null = null; - let frame: number | null = null; - let next = 0; - resizer.addEventListener("pointerdown", (event) => { - if (event.button !== 0) return; - event.preventDefault(); - resizer.setPointerCapture(event.pointerId); - start = { x: event.clientX, width: this.width }; - this.element.dataset.resizing = "true"; - }); - resizer.addEventListener("pointermove", (event) => { - if (start === null) return; - next = start.width + start.x - event.clientX; - frame ??= requestAnimationFrame(() => { - frame = null; - this.setWidth(next, false); - }); - }); - const finish = (): void => { - if (start === null) return; - start = null; - if (frame !== null) cancelAnimationFrame(frame); - frame = null; - delete this.element.dataset.resizing; - this.setWidth(this.width, true); - }; - resizer.addEventListener("pointerup", finish); - resizer.addEventListener("pointercancel", finish); - resizer.addEventListener("dblclick", () => this.setWidth(null, true)); - resizer.addEventListener("keydown", (event) => { - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; - event.preventDefault(); - this.setWidth(this.width + (event.key === "ArrowLeft" ? KEY_STEP : -KEY_STEP), true); - }); - } - - open(node: AgentNode, nodes: ReadonlyMap, now: number): void { - const switched = this.agent?.id !== node.id; - this.agent = node; - this.nodes = nodes; - if (switched) { - this.items.clear(); - this.expanded.clear(); - this.older = null; - this.loaded = false; - this.stuck = true; - this.list.replaceChildren(this.skeleton()); - this.emptyNote.hidden = true; - this.olderButton.hidden = true; - this.jump.hidden = true; - } - this.element.dataset.open = "true"; - this.applyWidth(); - this.renderHeader(now); - } - - close(): void { - this.agent = null; - this.element.dataset.open = "false"; - } - - update(nodes: ReadonlyMap, now: number, links: readonly MessageLink[]): void { - if (this.agent === null) return; - this.nodes = nodes; - this.links = links; - const latest = nodes.get(this.agent.id); - if (latest !== undefined) this.agent = latest; - this.renderHeader(now); - if (this.loaded) this.renderItems(false); - } - - setPage(page: TimelinePage): void { - if (page.agent !== this.agent?.id) return; - this.items.clear(); - for (const item of page.items) this.items.set(item.id, item); - this.older = page.older; - this.loaded = true; - this.renderItems(false); - this.scrollToEnd(false); - } - - append(agent: AgentId, items: readonly TimelineItem[]): void { - if (agent !== this.agent?.id || !this.loaded) return; - for (const item of items) this.items.set(item.id, item); - this.renderItems(false); - if (this.stuck) this.scrollToEnd(false); - else this.jump.hidden = false; - } - - tick(now: number): void { - if (this.agent === null) return; - if (this.agent.status.kind === "running" || this.agent.status.kind === "idle") this.renderHeader(now); - } - - private renderHeader(now: number): void { - const node = this.agent; - if (node === null) return; - this.element.dataset.harness = node.harness; - this.element.dataset.provider = node.model.provider; - const glyphName = node.flavor.kind === "session" ? providerIcon(node.harness) : providerIcon(node.model.provider); - if (this.glyph.dataset.icon !== glyphName) { - this.glyph.dataset.icon = glyphName; - this.glyph.replaceChildren(icon(glyphName)); - } - this.title.textContent = node.title; - this.kind.textContent = kindLabel(node); - this.chip.dataset.status = node.status.kind; - this.chip.dataset.stalled = String(stalled(node, now)); - this.chip.replaceChildren(icon(statusIcon(node)), h("span", { text: statusLine(node, now) })); - - this.task.hidden = node.prompt === null; - this.taskText.textContent = node.prompt?.text ?? ""; - this.taskText.title = node.prompt?.text ?? ""; - const running = node.status.kind === "running" && !stalled(node, now); - const doing = activityLine(node); - const since = node.pending?.since ?? node.activity?.at ?? null; - const age = since === null ? "" : ago(since, now); - this.now.hidden = doing === null; - this.now.dataset.pending = String(node.pending !== null && running); - this.nowLabel.textContent = running ? "Now" : "Last"; - this.nowText.textContent = doing === null ? "" : age.length > 0 ? `${doing} · ${age}` : doing; - this.nowText.title = doing ?? ""; - - const rows: [string, Node | string][] = []; - const model = modelOf(node); - if (model !== null) { - const requested = prettyModel(node.model.requested); - const reported = prettyModel(node.model.reported); - rows.push(["Model", requested !== null && reported !== null && requested !== reported ? `${reported} (asked for ${requested})` : model]); - } - if (node.model.effort !== null) rows.push(["Effort", node.model.effort]); - if (node.startedAt !== null) rows.push(["Started", `${clockTime(node.startedAt)}`]); - const span = elapsed(node, now); - if (span !== null) rows.push([node.status.kind === "running" ? "Running for" : "Took", duration(span)]); - if (node.usage !== null) { - const input = node.usage.inputTokens ?? 0; - const output = node.usage.outputTokens ?? 0; - const cached = node.usage.cachedInputTokens ?? 0; - rows.push(["Tokens", `${compactNumber(input)} in · ${compactNumber(output)} out${cached > 0 ? ` · ${compactNumber(cached)} cached` : ""}`]); - } - const parent = node.parent === null ? undefined : this.nodes.get(node.parent); - if (parent !== undefined) { - const link = h("button", { class: "link", text: parent.title, attrs: { type: "button" } }); - link.addEventListener("click", () => this.events.select(parent.id)); - rows.push(["Spawned by", link]); - } - const sent = this.links.filter((link) => link.from === node.id).reduce((sum, link) => sum + link.count, 0); - const received = this.links.filter((link) => link.to === node.id).reduce((sum, link) => sum + link.count, 0); - if (sent + received > 0) rows.push(["Messages", `${sent} sent · ${received} received`]); - if (node.cwd !== null) rows.push(["Folder", h("span", { class: "mono", text: shortPath(node.cwd), title: node.cwd })]); - this.facts.replaceChildren(...rows.flatMap(([term, value]) => [h("dt", { text: term }), h("dd", {}, value)])); - - const notes: string[] = []; - if (node.status.kind === "unknown") notes.push(capitalize(node.status.why)); - if (node.health === "degraded") notes.push("Some records were not recognized."); - this.note.textContent = notes.join(" "); - this.note.hidden = notes.length === 0; - } - - private renderItems(prepended: boolean): void { - const node = this.agent; - if (node === null) return; - const previousHeight = this.scroller.scrollHeight; - const sorted = [...this.items.values()].sort(compareItems); - const calls = new Set(); - const results = new Map(); - for (const item of sorted) if (item.kind === "tool-call") calls.add(item.callId); - for (const item of sorted) if (item.kind === "tool-result" && calls.has(item.callId)) results.set(item.callId, item); - const spawned = new Map(); - for (const candidate of this.nodes.values()) { - if (candidate.parent === node.id && candidate.spawnCall !== null) spawned.set(candidate.spawnCall, candidate); - } - - const rows: HTMLElement[] = []; - let unparsed: { count: number; types: Set } | null = null; - const flushUnparsed = (): void => { - if (unparsed === null) return; - const types = [...unparsed.types].join(", "); - rows.push(row("unparsed", "question", h("p", { - class: "t-quiet", - text: `${unparsed.count} unrecognized ${unparsed.count === 1 ? "record" : "records"}${types.length > 0 ? ` (${types})` : ""}`, - }))); - unparsed = null; - }; - for (const item of sorted) { - if (item.kind === "unparsed") { - unparsed ??= { count: 0, types: new Set() }; - unparsed.count += 1; - if (item.recordType !== null) unparsed.types.add(item.recordType); - continue; - } - flushUnparsed(); - if (item.kind === "tool-result" && calls.has(item.callId)) continue; - rows.push(this.renderItem(item, results, spawned)); - } - flushUnparsed(); - - this.list.replaceChildren(...rows); - this.olderButton.hidden = this.older === null; - this.emptyNote.hidden = rows.length > 0; - this.emptyNote.textContent = node.flavor.kind === "lane" && node.flavor.stream === "at-exit" - ? "The reply arrives when the lane exits." - : "No activity yet."; - if (prepended) this.scroller.scrollTop += this.scroller.scrollHeight - previousHeight; - } - - private renderItem(item: TimelineItem, results: ReadonlyMap, spawned: ReadonlyMap): HTMLElement { - switch (item.kind) { - case "prompt": - return row("prompt", "user", head("Prompt", item.at), this.longText(item.id, item.body)); - case "text": - return row("text", "reply", head("Response", item.at), this.longText(item.id, item.body)); - case "thinking": { - if (item.body === null) return row("thinking", "thought", head("Thinking", item.at, h("span", { class: "t-quiet", text: "not recorded" }))); - const open = this.expanded.has(item.id); - const toggle = this.toggle(item.id, "Thinking", item.at, open); - return row("thinking", "thought", toggle, open ? h("div", {}, h("p", { class: "t-text t-thinking", text: item.body.text }), clippedNote(item.body)) : null); - } - case "tool-call": - return this.toolRow(item, results.get(item.callId) ?? null, spawned.get(item.callId) ?? null); - case "tool-result": { - const open = this.expanded.has(item.id); - return row( - "tool", - item.ok === false ? "alert" : "tool", - this.toggle(item.id, "Result", item.at, open), - open ? h("div", {}, h("pre", { class: "t-pre", text: item.output.text }), clippedNote(item.output)) : null, - ); - } - case "notice": - return row(item.level === "error" ? "error" : "notice", item.level === "error" ? "alert" : "info", h("p", { class: "t-notice", text: item.text })); - case "unparsed": - return row("unparsed", "question", h("p", { class: "t-quiet", text: "unrecognized record" })); - } - } - - private toolRow(call: ToolCall, result: ToolResult | null, child: AgentNode | null): HTMLElement { - const open = this.expanded.has(call.id); - const state = result === null ? "pending" : result.ok === false ? "error" : "ok"; - const summary = summarizeInput(call.input.text); - const toggle = h( - "button", - { class: "t-toggle t-tool", attrs: { type: "button", "aria-expanded": String(open) } }, - icon(open ? "chevronDown" : "chevronRight", "icon t-chevron"), - h("span", { class: "t-tool-name", text: call.name }), - h("span", { class: "t-tool-summary mono", text: summary }), - h("span", { class: "t-tool-state", attrs: { "data-state": state, "aria-label": state === "pending" ? "running" : state === "error" ? "failed" : "done" } }, icon(state === "pending" ? "spinner" : state === "error" ? "cross" : "check")), - h("time", { class: "t-time", text: clockTime(call.at), attrs: { datetime: call.at ?? "" } }), - ); - toggle.addEventListener("click", () => this.flip(call.id)); - const childLink = child === null ? null : (() => { - const button = h( - "button", - { class: "t-child", attrs: { type: "button", "data-status": child.status.kind } }, - icon("agent"), - h("span", { class: "t-child-title", text: child.title }), - h("span", { class: "t-child-status", text: statusLine(child, Date.now()) }), - icon("arrowRight"), - ); - button.addEventListener("click", () => this.events.select(child.id)); - return button; - })(); - const details = open - ? h( - "div", - { class: "t-io" }, - h("p", { class: "t-io-label", text: "Input" }), - h("pre", { class: "t-pre", text: call.input.text }), - clippedNote(call.input), - h("p", { class: "t-io-label", text: "Output" }), - result === null - ? h("p", { class: "t-quiet", text: "Waiting for output…" }) - : h("pre", { class: "t-pre", attrs: { "data-state": state }, text: result.output.text.length > 0 ? result.output.text : "(empty)" }), - result === null ? null : clippedNote(result.output), - ) - : null; - return row("tool", toolIcon(call.name), toggle, childLink, details); - } - - private longText(id: string, body: Clipped): HTMLElement { - const long = body.text.length > CLAMP_CHARS || body.text.split("\n").length > 10; - const open = this.expanded.has(id); - const text = h("p", { class: "t-text", text: body.text }); - if (long && !open) text.classList.add("is-clamped"); - const more = long - ? (() => { - const button = h("button", { class: "t-more", text: open ? "Show less" : "Show all", attrs: { type: "button" } }); - button.addEventListener("click", () => this.flip(id)); - return button; - })() - : null; - return h("div", {}, text, open ? clippedNote(body) : null, more); - } - - private toggle(id: string, label: string, at: string | null, open: boolean): HTMLButtonElement { - const button = h( - "button", - { class: "t-toggle", attrs: { type: "button", "aria-expanded": String(open) } }, - icon(open ? "chevronDown" : "chevronRight", "icon t-chevron"), - h("span", { class: "t-label", text: label }), - h("time", { class: "t-time", text: clockTime(at), attrs: { datetime: at ?? "" } }), - ); - button.addEventListener("click", () => this.flip(id)); - return button; - } - - private flip(id: string): void { - if (this.expanded.has(id)) this.expanded.delete(id); - else this.expanded.add(id); - const top = this.scroller.scrollTop; - this.renderItems(false); - this.scroller.scrollTop = top; - } - - private skeleton(): HTMLElement { - return h("li", { class: "t-skeleton", attrs: { "aria-hidden": "true" } }, h("span"), h("span"), h("span")); - } - - private onScroll(): void { - const distance = this.scroller.scrollHeight - this.scroller.scrollTop - this.scroller.clientHeight; - this.stuck = distance < STICK_THRESHOLD; - if (this.stuck) this.jump.hidden = true; - if (this.scroller.scrollTop < LOAD_OLDER_THRESHOLD) void this.loadOlder(); - } - - private async loadOlder(): Promise { - const agent = this.agent; - if (agent === null || this.older === null || this.loadingOlder) return; - this.loadingOlder = true; - this.olderButton.textContent = "Loading…"; - try { - const page = await this.events.loadOlder(agent.id, this.older); - if (page === null || this.agent?.id !== agent.id) return; - for (const item of page.items) this.items.set(item.id, item); - this.older = page.older; - this.renderItems(true); - } finally { - this.loadingOlder = false; - this.olderButton.textContent = "Load earlier activity"; - } - } - - private scrollToEnd(smooth: boolean): void { - this.scroller.scrollTo({ top: this.scroller.scrollHeight, behavior: smooth ? "smooth" : "auto" }); - this.stuck = true; - this.jump.hidden = true; - } -} - -function row(kind: string, iconName: IconName, ...content: (Node | null)[]): HTMLElement { - return h("li", { class: "t", attrs: { "data-kind": kind } }, h("span", { class: "t-icon" }, icon(iconName)), h("div", { class: "t-body" }, ...content)); -} - -function head(label: string, at: string | null, extra: Node | null = null): HTMLElement { - return h( - "div", - { class: "t-head" }, - h("span", { class: "t-label", text: label }), - extra, - h("time", { class: "t-time", text: clockTime(at), attrs: { datetime: at ?? "" } }), - ); -} - -function statusIcon(node: AgentNode): IconName { - switch (node.status.kind) { - case "running": - return "spinner"; - case "idle": - return "pause"; - case "done": - return "check"; - case "failed": - return "cross"; - case "cancelled": - return "stop"; - case "ended": - return "dot"; - case "unknown": - return "question"; - } -} - -function elapsed(node: AgentNode, now: number): number | null { - if (node.startedAt === null) return null; - const start = Date.parse(node.startedAt); - const status = node.status; - const endIso = status.kind === "running" - ? null - : ("at" in status && status.at !== null ? status.at : node.lastActivityAt); - const end = endIso === null ? now : Date.parse(endIso); - return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null; -} - -function capitalize(value: string): string { - return value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`; -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/rail.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/rail.ts deleted file mode 100644 index fd671e84..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/rail.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { AgentId, AgentNode } from "../domain.ts"; -import { activityLine, ago, kindLabel, working } from "../format.ts"; -import { h, icon, providerIcon } from "./dom.ts"; - -// pstack-flex addition. The session list, grouped by whether anyone is working. - -export type Scope = "pstack" | "all"; - -export interface RailEvents { - select(root: AgentId): void; - toggleScope(): void; -} - -export interface Descendants { - readonly total: number; - readonly running: number; -} - -type Group = "working" | "open" | "earlier"; - -const GROUPS: readonly { readonly key: Group; readonly label: string }[] = [ - { key: "working", label: "Working" }, - { key: "open", label: "Open" }, - { key: "earlier", label: "Earlier" }, -]; - -function groupOf(root: AgentNode, counts: Descendants | undefined, now: number): Group { - if (working(root, now) || (counts?.running ?? 0) > 0) return "working"; - if (root.status.kind === "idle" && root.status.evidence === "pid") return "open"; - return "earlier"; -} - -function folderName(cwd: string | null): string { - if (cwd === null) return ""; - const parts = cwd.split("/").filter((part) => part.length > 0); - return parts.at(-1) ?? cwd; -} - -/** The row's second line: what the session is doing, or when it last did anything. */ -function rowActivity(root: AgentNode, group: Group, now: number): string { - const when = ago(root.lastActivityAt, now); - if (group === "working") return activityLine(root) ?? "working"; - if (group === "open") return `waiting for input${when.length > 0 ? ` · ${when}` : ""}`; - return [folderName(root.cwd), when].filter((part) => part.length > 0).join(" · "); -} - -export class Rail { - readonly element: HTMLElement; - private readonly lists = new Map(); - private readonly groups = new Map(); - private readonly title: HTMLElement; - private readonly count: HTMLElement; - private readonly scopeButton: HTMLButtonElement; - private readonly foot: HTMLElement; - private readonly empty: HTMLElement; - - constructor(private readonly events: RailEvents) { - this.title = h("span", { class: "rail-title" }); - this.count = h("span", { class: "rail-count" }); - this.scopeButton = h("button", { class: "rail-scope", attrs: { type: "button" } }); - this.scopeButton.addEventListener("click", () => this.events.toggleScope()); - const sections = GROUPS.map(({ key, label }) => { - const list = h("ul", { class: "sessions" }); - const section = h("section", { class: "rail-group", attrs: { "data-group": key } }, h("h2", { class: "rail-heading", text: label }), list); - this.lists.set(key, list); - this.groups.set(key, section); - return section; - }); - this.empty = h("p", { class: "rail-empty" }); - this.foot = h("p", { class: "rail-foot" }); - this.element = h( - "nav", - { class: "rail", attrs: { "aria-label": "Sessions" } }, - h("header", { class: "rail-head" }, h("span", { class: "rail-names" }, this.title, this.count), this.scopeButton), - h("div", { class: "rail-scroll" }, ...sections, this.empty), - this.foot, - ); - } - - render( - roots: readonly AgentNode[], - descendants: ReadonlyMap, - selected: AgentId | null, - now: number, - scope: Scope, - footer: string, - ): void { - const rows = new Map(GROUPS.map(({ key }) => [key, []])); - for (const root of roots) { - const counts = descendants.get(root.id); - const group = groupOf(root, counts, now); - rows.get(group)!.push(this.row(root, counts, group, root.id === selected, now)); - } - for (const { key } of GROUPS) { - const items = rows.get(key)!; - this.lists.get(key)!.replaceChildren(...items); - this.groups.get(key)!.hidden = items.length === 0; - } - this.title.textContent = scope === "pstack" ? "pstack sessions" : "All sessions"; - this.scopeButton.textContent = scope === "pstack" ? "Show all" : "pstack only"; - this.scopeButton.setAttribute("aria-pressed", String(scope === "all")); - this.empty.textContent = scope === "pstack" ? "No pstack sessions yet." : "No sessions yet."; - this.empty.hidden = roots.length > 0; - this.count.textContent = String(roots.length); - this.foot.textContent = footer; - } - - private row(root: AgentNode, counts: Descendants | undefined, group: Group, selected: boolean, now: number): HTMLElement { - const total = counts?.total ?? 0; - const running = counts?.running ?? 0; - const button = h( - "button", - { - class: "session", - title: `${root.title}\n${kindLabel(root)}${root.cwd === null ? "" : `\n${root.cwd}`}`, - attrs: { - type: "button", - "data-group": group, - "data-status": root.status.kind, - "data-harness": root.harness, - ...(selected ? { "aria-current": "true" } : {}), - }, - }, - h("span", { class: "session-glyph" }, icon(providerIcon(root.harness))), - h( - "span", - { class: "session-text" }, - h("span", { class: "session-title", text: root.title }), - h("span", { class: "session-meta", text: rowActivity(root, group, now) }), - ), - h( - "span", - { - class: "session-side", - attrs: { "aria-label": `${total} ${total === 1 ? "agent" : "agents"}${running > 0 ? `, ${running} running` : ""}` }, - }, - total > 0 ? h("span", { class: "session-count", attrs: { "data-running": String(running > 0) } }, icon("agent"), h("span", { text: String(total) })) : null, - h("span", { class: "session-dot", attrs: { "aria-hidden": "true" } }), - ), - ); - button.addEventListener("click", () => this.events.select(root.id)); - return h("li", {}, button); - } -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/tsconfig.json b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/tsconfig.json deleted file mode 100644 index 5314b67f..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "allowImportingTsExtensions": true, - "module": "esnext", - "moduleResolution": "bundler", - "noEmit": true, - "skipLibCheck": true, - "strict": true, - "target": "esnext", - "lib": ["esnext", "dom", "dom.iterable"], - "types": [] - }, - "include": ["*.ts"] -} diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/wire.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/wire.ts deleted file mode 100644 index 466dee8c..00000000 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/wire.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { AgentId, AgentNode, Health, MessageLink, SourceKind, TimelineItem } from "./domain.ts"; - -// pstack-flex addition. The JSON shapes the server sends the browser. - -export interface SourceHealth { - readonly source: SourceKind; - readonly state: Health; - readonly present: boolean; - readonly files: number; - readonly lines: number; - readonly parsed: number; - readonly notJson: number; - readonly shape: number; - readonly oversized: number; - readonly unknownTypes: Readonly>; - readonly cliVersions: readonly string[]; - /** Versions newer than the one the adapter was checked against. */ - readonly unchecked: readonly string[]; -} - -export interface ServerInfo { - readonly app: "pstack-monitor"; - readonly version: string; - readonly instance: string; - readonly pid: number; - readonly startedAt: string; - readonly watching: "events" | "poll"; - readonly indexing: boolean; - readonly windowHours: number; -} - -export interface Snapshot { - readonly rev: number; - readonly agents: readonly AgentNode[]; - readonly links: readonly MessageLink[]; - readonly health: readonly SourceHealth[]; - readonly server: ServerInfo; -} - -export interface Delta { - readonly rev: number; - readonly upserts: readonly AgentNode[]; - /** The whole link list when it changed; null when it did not. */ - readonly links: readonly MessageLink[] | null; - readonly health: readonly SourceHealth[] | null; - readonly indexing: boolean; -} - -export interface TimelinePage { - readonly agent: AgentId; - readonly items: readonly TimelineItem[]; - /** Pass as `before` to load older items; null at the start of the transcript. */ - readonly older: number | null; -} - -export interface TimelineAppend { - readonly agent: AgentId; - readonly items: readonly TimelineItem[]; -} - -export type ServerEvent = - | { readonly event: "snapshot"; readonly data: Snapshot } - | { readonly event: "delta"; readonly data: Delta } - | { readonly event: "timeline"; readonly data: TimelinePage } - | { readonly event: "append"; readonly data: TimelineAppend }; diff --git a/plugins/pstack/skills/poteto-mode/scripts/package.json b/plugins/pstack/skills/poteto-mode/scripts/package.json index 6aa871c2..069d0bc4 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/package.json +++ b/plugins/pstack/skills/poteto-mode/scripts/package.json @@ -3,8 +3,8 @@ "private": true, "type": "module", "scripts": { - "test": "\"$npm_execpath\" test --parallel bootstrap orch watch-pr runner check-plan monitor", - "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict && tsc --project runner/tsconfig.json --noEmit --strict && tsc --project check-plan.tsconfig.json --noEmit --strict && tsc --project monitor/tsconfig.json --noEmit --strict && tsc --project monitor/web/tsconfig.json --noEmit --strict" + "test": "\"$npm_execpath\" test --parallel bootstrap orch watch-pr runner check-plan", + "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict && tsc --project runner/tsconfig.json --noEmit --strict && tsc --project check-plan.tsconfig.json --noEmit --strict" }, "dependencies": { "commander": "14.0.0" diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts index 4afada0c..ce440f8b 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts @@ -6,7 +6,7 @@ import type { AccessMode, Effort, Parent, Provider, RunnerOptions, RunnerReceipt // pstack-flex addition. An opt-in journal of each external lane, so the agent // monitor can show a lane while it runs. Journaling is on only when the lanes -// directory exists; `pstack-monitor journal on` creates it. A journal failure +// directory exists; psf-monitor's `journal on` creates it. A journal failure // never changes the lane's receipt, exit code, or output. export const LANES_DIR_VAR = "PSTACK_FLEX_LANES_DIR";