@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
Maintainers
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:
- Listens to the
tool:before.execute,tool:after.execute,task:before.create,task:after.create,task:after.complete(preferred) andtask:after.update(legacy fallback) hook points. - Records every tool invocation (tool name, args, result preview, duration, success flag, timestamp) into an in-memory collector keyed by task id.
- Pushes every state change over a Server-Sent Events stream served by
the plugin's local HTTP service. The frontend subscribes to
GET /api/eventsand 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)
- a new task being created (
- Serves the visualization on a local HTTP server (default
http://127.0.0.1:7788/) — index page, per-task detail page, and the/api/eventsSSE 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 previousenv.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 lifecycleInstall (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 effectUseful 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 --globalalways 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.jsonis updated correctly. Verify withcat ~/.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 installOr run it standalone for demo purposes:
cd roy-plugin-task-show
bun install
bun run buildLoading 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-showThis 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.
extractTaskIdFromToolCtxandextractTaskIdnow readctx.metadata.envContext.agentContext.currentTaskIdas the first-priority candidate. This is exactly the field thatroy-agent'sHookManager.execute()already injects fromAsyncLocalStorage, 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.
recordToolCallreturnsnumber | null.extractTaskIdFromToolCtxreturnsnumber | null(wasnumberwith-1sentinel).synthCounterand theresolveSessionFallbackhelper 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.sessionIdhash when nocurrentTaskIdis present. - Better
toolNamefield mapping (5-key fallbacktool.name→toolDef.name→toolName→toolDef.toolName→name). - Finalize running sessions on
dispose()(broadcasttask.completed).
v0.6.1 — print LAN hostname on start (2026-07-14)
- New
src/lan-host.tsexportsgetLanHostname()—os.networkInterfaces()first non-loopback IPv4, falls back to127.0.0.1. printStartBanner(lanHost, port, logger?)exported and called fromTaskShowPlugin.init()after a successfulserver.start().- Defaults
hostfrom127.0.0.1to0.0.0.0(so LAN access works out of the box; can still be set to127.0.0.1per-instance for local-only mode).
v0.6.0 — runtime version alignment (2026-07-15)
readonly version = "0.6.1"→"0.6.0"insrc/plugin.ts(was lagging because release-please only updates the JSON manifests, not source code).pluginVersionliteral insrc/server.tssnapshot 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
urlInjectEnabledconfig 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:
- An initial
snapshotframe carrying the full list of currently-known sessions — useful for instant render on connect / reconnect. - A
task.createdframe whenever a new task is opened (task:before.createortask:after.create). - A
tool.recordedframe after each tool call (tool:after.execute). - A
task.updatedframe on any non-terminal status transition (task:after.updatewithrunning/paused/ etc.). - A
task.completedframe on terminal transition (task:after.completeortask:after.updatewith 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 withnew EventSource('/api/events')(browser) orhttp.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
- No host dependency: the SSE stream is served by the plugin's own HTTP server. Works with any roy-agent host — no NotificationChannel required.
- 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.
- 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.
- Simpler host contract: removed
env.notifyplumbing from the plugin surface. The plugin'sPluginEnvLiketype no longer mentionsnotify.
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
EADDRINUSEtriggers a retry. Any otherlisten()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
65535and never falls back tolisten(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.portRewrittenistruewhenever the bound port differs fromcfg.port. The same flag is exposed on theServerInfoobject passed to whoever calledstart().- The probing helper (
listenWithProbing()insrc/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:demoDebug logging
Set TASK_SHOW_DEBUG=1 to enable verbose logging from the collector:
TASK_SHOW_DEBUG=1 bun run start:demoHook 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):
ctx.metadata.envContext.agentContext.currentTaskId— preferred. Theroy-agentHookManager.execute()injectsmetadata.envContextfromAsyncLocalStorage, so this is the canonical way to read the current task id without host-side changes.ctx.data.metadata.envContext.agentContext.currentTaskId— alt payload shape.ctx.currentTaskId— explicit field the host may have set.ctx.context.taskId/ctx.context.current_task_id— legacy fields.metadata.current_task_id/result.metadata.current_task_id— older hosts.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-agentinstances on the same machine, change theportto avoid clashes. The server probesport + 1,port + 2, ... in ascending order until a free TCP port is found (sequential port probing — no random / ephemeral fallback). If everything fromportthrough65535is 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@10from 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
