@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/dist/server.d.ts CHANGED
@@ -7,16 +7,20 @@
7
7
  * GET /task/:taskId → detail page (mermaid flow + tables)
8
8
  * GET /api/sessions → JSON list of sessions
9
9
  * GET /api/sessions/:taskId → JSON detail of one session
10
+ * GET /api/events → Server-Sent Events stream (real-time push)
10
11
  * GET /static/* → static frontend assets (CSS/JS)
11
12
  *
12
13
  * The server is started/stopped via `TaskShowServer.start()` /
13
- * `.stop()`. Multiple instances cooperate by random port fallback if the
14
- * configured port is busy we let the OS pick a free one and log the actual
15
- * port.
14
+ * `.stop()`. When the configured port is busy we probe `port + 1`,
15
+ * `port + 2`, ... in ascending order until a free TCP port is found
16
+ * never falling back to an OS-assigned ephemeral port (`listen(0)`).
17
+ * That keeps the bound port predictable for documentation, fire-and-forget
18
+ * scripts and ad-hoc coordination between processes.
16
19
  */
17
20
  import * as http from "node:http";
18
21
  import type { TaskShowConfig } from "./types.js";
19
22
  import type { ToolCallCollector } from "./collector.js";
23
+ import type { EventBus } from "./event-bus.js";
20
24
  /** Public-facing server info (returned to whoever called `start`). */
21
25
  export interface ServerInfo {
22
26
  host: string;
@@ -26,15 +30,57 @@ export interface ServerInfo {
26
30
  /** True if the original port was busy and we had to pick a free one. */
27
31
  portRewritten: boolean;
28
32
  }
33
+ /** Upper bound of the TCP user port range. Probing never wraps past this. */
34
+ export declare const MAX_TCP_PORT = 65535;
35
+ /** Smallest legal TCP user port (avoid binding to well-known 0..1023). */
36
+ export declare const MIN_TCP_PORT = 1024;
37
+ /** Minimal logger interface used by `listenWithProbing`. */
38
+ export type PortProbingLogger = {
39
+ info: (m: string) => void;
40
+ warn: (m: string) => void;
41
+ error: (m: string) => void;
42
+ };
43
+ /** Outcome of `listenWithProbing`. */
44
+ export interface PortProbingResult {
45
+ /** The port that was actually bound. */
46
+ port: number;
47
+ /** `true` iff we ended up on a port other than the requested start. */
48
+ portRewritten: boolean;
49
+ }
50
+ /**
51
+ * Try `server.listen(port, host, …)` starting at `startPort`. If the OS
52
+ * reports `EADDRINUSE`, increment the port and retry. Stops at 65535 —
53
+ * never wraps, never falls back to an OS-assigned ephemeral port.
54
+ *
55
+ * Only `EADDRINUSE` is retried. Any other listen error (e.g. `EACCES`,
56
+ * `ENOTSUP`) is surfaced as a normal rejection — the caller is
57
+ * responsible for closing the server in that case (this helper does
58
+ * not own the server's lifecycle).
59
+ *
60
+ * Listener hygiene: each attempt attaches a fresh one-shot `error`
61
+ * listener and detaches it as soon as we move on (success, permanent
62
+ * failure, or exhaustion). This guarantees no duplicate resolve /
63
+ * reject calls and no listener leaks even when the helper is called
64
+ * many times on the same server.
65
+ */
66
+ export declare function listenWithProbing(args: {
67
+ server: http.Server;
68
+ host: string;
69
+ startPort: number;
70
+ logger?: PortProbingLogger;
71
+ }): Promise<PortProbingResult>;
29
72
  export declare class TaskShowServer {
30
73
  private server;
31
74
  private readonly cfg;
32
75
  private readonly collector;
76
+ private readonly eventBus;
33
77
  private readonly publicRoot;
34
78
  private readonly logger;
35
79
  constructor(opts: {
36
80
  cfg: TaskShowConfig;
37
81
  collector: ToolCallCollector;
82
+ /** EventBus for SSE broadcasts. Defaults to a new EventBus if omitted. */
83
+ eventBus?: EventBus;
38
84
  /** Absolute or relative path to the directory with public/index.html. */
39
85
  publicDir?: string;
40
86
  logger?: {
@@ -44,9 +90,19 @@ export declare class TaskShowServer {
44
90
  };
45
91
  metaUrl?: string;
46
92
  });
93
+ /**
94
+ * Get the SSE event bus (for tests / health checks).
95
+ */
96
+ getEventBus(): EventBus;
47
97
  /**
48
98
  * Start the listener. The promise resolves once the server is bound — but
49
99
  * the caller can race against `getInfo()` immediately afterwards.
100
+ *
101
+ * Port handling: we attempt to bind on `cfg.port` first; if the OS reports
102
+ * `EADDRINUSE` we probe `cfg.port + 1`, `cfg.port + 2`, ... in ascending
103
+ * order until a free port is found. The probing never wraps past 65535
104
+ * and never falls back to `listen(0)`. `ServerInfo.portRewritten` is
105
+ * `true` whenever the bound port differs from `cfg.port`.
50
106
  */
51
107
  start(): Promise<ServerInfo>;
52
108
  /**
@@ -63,6 +119,13 @@ export declare class TaskShowServer {
63
119
  */
64
120
  stop(): Promise<void>;
65
121
  private handle;
122
+ /**
123
+ * SSE handler — sets the SSE headers, sends an initial snapshot, and
124
+ * subscribes the response to the event bus. The bus will write frames
125
+ * until the socket closes; the `res.on("close", ...)` listener removes
126
+ * the subscriber.
127
+ */
128
+ private handleSSE;
66
129
  /**
67
130
  * Render the index page (lists known tasks).
68
131
  */
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,EAEV,cAAc,EACf,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAMxD,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IACpB,wEAAwE;IACxE,aAAa,EAAE,OAAO,CAAC;CACxB;AAcD,qBAAa,cAAc;IACzB,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAiB;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoB;IAC9C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuF;gBAElG,IAAI,EAAE;QAChB,GAAG,EAAE,cAAc,CAAC;QACpB,SAAS,EAAE,iBAAiB,CAAC;QAC7B,yEAAyE;QACzE,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,MAAM,CAAC,EAAE;YAAE,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;YAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;YAAC,KAAK,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;SAAE,CAAC;QAC9F,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB;IAOD;;;OAGG;IACH,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;IA0D5B;;;OAGG;IACH,OAAO,IAAI;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAW7D;;OAEG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAyBP,MAAM;IAuEpB;;OAEG;IACH,OAAO,CAAC,SAAS;IAKjB;;OAEG;IACH,OAAO,CAAC,YAAY;IAWpB,OAAO,CAAC,WAAW;IAuBnB,OAAO,CAAC,IAAI;IAQZ,OAAO,CAAC,IAAI;IAMZ,OAAO,CAAC,QAAQ;CAMjB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/dist/server.js CHANGED
@@ -7,18 +7,156 @@
7
7
  * GET /task/:taskId → detail page (mermaid flow + tables)
8
8
  * GET /api/sessions → JSON list of sessions
9
9
  * GET /api/sessions/:taskId → JSON detail of one session
10
+ * GET /api/events → Server-Sent Events stream (real-time push)
10
11
  * GET /static/* → static frontend assets (CSS/JS)
11
12
  *
12
13
  * The server is started/stopped via `TaskShowServer.start()` /
13
- * `.stop()`. Multiple instances cooperate by random port fallback if the
14
- * configured port is busy we let the OS pick a free one and log the actual
15
- * port.
14
+ * `.stop()`. When the configured port is busy we probe `port + 1`,
15
+ * `port + 2`, ... in ascending order until a free TCP port is found
16
+ * never falling back to an OS-assigned ephemeral port (`listen(0)`).
17
+ * That keeps the bound port predictable for documentation, fire-and-forget
18
+ * scripts and ad-hoc coordination between processes.
16
19
  */
17
20
  import * as fs from "node:fs";
18
21
  import * as path from "node:path";
19
22
  import * as http from "node:http";
20
23
  import { fileURLToPath } from "node:url";
24
+ import { EventBus as DefaultEventBus, writeSSEHeaders } from "./event-bus.js";
25
+ /** Upper bound of the TCP user port range. Probing never wraps past this. */
26
+ export const MAX_TCP_PORT = 65535;
27
+ /** Smallest legal TCP user port (avoid binding to well-known 0..1023). */
28
+ export const MIN_TCP_PORT = 1024;
21
29
  const PUBLIC_DIR = "public";
30
+ // ============================================================================
31
+ // listenWithProbing — sequential port probing helper
32
+ // ============================================================================
33
+ /**
34
+ * Try `server.listen(port, host, …)` starting at `startPort`. If the OS
35
+ * reports `EADDRINUSE`, increment the port and retry. Stops at 65535 —
36
+ * never wraps, never falls back to an OS-assigned ephemeral port.
37
+ *
38
+ * Only `EADDRINUSE` is retried. Any other listen error (e.g. `EACCES`,
39
+ * `ENOTSUP`) is surfaced as a normal rejection — the caller is
40
+ * responsible for closing the server in that case (this helper does
41
+ * not own the server's lifecycle).
42
+ *
43
+ * Listener hygiene: each attempt attaches a fresh one-shot `error`
44
+ * listener and detaches it as soon as we move on (success, permanent
45
+ * failure, or exhaustion). This guarantees no duplicate resolve /
46
+ * reject calls and no listener leaks even when the helper is called
47
+ * many times on the same server.
48
+ */
49
+ export function listenWithProbing(args) {
50
+ const { server, host, startPort } = args;
51
+ const logger = args.logger ?? console;
52
+ // Port 0 is a special sentinel: the caller is asking the OS to assign
53
+ // an ephemeral port. Honor that directly — no probing needed, no
54
+ // portRewritten. Any other value must be a valid user-config port.
55
+ if (startPort === 0) {
56
+ return new Promise((resolve, reject) => {
57
+ let settled = false;
58
+ const onError = (err) => {
59
+ if (settled)
60
+ return;
61
+ settled = true;
62
+ reject(err);
63
+ };
64
+ server.once("error", onError);
65
+ server.listen(0, host, () => {
66
+ if (settled)
67
+ return;
68
+ server.removeListener("error", onError);
69
+ settled = true;
70
+ const addr = server.address();
71
+ if (typeof addr === "string" || addr === null) {
72
+ reject(new Error("Failed to determine assigned port after listen(0)"));
73
+ return;
74
+ }
75
+ resolve({ port: addr.port, portRewritten: false });
76
+ });
77
+ });
78
+ }
79
+ // Reject impossible start ports up-front — no need to touch the
80
+ // server at all. Saves a confusing EADDRINUSE loop when someone
81
+ // passes 70000.
82
+ if (!Number.isInteger(startPort) ||
83
+ startPort < MIN_TCP_PORT ||
84
+ startPort > MAX_TCP_PORT) {
85
+ return Promise.reject(new Error(`Invalid startPort ${startPort}: must be 0 (OS-assigned) or an integer in [${MIN_TCP_PORT}, ${MAX_TCP_PORT}]`));
86
+ }
87
+ return new Promise((resolve, reject) => {
88
+ let port = startPort;
89
+ let settled = false;
90
+ // Per-attempt error listener — we explicitly removeListener it before
91
+ // attaching a new one, so no listeners accumulate across attempts.
92
+ let onError = null;
93
+ const finishError = (err) => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ if (onError) {
98
+ server.removeListener("error", onError);
99
+ onError = null;
100
+ }
101
+ reject(err);
102
+ };
103
+ const finishSuccess = (boundPort) => {
104
+ if (settled)
105
+ return;
106
+ settled = true;
107
+ if (onError) {
108
+ server.removeListener("error", onError);
109
+ onError = null;
110
+ }
111
+ resolve({
112
+ port: boundPort,
113
+ portRewritten: boundPort !== startPort,
114
+ });
115
+ };
116
+ const tryListen = () => {
117
+ if (settled)
118
+ return;
119
+ if (port > MAX_TCP_PORT) {
120
+ finishError(Object.assign(new Error(`No free TCP port in [${startPort}, ${MAX_TCP_PORT}]: every port busy (EADDRINUSE)`), { code: "EADDRINUSE" }));
121
+ return;
122
+ }
123
+ onError = (err) => {
124
+ if (settled)
125
+ return;
126
+ const code = err && err.code;
127
+ if (code === "EADDRINUSE") {
128
+ logger.warn(`Port ${port} busy — trying ${port + 1}`);
129
+ // Move on. Detach this listener before the next attempt.
130
+ if (onError) {
131
+ server.removeListener("error", onError);
132
+ onError = null;
133
+ }
134
+ port += 1;
135
+ tryListen();
136
+ return;
137
+ }
138
+ // Any other error: stop immediately.
139
+ finishError(err);
140
+ };
141
+ server.once("error", onError);
142
+ server.listen(port, host, () => {
143
+ if (settled)
144
+ return;
145
+ const addr = server.address();
146
+ if (typeof addr === "string" || addr === null) {
147
+ finishError(new Error("Failed to determine assigned port after listen()"));
148
+ return;
149
+ }
150
+ // `addr.port` is the authoritative bound port. Use it (rather
151
+ // than our local `port` counter) so a stub server that emits
152
+ // 'listening' with a port it picked itself still resolves
153
+ // truthfully.
154
+ finishSuccess(addr.port);
155
+ });
156
+ };
157
+ tryListen();
158
+ });
159
+ }
22
160
  /** Cached __dirname equivalent for ESM. */
23
161
  function getDirname(metaUrl) {
24
162
  if (metaUrl)
@@ -32,69 +170,85 @@ export class TaskShowServer {
32
170
  server = null;
33
171
  cfg;
34
172
  collector;
173
+ eventBus;
35
174
  publicRoot;
36
175
  logger;
37
176
  constructor(opts) {
38
177
  this.cfg = opts.cfg;
39
178
  this.collector = opts.collector;
179
+ this.eventBus = opts.eventBus ?? new DefaultEventBus();
40
180
  this.publicRoot = resolvePublicDir(opts.publicDir ?? PUBLIC_DIR, opts.metaUrl);
41
181
  this.logger = opts.logger ?? console;
42
182
  }
183
+ /**
184
+ * Get the SSE event bus (for tests / health checks).
185
+ */
186
+ getEventBus() {
187
+ return this.eventBus;
188
+ }
43
189
  /**
44
190
  * Start the listener. The promise resolves once the server is bound — but
45
191
  * the caller can race against `getInfo()` immediately afterwards.
192
+ *
193
+ * Port handling: we attempt to bind on `cfg.port` first; if the OS reports
194
+ * `EADDRINUSE` we probe `cfg.port + 1`, `cfg.port + 2`, ... in ascending
195
+ * order until a free port is found. The probing never wraps past 65535
196
+ * and never falls back to `listen(0)`. `ServerInfo.portRewritten` is
197
+ * `true` whenever the bound port differs from `cfg.port`.
46
198
  */
47
199
  start() {
48
200
  if (this.server) {
49
201
  throw new Error("TaskShowServer already started");
50
202
  }
51
- return new Promise((resolve, reject) => {
52
- const requestListener = (req, res) => {
53
- this.handle(req, res).catch((err) => {
54
- this.logger.error(`Unhandled error in HTTP handler: ${err}`);
55
- try {
56
- res.statusCode = 500;
57
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
58
- res.end("Internal Server Error");
59
- }
60
- catch {
61
- // ignore — socket may already be closed
62
- }
63
- });
64
- };
65
- const desiredPort = this.cfg.port;
66
- const server = http.createServer(requestListener);
67
- server.on("error", (err) => {
68
- if (err.code === "EADDRINUSE") {
69
- this.logger.warn(`Port ${desiredPort} busy — falling back to an ephemeral port`);
70
- // Retry on an OS-assigned port.
71
- server.listen(0, this.cfg.host, () => {
72
- const addr = server.address();
73
- if (typeof addr === "string" || addr === null) {
74
- reject(new Error("Failed to determine assigned port"));
75
- return;
76
- }
77
- finish(server, addr.port, true);
78
- });
79
- }
80
- else {
81
- reject(err);
203
+ const requestListener = (req, res) => {
204
+ this.handle(req, res).catch((err) => {
205
+ this.logger.error(`Unhandled error in HTTP handler: ${err}`);
206
+ try {
207
+ res.statusCode = 500;
208
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
209
+ res.end("Internal Server Error");
82
210
  }
83
- });
84
- server.listen(desiredPort, this.cfg.host, () => {
85
- const addr = server.address();
86
- if (typeof addr === "string" || addr === null) {
87
- reject(new Error("Failed to determine assigned port"));
88
- return;
211
+ catch {
212
+ // ignore socket may already be closed
89
213
  }
90
- finish(server, addr.port, false);
91
214
  });
92
- const finish = (srv, port, portRewritten) => {
93
- this.server = srv;
94
- const url = `http://${this.cfg.host === "0.0.0.0" ? "localhost" : this.cfg.host}:${port}/`;
95
- this.logger.info(`HTTP service listening on ${url}`);
96
- resolve({ host: this.cfg.host, port, url, server: srv, portRewritten });
215
+ };
216
+ const desiredPort = this.cfg.port;
217
+ const server = http.createServer(requestListener);
218
+ return listenWithProbing({
219
+ server,
220
+ host: this.cfg.host,
221
+ startPort: desiredPort,
222
+ logger: this.logger,
223
+ }).then((probe) => {
224
+ // success path
225
+ this.server = server;
226
+ const url = `http://${this.cfg.host === "0.0.0.0" ? "localhost" : this.cfg.host}:${probe.port}/`;
227
+ this.logger.info(`HTTP service listening on ${url}` +
228
+ (probe.portRewritten
229
+ ? ` (port rewritten from ${desiredPort} due to conflict)`
230
+ : ""));
231
+ const info = {
232
+ host: this.cfg.host,
233
+ port: probe.port,
234
+ url,
235
+ server,
236
+ portRewritten: probe.portRewritten,
97
237
  };
238
+ return info;
239
+ }, (err) => {
240
+ // Probing gave up (port range exhausted or a non-EADDRINUSE error
241
+ // bubbled up). Make sure we close the server we just created so we
242
+ // don't leak the http.Server handle.
243
+ try {
244
+ server.close(() => {
245
+ /* swallow — best effort */
246
+ });
247
+ }
248
+ catch {
249
+ /* ignore */
250
+ }
251
+ throw err;
98
252
  });
99
253
  }
100
254
  /**
@@ -124,6 +278,8 @@ export class TaskShowServer {
124
278
  }
125
279
  const s = this.server;
126
280
  this.server = null;
281
+ // Force-close all SSE clients so the heartbeat loop can exit.
282
+ this.eventBus.closeAll();
127
283
  s.close(() => resolve());
128
284
  // Best-effort: tell outstanding sockets to close.
129
285
  try {
@@ -148,6 +304,11 @@ export class TaskShowServer {
148
304
  return;
149
305
  }
150
306
  const pathname = url.pathname;
307
+ // /api/events — SSE stream
308
+ if (pathname === "/api/events") {
309
+ this.handleSSE(req, res);
310
+ return;
311
+ }
151
312
  // /api/sessions
152
313
  if (pathname === "/api/sessions") {
153
314
  this.json(res, 200, this.collector.listSessions());
@@ -199,6 +360,35 @@ export class TaskShowServer {
199
360
  }
200
361
  this.text(res, 404, "Not Found");
201
362
  }
363
+ /**
364
+ * SSE handler — sets the SSE headers, sends an initial snapshot, and
365
+ * subscribes the response to the event bus. The bus will write frames
366
+ * until the socket closes; the `res.on("close", ...)` listener removes
367
+ * the subscriber.
368
+ */
369
+ handleSSE(_req, res) {
370
+ writeSSEHeaders(res);
371
+ // 1. Send an initial snapshot so the frontend can render immediately.
372
+ const sessions = this.collector.listSessions();
373
+ const hello = {
374
+ type: "snapshot",
375
+ taskId: 0,
376
+ timestamp: Date.now(),
377
+ pluginVersion: "0.5.1",
378
+ data: { sessions },
379
+ };
380
+ try {
381
+ res.write(`event: snapshot\ndata: ${JSON.stringify(hello)}\n\n`);
382
+ }
383
+ catch {
384
+ // socket may already be closed
385
+ return;
386
+ }
387
+ // 2. Subscribe to the event bus. The bus handles keep-alive pings and
388
+ // auto-removal on socket close.
389
+ this.eventBus.subscribe(res, "sse");
390
+ this.logger.info(`SSE client connected (total: ${this.eventBus.size()})`);
391
+ }
202
392
  /**
203
393
  * Render the index page (lists known tasks).
204
394
  */
@@ -302,89 +492,79 @@ function redactArgs(args) {
302
492
  */
303
493
  function renderTaskPage(session) {
304
494
  const statusBadge = session.status;
305
- const totalDuration = session.toolCalls.reduce((acc, c) => acc + c.durationMs, 0);
306
- const toolStats = aggregateStats(session.toolCalls);
495
+ const totalMs = session.toolCalls.reduce((a, c) => a + c.durationMs, 0);
496
+ const successCount = session.toolCalls.filter((c) => c.success).length;
497
+ const failCount = session.toolCalls.length - successCount;
498
+ const aggregate = aggregateStats(session.toolCalls);
499
+ const statsRows = Object.entries(aggregate)
500
+ .sort((a, b) => b[1].count - a[1].count)
501
+ .map(([tool, s]) => `<tr><td><code>${htmlEscape(tool)}</code></td>` +
502
+ `<td>${s.count}</td><td>${s.success}</td><td>${s.fail}</td><td>${s.totalMs} ms</td></tr>`)
503
+ .join("");
504
+ const toolcallsTable = renderToolcallsTable(session.toolCalls);
307
505
  const mermaid = buildMermaidDiagram(session);
308
- const table = renderToolCallsTable(session);
309
- const prettyArgs = renderPrettyJson(redactSessionArgs(session.toolCalls));
310
506
  return /* html */ `<!DOCTYPE html>
311
507
  <html lang="en">
312
508
  <head>
313
509
  <meta charset="utf-8">
314
- <title>Task #${session.taskId} — visualization</title>
510
+ <title>Task #${session.taskId} — roy-plugin-task-show</title>
315
511
  <link rel="stylesheet" href="/static/style.css">
316
512
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
317
513
  </head>
318
- <body>
514
+ <body data-live-refresh data-task-id="${session.taskId}" data-status="${statusBadge}">
319
515
  <header class="topbar">
320
- <a href="/" class="back">← All tasks</a>
321
- <h1>Task #${session.taskId} <span class="badge badge-${statusBadge}">${htmlEscape(statusBadge)}</span></h1>
516
+ <a class="back" href="/">← All tasks</a>
517
+ <h1>📊 Task #${session.taskId}</h1>
518
+ <p class="task-title">${htmlEscape(session.title || "(no title)")}</p>
322
519
  <p class="meta">
323
- Started: ${htmlEscape(fmtTs(session.startedAt))}
324
- ${session.endedAt ? ` · Ended: ${htmlEscape(fmtTs(session.endedAt))}` : " · still running"}
325
- · ${session.toolCalls.length} tool calls · total ~${totalDuration} ms
520
+ <span class="badge badge-${statusBadge}">${statusBadge}</span>
521
+ · ${session.toolCalls.length} tool call(s)
522
+ · ${totalMs} ms total
523
+ · ${successCount} ok / ${failCount} fail
524
+ · started ${htmlEscape(fmtTs(session.startedAt))}
525
+ ${session.endedAt ? `· ended ${htmlEscape(fmtTs(session.endedAt))}` : ""}
326
526
  </p>
327
- ${session.title ? `<p class="task-title">${htmlEscape(session.title)}</p>` : ""}
328
527
  </header>
329
528
 
330
529
  <section class="panel">
331
- <h2>📈 Tool frequency</h2>
332
- <table class="stats">
333
- <thead><tr><th>Tool</th><th>Calls</th><th>Success</th><th>Fail</th><th>Total ms</th><th>Avg ms</th></tr></thead>
334
- <tbody>
335
- ${Object.entries(toolStats).map(([name, s]) => `
336
- <tr>
337
- <td><code>${htmlEscape(name)}</code></td>
338
- <td>${s.count}</td>
339
- <td>${s.success}</td>
340
- <td>${s.fail}</td>
341
- <td>${s.totalMs}</td>
342
- <td>${s.count ? Math.round(s.totalMs / s.count) : 0}</td>
343
- </tr>`).join("")}
344
- </tbody>
345
- </table>
530
+ <h2>Execution flow (Mermaid)</h2>
531
+ <div class="mermaid">${mermaid}</div>
346
532
  </section>
347
533
 
348
534
  <section class="panel">
349
- <h2>🧭 Tool call flow (mermaid)</h2>
350
- <div class="mermaid" id="mermaid">
351
- ${mermaid}
352
- </div>
353
- <p class="hint">Hover a node to inspect the parameters/result preview. Click to expand the JSON.</p>
535
+ <h2>Tool call stats</h2>
536
+ <table class="stats">
537
+ <thead><tr><th>Tool</th><th>Calls</th><th>OK</th><th>Fail</th><th>Total ms</th></tr></thead>
538
+ <tbody>${statsRows || `<tr><td colspan="5" class="empty">No tool calls recorded.</td></tr>`}</tbody>
539
+ </table>
354
540
  </section>
355
541
 
356
542
  <section class="panel">
357
- <h2>📜 Tool calls (chronological)</h2>
358
- ${table}
543
+ <h2>Tool calls</h2>
544
+ ${toolcallsTable}
359
545
  </section>
360
546
 
361
547
  <section class="panel">
362
- <h2>🔍 Full payload (JSON)</h2>
548
+ <h2>Raw JSON</h2>
363
549
  <details>
364
- <summary>Show / hide raw arguments & output previews</summary>
365
- <pre class="json">${htmlEscape(prettyArgs)}</pre>
550
+ <summary>Click to expand the raw session payload</summary>
551
+ <pre class="json">${htmlEscape(JSON.stringify(session, null, 2))}</pre>
366
552
  </details>
367
553
  </section>
368
554
 
555
+ <script src="/static/app.js"></script>
369
556
  <script>
370
- // Mermaid boot
371
- document.addEventListener('DOMContentLoaded', function() {
372
- if (window.mermaid) {
373
- window.mermaid.initialize({ startOnLoad: true, securityLevel: 'loose', theme: 'default' });
374
- }
375
- });
557
+ if (window.mermaid) {
558
+ window.mermaid.initialize({ startOnLoad: true, theme: 'default', securityLevel: 'loose' });
559
+ }
376
560
  </script>
377
- <script src="/static/app.js"></script>
378
561
  </body>
379
562
  </html>`;
380
563
  }
381
- function renderToolCallsTable(session) {
382
- if (session.toolCalls.length === 0) {
383
- return `<p class="empty">No tool calls have been recorded yet for this task.</p>`;
384
- }
385
- const rows = session.toolCalls
564
+ function renderToolcallsTable(toolCalls) {
565
+ const rows = toolCalls
386
566
  .map((call) => {
387
- const argsJson = JSON.stringify(redactArgs(call.args)).slice(0, 240);
567
+ const argsJson = JSON.stringify(redactArgs(call.args));
388
568
  const preview = call.error
389
569
  ? (call.error.slice(0, 200) + (call.error.length > 200 ? "…" : ""))
390
570
  : (call.outputPreview.slice(0, 240) + (call.outputPreview.length > 240 ? "…" : ""));
@@ -436,11 +616,19 @@ function renderIndexPage(sessions) {
436
616
  <meta charset="utf-8">
437
617
  <title>roy-plugin-task-show · index</title>
438
618
  <link rel="stylesheet" href="/static/style.css">
619
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
439
620
  </head>
440
- <body>
621
+ <body data-live-refresh>
441
622
  <header class="topbar">
442
623
  <h1>📊 roy-plugin-task-show</h1>
443
- <p class="meta">${sessions.length} task session(s) collected this run</p>
624
+ <p class="meta">
625
+ ${sessions.length} task session(s) collected this run.
626
+ Live updates stream over <code>/api/events</code> (SSE).
627
+ </p>
628
+ <p class="meta connection-status" data-connection>
629
+ <span class="conn-dot conn-disconnected"></span>
630
+ <span class="conn-label">Connecting…</span>
631
+ </p>
444
632
  </header>
445
633
  <section class="panel">
446
634
  <table class="sessions">
@@ -454,10 +642,17 @@ function renderIndexPage(sessions) {
454
642
  <h2>How to read this page</h2>
455
643
  <ul>
456
644
  <li>Click a task ID to open the mermaid flow + tool call table for that task.</li>
457
- <li>The plugin appends a 🔗 URL to the <strong>last</strong> tool result when the task transitions to <code>completed</code> / <code>failed</code>.</li>
645
+ <li>The page subscribes to <code>GET /api/events</code> for real-time updates (created / recorded / completed).</li>
646
+ <li>A 3-second polling fallback keeps the page fresh if SSE is blocked (e.g. behind certain proxies).</li>
458
647
  <li>Sessions are kept in memory only. Restart the host process to clear all data.</li>
459
648
  </ul>
460
649
  </section>
650
+ <script src="/static/app.js"></script>
651
+ <script>
652
+ if (window.mermaid) {
653
+ window.mermaid.initialize({ startOnLoad: true, theme: 'default', securityLevel: 'loose' });
654
+ }
655
+ </script>
461
656
  </body>
462
657
  </html>`;
463
658
  }