@ai-setting/roy-plugin-task-show 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,35 +1,41 @@
1
1
  # roy-plugin-task-show
2
2
 
3
3
  > A [roy-agent](https://example/roy-agent) plugin that visualizes the tool-call
4
- > chain of every task on a local web service and **notifies** the visualization
5
- > URL through the host's `env.notify` event-bus API.
4
+ > chain of every task on a local web service with **real-time Server-Sent
5
+ > Events** for live page updates.
6
6
 
7
7
  ## What it does
8
8
 
9
9
  When loaded into a `roy-agent` host, this plugin:
10
10
 
11
11
  1. Listens to the `tool:before.execute`, `tool:after.execute`,
12
- `task:after.complete` (preferred) and `task:after.update` (legacy fallback)
13
- hook points.
12
+ `task:before.create`, `task:after.create`, `task:after.complete`
13
+ (preferred) and `task:after.update` (legacy fallback) hook points.
14
14
  2. Records every tool invocation (tool name, args, result preview, duration,
15
15
  success flag, timestamp) into an in-memory collector keyed by task id.
16
- 3. When a task transitions to a terminal status (`completed` / `failed` /
17
- `cancelled` / `timeout`), emits a **`visualization_ready` notification**
18
- through `env.notify({ type: "visualization_ready", ... })`. The host's
19
- `NotificationChannel` pipeline (default: `EventBusNotificationChannel`)
20
- re-emits it on the env event bus as
21
- `plugin.notification.visualization_ready`.
16
+ 3. **Pushes every state change over a Server-Sent Events stream** served by
17
+ the plugin's local HTTP service. The frontend subscribes to
18
+ `GET /api/events` and re-renders the page within milliseconds of:
19
+ - a new task being created (`task.created`)
20
+ - a tool call being recorded (`tool.recorded`)
21
+ - a task status changing (`task.updated`)
22
+ - a task transitioning to a terminal status (`task.completed`)
22
23
  4. Serves the visualization on a local HTTP server (default
23
- `http://127.0.0.1:7788/`).
24
-
25
- The result: every task in the chat gets a 🔗 link the user can click to open
26
- a mermaid-powered flow chart showing exactly how the agent solved it. The
27
- link can be rendered by any subscriber to the env event bus (CLI / IM
28
- adapter / custom webhook).
29
-
30
- > **v0.4.0+** — the plugin no longer mutates tool result output. The
31
- > visualization reach-out goes through `env.notify` instead. See
32
- > [env.notify Migration (v0.4.0+)](#envnotify-migration-v040) for the
24
+ `http://127.0.0.1:7788/`) — index page, per-task detail page, and the
25
+ `/api/events` SSE stream.
26
+
27
+ The result: every task gets a clickable link the user can open in a
28
+ browser. Once open, the page **stays in sync** with the running agent
29
+ loop tool calls appear as they happen, status badges update in
30
+ real-time, and the progress bar grows with each call. No manual refresh
31
+ needed.
32
+
33
+ > **v0.5.0+** the plugin no longer uses `env.notify`. The previous
34
+ > `env.notify({type:"visualization_ready"})` mechanism has been replaced
35
+ > with an in-process event bus + SSE stream. This makes the plugin work
36
+ > with **any** roy-agent host (no NotificationChannel required) and gives
37
+ > the frontend proper incremental updates instead of just a one-shot URL
38
+ > at the end. See [SSE Migration (v0.5.0+)](#sse-migration-v050) for the
33
39
  > migration guide.
34
40
 
35
41
  ## Repository layout
@@ -46,17 +52,20 @@ roy-plugin-task-show/
46
52
  │ └── app.js
47
53
  ├── src/
48
54
  │ ├── index.ts ← public API (re-exports)
49
- │ ├── types.ts ← shared types & default config
55
+ │ ├── types.ts ← shared types & default config + TaskEvent
50
56
  │ ├── collector.ts ← in-memory tool-call collector
51
- │ ├── server.ts ← tiny standalone Node http server (no deps)
52
- │ ├── url-injector.ts tool-result mutation helpers
57
+ │ ├── server.ts ← tiny standalone Node http server + SSE endpoint
58
+ │ ├── event-bus.ts SSE event bus (broadcast / subscribe / keep-alive)
59
+ │ ├── url-injector.ts ← URL builders (no more mutation helpers)
53
60
  │ ├── plugin.ts ← the TaskShowPlugin class (BasePlugin-compatible)
54
61
  │ └── core-stub.d.ts ← type stub for the optional core peer dep
55
62
  ├── test/
56
63
  │ ├── collector.test.ts
57
64
  │ ├── url-injector.test.ts
58
- │ ├── server.test.ts
59
- └── plugin.test.ts
65
+ │ ├── server.test.ts ← includes SSE endpoint test
66
+ ├── plugin.test.ts ← covers all six hooks + broadcast assertions
67
+ │ ├── plugin-sse.test.ts ← dedicated SSE event-bus consumer tests
68
+ │ └── integration.test.ts ← full lifecycle through HTTP + SSE
60
69
  └── scripts/
61
70
  ├── verify-service.ts ← end-to-end check (port 7788 + curl pages)
62
71
  └── run-demo.ts ← demo that simulates a real task lifecycle
@@ -134,85 +143,187 @@ await taskShow.dispose();
134
143
 
135
144
  The plugin registers hooks for:
136
145
 
137
- - `tool:after.execute` — collects every tool call (priority 50).
138
- - `task:after.complete` — PREFERRED freezes the session and injects
139
- the visualization URL on terminal status transition. Available on
140
- `roy-agent` versions >= 2026-07-10 (priority 60).
141
- - `task:after.update` LEGACY fallback for hosts predating the new
142
- hook point. The handler also dedupes against any `task:after.complete`
143
- that fires simultaneously, so on a modern host the URL is injected
144
- exactly once (priority 60).
146
+ | Hook point | Priority | Purpose |
147
+ | ---------------------- | -------- | ------------------------------------------------------------------------ |
148
+ | `tool:before.execute` | 40 | Records per-call start timestamp for accurate `durationMs` |
149
+ | `tool:after.execute` | 50 | Records the call into the collector and broadcasts `tool.recorded` |
150
+ | `task:before.create` | 55 | Mints a fresh `TaskSession` and broadcasts `task.created` |
151
+ | `task:after.create` | 55 | Same as above (fallback for hosts that only emit `task:after.create`) |
152
+ | `task:after.complete` | 60 | **PREFERRED** broadcasts `task.completed` on terminal transition |
153
+ | `task:after.update` | 60 | Legacy fallback; broadcasts `task.updated` or `task.completed` |
154
+
155
+ When both `task:after.complete` AND `task:after.update` fire (newer
156
+ hosts do this on completion), the handler dedupes — at most one
157
+ `task.completed` event per task.
145
158
 
146
159
  ## Configuration
147
160
 
148
161
  All config lives in `plugin.json` (and is overridable at construction time):
149
162
 
150
- | key | type | default | description |
151
- | ------------------- | -------- | ----------- | -------------------------------------------------------- |
152
- | `port` | number | `7788` | HTTP port for the visualization service. |
153
- | `host` | string | `127.0.0.1` | HTTP host. Use `0.0.0.0` to allow LAN access. |
154
- | `autoStart` | boolean | `true` | Start the HTTP server when `init()` is called. |
155
- | `maxStoredTasks` | number | `50` | Max sessions kept in memory. Older ones are evicted. |
156
- | `urlInjectEnabled` | boolean | `false` | **DEPRECATED** in v0.4.0. Appends the visualization URL to the last tool result. Defaults to `false` — the plugin reaches out via `env.notify` instead. |
157
- | `publicDir` | string | `public` | Directory holding the static frontend. |
163
+ | key | type | default | description |
164
+ | ---------------- | -------- | ----------- | -------------------------------------------------------- |
165
+ | `port` | number | `7788` | HTTP port for the visualization service. |
166
+ | `host` | string | `127.0.0.1` | HTTP host. Use `0.0.0.0` to allow LAN access. |
167
+ | `autoStart` | boolean | `true` | Start the HTTP server when `init()` is called. |
168
+ | `maxStoredTasks` | number | `50` | Max sessions kept in memory. Older ones are evicted. |
169
+ | `publicDir` | string | `public` | Directory holding the static frontend. |
170
+
171
+ > **v0.5.0**: the previous `urlInjectEnabled` config was removed. The
172
+ > plugin no longer mutates tool result output.
158
173
 
159
174
  Override at construction time:
160
175
 
161
176
  ```ts
162
177
  createTaskShowPlugin({
163
178
  port: 9000,
164
- urlInjectEnabled: false,
179
+ maxStoredTasks: 100,
165
180
  });
166
181
  ```
167
182
 
168
183
  ## HTTP API
169
184
 
170
- | route | description |
171
- | --------------------------- | ------------------------------------------------------ |
172
- | `GET /` | Index of recent task sessions. |
173
- | `GET /task/:taskId` | Per-task page with mermaid flow + tool-call table. |
174
- | `GET /api/sessions` | JSON list of all sessions (newest first). |
175
- | `GET /api/sessions/:taskId` | JSON detail of one session (full payload). |
176
- | `GET /static/*` | Static frontend assets (`style.css`, `app.js`). |
185
+ | route | description |
186
+ | --------------------------- | ------------------------------------------------------------ |
187
+ | `GET /` | Index of recent task sessions. |
188
+ | `GET /task/:taskId` | Per-task page with mermaid flow + tool-call table. |
189
+ | `GET /api/sessions` | JSON list of all sessions (newest first). |
190
+ | `GET /api/sessions/:taskId` | JSON detail of one session (full payload). |
191
+ | **`GET /api/events`** | **SSE stream** see [Server-Sent Events](#server-sent-events). |
192
+ | `GET /static/*` | Static frontend assets (`style.css`, `app.js`). |
177
193
 
178
194
  If the configured `port` is already in use, the service falls back to an OS
179
195
  assigned port and logs the actual URL to stderr.
180
196
 
197
+ ## Server-Sent Events
198
+
199
+ The local HTTP service exposes `GET /api/events` as a standard Server-Sent
200
+ Events stream. Every connected client receives:
201
+
202
+ 1. An initial `snapshot` frame carrying the full list of currently-known
203
+ sessions — useful for instant render on connect / reconnect.
204
+ 2. A `task.created` frame whenever a new task is opened
205
+ (`task:before.create` or `task:after.create`).
206
+ 3. A `tool.recorded` frame after each tool call
207
+ (`tool:after.execute`).
208
+ 4. A `task.updated` frame on any non-terminal status transition
209
+ (`task:after.update` with `running` / `paused` / etc.).
210
+ 5. A `task.completed` frame on terminal transition
211
+ (`task:after.complete` or `task:after.update` with terminal status).
212
+
213
+ A 15-second keep-alive comment (`:keep-alive`) prevents reverse proxies /
214
+ load balancers from dropping the connection.
215
+
216
+ ### Event shape
217
+
218
+ Every event has the same envelope:
219
+
220
+ ```ts
221
+ interface TaskEvent<T> {
222
+ type: "task.created" | "task.updated" | "task.completed" | "tool.recorded";
223
+ taskId: number;
224
+ timestamp: number; // Unix ms
225
+ pluginVersion: string; // "0.5.0"
226
+ data: T;
227
+ }
228
+ ```
229
+
230
+ The `data` payload differs per `type`:
231
+
232
+ ```ts
233
+ // task.created
234
+ data: { session: TaskSession }
235
+
236
+ // task.updated
237
+ data: { session: TaskSession, newStatus: TaskStatus, previousStatus?: TaskStatus }
238
+
239
+ // task.completed
240
+ data: { session: TaskSession, terminalStatus: TaskStatus }
241
+
242
+ // tool.recorded
243
+ data: { session: TaskSession, toolCall: ToolCallRecord }
244
+ ```
245
+
246
+ ### Subscribing from the browser
247
+
248
+ ```ts
249
+ const source = new EventSource("/api/events");
250
+ source.addEventListener("snapshot", (ev) => {
251
+ const data = JSON.parse(ev.data);
252
+ console.log("initial sessions:", data.data.sessions);
253
+ });
254
+ source.addEventListener("task.created", (ev) => {
255
+ const data = JSON.parse(ev.data);
256
+ console.log("new task:", data.data.session.taskId);
257
+ });
258
+ source.addEventListener("tool.recorded", (ev) => {
259
+ const data = JSON.parse(ev.data);
260
+ console.log("tool call:", data.data.toolCall.toolName);
261
+ });
262
+ source.addEventListener("task.completed", (ev) => {
263
+ const data = JSON.parse(ev.data);
264
+ console.log("task done:", data.data.terminalStatus);
265
+ });
266
+ // EventSource auto-reconnects on connection drop. If SSE is unavailable
267
+ // (e.g. ancient browser), the bundled frontend falls back to a 3-second
268
+ // poll of /api/sessions.
269
+ ```
270
+
271
+ ### Subscribing from Node / custom adapter
272
+
273
+ ```ts
274
+ import http from "node:http";
275
+
276
+ http.get("http://127.0.0.1:7788/api/events", (res) => {
277
+ let buf = "";
278
+ res.on("data", (chunk) => {
279
+ buf += chunk.toString("utf-8");
280
+ let idx;
281
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
282
+ const frame = buf.slice(0, idx);
283
+ buf = buf.slice(idx + 2);
284
+ // frame is `event: <type>\ndata: <json>`
285
+ // ...
286
+ }
287
+ });
288
+ });
289
+ ```
290
+
181
291
  ## Architecture
182
292
 
183
- The plugin runs as a peer of the host (`roy-agent`) and depends on three
184
- host-side constructs:
293
+ The plugin runs entirely inside its local HTTP server. The hook handlers
294
+ emit events into an in-process `EventBus` (`src/event-bus.ts`), and every
295
+ connected SSE client receives a frame. No host-side NotificationChannel
296
+ plumbing is required.
185
297
 
186
298
  ```
187
- TaskShowPlugin (task:after.complete hook)
299
+ TaskShowPlugin (tool/task hooks)
188
300
 
189
- env.notify({type: "visualization_ready", title, content, metadata})
301
+ eventBus.broadcast({type, taskId, timestamp, data})
190
302
 
191
- PluginComponent.routeNotification()
303
+ EventBus (in-process subscriber Set + keep-alive loop)
192
304
 
305
+ │ write frame to every connected http.ServerResponse
193
306
 
194
- EventBusNotificationChannel (default)
307
+ TaskShowServer.handleSSE
195
308
 
196
- env.pushEnvEvent({
197
- │ type: "plugin.notification.visualization_ready",
198
- │ metadata: { source: "plugin:roy-plugin-task-show", ...pluginMetadata },
199
- │ payload: { title, content },
200
- │ })
309
+ Content-Type: text/event-stream
201
310
 
202
- Env-level Event Subscriber
311
+ Browser EventSource ◄──── 3-second polling fallback if SSE unavailable
203
312
 
204
- ├──► CLI adapter → print "📊 Task #X completed: http://..."
205
- ├──► IM adapter → lark-cli / webhook 飞书消息
206
- └──► custom sinks → log, metrics, etc.
313
+
314
+ DOM updates (badge / progress / timeline / mermaid re-render)
207
315
  ```
208
316
 
209
- The plugin's own notification layer is intentionally minimal: it
210
- formats the title + content for human display and packages structured
211
- metadata for subscribers. Delivery, deduplication across subscribers,
212
- and persistent retry logic all live in the host. This keeps the
213
- plugin easy to test (we just assert `env.notify` was called with the
214
- right payload) and lets each deployment pick the IM / CLI front-ends
215
- that match its audience.
317
+ The in-process bus also exposes `addListener(fn)` so custom callers
318
+ (CLI tools, IM adapters, log shippers) can subscribe to the same event
319
+ stream without going through HTTP:
320
+
321
+ ```ts
322
+ const plugin = createTaskShowPlugin();
323
+ plugin.getEventBus().addListener((event) => {
324
+ console.log("event:", event.type, event.taskId);
325
+ });
326
+ ```
216
327
 
217
328
  ### Plugin ↔ host version correspondence
218
329
 
@@ -221,143 +332,98 @@ that match its audience.
221
332
  | `0.1.0` | pre-2026-07-10 (`task:after.update`) | Mutates tool result. **Removed** in v0.4.0 plugin. |
222
333
  | `0.2.0` | pre-2026-07-10 (`task:after.update`) | Same as 0.1.0; tiny refactors. |
223
334
  | `0.3.0` | `>= 2026-07-10` | Adds `tool:before.execute` for self-managed timing. |
224
- | `0.4.0` (this) | `>= 2026-07-10` (NotificationChannel) | Switches to `env.notify` consumer; no longer mutates output. |
225
-
226
- The host's `PluginEnv.notify` method is **optional** for the plugin — it
227
- degrades gracefully on older hosts (logs a one-shot warn, continues
228
- serving the visualization page). The plugin therefore remains
229
- installable on any host version, but newer features only fire on
230
- hosts implementing the v0.4.0+ contract.
335
+ | `0.4.0` | `>= 2026-07-10` (NotificationChannel) | Switches to `env.notify` consumer; no longer mutates output. |
336
+ | **`0.5.0` (this)** | **`>= 2026-07-10`** | **Adds `task:before.create` / `task:after.create`; switches from `env.notify` to in-process EventBus + SSE.** |
231
337
 
232
338
  ### Host design reference
233
339
 
234
340
  The full host-side design — including the `NotificationChannel`
235
- interface, `EventBusNotificationChannel` (default) and
236
- `LarkCliNotificationChannel` (legacy, `@deprecated`) — is documented
341
+ interface and `EventBusNotificationChannel` (default) — is documented
237
342
  in the host repository:
238
343
 
239
- > **`roy-agent/docs/plugin-emit-notification.md`** — design doc,
240
- > version: feature/plugin-emit-notification-v2
241
- > (commits `f96508f4` → `9ec78817`)
344
+ >
242
345
 
243
- If you're integrating the plugin with a custom host, that document is
244
- the authoritative reference for the `NotificationOptions` contract
245
- and the metadata merge semantics.
346
+ ## SSE Migration (v0.5.0+)
246
347
 
247
- ## Notification payload (v0.4.0+)
348
+ > **TL;DR**: stop depending on `env.notify`. The plugin now pushes events
349
+ > over SSE. Connect with `new EventSource('/api/events')` (browser) or
350
+ > `http.get(...)` (Node).
248
351
 
249
- When a task reaches a terminal status, the plugin calls
250
- `env.notify(...)` with a `visualization_ready` payload. The host's
251
- `NotificationChannel` (default: `EventBusNotificationChannel`) re-emits
252
- this on the env event bus as `plugin.notification.visualization_ready`.
253
-
254
- The payload looks like:
352
+ ### Before (v0.4.0)
255
353
 
256
354
  ```ts
257
- {
258
- type: "visualization_ready", // == NOTIFICATION_TYPE_VISUALIZATION_READY
259
- title: "Task #1234 completed",
260
- content: [
261
- "Task: solve the bug",
262
- "Status: completed",
263
- "Visualization: http://127.0.0.1:7788/task/1234",
264
- ].join("\n"),
265
- metadata: {
266
- taskId: 1234,
267
- terminalStatus: "completed",
268
- url: "http://127.0.0.1:7788/task/1234",
269
- taskTitle: "solve the bug", // when present in the hook payload
270
- sessionId: "sess-xyz", // when present in the hook payload
271
- pluginName: "roy-plugin-task-show",
272
- pluginVersion: "0.4.0", // sync with host v2 (Task #N/A)
273
- notificationTimestamp: 1718000000000, // captured at emit time (Date.now())
274
- },
275
- }
355
+ // Plugin called env.notify({type:"visualization_ready", ...}).
356
+ // Host's EventBusNotificationChannel re-emitted on env event bus:
357
+ // env.pushEnvEvent({type:"plugin.notification.visualization_ready", ...})
358
+ // Subscribers (CLI / IM) listened on the env event bus.
276
359
  ```
277
360
 
278
- The `pluginName` / `pluginVersion` pair lets subscribers correlate the
279
- event with the plugin that emitted it (useful when multiple versions
280
- of the same plugin are installed in different workspaces). The
281
- `notificationTimestamp` is captured at emit time and is distinct from
282
- the host's `event.timestamp` (which the host stamps at
283
- `pushEnvEvent` call time) — useful for measuring queue / delivery
284
- latency downstream.
285
-
286
- ### Subscribing to the notification
287
-
288
- The cleanest way to surface the URL to users is to subscribe to the
289
- env event bus for `plugin.notification.visualization_ready`. Example
290
- (in the host, e.g. `packages/cli`):
361
+ ### After (v0.5.0+)
291
362
 
292
363
  ```ts
293
- import { NOTIFICATION_TYPE_VISUALIZATION_READY } from "@.../roy-plugin-task-show";
294
-
295
- env.eventBus.on(
296
- `plugin.notification.${NOTIFICATION_TYPE_VISUALIZATION_READY}`,
297
- (event) => {
298
- const { taskId, url, terminalStatus, taskTitle } = event.metadata;
299
- // Render the URL in the chat / send a message / open the browser, etc.
300
- console.log(`📊 Task #${taskId} ${terminalStatus}: ${url}`);
301
- },
302
- );
364
+ // Plugin emits via in-process EventBus; HTTP server streams frames
365
+ // over /api/events. Frontend connects with EventSource — no host
366
+ // NotificationChannel required.
367
+ const source = new EventSource("/api/events");
368
+ source.addEventListener("task.completed", (ev) => {
369
+ const { taskId, data } = JSON.parse(ev.data);
370
+ console.log(`Task ${taskId} ${data.terminalStatus}`);
371
+ });
303
372
  ```
304
373
 
305
- The plugin also exposes the URL directly via
306
- `TaskShowPlugin.getVisualizationUrl(taskId)` for callers that need it
307
- synchronously.
374
+ ### Why we changed
308
375
 
309
- ## env.notify Migration (v0.4.0+)
376
+ 1. **No host dependency**: the SSE stream is served by the plugin's own
377
+ HTTP server. Works with any roy-agent host — no NotificationChannel
378
+ required.
379
+ 2. **Incremental updates**: instead of one-shot "URL injection" on
380
+ terminal status, the frontend receives a frame for *every*
381
+ meaningful change (created / recorded / updated / completed). The
382
+ page updates as the agent works, not after.
383
+ 3. **Progress bar + timeline**: with continuous updates, the frontend
384
+ can show a live progress bar (tool call count) and a real-time
385
+ timeline without manual refresh.
386
+ 4. **Simpler host contract**: removed `env.notify` plumbing from the
387
+ plugin surface. The plugin's `PluginEnvLike` type no longer mentions
388
+ `notify`.
310
389
 
311
- > **TL;DR**: stop mutating tool result output. Call `env.notify` instead.
390
+ ### Backward compatibility
312
391
 
313
- ### Before (v0.3.0 and earlier)
392
+ The plugin no longer mutates tool result output (that mechanism was
393
+ removed in v0.4.0). Pin to `roy-plugin-task-show@0.4.x` if you depend
394
+ on the legacy `env.notify` consumer path.
314
395
 
315
- ```ts
316
- // Plugin mutated ctx.result.output by appending a markdown banner.
317
- await plugin.onTaskAfterComplete({
318
- data: { task: { id: 1234, status: "completed", title: "demo" } },
319
- });
320
- // ctx.result.output now contains:
321
- // "...original...\n---\n📊 可视化工具调用链路: <http://...>"
322
- ```
396
+ ## Port handling
323
397
 
324
- ### After (v0.4.0+)
398
+ The plugin binds a local `http.Server` on the port you configure (default
399
+ `7788`). If that port is busy (`EADDRINUSE`), the server **probes
400
+ `port + 1`, `port + 2`, … in ascending order** until a free port is
401
+ found. Each skipped port emits a `WARN` log so it's easy to spot:
325
402
 
326
- ```ts
327
- // Plugin calls env.notify; the host's NotificationChannel pipeline
328
- // re-emits it on the env event bus. ctx.result.output is preserved.
329
- await plugin.onTaskAfterComplete({
330
- data: {
331
- task: { id: 1234, status: "completed", title: "demo" },
332
- terminalStatus: "completed",
333
- },
334
- });
335
- // env.notify was called with { type: "visualization_ready", ... }
336
- // ctx.result.output is unchanged.
403
+ ```
404
+ [task-show:server] WARN Port 7788 busy trying 7789
405
+ [task-show:server] WARN Port 7789 busy trying 7790
406
+ [task-show:server] INFO HTTP service listening on http://127.0.0.1:7790/ (port rewritten from 7788 due to conflict)
337
407
  ```
338
408
 
339
- ### Why we changed
340
-
341
- 1. **Decoupling**: tool result output is owned by the tool (and the
342
- LLM that consumes it). Mutating it as a side effect of "the task is
343
- done" is leaky the visualization URL is not part of the tool's
344
- output, it's a UI/notification concern.
345
- 2. **Multiple subscribers**: with `env.notify`, the env event bus fans
346
- the URL out to every subscriber (CLI, IM, webhooks, custom loggers).
347
- Mutating output only surfaces the URL in the LLM's next prompt.
348
- 3. **Pluggable delivery**: `EventBusNotificationChannel` is the default,
349
- but hosts can register `LarkCliNotificationChannel` (legacy, sends a
350
- Feishu message directly) or a custom `WebhookChannel` without
351
- touching the plugin. The plugin just calls `env.notify` and walks
352
- away.
353
-
354
- ### Backward compatibility
355
-
356
- The legacy `injectVisualizationUrl()` helper in `src/url-injector.ts`
357
- is marked `@deprecated` but is still exported. Set
358
- `urlInjectEnabled: true` in the plugin config to opt back into the
359
- mutation behavior — useful for test harnesses and very old hosts that
360
- don't have the `NotificationChannel` abstraction.
409
+ Guarantees:
410
+
411
+ - Only `EADDRINUSE` triggers a retry. Any other `listen()` error
412
+ (`EACCES`, `ENOTSUP`, ) is surfaced as a normal rejection the
413
+ caller still owns the error and can decide what to do.
414
+ - Probing **never wraps** past `65535` and **never** falls back to
415
+ `listen(0)` (OS-assigned ephemeral port). If everything in
416
+ `[port, 65535]` is busy, `start()` rejects with a clear error so
417
+ you know to free a port or move the host elsewhere.
418
+ - `ServerInfo.portRewritten` is `true` whenever the bound port differs
419
+ from `cfg.port`. The same flag is exposed on the `ServerInfo` object
420
+ passed to whoever called `start()`.
421
+ - The probing helper (`listenWithProbing()` in `src/server.ts`) is
422
+ exported so the same logic can be reused or unit-tested in isolation.
423
+
424
+ If you prefer the OS-assigned-port semantics (e.g. for tests), set
425
+ `port: 0` in the config — the server will then call `listen(0)`
426
+ directly, no probing, and `portRewritten` stays `false`.
361
427
 
362
428
  ## Development
363
429
 
@@ -368,7 +434,7 @@ bun run typecheck
368
434
  # Build (emit dist/)
369
435
  bun run build
370
436
 
371
- # Run unit tests (collector, url-injector, server, plugin, plugin-notify)
437
+ # Run unit tests (63 tests across 7 files)
372
438
  bun test
373
439
 
374
440
  # End-to-end check (boots the service, hits it, asserts response)
@@ -410,54 +476,71 @@ It also recognizes (in priority order):
410
476
  If no task id can be recovered, the call lands in a synthetic session so
411
477
  the visualization still works.
412
478
 
413
- ### `task:after.complete` (preferred, consumed)
414
-
415
- The plugin accepts the standard `TaskCompleteResultContext`:
479
+ ### `task:before.create` (v0.5.0+)
416
480
 
417
481
  ```ts
418
482
  {
419
- task: { id: number, status: 'completed' | 'failed' | 'cancelled' | 'timeout', title?: string },
420
- terminalStatus: 'completed' | 'failed' | 'cancelled' | 'timeout',
421
- changes: Partial<UpdateTaskOptions>,
422
- sessionId: string,
423
- tagService: TagService,
483
+ data: { task: { id: number, title?: string } }
484
+ // OR flat: { taskId: number, title?: string }
424
485
  }
425
486
  ```
426
487
 
427
- Because the host pre-filters to terminal transitions, no plugin-side status
428
- filtering is needed. The plugin dedupes by `taskId` against the legacy hook
429
- below.
488
+ If a session already exists for the task id (re-create / retry), its
489
+ `toolCalls` are preserved.
430
490
 
431
- ### `task:after.update` (legacy fallback, consumed)
491
+ ### `task:after.create` (v0.5.0+)
492
+
493
+ Same payload as `task:before.create`. Useful for hosts that don't emit
494
+ `task:before.create`; the plugin opens the session here and broadcasts
495
+ `task.created`.
432
496
 
433
- On hosts predating the `AFTER_COMPLETE` hook point, the plugin also subscribes
434
- to `TaskUpdateResultContext`:
497
+ ### `task:after.complete` (preferred, consumed)
498
+
499
+ ```ts
500
+ {
501
+ data: {
502
+ task: { id: number, status: 'completed' | 'failed' | 'cancelled', title?: string },
503
+ terminalStatus: 'completed' | 'failed' | 'cancelled',
504
+ changes: Partial<UpdateTaskOptions>,
505
+ sessionId: string,
506
+ tagService: TagService,
507
+ }
508
+ }
509
+ ```
510
+
511
+ ### `task:after.update` (legacy fallback, consumed)
435
512
 
436
513
  ```ts
437
514
  { data: { task: { id: number, status: string, title?: string }, changes, tagService } }
438
515
  ```
439
516
 
440
- The handler filters for terminal status (`completed` / `failed` / `cancelled` /
441
- `timeout`) and dedupes — if `AFTER_COMPLETE` also fires (it can, on a host
442
- that supports both hooks), the URL is injected exactly once.
517
+ The handler filters for terminal status (`completed` / `failed` /
518
+ `cancelled`) and dedupes — if `AFTER_COMPLETE` also fires (it can, on a
519
+ host that supports both hooks), only one `task.completed` event is
520
+ broadcast.
443
521
 
444
522
  ## Limitations & follow-ups
445
523
 
446
- - **Memory-only**: the collector keeps data in memory only. Restarting the
447
- host process drops everything. Persisting to a file would be a small
448
- follow-up.
449
- - **Single host**: if you launch multiple `roy-agent` instances on the same
450
- machine, change the `port` to avoid clashes (the service falls back to a
451
- random port automatically, but the URL won't be predictable).
452
- - **Mermaid CDN**: the frontend loads `mermaid@10` from jsDelivr. Air-gapped
453
- deployments should vendor a copy locally.
454
- - **No agent-level hooks**: the plugin currently does not subscribe to
455
- `agent:after.execute` because the agent tool loop already fires
456
- `tool:after.execute` per tool. If you want a "task finished" view that
457
- includes the final agent message, listen to `agent:after.execute` too.
458
- - **No file persistence**: data lives only in memory. A future improvement
459
- would be a tiny SQLite sink so a restart still shows recent tasks.
524
+ - **Memory-only**: the collector keeps data in memory only. Restarting
525
+ the host process drops everything. Persisting to a file would be a
526
+ small follow-up.
527
+ - **Single host**: if you launch multiple `roy-agent` instances on the
528
+ same machine, change the `port` to avoid clashes. The server probes
529
+ `port + 1`, `port + 2`, ... in ascending order until a free TCP port
530
+ is found (sequential port probing no random / ephemeral fallback).
531
+ If everything from `port` through `65535` is busy, `start()` rejects
532
+ with a clear error so you know to free a port or move the host
533
+ elsewhere.
534
+ - **Mermaid CDN**: the frontend loads `mermaid@10` from jsDelivr.
535
+ Air-gapped deployments should vendor a copy locally.
536
+ - **No file persistence**: data lives only in memory. A future
537
+ improvement would be a tiny SQLite sink so a restart still shows
538
+ recent tasks.
539
+ - **SSE-only push**: the bundled frontend uses EventSource as the
540
+ primary update channel. A 3-second polling fallback covers older
541
+ browsers and proxies that strip SSE, but it's a degraded experience.
542
+ WebSocket support is a possible future addition.
460
543
 
461
544
  ## License
462
545
 
463
- MIT
546
+ MIT