npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ai-setting/roy-plugin-task-show

v0.6.10

Published

roy-agent plugin: visualize task solving process via tool call flow on a local web service

Downloads

2,260

Readme

roy-plugin-task-show

A 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.

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+) 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:

# 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

roy-agent install --global @ai-setting/[email protected]
npm install -g  @ai-setting/[email protected]   # 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:

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

Or run it standalone for demo purposes:

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:

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:

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:

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.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.nametoolDef.nametoolNametoolDef.toolNamename).
  • 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:

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. | | 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:

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:

// 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

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

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:

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)

// 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+)

// 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 [email protected] 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

# 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:

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:

{
  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+)

{
  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)

{
  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)

{ 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