# roy-plugin-task-show

> A [roy-agent](https://example/roy-agent) plugin that visualizes the tool-call
> chain of every task on a local web service with **real-time Server-Sent
> Events** for live page updates.

## What it does

When loaded into a `roy-agent` host, this plugin:

1. Listens to the `tool:before.execute`, `tool:after.execute`,
   `task:before.create`, `task:after.create`, `task:after.complete`
   (preferred) and `task:after.update` (legacy fallback) hook points.
2. Records every tool invocation (tool name, args, result preview, duration,
   success flag, timestamp) into an in-memory collector keyed by task id.
3. **Pushes every state change over a Server-Sent Events stream** served by
   the plugin's local HTTP service. The frontend subscribes to
   `GET /api/events` and re-renders the page within milliseconds of:
   - a new task being created (`task.created`)
   - a tool call being recorded (`tool.recorded`)
   - a task status changing (`task.updated`)
   - a task transitioning to a terminal status (`task.completed`)
4. Serves the visualization on a local HTTP server (default
   `http://127.0.0.1:7788/`) — index page, per-task detail page, and the
   `/api/events` SSE stream.
5. **v0.7.0+ — Task lifecycle pipeline.** The per-task detail page now
   also renders a vertical timeline of the task's own operation history
   (`create`, `progress`, `milestone`, `problem`, `solution`, `decision`,
   `review`, `completed`). The data comes from `roy-agent tasks get <id>
   --operations --json`, served by a new endpoint `GET
   /api/tasks/:id/operations`. See
   [Task lifecycle pipeline (v0.7.0+)](#task-lifecycle-pipeline-v070) for
   details.

The result: every task gets a clickable link the user can open in a
browser. Once open, the page **stays in sync** with the running agent
loop — tool calls appear as they happen, status badges update in
real-time, and the progress bar grows with each call. No manual refresh
needed.

> **v0.5.0+** — the plugin no longer uses `env.notify`. The previous
> `env.notify({type:"visualization_ready"})` mechanism has been replaced
> with an in-process event bus + SSE stream. This makes the plugin work
> with **any** roy-agent host (no NotificationChannel required) and gives
> the frontend proper incremental updates instead of just a one-shot URL
> at the end. See [SSE Migration (v0.5.0+)](#sse-migration-v050) for the
> migration guide.

## Repository layout

```
roy-plugin-task-show/
├── plugin.json              ← roy-agent plugin manifest
├── package.json
├── tsconfig.json
├── README.md
├── public/                  ← static frontend (HTML/CSS/JS, served by Node http)
│   ├── index.html
│   ├── style.css
│   └── app.js
├── src/
│   ├── index.ts             ← public API (re-exports)
│   ├── types.ts             ← shared types & default config + TaskEvent
│   ├── collector.ts         ← in-memory tool-call collector
│   ├── server.ts            ← tiny standalone Node http server + SSE endpoint
│   ├── event-bus.ts         ← SSE event bus (broadcast / subscribe / keep-alive)
│   ├── url-injector.ts      ← URL builders (no more mutation helpers)
│   ├── plugin.ts            ← the TaskShowPlugin class (BasePlugin-compatible)
│   └── core-stub.d.ts       ← type stub for the optional core peer dep
├── test/
│   ├── collector.test.ts
│   ├── url-injector.test.ts
│   ├── server.test.ts       ← includes SSE endpoint test
│   ├── plugin.test.ts       ← covers all six hooks + broadcast assertions
│   ├── plugin-sse.test.ts   ← dedicated SSE event-bus consumer tests
│   └── integration.test.ts  ← full lifecycle through HTTP + SSE
└── scripts/
    ├── verify-service.ts    ← end-to-end check (port 7788 + curl pages)
    └── run-demo.ts          ← demo that simulates a real task lifecycle
```

## Install (npm published, recommended for end users)

The plugin is published to npmjs as `@ai-setting/roy-plugin-task-show`. For most users, just run:

```bash
# install + register with roy-agent in one step
roy-agent install --global @ai-setting/roy-plugin-task-show

# launch interactive session — task-show is auto-loaded via ~/.roy-agent/plugins.json
roy-agent interactive --plugin tslsp

# open the printed URL in any browser (shows your LAN IP, not 127.0.0.1):
#   🚀 task-show running at http://<lan-ip>:<port>/
```

### Pin a specific version

```bash
roy-agent install --global @ai-setting/roy-plugin-task-show@0.6.7
npm install -g  @ai-setting/roy-plugin-task-show@0.6.7   # bypass wrapper, same effect
```

### Useful sub-commands

| Action | Command |
| ------ | ------- |
| latest version on npm | `npm view @ai-setting/roy-plugin-task-show dist-tags` |
| list installed plugins | `roy-agent install list` |
| upgrade to latest | `roy-agent install --global @ai-setting/roy-plugin-task-show` |
| uninstall | `npm uninstall -g @ai-setting/roy-plugin-task-show` |

> **Known warning you can ignore:** `roy-agent install --global` always prints
>
> ```
> ⚠️  Could not find package.json for '@ai-setting/roy-plugin-task-show@<v>' (searched 5 paths)
> ```
>
> That's a benign limitation of the installer hook — the package is installed and `~/.roy-agent/plugins.json` is updated correctly. Verify with `cat ~/.roy-agent/plugins.json | grep task-show`.

### Host-side integration: nothing to change in `roy-agent`

Starting with `roy-agent` builds where `HookManager.execute()` already injects
`metadata.envContext` from `AsyncLocalStorage`, the plugin reads
`ctx.metadata.envContext.agentContext.currentTaskId` automatically. No
roy-agent patch is required to use v0.6.7+.

## Install (from source)

This plugin lives in its own package. Inside a `roy-agent` workspace:

```bash
# (already wired in monorepo workspaces — otherwise `pnpm add ./roy-plugin-task-show`)
pnpm install
```

Or run it standalone for demo purposes:

```bash
cd roy-plugin-task-show
bun install
bun run build
```

## Loading into roy-agent

`roy-agent`'s plugin loader (`EnvironmentService.loadPlugins()`) supports
two plugin forms:

| form                              | how it works                                              |
| --------------------------------- | --------------------------------------------------------- |
| `import('@scope/plugin')`         | Dynamically imports a module and instantiates the plugin. |
| file path (`/abs/path/to/plug.js`)| Treated as an external `ToolPlugin` script.               |

For this plugin, we ship a **named export** so it slots into the same
mechanism used by `task-tag`, `lsp`, etc.

### Option A — register via the loader (recommended)

In `roy-agent`'s CLI args:

```bash
roy-agent interactive --plugin task-show
```

This works out of the box once `@roy-agent/task-show` is added to the
workspaces' plugin allow-list. To register it manually, add a case to the
`loadPlugins()` switch in `packages/cli/src/services/environment.service.ts`:

```ts
case "task-show": {
  const { createTaskShowPlugin } = await import("@roy-agent/task-show");
  plugin = createTaskShowPlugin({ port: 7788 });
  break;
}
```

### Option B — wire directly in interactive / act mode

If you only need it locally, drop the plugin init into the
`interactive-shutdown.ts` (or `act.ts`) flow next to the
`pluginAdapterDispose` plumbing. The plugin's lifecycle is:

```ts
import { createTaskShowPlugin } from "@roy-agent/task-show";

const taskShow = createTaskShowPlugin({ port: 7788 });
await taskShow.init({
  registerHook: (def) => myPluginComponent.register(def),
  getComponent: (name) => env.getComponent(name),
  getConfig: (key) => configComponent?.get(key),
});

// Later, on shutdown:
await taskShow.dispose();
```

The plugin registers hooks for:

| Hook point             | Priority | Purpose                                                                  |
| ---------------------- | -------- | ------------------------------------------------------------------------ |
| `tool:before.execute`  | 40       | Records per-call start timestamp for accurate `durationMs`              |
| `tool:after.execute`   | 50       | Records the call into the collector and broadcasts `tool.recorded`       |
| `task:before.create`   | 55       | Mints a fresh `TaskSession` and broadcasts `task.created`               |
| `task:after.create`    | 55       | Same as above (fallback for hosts that only emit `task:after.create`)    |
| `task:after.complete`  | 60       | **PREFERRED** — broadcasts `task.completed` on terminal transition       |
| `task:after.update`    | 60       | Legacy fallback; broadcasts `task.updated` or `task.completed`           |

When both `task:after.complete` AND `task:after.update` fire (newer
hosts do this on completion), the handler dedupes — at most one
`task.completed` event per task.

## Start banner (v0.6.1+)

On successful startup the plugin prints a single line to the host's stdout:

```
🚀 task-show running at http://<lan-ip>:<port>/
```

`<lan-ip>` is the **first non-loopback IPv4 address** discovered via
`os.networkInterfaces()` (e.g. `10.1.188.34`), **not** `127.0.0.1` /
`localhost`. Click from any machine on the same LAN — no port-forwarding
needed.

If `server.start()` rejects with `EADDRINUSE` (e.g. the configured port
already in use), the plugin stays **fail-open**: it logs the error,
prints a multi-line `⚠️` warning banner naming the configured port and
an actionable alternate-port hint, and still registers all six hooks.
Visualizations will be unavailable for that session, but the host stays
healthy.

## Changelog

### v0.6.12 — Task lifecycle pipeline integration fix (2026-07-24)

**For end users**: `npm install` as usual — no host-side change required.
Re-published as a patch release to fix a regression in the v0.7.0-pre
"Task lifecycle pipeline" feature where the page rendered the pipeline
panel but never actually populated it with operations.

- `renderOperationsPipeline(input)` now accepts `input.taskId` and emits
  `data-task-id="<id>"` on the `<section data-pipeline-root>` element
  (empty, populated, and error states). Non-numeric ids are silently
  dropped, so bad input cannot break HTML attribute quoting.
- `renderTaskPage()` in `src/server.ts` now passes `taskId: session.taskId`
  so every `/task/<id>` response carries the attribute and the client-side
  `boot()` no longer silently bails out.
- Client-side defense in depth: `boot()` in `public/task-operations.js`
  falls back to `document.body.dataset.taskId` when the section is missing
  the attribute, and `renderPipelineHtml` / `renderErrorHtml` preserve
  the attribute across `swapIn()` calls.

12 new TDD cases in `test/task-operations-browser-integration.test.ts`
(happy-dom browser integration + unit + server boot). New
`scripts/verify-task-operations-browser.ts` boots a real Playwright
Chromium against `TaskShowServer` + the real `roy-agent` CLI and asserts
7 op-node cards are rendered for Task #2388. All 161 tests pass.

### v0.6.11 — Mermaid SVG regression fix (2026-07-24)

**For end users**: `npm install` as usual — no host-side change required.
Re-published as a patch release to address a bug where the per-task page
initially rendered the Mermaid flowchart as SVG but reverted to raw
`flowchart TD` source text on every SSE/polling update.

- New `public/mermaid-renderer.js` controller wraps `mermaid.render(id, source)`
  with unique render IDs, async race protection, and a recoverable
  `.mermaid-error` block when rendering fails or Mermaid hasn't loaded.
- `public/app.js` `applyTaskUpdate` now delegates Mermaid re-rendering to
  the controller instead of calling the brittle `mermaid.run({nodes})`.
- Both `/task/:id` and `/` pages load `/static/mermaid-renderer.js`.
- New CSS for `.mermaid-error`, `.mermaid-error-header`, `.mermaid-error-msg`,
  `.mermaid-error-source` so the error fallback is readable.

10 new TDD cases in `test/mermaid-renderer.test.ts` + 5 regression cases
(`test/regression-mermaid-include.test.ts`, `test/regression-long-task.test.ts`).
All 96 tests pass.

### v0.6.7 — derive task id from host-provided envContext (2026-07-15)

**For end users**: `npm install` as usual — no host-side change required.

- `extractTaskIdFromToolCtx` and `extractTaskId` now read
  `ctx.metadata.envContext.agentContext.currentTaskId` as the
  **first-priority** candidate. This is exactly the field that
  `roy-agent`'s `HookManager.execute()` already injects from
  `AsyncLocalStorage`, so the plugin finally aligns with the host's
  task system without any extra wiring.

### v0.6.6 — drop tool calls without an env-context task id (2026-07-15)

**Breaking**: the plugin no longer mints synthetic sessions for tool calls
that have no upstream task in the `roy-agent` task system. Previously,
`ToolCallCollector.recordToolCall` would fall back to `synthCounter++`
and create a new `(no title) / running / unknown` card per tool call —
now it returns `null` and the call is filtered out.

- `recordToolCall` returns `number | null`.
- `extractTaskIdFromToolCtx` returns `number | null` (was `number` with `-1` sentinel).
- `synthCounter` and the `resolveSessionFallback` helper deleted.

### v0.6.5 — force-correct tarball with rebuilt dist (2026-07-15)

**Bugfix**: the `scripts/publish.ts` `ensureBuild()` step silently
skipped the rebuild when it detected a pre-existing `dist/`. v0.6.5 forces
a clean rebuild so tarballs always carry the current source code, not
stale `dist/` from a previous build.

### v0.6.4, v0.6.3, v0.6.2 — same scope as v0.6.5

These three were released as published "ghost" versions (npm tarball
contained the legacy `0.6.1` `dist/` because of the `ensureBuild()`
bug). They were abandoned in favour of v0.6.5; users on `0.6.1` should
upgrade directly to `0.6.7`.

### v0.6.2 — banner resilience + session aggregation + toolName (2026-07-15) [superseded]

> Note: a real release with these features ships as v0.6.7. The v0.6.2/3/4/5
> tags exist on npm but only **v0.6.5+** have the corresponding source
> on the npm registry tarball.

- Banner always prints (a multi-line `⚠️` warning block on port conflict).
- Aggregate tool calls by `ctx.sessionId` hash when no `currentTaskId` is present.
- Better `toolName` field mapping (5-key fallback `tool.name` →
  `toolDef.name` → `toolName` → `toolDef.toolName` → `name`).
- Finalize running sessions on `dispose()` (broadcast `task.completed`).

### v0.6.1 — print LAN hostname on start (2026-07-14)

- New `src/lan-host.ts` exports `getLanHostname()` — `os.networkInterfaces()`
  first non-loopback IPv4, falls back to `127.0.0.1`.
- `printStartBanner(lanHost, port, logger?)` exported and called from
  `TaskShowPlugin.init()` after a successful `server.start()`.
- Defaults `host` from `127.0.0.1` to `0.0.0.0` (so LAN access works
  out of the box; can still be set to `127.0.0.1` per-instance for
  local-only mode).

### v0.6.0 — runtime version alignment (2026-07-15)

- `readonly version = "0.6.1"` → `"0.6.0"` in `src/plugin.ts` (was lagging
  because release-please only updates the JSON manifests, not source
  code).
- `pluginVersion` literal in `src/server.ts` snapshot event payload
  bumped in lock-step.

## Configuration

All config lives in `plugin.json` (and is overridable at construction time):

| key              | type     | default     | description                                              |
| ---------------- | -------- | ----------- | -------------------------------------------------------- |
| `port`           | number   | `7788`      | HTTP port for the visualization service.                 |
| `host`           | string   | `0.0.0.0`   | HTTP host. Use `0.0.0.0` to allow LAN access.            |
| `autoStart`      | boolean  | `true`      | Start the HTTP server when `init()` is called.           |
| `maxStoredTasks` | number   | `50`        | Max sessions kept in memory. Older ones are evicted.     |
| `publicDir`      | string   | `public`    | Directory holding the static frontend.                   |

> **v0.5.0**: the previous `urlInjectEnabled` config was removed. The
> plugin no longer mutates tool result output.

Override at construction time:

```ts
createTaskShowPlugin({
  port: 9000,
  maxStoredTasks: 100,
});
```

## HTTP API

| route                       | description                                                  |
| --------------------------- | ------------------------------------------------------------ |
| `GET /`                     | Index of recent task sessions.                               |
| `GET /task/:taskId`         | Per-task page with mermaid flow + tool-call table.           |
| `GET /api/sessions`         | JSON list of all sessions (newest first).                    |
| `GET /api/sessions/:taskId` | JSON detail of one session (full payload).                   |
| **`GET /api/events`**       | **SSE stream** — see [Server-Sent Events](#server-sent-events). |
| `GET /static/*`             | Static frontend assets (`style.css`, `app.js`).              |

If the configured `port` is already in use, the service falls back to an OS
assigned port and logs the actual URL to stderr.

## Server-Sent Events

The local HTTP service exposes `GET /api/events` as a standard Server-Sent
Events stream. Every connected client receives:

1. An initial `snapshot` frame carrying the full list of currently-known
   sessions — useful for instant render on connect / reconnect.
2. A `task.created` frame whenever a new task is opened
   (`task:before.create` or `task:after.create`).
3. A `tool.recorded` frame after each tool call
   (`tool:after.execute`).
4. A `task.updated` frame on any non-terminal status transition
   (`task:after.update` with `running` / `paused` / etc.).
5. A `task.completed` frame on terminal transition
   (`task:after.complete` or `task:after.update` with terminal status).

A 15-second keep-alive comment (`:keep-alive`) prevents reverse proxies /
load balancers from dropping the connection.

### Event shape

Every event has the same envelope:

```ts
interface TaskEvent<T> {
  type: "task.created" | "task.updated" | "task.completed" | "tool.recorded";
  taskId: number;
  timestamp: number;       // Unix ms
  pluginVersion: string;   // "0.5.0"
  data: T;
}
```

The `data` payload differs per `type`:

```ts
// task.created
data: { session: TaskSession }

// task.updated
data: { session: TaskSession, newStatus: TaskStatus, previousStatus?: TaskStatus }

// task.completed
data: { session: TaskSession, terminalStatus: TaskStatus }

// tool.recorded
data: { session: TaskSession, toolCall: ToolCallRecord }
```

### Subscribing from the browser

```ts
const source = new EventSource("/api/events");
source.addEventListener("snapshot", (ev) => {
  const data = JSON.parse(ev.data);
  console.log("initial sessions:", data.data.sessions);
});
source.addEventListener("task.created", (ev) => {
  const data = JSON.parse(ev.data);
  console.log("new task:", data.data.session.taskId);
});
source.addEventListener("tool.recorded", (ev) => {
  const data = JSON.parse(ev.data);
  console.log("tool call:", data.data.toolCall.toolName);
});
source.addEventListener("task.completed", (ev) => {
  const data = JSON.parse(ev.data);
  console.log("task done:", data.data.terminalStatus);
});
// EventSource auto-reconnects on connection drop. If SSE is unavailable
// (e.g. ancient browser), the bundled frontend falls back to a 3-second
// poll of /api/sessions.
```

### Subscribing from Node / custom adapter

```ts
import http from "node:http";

http.get("http://127.0.0.1:7788/api/events", (res) => {
  let buf = "";
  res.on("data", (chunk) => {
    buf += chunk.toString("utf-8");
    let idx;
    while ((idx = buf.indexOf("\n\n")) !== -1) {
      const frame = buf.slice(0, idx);
      buf = buf.slice(idx + 2);
      // frame is `event: <type>\ndata: <json>`
      // ...
    }
  });
});
```

## Architecture

The plugin runs entirely inside its local HTTP server. The hook handlers
emit events into an in-process `EventBus` (`src/event-bus.ts`), and every
connected SSE client receives a frame. No host-side NotificationChannel
plumbing is required.

```
TaskShowPlugin (tool/task hooks)
       │
       │  eventBus.broadcast({type, taskId, timestamp, data})
       ▼
EventBus  (in-process subscriber Set + keep-alive loop)
       │
       │  write frame to every connected http.ServerResponse
       ▼
TaskShowServer.handleSSE
       │
       │  Content-Type: text/event-stream
       ▼
Browser EventSource  ◄──── 3-second polling fallback if SSE unavailable
       │
       ▼
DOM updates (badge / progress / timeline / mermaid re-render)
```

The in-process bus also exposes `addListener(fn)` so custom callers
(CLI tools, IM adapters, log shippers) can subscribe to the same event
stream without going through HTTP:

```ts
const plugin = createTaskShowPlugin();
plugin.getEventBus().addListener((event) => {
  console.log("event:", event.type, event.taskId);
});
```

### Plugin ↔ host version correspondence

| Plugin version | Compatible host (`roy-agent`)        | Notes                                                              |
| -------------- | ------------------------------------ | ------------------------------------------------------------------ |
| `0.1.0`        | pre-2026-07-10 (`task:after.update`) | Mutates tool result. **Removed** in v0.4.0 plugin.                 |
| `0.2.0`        | pre-2026-07-10 (`task:after.update`) | Same as 0.1.0; tiny refactors.                                     |
| `0.3.0`        | `>= 2026-07-10`                      | Adds `tool:before.execute` for self-managed timing.                |
| `0.4.0`        | `>= 2026-07-10` (NotificationChannel) | Switches to `env.notify` consumer; no longer mutates output.     |
| **`0.5.0` (this)** | **`>= 2026-07-10`**             | **Adds `task:before.create` / `task:after.create`; switches from `env.notify` to in-process EventBus + SSE.** |

### Host design reference

The full host-side design — including the `NotificationChannel`
interface and `EventBusNotificationChannel` (default) — is documented
in the host repository:

>

## SSE Migration (v0.5.0+)

> **TL;DR**: stop depending on `env.notify`. The plugin now pushes events
> over SSE. Connect with `new EventSource('/api/events')` (browser) or
> `http.get(...)` (Node).

### Before (v0.4.0)

```ts
// Plugin called env.notify({type:"visualization_ready", ...}).
// Host's EventBusNotificationChannel re-emitted on env event bus:
//   env.pushEnvEvent({type:"plugin.notification.visualization_ready", ...})
// Subscribers (CLI / IM) listened on the env event bus.
```

### After (v0.5.0+)

```ts
// Plugin emits via in-process EventBus; HTTP server streams frames
// over /api/events. Frontend connects with EventSource — no host
// NotificationChannel required.
const source = new EventSource("/api/events");
source.addEventListener("task.completed", (ev) => {
  const { taskId, data } = JSON.parse(ev.data);
  console.log(`Task ${taskId} → ${data.terminalStatus}`);
});
```

### Why we changed

1. **No host dependency**: the SSE stream is served by the plugin's own
   HTTP server. Works with any roy-agent host — no NotificationChannel
   required.
2. **Incremental updates**: instead of one-shot "URL injection" on
   terminal status, the frontend receives a frame for *every*
   meaningful change (created / recorded / updated / completed). The
   page updates as the agent works, not after.
3. **Progress bar + timeline**: with continuous updates, the frontend
   can show a live progress bar (tool call count) and a real-time
   timeline without manual refresh.
4. **Simpler host contract**: removed `env.notify` plumbing from the
   plugin surface. The plugin's `PluginEnvLike` type no longer mentions
   `notify`.

### Backward compatibility

The plugin no longer mutates tool result output (that mechanism was
removed in v0.4.0). Pin to `roy-plugin-task-show@0.4.x` if you depend
on the legacy `env.notify` consumer path.

## Port handling

The plugin binds a local `http.Server` on the port you configure (default
`7788`). If that port is busy (`EADDRINUSE`), the server **probes
`port + 1`, `port + 2`, … in ascending order** until a free port is
found. Each skipped port emits a `WARN` log so it's easy to spot:

```
[task-show:server] WARN Port 7788 busy — trying 7789
[task-show:server] WARN Port 7789 busy — trying 7790
[task-show:server] INFO  HTTP service listening on http://127.0.0.1:7790/ (port rewritten from 7788 due to conflict)
```

Guarantees:

- Only `EADDRINUSE` triggers a retry. Any other `listen()` error
  (`EACCES`, `ENOTSUP`, …) is surfaced as a normal rejection — the
  caller still owns the error and can decide what to do.
- Probing **never wraps** past `65535` and **never** falls back to
  `listen(0)` (OS-assigned ephemeral port). If everything in
  `[port, 65535]` is busy, `start()` rejects with a clear error so
  you know to free a port or move the host elsewhere.
- `ServerInfo.portRewritten` is `true` whenever the bound port differs
  from `cfg.port`. The same flag is exposed on the `ServerInfo` object
  passed to whoever called `start()`.
- The probing helper (`listenWithProbing()` in `src/server.ts`) is
  exported so the same logic can be reused or unit-tested in isolation.

If you prefer the OS-assigned-port semantics (e.g. for tests), set
`port: 0` in the config — the server will then call `listen(0)`
directly, no probing, and `portRewritten` stays `false`.

## Development

```bash
# Type-check (no emit)
bun run typecheck

# Build (emit dist/)
bun run build

# Run unit tests (63 tests across 7 files)
bun test

# End-to-end check (boots the service, hits it, asserts response)
bun run verify

# Manual demo (boots the service + simulates a real task lifecycle)
bun run start:demo
```

### Debug logging

Set `TASK_SHOW_DEBUG=1` to enable verbose logging from the collector:

```bash
TASK_SHOW_DEBUG=1 bun run start:demo
```

## Hook payload reference

### `tool:after.execute` (collected)

The plugin accepts the standard payload the `globalHookManager` emits for
this hook:

```ts
{
  tool: { name: string, ... },
  args: Record<string, unknown>,
  context: { taskId?: number, ... },
  result: { success: boolean, output: string, error?: string, metadata?: any },
}
```

**Task-id resolution order (v0.6.7+, priority top to bottom):**

1. `ctx.metadata.envContext.agentContext.currentTaskId` — preferred.
   The `roy-agent` `HookManager.execute()` injects `metadata.envContext`
   from `AsyncLocalStorage`, so this is the canonical way to read the
   current task id without host-side changes.
2. `ctx.data.metadata.envContext.agentContext.currentTaskId` — alt payload shape.
3. `ctx.currentTaskId` — explicit field the host may have set.
4. `ctx.context.taskId` / `ctx.context.current_task_id` — legacy fields.
5. `metadata.current_task_id` / `result.metadata.current_task_id` — older hosts.
6. `ctx.taskId` — final fallback.

**If no candidate resolves, the call is filtered out** (since v0.6.6).
The plugin no longer mints a synthetic session for tool calls that have
no upstream task in the `roy-agent` task system — this avoided the
historical bug where every interactive-mode tool call produced a new
`(no title) / running / unknown` card (one per tool call) and made the
visualization meaningless.

If a synthetic `task` session is still desired (e.g. for demos without
a TaskComponent, or for legacy hosts), pass `explicitTaskId` at the
recordToolCall layer — but the plugin itself never invents one.

### `task:before.create` (v0.5.0+)

```ts
{
  data: { task: { id: number, title?: string } }
  // OR flat: { taskId: number, title?: string }
}
```

If a session already exists for the task id (re-create / retry), its
`toolCalls` are preserved.

### `task:after.create` (v0.5.0+)

Same payload as `task:before.create`. Useful for hosts that don't emit
`task:before.create`; the plugin opens the session here and broadcasts
`task.created`.

### `task:after.complete` (preferred, consumed)

```ts
{
  data: {
    task: { id: number, status: 'completed' | 'failed' | 'cancelled', title?: string },
    terminalStatus: 'completed' | 'failed' | 'cancelled',
    changes: Partial<UpdateTaskOptions>,
    sessionId: string,
    tagService: TagService,
  }
}
```

### `task:after.update` (legacy fallback, consumed)

```ts
{ data: { task: { id: number, status: string, title?: string }, changes, tagService } }
```

The handler filters for terminal status (`completed` / `failed` /
`cancelled`) and dedupes — if `AFTER_COMPLETE` also fires (it can, on a
host that supports both hooks), only one `task.completed` event is
broadcast.

## Limitations & follow-ups

- **Memory-only**: the collector keeps data in memory only. Restarting
  the host process drops everything. Persisting to a file would be a
  small follow-up.
- **Single host**: if you launch multiple `roy-agent` instances on the
  same machine, change the `port` to avoid clashes. The server probes
  `port + 1`, `port + 2`, ... in ascending order until a free TCP port
  is found (sequential port probing — no random / ephemeral fallback).
  If everything from `port` through `65535` is busy, `start()` rejects
  with a clear error so you know to free a port or move the host
  elsewhere.
- **Mermaid CDN**: the frontend loads `mermaid@10` from jsDelivr.
  Air-gapped deployments should vendor a copy locally.
- **No file persistence**: data lives only in memory. A future
  improvement would be a tiny SQLite sink so a restart still shows
  recent tasks.
- **SSE-only push**: the bundled frontend uses EventSource as the
  primary update channel. A 3-second polling fallback covers older
  browsers and proxies that strip SSE, but it's a degraded experience.
  WebSocket support is a possible future addition.

## License

MIT
## Task lifecycle pipeline (v0.7.0+)

In addition to the tool-call flow (the existing Mermaid diagram), every
`/task/:id` page now includes a **Task lifecycle pipeline** section that
shows the task's own operation history — the create / progress / milestone /
problem / solution / decision / review / completed events that the
`roy-agent tasks` CLI records for that task.

### Where the data comes from

The plugin spawns `roy-agent tasks get <id> --operations --json` (via
discrete argv — no shell string), parses the `{ task, operations }`
envelope, and serves a stable schema from the new endpoint:

```
GET /api/tasks/:id/operations
→ 200 {
    task: { id, title, status, priority, type, progress?, createdAt, updatedAt, tags, projectPath? },
    operations: [
      { id, sequence, milestoneType, title, description, timestamp, sessionShort }
    ],
    fetchedAt: ISO8601,
    stale: boolean
  }
→ 404 { error: "not_found", id }      // CLI reported "Task not found"
→ 400 { error: "bad_id", id }          // invalid task id
→ 503 { error: "cli_failed", id }      // CLI exited with non-zero status
→ 500 { error: "internal_error", id }  // unexpected exception
```

The CLI is invoked safely:

- Arg array form: `["node", "<cliPath>", "tasks", "get", "<id>",
  "--operations", "--json"]`. Shell metacharacters in the task id are
  never interpreted.
- Wall-clock timeout (`cliTimeoutMs`, default 5 s).
- Max stdout bytes (`cliMaxBytes`, default 1 MiB).
- Stderr is captured so the CLI's `✗ Task not found: <id>` message is
  mapped to HTTP 404.

### Caching & dedup

A TTL cache (default 5 s) coalesces concurrent requests for the same
task id and limits how often the plugin spawns the host CLI. Stale
entries are served with `stale: true` while a background refresh runs.

### Polling

For active tasks, the page polls `/api/tasks/:id/operations` every 5 s.
The client merges new operations into the existing timeline by id
(idempotent — no duplicates) and stops polling once the task reaches a
terminal status (`completed` / `failed` / `cancelled`). On any CLI
failure the page shows a "Retry" button and applies exponential backoff
up to 30 s.

### Security

- Task ids are validated as positive integers in the safe range.
- All operation titles, descriptions, and process notes are HTML-escaped.
- `processDescription` and `current_status` are stripped from public
  responses (only safe fields reach the browser).
- The full session id is shortened to 8 hex chars (`sessionShort`).

### Configuration

| Key | Default | Purpose |
|---|---|---|
| `royAgentCliPath` | auto-detect | Absolute path to the `roy-agent` executable. |
| `operationsCacheTtlMs` | 5000 | TTL for parsed envelopes. |
| `maxOperations` | 200 | Hard cap on operations per task. |
| `maxDescriptionChars` | 2000 | Per-description char cap. |
| `cliTimeoutMs` | 5000 | Wall-clock timeout for the CLI spawn. |
| `cliMaxBytes` | 1048576 | Maximum stdout bytes. |

### CLI path resolution

The plugin auto-detects the `roy-agent` CLI from these locations
(first match wins):

1. `cfg.royAgentCliPath` (explicit override).
2. `$ROY_AGENT_CLI` environment variable.
3. `<cwd>/../roy-agent/packages/cli/dist/bin/roy-agent.js`
   (sibling-repo layout — the typical dev setup).
4. The monorepo layout
   (`<pluginDist>/../../../../packages/cli/dist/bin/roy-agent.js`).
5. `roy-agent` on PATH (fallback).

