diff --git a/CHANGES.md b/CHANGES.md index 8a7d5bf4..18072306 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,13 @@ # CHANGES — applied substitutions +## Unreleased: agent monitor + +- Add `/pstack:monitor` and `pstack-monitor`, a read-only local server with a live node canvas of the agents on this machine: Claude Code and Codex sessions, the subagents they spawn, and each agent's model. Any agent opens into a panel that streams its prompts, replies, thinking, and tool calls. The theme follows the session's harness: warm for Claude Code, blue for Codex. +- The monitor reads the transcripts each harness already writes and adds no hook, so it costs nothing in sessions that never open it. Status comes from process records, lifecycle events, and parent tool results, never file times; what cannot be known shows as unknown. Unrecognized transcript records are counted and surfaced instead of dropped, and `pstack-monitor doctor` reports them without printing content. +- Messages between agents appear as dashed, arrowed arcs with a count, from Codex's agent messages and Claude Code's `SendMessage` calls. Each new message sends a spark from sender to recipient. +- External lanes become visible while they run. `pstack-runner` writes an opt-in journal (start record, stdout as it arrives, receipt copy) under `~/.pstack-flex/lanes/` whenever that directory exists; `pstack-monitor start` creates it and `pstack-monitor journal off` deletes it. The new `--label` flag names a lane by its role, such as `arena cross-judge`, and routes nothing. A journal failure never changes a lane's receipt, exit status, or output. +- The server binds `127.0.0.1`, accepts no writes, rejects foreign `Host` and `Origin` headers, and requires a per-start token. It runs until `pstack-monitor stop`. 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) - Merge open-pstack 1.5.0, which syncs Cursor pstack 0.15.2 to 0.15.5: code-ready rounds and owner authority in the autopilot playbooks, Swarm SHA and method briefs, Architect reading `architect runners`, decision-trail `start` rows and the non-truncating `log.sh`, retired-role handling in setup, and the upstream prompt cuts. The upstream exclusions in `UPSTREAM.md` carry over. diff --git a/NOTICE.md b/NOTICE.md index a1c62890..6f261609 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -58,6 +58,7 @@ 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 bd711ed1..8dd44487 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,17 @@ 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, 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, watching your agents, 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 every session, the agents it spawned, the external lanes pstack launched, and the messages between them, 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. ## Useful skills @@ -157,6 +167,7 @@ That is the main workflow. The other skills are there when poteto-mode needs the | `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 every session, subagent, and model lane, with each agent's activity a click away. | 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 8cbe34f8..891e5b18 100644 --- a/UPSTREAM-FLEX.md +++ b/UPSTREAM-FLEX.md @@ -37,6 +37,8 @@ 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 43433763..654a0195 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -218,6 +218,78 @@ 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 + +The monitor is a local web page that draws every Claude Code and Codex session on this machine as a live graph. It shows the agents each session spawned, the external lanes pstack launched, and the messages they sent each other. Click any agent to read what it is doing. + +### It does not start by itself + +Nothing launches the monitor when a session starts or when you log in. Start it once. It then runs in the background, outside any session, until you stop it or restart the machine. Starting it while it already runs prints the same link again. After a plugin update, the next start replaces the old server with the new build. + +### Start it + +In Claude Code: + +```text +/pstack:monitor +``` + +In Codex: + +```text +Use pstack:monitor. +``` + +Either one prints a link such as `http://127.0.0.1:47317/?token=…`. Open it in your browser. The link carries an access token that is new each time the monitor starts, so an old link stops working after a restart. + +From a terminal, run the launcher inside the installed plugin. It needs Bun, which pstack's runner already uses. + +```shell +# Claude Code install +~/.claude/plugins/cache/open-pstack/pstack//skills/poteto-mode/scripts/monitor/pstack-monitor start --parent claude + +# Codex install +~/.codex/plugins/cache/open-pstack/pstack//skills/poteto-mode/scripts/monitor/pstack-monitor start --parent codex + +# a checkout of this repository +plugins/pstack/skills/poteto-mode/scripts/monitor/pstack-monitor start +``` + +`--parent` picks the page's starting theme, orange for Claude Code and blue for Codex, and selects that harness's current session first. Other options: `--port ` (default 47317), `--hours ` for how far back the session list reaches (default 24), and `--focus `. + +### Stop it and other commands + +| Ask pstack | Or run | What happens | +| --- | --- | --- | +| "pstack, kill the monitor" | `pstack-monitor stop` | Stops the server. Your agents keep running; the monitor never stops an agent. | +| "is the monitor running?" | `pstack-monitor status` | Prints agent counts and the link. | +| | `pstack-monitor doctor` | Reports how well recent transcripts parsed, as counts only. | +| | `pstack-monitor journal off` | Stops recording external lanes and deletes what was recorded. `on` and `status` also work. | + +To stop an agent itself, interrupt it in its own session, or cancel a pstack lane through the background task that launched it. The runner then writes a `cancelled` receipt. + +### External lanes + +pstack runs some lanes outside the harness, such as a Codex cross-judge launched from Claude Code. Those lanes normally leave nothing on disk until they finish. The first `start` turns on the lane journal, which records each lane in `~/.pstack-flex/lanes/` as it runs, so the monitor can show it live. The journal keeps each lane for 7 days, readable only by you. Lanes that started before the journal was on do not appear. + +Codex and Grok lanes stream while they work. Claude, DeepSeek, and MiniMax lanes appear as soon as they start, but their reply arrives only when they finish, because those CLIs print one result at exit. + +### What it reads and writes + +It reads the transcripts Claude Code and Codex already keep in `~/.claude` and `~/.codex` (or `CLAUDE_CONFIG_DIR` and `CODEX_HOME`). It writes only its own record and log in `~/.pstack-flex/monitor/` and the lane journal. It adds no hook and changes no harness setting. It listens on `127.0.0.1` only and accepts no writes. `PSTACK_FLEX_MONITOR_DIR` and `PSTACK_FLEX_LANES_DIR` move its two directories. + +### When something looks wrong + +| Symptom | Meaning | Fix | +| --- | --- | --- | +| `port 47317 is unavailable` | another program uses the port | `pstack-monitor start --port 47400` | +| The page says "This link has expired" | the monitor restarted, after an update or a reboot | run start again and open the new link | +| Start fails inside a sandboxed session | the sandbox blocked the local port or `~/.pstack-flex` | run the start command in your own terminal; in Claude Code, type it after `!` | +| A session says "status unknown" | Claude Desktop and SDK sessions keep no process record | nothing to fix; its activity still streams | +| A banner says a source is degraded or newer than checked | a CLI update changed its transcript format | run `pstack-monitor doctor` and open an issue with its output | +| An external lane never appears | the journal was off when the lane started | `pstack-monitor journal status`, then `pstack-monitor journal on` | +| A Codex thread stays "in turn" | Codex was killed mid-turn and wrote no ending | nothing to fix | + ## Cost playbook - High-volume code-writing roles (`feature`, `bug-fix`, `swarm workers`) -> `deepseek:deepseek-flash` — cheapest tokens, near-free cache hits, and half price in the off-peak window. diff --git a/docs/reference.md b/docs/reference.md index 00dc7ab3..94a38496 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -59,9 +59,9 @@ The marketplace install is the normal user path. Direct links are only for testi ├── plugins/pstack/ # the plugin itself │ ├── .claude-plugin/plugin.json # Claude Code manifest │ ├── .codex-plugin/plugin.json # Codex manifest (skills: ./skills/) -│ ├── skills/ # 54 skills shared by Claude Code and Codex +│ ├── 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, check-plan.mjs, worktree-audit.sh +│ │ └── poteto-mode/scripts/ # bun/bash/node tooling: watch-pr, orch, runner, monitor, 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,6 +151,19 @@ 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 + +`/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 that carries a per-start access token. The page draws the selected session's agents as a node canvas, themed by the session's harness, and streams any agent's timeline in a side panel. + +- **Sources.** It reads what each harness already writes: Claude Code session transcripts, subagent transcripts and their `.meta.json` sidecars, and `~/.claude/sessions/.json` process records; Codex rollouts under `~/.codex/sessions/`, where a child thread names its parent. It adds no hook and changes no harness setting. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` move the sources; `PSTACK_FLEX_MONITOR_DIR` moves the server record and log. +- **Status comes from evidence, never file times.** A Claude Code terminal session is live while its process record names a running process with the recorded start time. A subagent finishes on its parent's tool result or background-task notification. A Codex thread is in a turn between `task_started` and `task_complete` or `turn_aborted`. Claude Desktop and SDK sessions keep no process record, so their status shows as unknown rather than guessed. +- **External lanes.** `pstack-runner` keeps a lane's output in memory until it exits, so the runner also writes an opt-in journal: one directory per lane under `~/.pstack-flex/lanes/` (or `PSTACK_FLEX_LANES_DIR`) with `lane.json` (provider, model, effort, label, runner pid, parent session), `stream.jsonl` (the CLI's stdout as it arrives), and `receipt.json` (a copy of the receipt). The journal is on only while that directory exists: `pstack-monitor start` creates it, `pstack-monitor journal off` deletes it with everything recorded, and the server prunes lanes older than 7 days. A lane hangs under the session that launched it, read from that harness's own session variable. Codex and Grok lanes stream; Claude, DeepSeek, and MiniMax lanes print one result when they exit. A journal failure never changes a lane's receipt, exit status, or output. +- **Messages between agents.** Dashed, arrowed arcs show who messaged whom, with a count; each new message sends a spark along its arc, and an agent's panel totals what it sent and received. Codex records each message once, in the recipient's rollout, addressed by agent path (`/root`, `/root/`), and the monitor resolves the path within that tree. Claude Code messages come from `SendMessage` calls, addressed by agent id, name, or `main`. A message to another session's socket is left off the canvas, since that session is drawn separately. +- **Format drift is visible.** These transcript formats are undocumented. Unrecognized record types and malformed records are counted per source; the page shows a banner and `pstack-monitor doctor` prints the counts, record types, and CLI versions, never content. +- **Lifecycle.** `start` reuses a running server of the same build and replaces one from another build. `status`, `stop`, `doctor`, and `journal on|off|status` complete the CLI. Nothing times the server out. +- **Security.** The server binds loopback only, rejects foreign `Host` and `Origin` headers, accepts no writes, and requires the token (exchanged for an `HttpOnly`, `SameSite=Strict` cookie) on every route except a health check that reveals no data. The page runs under a same-origin content security policy and renders transcript text only as text. ## Subagents diff --git a/plugins/pstack/skills/monitor/SKILL.md b/plugins/pstack/skills/monitor/SKILL.md new file mode 100644 index 00000000..187b6deb --- /dev/null +++ b/plugins/pstack/skills/monitor/SKILL.md @@ -0,0 +1,41 @@ +--- +name: monitor +description: "Open a live local view of the agents on this machine: Claude Code and Codex sessions, every subagent they spawn, and pstack's external model lanes, with each agent's activity streaming in a drill-down panel. 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 + +Start the agent monitor and hand the user its link, or stop it when asked. + +Match the request to one command and run only that one: + +| The user asks to | Run | +| --- | --- | +| open, show, or start the monitor (the default) | `pstack-monitor start --parent ` | +| stop, kill, close, or shut down the monitor | `pstack-monitor stop` | +| check whether it is running, or get the link again | `pstack-monitor status` | + +`stop` ends the monitor server only. It never touches the sessions or agents the page shows; the monitor is read-only. If the user wants a running agent stopped, tell them to stop it where it runs: interrupt it in its own Claude Code or Codex session, or cancel a pstack lane through the background task that launched it, which makes the runner write a `cancelled` receipt. + +The launcher lives at `skills/poteto-mode/scripts/monitor/pstack-monitor` under the installed plugin. Run it with the harness you are running in: + +```text +pstack-monitor start --parent +``` + +`start` returns at once. It reuses a running monitor of the same build, replaces one from an older build, or launches a new one in the background, then prints one link. Give the user that link exactly as printed and say nothing else is needed. The link carries the server's access token: never paste it anywhere but the reply to the user, and never open it with a fetch tool. + +The first `start` also turns on the lane journal, so external lanes launched through `pstack-runner` appear while they run, and says so on stderr. Relay that line: the journal keeps lane output on disk for 7 days, and `pstack-monitor journal off` stops it and deletes what it kept. + +`--parent` sets the page's theme (warm for Claude Code, blue for Codex) and selects your own session first. It names the harness you are; it does not route anything. + +`stop` waits until the server has exited, then prints `pstack-monitor stopped`, or `pstack-monitor is not running` when there was nothing to stop. Relay that line. Stopping leaves the lane journal as it is; `journal off` is a separate request. + +Other commands: + +- `pstack-monitor doctor` reports how well recent transcripts parsed, as counts only. Run it when the page warns that a source is degraded or newer than the monitor was checked against, and relay the result. +- `pstack-monitor journal ` controls the lane journal. + +Apart from the lane journal, the monitor only reads transcripts that Claude Code and Codex already write. It changes no harness setting, adds no hook, and has no idle timeout: it runs until `stop`. + +If `start` fails because the sandbox blocks a local port or `~/.pstack-flex/monitor`, do not raise permissions. Give the user the command to run in their own terminal; 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 45a954a4..afbffb64 100644 --- a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md +++ b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md @@ -103,6 +103,8 @@ 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. + 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. Grok authentication preflight has one bounded retry. If the first `grok models` result would be classified as unauthenticated, the runner waits five seconds and tries the same preflight once more. A second failure is terminal. The delay and second attempt share the runner's absolute deadline and cancellation latch, and the receipt keeps evidence from both attempts. Model execution is never retried. diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts new file mode 100644 index 00000000..f366ac45 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts @@ -0,0 +1,185 @@ +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 } + // `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 new file mode 100644 index 00000000..e874e548 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/blocks.ts @@ -0,0 +1,106 @@ +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; + +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 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; + 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: describeTool(name, block.input), at }, + }); + if (SPAWN_TOOLS.has(name)) out.facts.push({ kind: "spawn-call", by: agent, callId }); + 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 new file mode 100644 index 00000000..a3357344 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.test.ts @@ -0,0 +1,178 @@ +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("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"); + }); + + 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 new file mode 100644 index 00000000..488eaaf5 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.ts @@ -0,0 +1,406 @@ +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, + collected, + itemId, + 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", +}; + +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); + 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) 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); + } 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)}` }); + 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" }); + 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) } }); + } +} + +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 (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 new file mode 100644 index 00000000..a08dd149 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.test.ts @@ -0,0 +1,131 @@ +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("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 new file mode 100644 index 00000000..8f397dec --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.ts @@ -0,0 +1,319 @@ +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, describeTool, itemId, 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", +]); + +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) } }]; + 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}`; + return parsed( + [{ kind: "activity", id: this.agent, activity: { what: "tool", snippet: describeTool(name, firstLine(input)), at } }], + [{ 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([], [{ 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 new file mode 100644 index 00000000..fa6bb13b --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.test.ts @@ -0,0 +1,156 @@ +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("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("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 new file mode 100644 index 00000000..5e6e901f --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.ts @@ -0,0 +1,315 @@ +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, collected, describeTool, itemId, 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[] = []; + 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 !== null) items.push(item); + }); + return parsed([], 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); + return parsed( + [{ kind: "activity", id: this.agent, activity: { what: "tool", snippet: describeTool(name, input), at: null } }], + [{ 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, [ + ...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 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) } : {}), + }, + }, + { + kind: "process", + key: path, + id, + process: { pid, startedAtMs: Number.isFinite(startedMs) ? startedMs : null, state: "running" }, + }, + ]; + 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 new file mode 100644 index 00000000..1f52be66 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/assets.test.ts @@ -0,0 +1,15 @@ +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 new file mode 100644 index 00000000..0cfa6578 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/cli.ts @@ -0,0 +1,161 @@ +import { parseArgs as parseNodeArgs } from "node:util"; +import { buildAssets } from "./assets.ts"; +import { DEFAULT_PORT, serve, start, status, stop, type Io } 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 in the background, or reuse the running one, + and print the link to open. Safe to run repeatedly. + status Print a one-line summary and the link. + stop Stop the running monitor. + doctor Index recent transcripts headlessly and report how well each + source parsed. Prints counts, never content. + journal + Record external pstack lanes so the monitor can show them live. + \`start\` turns this on; \`off\` stops it and deletes the records. + serve Run the server in the foreground (what \`start\` launches). + +Options: + --parent The harness asking; sets the page's default theme + and focuses that harness's current session. + --focus Session to select first (defaults from --parent). + --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 has no idle timeout; it 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; +} + +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" }, + 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}`; + if (parsed.values.help === true || command === undefined || command === "help") { + return { command: "help", journal: "status", port, hours, harness, focus }; + } + 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 }; +} + +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 }, io); + case "status": + return status(where, io); + 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 new file mode 100644 index 00000000..415e1483 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/daemon.ts @@ -0,0 +1,236 @@ +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 interface StartOptions { + readonly port: number; + readonly windowHours: number; + readonly harness: Harness | null; + readonly focus: string | null; +} + +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): string { + const query = new URLSearchParams({ token: record.token }); + if (harness !== null) query.set("harness", harness); + if (focus !== null) query.set("focus", focus); + 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)}\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)}\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): 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" : ""; + io.stdout(`pstack-monitor ${running.version} · ${summarize(snapshot.agents)}${indexing}\n${launchUrl(record, null, null)}\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 new file mode 100644 index 00000000..14683719 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/doctor.ts @@ -0,0 +1,63 @@ +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 new file mode 100644 index 00000000..7c0b1a4e --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/domain.ts @@ -0,0 +1,110 @@ +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 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; + 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 new file mode 100644 index 00000000..03d57e47 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/format.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { ago, compactNumber, duration, prettyModel, shortPath, summarizeInput } from "./format.ts"; + +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"); + }); +}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts new file mode 100644 index 00000000..eff9a9d0 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts @@ -0,0 +1,126 @@ +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", +}; + +/** One line of status for a card, e.g. `running · 2m 14s` or `done · 4m ago`. */ +export function statusLine(node: AgentNode, now: number): string { + const status = node.status; + switch (status.kind) { + case "running": { + const since = node.startedAt === null ? null : now - Date.parse(node.startedAt); + const verb = status.evidence === "lifecycle" ? "in turn" : "running"; + return since === null || !Number.isFinite(since) ? verb : `${verb} · ${duration(since)}`; + } + case "idle": + return status.detail === null ? `idle · ${ago(node.lastActivityAt, now)}` : `${status.detail.replace(/_/g, " ")}`; + 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 `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 new file mode 100644 index 00000000..5ca7d64b --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/fs.ts @@ -0,0 +1,63 @@ +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 new file mode 100644 index 00000000..88393b95 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/graph.ts @@ -0,0 +1,124 @@ +import type { AgentId, AgentNode, NormalizedUsage } from "./domain.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): boolean { + return node.status.kind === "running" || (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): AgentNode[] { + return [...nodes.values()] + .filter((node) => parentOf(node, nodes) === null) + .sort((a, b) => { + const live = Number(isLive(b)) - Number(isLive(a)); + 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): 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": + 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 new file mode 100644 index 00000000..723b15ef --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/index.test.ts @@ -0,0 +1,169 @@ +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 new file mode 100644 index 00000000..baa62932 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/index.ts @@ -0,0 +1,356 @@ +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 new file mode 100644 index 00000000..f1574056 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/journal.test.ts @@ -0,0 +1,41 @@ +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 new file mode 100644 index 00000000..190baf56 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/journal.ts @@ -0,0 +1,49 @@ +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 new file mode 100644 index 00000000..73521180 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/json.ts @@ -0,0 +1,84 @@ +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 new file mode 100644 index 00000000..3dea9e81 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.test.ts @@ -0,0 +1,112 @@ +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, + 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 new file mode 100644 index 00000000..c440a5af --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.ts @@ -0,0 +1,156 @@ +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 = 64; +export const ROOT_WIDTH = 304; +export const ROOT_HEIGHT = 80; +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 new file mode 100644 index 00000000..ddd7eccb --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/monitor.ts @@ -0,0 +1,223 @@ +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 new file mode 100644 index 00000000..afbaf5db --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/probe.ts @@ -0,0 +1,41 @@ +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 new file mode 100755 index 00000000..c6d66997 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/pstack-monitor @@ -0,0 +1,3 @@ +#!/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 new file mode 100644 index 00000000..3f3f0355 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/record.ts @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..9c64bf21 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/server.test.ts @@ -0,0 +1,148 @@ +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 new file mode 100644 index 00000000..bf259092 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/main.ts @@ -0,0 +1,378 @@ +import type { AgentId, AgentNode, Harness, MessageLink, SourceKind } from "../domain.ts"; +import { compactNumber, shortPath } 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 } from "./panel.ts"; +import { Rail, type Descendants } 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 PANEL_WIDTH = 460; +const RETRY_MS = 3_000; + +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; +} + +const params = new URLSearchParams(location.search); +const defaultHarness: Harness = params.get("harness") === "codex" ? "codex" : "claude"; +let pendingFocus = params.get("focus") as AgentId | null; + +const state: State = { + nodes: new Map(), + links: [], + health: [], + server: null, + session: null, + agent: null, + connection: "connecting", + dismissed: "", +}; + +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), +}); +const rail = new Rail({ select: (root) => selectSession(root) }); + +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; + if (state.session === null || !state.nodes.has(state.session)) state.session = chooseSession(); + if (state.agent !== null && !state.nodes.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) 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 { + if (pendingFocus !== null) { + const focus = pendingFocus; + if (state.nodes.has(focus)) { + pendingFocus = null; + return rootOf(focus, state.nodes); + } + } + const roots = rootsOf(state.nodes); + return (roots.find(isLive) ?? roots[0])?.id ?? null; +} + +// --- 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 node = state.nodes.get(id); + if (node === undefined) return; + const root = rootOf(id, state.nodes); + if (root !== state.session) state.session = root; + panel.open(node, state.nodes, Date.now()); + render(); + canvas.setRightInset(wide() ? PANEL_WIDTH : 0); + canvas.reveal(id); + // The stream carries the watched agent's timeline, so a new selection reconnects. + connect(); +} + +function wide(): boolean { + return matchMedia("(min-width: 1100px)").matches; +} + +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 roots = rootsOf(state.nodes); + const descendants = new Map(); + for (const node of state.nodes.values()) { + const root = rootOf(node.id, state.nodes); + if (root === node.id) continue; + const entry = descendants.get(root) ?? { total: 0, running: 0 }; + descendants.set(root, { total: entry.total + 1, running: entry.running + (node.status.kind === "running" ? 1 : 0) }); + } + const hours = state.server?.windowHours ?? 24; + rail.render(roots, descendants, state.session, now, `Showing the last ${hours === 24 ? "24 hours" : `${hours} hours`}`); + + const tree = state.session === null ? null : treeOf(state.session, state.nodes); + 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: "This link has expired" }), + h("p", {}, "The monitor restarted with a new access token. Run ", h("code", { text: "pstack-monitor start" }), " and open the link it prints."), + ), + ); + } else if (state.nodes.size === 0) { + canvas.setEmpty(emptyState(state.server?.indexing === true)); + } else if (tree !== null && tree.nodes.length === 1) { + canvas.setEmpty(h("p", { class: "canvas-hint", text: "No subagents in this session yet. When it spawns agents or runs pstack lanes, they appear here." })); + } 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); + 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("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(state.nodes, 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): HTMLElement { + return h( + "div", + { class: "empty" }, + logo(), + h("h2", { text: indexing ? "Reading recent transcripts…" : `No agent activity in the last ${state.server?.windowHours ?? 24} hours` }), + h("p", { + text: indexing + ? "This takes a moment the first time." + : "Start a Claude Code or Codex session. It appears here as soon as it writes its first message, and every agent it spawns joins the graph.", + }), + ); +} + +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} ${source.shape === 1 ? "record is" : "records are"} in a shape the monitor does not recognize, so some agents may be missing activity.`); + } 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" ? "The monitor restarted. 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); +}); + +matchMedia("(min-width: 1100px)").addEventListener("change", () => { + canvas.setRightInset(state.agent !== null && wide() ? PANEL_WIDTH : 0); +}); + +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 new file mode 100644 index 00000000..71970199 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/panel.ts @@ -0,0 +1,446 @@ +import type { AgentId, AgentNode, Clipped, MessageLink, TimelineItem } from "../domain.ts"; +import { 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 interface PanelEvents { + close(): void; + select(id: AgentId): void; + loadOlder(agent: AgentId, before: number): Promise; +} + +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 note: HTMLElement; + 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.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.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" } }, + h( + "header", + { class: "panel-head" }, + h("div", { class: "panel-identity" }, this.glyph, h("div", { class: "panel-names" }, this.title, this.kind), close), + this.chip, + this.facts, + this.note, + ), + this.scroller, + this.jump, + ); + } + + get current(): AgentId | null { + return this.agent?.id ?? null; + } + + 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.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.replaceChildren(icon(statusIcon(node)), h("span", { text: statusLine(node, now) })); + + 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.flavor.kind === "lane" && node.flavor.stream === "at-exit") { + notes.push("This CLI reports its whole response when it exits, so activity appears at the end."); + } + if (node.health === "degraded") notes.push("Some of this agent's records are in a shape the monitor does not recognize."); + 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" + ? "Waiting for this lane to finish; its CLI prints everything at exit." + : "No activity recorded for this agent 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 new file mode 100644 index 00000000..a0b63803 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/rail.ts @@ -0,0 +1,102 @@ +import type { AgentId, AgentNode } from "../domain.ts"; +import { ago, kindLabel } from "../format.ts"; +import { isLive } from "../graph.ts"; +import { h, icon, providerIcon } from "./dom.ts"; + +// pstack-flex addition. The session list: live sessions first, then recent ones. + +export interface RailEvents { + select(root: AgentId): void; +} + +export interface Descendants { + readonly total: number; + readonly running: number; +} + +function folderName(cwd: string | null): string { + if (cwd === null) return ""; + const parts = cwd.split("/").filter((part) => part.length > 0); + return parts.at(-1) ?? cwd; +} + +export class Rail { + readonly element: HTMLElement; + private readonly live: HTMLUListElement; + private readonly earlier: HTMLUListElement; + private readonly liveGroup: HTMLElement; + private readonly earlierGroup: HTMLElement; + private readonly count: HTMLElement; + private readonly foot: HTMLElement; + private readonly empty: HTMLElement; + + constructor(private readonly events: RailEvents) { + this.count = h("span", { class: "rail-count" }); + this.live = h("ul", { class: "sessions" }); + this.earlier = h("ul", { class: "sessions" }); + this.liveGroup = h("section", { class: "rail-group" }, h("h2", { class: "rail-heading", text: "Live now" }), this.live); + this.earlierGroup = h("section", { class: "rail-group" }, h("h2", { class: "rail-heading", text: "Earlier" }), this.earlier); + this.empty = h("p", { class: "rail-empty", text: "Sessions appear here once Claude Code or Codex writes a transcript." }); + 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-title", text: "Sessions" }), this.count), + h("div", { class: "rail-scroll" }, this.liveGroup, this.earlierGroup, this.empty), + this.foot, + ); + } + + render( + roots: readonly AgentNode[], + descendants: ReadonlyMap, + selected: AgentId | null, + now: number, + footer: string, + ): void { + const live = roots.filter(isLive); + const earlier = roots.filter((root) => !isLive(root)); + this.live.replaceChildren(...live.map((root) => this.row(root, descendants.get(root.id), root.id === selected, now))); + this.earlier.replaceChildren(...earlier.map((root) => this.row(root, descendants.get(root.id), root.id === selected, now))); + this.liveGroup.hidden = live.length === 0; + this.earlierGroup.hidden = earlier.length === 0; + this.empty.hidden = roots.length > 0; + this.count.textContent = String(roots.length); + this.foot.textContent = footer; + } + + private row(root: AgentNode, counts: Descendants | undefined, selected: boolean, now: number): HTMLElement { + const folder = folderName(root.cwd); + const when = ago(root.lastActivityAt, now); + const meta = [folder, when].filter((part) => part.length > 0).join(" · "); + 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-status": root.status.kind, + "data-evidence": root.status.kind === "running" || root.status.kind === "idle" ? root.status.evidence : "", + "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: meta })), + 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 new file mode 100644 index 00000000..5314b67f --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "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 new file mode 100644 index 00000000..466dee8c --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/wire.ts @@ -0,0 +1,65 @@ +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 069d0bc4..6aa871c2 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", - "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict && tsc --project runner/tsconfig.json --noEmit --strict && tsc --project check-plan.tsconfig.json --noEmit --strict" + "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" }, "dependencies": { "commander": "14.0.0" diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts index e76b52dd..ceea65cd 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts @@ -55,6 +55,13 @@ describe("runner CLI parsing", () => { expect(parsed?.model).toBe("MiniMax-M3"); }); + it("takes an optional display label and nothing else from it", () => { + expect(parseArgs(argv())?.label).toBeUndefined(); + expect(parseArgs(argv(["--label", " arena cross-judge "]))?.label).toBe("arena cross-judge"); + expect(parseArgs(argv(["--label", " "]))?.label).toBeUndefined(); + expect(parseArgs(argv(["--label", "x".repeat(500)]))?.label).toHaveLength(120); + }); + it("names the gateway providers in the provider rejection", () => { expect(() => parseArgs( diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts index eb326f85..ac20bdf7 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts @@ -15,13 +15,17 @@ import { const HELP = `Usage: pstack-runner --parent --provider <${PROVIDERS.join("|")}> \\ --model --effort --mode \\ - --prompt --cwd --output --receipt [--timeout ] + --prompt --cwd --output --receipt [--timeout ] \\ + [--label ] Runs exactly one external model lane. Same-provider calls are rejected; use the parent harness's native subagent primitive for those lanes. Output and receipt paths must not already exist. There is no implicit timeout. Pass --timeout only when the user or task supplies a real deadline; it is one end-to-end launcher deadline shared by setup, preflight, and model execution. + +--label names the lane in the agent monitor (for example "arena cross-judge"). +It is display text only and changes nothing about how the lane runs. `; interface Io { @@ -74,6 +78,7 @@ export function parseArgs(argv: readonly string[]): RunnerOptions | null { output: { type: "string" }, receipt: { type: "string" }, timeout: { type: "string" }, + label: { type: "string" }, help: { type: "boolean", short: "h", default: false }, }, }); @@ -86,6 +91,8 @@ export function parseArgs(argv: readonly string[]): RunnerOptions | null { stringValue(parsed.values.mode), ACCESS_MODES ) as AccessMode; + const labelValue = stringValue(parsed.values.label)?.trim(); + const label = labelValue === undefined || labelValue.length === 0 ? undefined : labelValue.slice(0, 120); const timeoutValue = stringValue(parsed.values.timeout); const timeoutSeconds = timeoutValue === undefined ? null : Number(timeoutValue); if ( @@ -105,6 +112,7 @@ export function parseArgs(argv: readonly string[]): RunnerOptions | null { outputPath: required("output", stringValue(parsed.values.output)), receiptPath: required("receipt", stringValue(parsed.values.receipt)), timeoutMs: timeoutSeconds === null ? null : timeoutSeconds * 1_000, + ...(label === undefined ? {} : { label }), }); } diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.test.ts new file mode 100644 index 00000000..dddfdf3f --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LANES_DIR_VAR, lanesRoot, openLaneJournal } from "./flex-journal.ts"; +import type { RunnerOptions, RunnerReceipt } from "./types.ts"; + +let scratch = ""; + +const options: RunnerOptions = { + parent: "codex", + provider: "claude", + model: "fable", + effort: "max", + mode: "read-only", + promptPath: "/repo/prompt.md", + cwd: "/repo", + outputPath: "/repo/out.md", + receiptPath: "/repo/receipt.json", + timeoutMs: null, +}; + +const receipt = { schemaVersion: 1, status: "complete" } as unknown as RunnerReceipt; + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), "pstack-journal-")); +}); + +afterEach(() => { + rmSync(scratch, { recursive: true, force: true }); +}); + +describe("openLaneJournal", () => { + it("defaults under the user's pstack-flex directory and honors an override", () => { + expect(lanesRoot({})).toEndWith(join(".pstack-flex", "lanes")); + expect(lanesRoot({ [LANES_DIR_VAR]: "/elsewhere" })).toBe("/elsewhere"); + }); + + it("is a no-op when the lanes directory is missing", () => { + const tap = openLaneJournal(options, Date.now(), { [LANES_DIR_VAR]: join(scratch, "missing") }); + expect(tap.stdout).toBeUndefined(); + tap.finish(receipt); + expect(readdirSync(scratch)).toEqual([]); + }); + + it("names the parent session from the parent harness's own variable only", () => { + const root = join(scratch, "lanes"); + mkdirSync(root); + openLaneJournal(options, Date.now(), { [LANES_DIR_VAR]: root, CODEX_THREAD_ID: "thread-1", CLAUDE_CODE_SESSION_ID: "other" }); + const [lane] = readdirSync(root); + expect(JSON.parse(readFileSync(join(root, lane!, "lane.json"), "utf8"))).toMatchObject({ + parent: "codex", + parentSessionId: "thread-1", + label: null, + }); + }); + + it("writes stdout bytes exactly and never throws after the journal is gone", () => { + const root = join(scratch, "lanes"); + mkdirSync(root); + const tap = openLaneJournal(options, Date.now(), { [LANES_DIR_VAR]: root }); + const bytes = new TextEncoder().encode('{"type":"result","result":"héllo ✓"}\n'); + tap.stdout?.(bytes.subarray(0, 20)); + tap.stdout?.(bytes.subarray(20)); + const [lane] = readdirSync(root); + expect(readFileSync(join(root, lane!, "stream.jsonl"))).toEqual(Buffer.from(bytes)); + rmSync(root, { recursive: true, force: true }); + expect(() => tap.finish(receipt)).not.toThrow(); + expect(() => tap.stdout?.(bytes)).not.toThrow(); + }); +}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts new file mode 100644 index 00000000..e172ce3c --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts @@ -0,0 +1,125 @@ +import { randomBytes } from "node:crypto"; +import { closeSync, mkdirSync, openSync, renameSync, writeFileSync, writeSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { AccessMode, Effort, Parent, Provider, RunnerOptions, RunnerReceipt } from "./types.ts"; + +// 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 +// never changes the lane's receipt, exit code, or output. + +export const LANES_DIR_VAR = "PSTACK_FLEX_LANES_DIR"; + +// The parent harness's own session id, inherited by the runner from the tool that launched it. +const PARENT_SESSION_VAR: Record = { + claude: "CLAUDE_CODE_SESSION_ID", + codex: "CODEX_THREAD_ID", +}; + +export interface LaneRecord { + readonly schemaVersion: 1; + readonly laneId: string; + readonly runnerPid: number; + readonly startedAt: string; + readonly parent: Parent; + readonly parentSessionId: string | null; + readonly provider: Provider; + readonly model: string; + readonly effort: Effort; + readonly mode: AccessMode; + readonly label: string | null; + readonly cwd: string; + readonly promptPath: string; + readonly outputPath: string; + readonly receiptPath: string; +} + +export interface LaneTap { + /** Receives the child's stdout as it arrives; undefined when journaling is off. */ + readonly stdout: ((chunk: Uint8Array) => void) | undefined; + finish(receipt: RunnerReceipt): void; +} + +const OFF: LaneTap = { stdout: undefined, finish() {} }; + +export function lanesRoot(env: NodeJS.ProcessEnv = process.env): string { + const configured = env[LANES_DIR_VAR]; + return configured !== undefined && configured.trim().length > 0 + ? configured + : join(homedir(), ".pstack-flex", "lanes"); +} + +function writeAtomic(path: string, value: unknown): void { + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporary, path); +} + +export function openLaneJournal( + options: RunnerOptions, + started: number, + env: NodeJS.ProcessEnv = process.env +): LaneTap { + const laneId = `${started.toString(36)}-${process.pid.toString(36)}-${randomBytes(3).toString("hex")}`; + const dir = join(lanesRoot(env), laneId); + let descriptor: number | null = null; + try { + // Not recursive: a missing lanes directory means journaling is off. + mkdirSync(dir, { mode: 0o700 }); + const session = env[PARENT_SESSION_VAR[options.parent]]; + const record: LaneRecord = { + schemaVersion: 1, + laneId, + runnerPid: process.pid, + startedAt: new Date(started).toISOString(), + parent: options.parent, + parentSessionId: session !== undefined && session.length > 0 ? session : null, + provider: options.provider, + model: options.model, + effort: options.effort, + mode: options.mode, + label: options.label ?? null, + cwd: options.cwd, + promptPath: options.promptPath, + outputPath: options.outputPath, + receiptPath: options.receiptPath, + }; + writeAtomic(join(dir, "lane.json"), record); + descriptor = openSync(join(dir, "stream.jsonl"), "a", 0o600); + } catch { + if (descriptor !== null) closeSync(descriptor); + return OFF; + } + + let open: number | null = descriptor; + const close = (): void => { + if (open === null) return; + try { + closeSync(open); + } catch { + // Already closed; nothing to recover. + } + open = null; + }; + return { + stdout(chunk) { + if (open === null) return; + try { + let written = 0; + while (written < chunk.length) written += writeSync(open, chunk, written); + } catch { + // A full disk or revoked directory stops the journal, never the lane. + close(); + } + }, + finish(receipt) { + close(); + try { + writeAtomic(join(dir, "receipt.json"), receipt); + } catch { + // The canonical receipt is already written; the journal copy is a convenience. + } + }, + }; +} diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts index 8c2c0914..00da27a3 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -5,6 +5,7 @@ import { existsSync, mkdtempSync, mkdirSync, + readdirSync, readFileSync, rmSync, statSync, @@ -235,6 +236,8 @@ beforeEach(() => { for (const name of ["claude", "codex", "grok"]) makeExecutable(name); previousPath = process.env.PATH; process.env.PATH = `${bin}:${dirname(process.execPath)}:${previousPath ?? ""}`; + // A lanes directory that does not exist keeps the pstack-flex journal off. + process.env.PSTACK_FLEX_LANES_DIR = join(scratch, "no-lanes"); delete process.env.FAKE_TIMEOUT; delete process.env.FAKE_INVALID_MODEL; delete process.env.FAKE_CANCEL; @@ -259,6 +262,7 @@ beforeEach(() => { afterEach(() => { process.env.PATH = previousPath; + delete process.env.PSTACK_FLEX_LANES_DIR; delete process.env.FAKE_TIMEOUT; delete process.env.FAKE_INVALID_MODEL; delete process.env.FAKE_CANCEL; @@ -917,6 +921,89 @@ describe("runLane", () => { }); }); +describe("pstack-flex lane journal", () => { + let previousSession: string | undefined; + + beforeEach(() => { + previousSession = process.env.CLAUDE_CODE_SESSION_ID; + process.env.CLAUDE_CODE_SESSION_ID = "parent-session"; + }); + + afterEach(() => { + if (previousSession === undefined) delete process.env.CLAUDE_CODE_SESSION_ID; + else process.env.CLAUDE_CODE_SESSION_ID = previousSession; + }); + + function enableJournal(): string { + const root = join(scratch, "lanes"); + mkdirSync(root); + process.env.PSTACK_FLEX_LANES_DIR = root; + return root; + } + + function onlyLane(root: string): string { + const entries = readdirSync(root); + expect(entries).toHaveLength(1); + return join(root, entries[0]!); + } + + it("stays off while the lanes directory does not exist", async () => { + const result = await runLane(options("codex")); + expect(result.exitCode).toBe(0); + expect(existsSync(join(scratch, "no-lanes"))).toBe(false); + }); + + it("records the lane, its stdout as it arrives, and the receipt the caller gets", async () => { + const root = enableJournal(); + const input = { ...options("codex"), label: "arena cross-judge" }; + const result = await runLane(input); + expect(result.exitCode).toBe(0); + const dir = onlyLane(root); + expect(JSON.parse(readFileSync(join(dir, "lane.json"), "utf8"))).toMatchObject({ + schemaVersion: 1, + parent: "claude", + parentSessionId: "parent-session", + provider: "codex", + model: "gpt-5.6-sol", + label: "arena cross-judge", + runnerPid: process.pid, + }); + const events = readFileSync(join(dir, "stream.jsonl"), "utf8") + .trim() + .split("\n") + .map((line) => (JSON.parse(line) as { type: string }).type); + expect(events).toEqual(["thread.started", "item.completed", "turn.completed"]); + expect(JSON.parse(readFileSync(join(dir, "receipt.json"), "utf8"))).toEqual(receipt(input.receiptPath)); + expect(statSync(dir).mode & 0o777).toBe(0o700); + expect(statSync(join(dir, "stream.jsonl")).mode & 0o777).toBe(0o600); + }); + + it("copies a failed lane's receipt unchanged", async () => { + const root = enableJournal(); + process.env.FAKE_INVALID_MODEL = "1"; + const input = options("codex"); + const result = await runLane(input); + expect(result.exitCode).toBe(69); + const journaled = JSON.parse(readFileSync(join(onlyLane(root), "receipt.json"), "utf8")) as RunnerReceipt; + expect(journaled).toEqual(receipt(input.receiptPath)); + expect(journaled.status).toBe("unavailable-model"); + }); + + it("runs the lane unchanged when the journal cannot be written", async () => { + const root = enableJournal(); + chmodSync(root, 0o500); + try { + const input = options("codex"); + const result = await runLane(input); + expect(result.exitCode).toBe(0); + expect(receipt(input.receiptPath).status).toBe("complete"); + expect(readdirSync(root)).toEqual([]); + } finally { + chmodSync(root, 0o700); + } + }); +}); + describe("gateway lanes", () => { const GATEWAY_TEST_KEYS = [ "DEEPSEEK_API_KEY", diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts index b3e2ef03..0aacd0eb 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { dirname, resolve } from "node:path"; import { invocationCommand, preflightCommand, type CommandSpec } from "./commands.ts"; +import { openLaneJournal, type LaneTap } from "./flex-journal.ts"; import { GATEWAY_INHERITED_CONFLICTS, gatewayEnvironment, @@ -186,7 +187,10 @@ interface StreamCapture { cancel(): Promise; } -function captureStream(stream: ReadableStream): StreamCapture { +function captureStream( + stream: ReadableStream, + onChunk?: (chunk: Uint8Array) => void +): StreamCapture { const reader = stream.getReader(); const decoder = new TextDecoder(); let text = ""; @@ -197,6 +201,7 @@ function captureStream(stream: ReadableStream): StreamCapture { while (true) { const next = await reader.read(); if (next.done) break; + onChunk?.(next.value); text += decoder.decode(next.value, { stream: true }); } text += decoder.decode(); @@ -235,7 +240,8 @@ async function runProcess( env: NodeJS.ProcessEnv, prompt: string, deadlineAt: number | null, - cancellation: RunCancellation + cancellation: RunCancellation, + onStdout?: (chunk: Uint8Array) => void ): Promise { const child = Bun.spawn([executable, ...spec.args], { cwd, @@ -245,7 +251,7 @@ async function runProcess( stderr: "pipe", }); let deadlineTimer: ReturnType | null = null; - const stdoutCapture = captureStream(child.stdout); + const stdoutCapture = captureStream(child.stdout, onStdout); const stderrCapture = captureStream(child.stderr); const streams = Promise.all([stdoutCapture.result, stderrCapture.result]); const exited = child.exited.then((exitCode): ProcessEvent => ({ @@ -564,7 +570,8 @@ async function executeLane( deadlineAt: number | null, invocation: CommandSpec, preflight: CommandSpec, - progress: LaneProgress + progress: LaneProgress, + tap: LaneTap ): Promise { const startedAt = new Date(started).toISOString(); const prompt = readFileSync(options.promptPath, "utf8"); @@ -816,7 +823,8 @@ async function executeLane( env, prompt, deadlineAt, - cancellation + cancellation, + tap.stdout ); const completed = Date.now(); const base = { @@ -933,16 +941,20 @@ export async function runLane( const cancellation = installRunCancellation(); try { reserveOutputs(options); + const tap = openLaneJournal(options, started); try { - return await executeLane( + const result = await executeLane( options, cancellation, started, deadlineAt, invocation, preflight, - progress + progress, + tap ); + tap.finish(result.receipt); + return result; } catch (error) { const completed = Date.now(); const signal = cancellation.signal; @@ -982,6 +994,7 @@ export async function runLane( }); removeIfExists(options.outputPath); writeReceipt(options.receiptPath, receipt); + tap.finish(receipt); return { exitCode: statusExitCode(status), receipt }; } } finally { diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts index 418b2bb6..214d540d 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts @@ -28,6 +28,8 @@ export interface RunnerOptions { readonly outputPath: string; readonly receiptPath: string; readonly timeoutMs: number | null; + // pstack-flex: display name for the agent monitor, such as `arena cross-judge`. Never routes. + readonly label?: string; } export type ReceiptStatus =