@ai-setting/roy-plugin-task-show 0.3.0 → 0.5.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"url-injector.js","sourceRoot":"","sources":["../src/url-injector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CACnC,GAA0C,EAC1C,MAAuB;IAEvB,qDAAqD;IACrD,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC;IAC7D,OAAO,UAAU,IAAI,IAAI,GAAG,CAAC,IAAI,SAAS,kBAAkB,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;AACjF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAC3B,GAA0C;IAE1C,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC;IAC7D,OAAO,UAAU,IAAI,IAAI,GAAG,CAAC,IAAI,GAAG,CAAC;AACvC,CAAC;AAeD;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAItC;IACC,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAElC,IAAI,CAAC,GAAG,CAAC,gBAAgB;QAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IACrD,IAAI,CAAC,GAAG;QAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IAEpC,MAAM,MAAM,GAAG,GAAG,EAAE,MAAM,IAAI,GAAG,EAAE,UAAU,IAAI,IAAI,CAAC;IACtD,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;QACjD,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IAC5B,CAAC;IAED,MAAM,GAAG,GAAG,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IAC/C,MAAM,MAAM,GACV,+BAA+B,GAAG,KAAK;QACvC,2CAA2C,CAAC;IAC9C,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,GAAG,MAAM,CAAC;IACzC,MAAM,CAAC,MAAM,GAAG,SAAS,CAAC;IAE1B,qEAAqE;IACrE,qDAAqD;IACrD,IAAI,OAAO,MAAM,CAAC,QAAQ,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,KAAK,IAAI,EAAE,CAAC;QACnE,MAAM,CAAC,QAAoC,CAAC,sBAAsB,GAAG,GAAG,CAAC;IAC5E,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AACnD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CACvC,MAAc,EACd,GAA0C,EAC1C,MAAuB,EACvB,aAAuB,EAAE;IAEzB,MAAM,GAAG,GAAG,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IAC/C,MAAM,MAAM,GACV,+BAA+B,GAAG,GAAG;QACrC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAC/E,OAAO,MAAM,GAAG,MAAM,CAAC;AACzB,CAAC"}
1
+ {"version":3,"file":"url-injector.js","sourceRoot":"","sources":["../src/url-injector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CACnC,GAA0C,EAC1C,MAAuB;IAEvB,qDAAqD;IACrD,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC;IAC7D,OAAO,UAAU,IAAI,IAAI,GAAG,CAAC,IAAI,SAAS,kBAAkB,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;AACjF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAC3B,GAA0C;IAE1C,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC;IAC7D,OAAO,UAAU,IAAI,IAAI,GAAG,CAAC,IAAI,GAAG,CAAC;AACvC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-setting/roy-plugin-task-show",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "roy-agent plugin: visualize task solving process via tool call flow on a local web service",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,6 +15,7 @@
15
15
  "scripts": {
16
16
  "build": "tsc -p tsconfig.json",
17
17
  "dev": "tsc -p tsconfig.json --watch",
18
+ "typecheck": "tsc --noEmit -p tsconfig.json",
18
19
  "test": "bun test",
19
20
  "start:demo": "bun run scripts/run-demo.ts",
20
21
  "verify": "bun run scripts/verify-service.ts",
package/plugin.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@ai-setting/roy-plugin-task-show",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "tool-plugin",
5
- "description": "Visualize the tool call chain of a task on a local web service and inject the URL into tool results. Subscribes to tool:before.execute (for self-managed timing), tool:after.execute, task:after.complete (preferred, 2026-07-10+) and task:after.update (legacy fallback).",
5
+ "description": "Visualize the tool call chain of a task on a local web service with real-time SSE updates. v0.5.0+: page refreshes stream over GET /api/events (Server-Sent Events). Subscribes to tool:before.execute, tool:after.execute, task:before.create, task:after.create, task:after.complete (preferred, 2026-07-10+), and task:after.update (legacy fallback).",
6
6
  "main": "dist/index.js",
7
7
  "hooks": [
8
8
  {
@@ -11,15 +11,23 @@
11
11
  },
12
12
  {
13
13
  "point": "tool:after.execute",
14
- "purpose": "Collect every tool invocation: tool name, args, result, timing, success"
14
+ "purpose": "Collect every tool invocation: tool name, args, result, timing, success. Broadcasts a `tool.recorded` SSE event so the frontend timeline + progress bar update live."
15
+ },
16
+ {
17
+ "point": "task:before.create",
18
+ "purpose": "Open a fresh TaskSession before the task runs (v0.5.0+). Mints the visualization URL and broadcasts a `task.created` SSE event so the UI can render the card immediately, even if no tool call fires."
19
+ },
20
+ {
21
+ "point": "task:after.create",
22
+ "purpose": "Mark the session ready for tracking (v0.5.0+). For hosts that emit only this hook (no `task:before.create`), the plugin opens the session here. Broadcasts a `task.created` SSE event when the session is newly minted."
15
23
  },
16
24
  {
17
25
  "point": "task:after.complete",
18
- "purpose": "PREFERRED hook point (roy-agent 2026-07-10+). When a task transitions to a terminal status (completed/failed/cancelled/timeout), finalize the session and inject the visualization URL into the last tool result. terminalStatus field is explicit \u2014 no status filtering required."
26
+ "purpose": "PREFERRED hook point (roy-agent 2026-07-10+). When a task transitions to a terminal status (completed/failed/cancelled), finalize the session and broadcast a `task.completed` SSE event. terminalStatus field is explicit no status filtering required. The handler dedupes against any `task:after.update` that may also fire on the same transition."
19
27
  },
20
28
  {
21
29
  "point": "task:after.update",
22
- "purpose": "LEGACY fallback. Hosts predating the AFTER_COMPLETE hook point only expose AFTER_UPDATE; the plugin filters for terminal status internally and dedupes against any AFTER_COMPLETE that might also fire on the new code path."
30
+ "purpose": "GENERIC status update hook (v0.5.0+). Used for non-terminal transitions (running → paused → running) AND as a legacy fallback for hosts predating the AFTER_COMPLETE hook point. Broadcasts `task.updated` for non-terminal status, `task.completed` for terminal status. The internal dedup set guarantees at most one terminal broadcast per task even if both hooks fire."
23
31
  }
24
32
  ],
25
33
  "commands": [
@@ -49,17 +57,31 @@
49
57
  "default": 50,
50
58
  "description": "Maximum number of completed tasks to keep in memory (older ones are evicted)"
51
59
  },
52
- "urlInjectEnabled": {
53
- "type": "boolean",
54
- "default": true,
55
- "description": "Append a \ud83d\udcca visualization URL to the last tool result when a task completes"
56
- },
57
60
  "publicDir": {
58
61
  "type": "string",
59
62
  "default": "public",
60
63
  "description": "Directory holding the static visualization frontend (HTML/JS/CSS)"
61
64
  }
62
65
  },
66
+ "endpoints": {
67
+ "index": "GET / — index of recent task sessions",
68
+ "taskDetail": "GET /task/:taskId — detail page (mermaid flow + tables)",
69
+ "apiSessions": "GET /api/sessions — JSON list of sessions",
70
+ "apiSessionDetail": "GET /api/sessions/:taskId — JSON detail of one session",
71
+ "apiEvents": "GET /api/events — Server-Sent Events stream (real-time push)",
72
+ "static": "GET /static/* — static frontend assets (CSS/JS)"
73
+ },
74
+ "sse": {
75
+ "events": [
76
+ "snapshot — initial state dump on connect",
77
+ "task.created — new task session opened",
78
+ "task.updated — non-terminal status change",
79
+ "task.completed — task transitioned to a terminal status",
80
+ "tool.recorded — tool call appended to a session"
81
+ ],
82
+ "heartbeatMs": 15000,
83
+ "snapshotFrame": "event: snapshot\ndata: {type:'snapshot', data:{sessions:[...]}}\n\n"
84
+ },
63
85
  "roy-agent": {
64
86
  "minVersion": "1.6.0",
65
87
  "extends": "BasePlugin",
@@ -67,4 +89,4 @@
67
89
  },
68
90
  "repository": "github:ai-setting/roy-plugin-task-visualize",
69
91
  "bugs": "https://github.com/ai-setting/roy-plugin-task-visualize/issues"
70
- }
92
+ }
package/public/app.js CHANGED
@@ -1,27 +1,49 @@
1
1
  /* ------------------------------------------------------------------------- */
2
- /* roy-plugin-task-show — small progressive enhancements */
2
+ /* roy-plugin-task-show — frontend with SSE real-time updates */
3
3
  /* ------------------------------------------------------------------------- */
4
4
 
5
5
  /**
6
- * Allow each <tr data-args="..."> to expand a JSON dialog on click.
7
- * `data-args` carries a single-line JSON string; we pretty-print it on demand.
6
+ * Lightweight in-browser state for the index page:
7
+ * - map of taskId session
8
+ * - simple debounce/throttle for re-render
9
+ *
10
+ * The page renders:
11
+ * - a "connection" indicator (green when SSE is up, red when on fallback)
12
+ * - a stats summary (total sessions, running, completed, failed)
13
+ * - a task grid (one card per task with status badge + progress bar)
14
+ * - a live timeline (most recent tool calls across all tasks)
15
+ *
16
+ * Update path:
17
+ * 1. Open an EventSource on /api/events
18
+ * 2. On `snapshot`: replace local state with the server's snapshot
19
+ * 3. On `task.created` / `task.updated` / `task.completed`:
20
+ * replace or insert the session in state, then re-render
21
+ * 4. On `tool.recorded`:
22
+ * append the tool call to the session, update progress, re-render
23
+ * 5. If the SSE connection drops, fall back to a 3-second poll of
24
+ * /api/sessions (the legacy behavior)
8
25
  */
26
+
9
27
  (function attachArgsExpander() {
10
- const rows = document.querySelectorAll('tr[data-args]');
11
- rows.forEach((row) => {
12
- row.style.cursor = "zoom-in";
13
- row.addEventListener("click", () => {
14
- const raw = row.getAttribute("data-args") || "";
15
- let parsed;
16
- try {
17
- parsed = JSON.parse(raw);
18
- } catch {
19
- parsed = raw;
20
- }
21
- openDialog({
22
- title: row.querySelector("td:nth-child(2)")?.textContent?.trim() || "Tool call",
23
- body: JSON.stringify(parsed, null, 2),
24
- });
28
+ /**
29
+ * Allow each <tr data-args="..."> to expand a JSON dialog on click.
30
+ * `data-args` carries a single-line JSON string; we pretty-print it on demand.
31
+ */
32
+ document.addEventListener("click", (e) => {
33
+ const target = e.target;
34
+ if (!(target instanceof Element)) return;
35
+ const row = target.closest("tr[data-args]");
36
+ if (!row) return;
37
+ const raw = row.getAttribute("data-args") || "";
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(raw);
41
+ } catch {
42
+ parsed = raw;
43
+ }
44
+ openDialog({
45
+ title: row.querySelector("td:nth-child(2)")?.textContent?.trim() || "Tool call",
46
+ body: JSON.stringify(parsed, null, 2),
25
47
  });
26
48
  });
27
49
 
@@ -83,51 +105,381 @@
83
105
  })();
84
106
 
85
107
  /**
86
- * Poll the session summary every 3 seconds while a task is running so the
87
- * index page (and the per-task page when status changes) updates without a
88
- * manual refresh. Lightweight: single GET, no debouncing.
108
+ * Initialize a live timeline on the per-task page (when `data-task-id` is
109
+ * present). We re-render the timeline on every `tool.recorded` event.
89
110
  */
90
- (function attachLiveRefresh() {
91
- const banner = document.querySelector('[data-live-refresh]');
111
+ (function attachTaskPageTimeline() {
112
+ const banner = document.querySelector("[data-live-refresh][data-task-id]");
92
113
  if (!banner) return;
93
- const taskId = banner.getAttribute('data-task-id') || '';
94
- const initialStatus = banner.getAttribute('data-status') || 'running';
114
+ const taskId = Number(banner.getAttribute("data-task-id"));
115
+ if (!Number.isFinite(taskId)) return;
116
+
117
+ // Subscribe to SSE for this task only.
118
+ try {
119
+ const es = new EventSource(`/api/events`);
120
+ es.addEventListener("snapshot", (ev) => {
121
+ const data = JSON.parse((ev).data);
122
+ const session = (data.data?.sessions || []).find((s) => s.taskId === taskId);
123
+ if (session) applyTaskUpdate(session);
124
+ });
125
+ ["task.created", "task.updated", "task.completed"].forEach((type) => {
126
+ es.addEventListener(type, (ev) => {
127
+ const data = JSON.parse((ev).data);
128
+ const session = data.data?.session;
129
+ if (session && session.taskId === taskId) applyTaskUpdate(session);
130
+ });
131
+ });
132
+ es.addEventListener("tool.recorded", (ev) => {
133
+ const data = JSON.parse((ev).data);
134
+ const session = data.data?.session;
135
+ if (session && session.taskId === taskId) applyTaskUpdate(session);
136
+ });
137
+ es.onerror = () => {
138
+ // EventSource will auto-reconnect; nothing to do here.
139
+ };
140
+ } catch {
141
+ // ignore — the page will still render statically
142
+ }
143
+
144
+ function applyTaskUpdate(session) {
145
+ // Update status badge.
146
+ const badge = document.querySelector(".badge");
147
+ if (badge) {
148
+ const cls = `badge badge-${session.status}`;
149
+ badge.className = cls;
150
+ badge.textContent = session.status;
151
+ }
152
+ // Update meta line.
153
+ const meta = document.querySelector(".topbar .meta");
154
+ if (meta) {
155
+ const totalMs = session.toolCalls.reduce((a, c) => a + c.durationMs, 0);
156
+ const ok = session.toolCalls.filter((c) => c.success).length;
157
+ const fail = session.toolCalls.length - ok;
158
+ meta.innerHTML =
159
+ `<span class="badge badge-${session.status}">${session.status}</span>` +
160
+ ` · ${session.toolCalls.length} tool call(s)` +
161
+ ` · ${totalMs} ms total` +
162
+ ` · ${ok} ok / ${fail} fail` +
163
+ ` · started ${new Date(session.startedAt).toISOString().replace("T", " ").slice(0, 19)}` +
164
+ (session.endedAt ? ` · ended ${new Date(session.endedAt).toISOString().replace("T", " ").slice(0, 19)}` : "");
165
+ }
166
+ // Re-render tool calls table in place.
167
+ const tbody = document.querySelector("table.toolcalls tbody");
168
+ if (tbody) {
169
+ tbody.innerHTML = session.toolCalls
170
+ .map((c) => renderToolCallRow(c))
171
+ .join("");
172
+ }
173
+ // Re-render stats table.
174
+ const statsTbody = document.querySelector("table.stats tbody");
175
+ if (statsTbody) {
176
+ const aggregate = aggregateStats(session.toolCalls);
177
+ statsTbody.innerHTML =
178
+ Object.entries(aggregate)
179
+ .sort((a, b) => b[1].count - a[1].count)
180
+ .map(
181
+ ([tool, s]) =>
182
+ `<tr><td><code>${escapeHtmlAttr(tool)}</code></td>` +
183
+ `<td>${s.count}</td><td>${s.success}</td><td>${s.fail}</td><td>${s.totalMs} ms</td></tr>`,
184
+ )
185
+ .join("") || `<tr><td colspan="5" class="empty">No tool calls recorded.</td></tr>`;
186
+ }
187
+ // Re-render mermaid diagram.
188
+ const mermaidDiv = document.querySelector(".mermaid");
189
+ if (mermaidDiv && window.mermaid) {
190
+ mermaidDiv.innerHTML = buildMermaidSource(session);
191
+ try {
192
+ window.mermaid.run({ nodes: [mermaidDiv] });
193
+ } catch {
194
+ // ignore — re-render is best-effort
195
+ }
196
+ }
197
+ }
198
+
199
+ function renderToolCallRow(call) {
200
+ const argsJson = JSON.stringify(redactArgs(call.args || {}));
201
+ const preview = call.error
202
+ ? (call.error.slice(0, 200) + (call.error.length > 200 ? "…" : ""))
203
+ : (call.outputPreview.slice(0, 240) + (call.outputPreview.length > 240 ? "…" : ""));
204
+ const status = call.success ? "ok" : "fail";
205
+ const attach = call.hasAttachment ? ' <span class="attach">📎</span>' : "";
206
+ return (
207
+ `<tr class="row-${status}" data-args="${escapeHtmlAttr(argsJson)}">` +
208
+ `<td class="seq">${call.sequence}</td>` +
209
+ `<td><code>${escapeHtml(call.toolName)}</code>${attach}</td>` +
210
+ `<td class="status status-${status}">${status}</td>` +
211
+ `<td>${call.durationMs}</td>` +
212
+ `<td>${escapeHtml(formatTs(call.timestamp))}</td>` +
213
+ `<td class="args">${escapeHtml(argsJson)}</td>` +
214
+ `<td class="result">${escapeHtml(preview)}</td>` +
215
+ `</tr>`
216
+ );
217
+ }
218
+
219
+ function aggregateStats(calls) {
220
+ const out = {};
221
+ for (const c of calls) {
222
+ const cur = out[c.toolName] ?? { count: 0, success: 0, fail: 0, totalMs: 0 };
223
+ cur.count += 1;
224
+ if (c.success) cur.success += 1;
225
+ else cur.fail += 1;
226
+ cur.totalMs += c.durationMs;
227
+ out[c.toolName] = cur;
228
+ }
229
+ return out;
230
+ }
95
231
 
96
- async function poll() {
232
+ function buildMermaidSource(session) {
233
+ const lines = [`flowchart TD`];
234
+ lines.push(` classDef ok fill:#dcfce7,stroke:#16a34a,color:#064e3b`);
235
+ lines.push(` classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d`);
236
+ lines.push(` classDef runtime fill:#dbeafe,stroke:#2563eb,color:#1e3a8a`);
237
+ lines.push(` Start([Task #${session.taskId} start]):::runtime`);
238
+ let prev = "Start";
239
+ if (session.toolCalls.length === 0) {
240
+ lines.push(` End([end]):::runtime`);
241
+ lines.push(` Start --> End`);
242
+ return lines.join("\n");
243
+ }
244
+ session.toolCalls.forEach((call) => {
245
+ const nodeId = `n${call.sequence}`;
246
+ const label = `${call.sequence}. ${escapeMermaid(call.toolName)}${call.hasAttachment ? " 📎" : ""}`;
247
+ lines.push(` ${nodeId}["${label}<br/><small>${call.durationMs}ms · ${call.success ? "ok" : "FAIL"}</small>"]`);
248
+ lines.push(` class ${nodeId} ${call.success ? "ok" : "fail"}`);
249
+ lines.push(` ${prev} --> ${nodeId}`);
250
+ prev = nodeId;
251
+ });
252
+ const endLabel = session.status === "failed" ? `❌ Task ${session.status}` : `✅ Task ${session.status}`;
253
+ lines.push(` End([${endLabel}]):::runtime`);
254
+ lines.push(` ${prev} --> End`);
255
+ return lines.join("\n");
256
+ }
257
+
258
+ function formatTs(ms) {
97
259
  try {
98
- const url = taskId ? `/api/sessions/${encodeURIComponent(taskId)}` : '/api/sessions';
99
- const res = await fetch(url, { headers: { accept: 'application/json' } });
100
- if (!res.ok) return;
101
- const data = await res.json();
102
- if (Array.isArray(data)) {
103
- // Index page — refresh the table cells without losing scroll position.
104
- const tbody = document.querySelector('.sessions tbody');
105
- if (!tbody) return;
106
- tbody.innerHTML = data
107
- .map((s) => {
108
- const tools = Array.from(new Set((s.toolCalls || []).map((c) => c.toolName))).map((t) => `<code>${t}</code>`).join(' ');
109
- return (
110
- '<tr>' +
111
- `<td><a href="/task/${s.taskId}">#${s.taskId}</a></td>` +
112
- `<td><span class="badge badge-${s.status}">${s.status}</span></td>` +
113
- `<td>${(s.toolCalls || []).length} calls · ${(s.toolCalls || []).reduce((a, c) => a + c.durationMs, 0)} ms</td>` +
114
- `<td>${(s.title || '(no title)')}</td>` +
115
- `<td>${new Date(s.startedAt).toISOString().replace('T', ' ').slice(0, 19)}</td>` +
116
- `<td class="tools">${tools}</td>` +
117
- '</tr>'
118
- );
119
- })
120
- .join('');
121
- } else if (data && data.taskId != null) {
122
- if (data.status !== initialStatus) {
123
- // Reload to pick up the new status badge & flow chart.
124
- location.reload();
125
- }
260
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
261
+ } catch {
262
+ return String(ms);
263
+ }
264
+ }
265
+
266
+ function redactArgs(args) {
267
+ const clone = {};
268
+ for (const [k, v] of Object.entries(args || {})) {
269
+ const lk = k.toLowerCase();
270
+ if (/(api[_-]?key|token|password|pass|secret)/.test(lk)) {
271
+ clone[k] = "***REDACTED***";
272
+ } else {
273
+ clone[k] = v;
126
274
  }
127
- } catch (e) {
128
- // network blip — try again next tick
129
275
  }
276
+ return clone;
277
+ }
278
+
279
+ function escapeMermaid(s) {
280
+ return String(s).replace(/[<>"#]/g, "").replace(/[^A-Za-z0-9_.\-]/g, "_");
281
+ }
282
+
283
+ function escapeHtml(s) {
284
+ return String(s)
285
+ .replace(/&/g, "&amp;")
286
+ .replace(/</g, "&lt;")
287
+ .replace(/>/g, "&gt;")
288
+ .replace(/"/g, "&quot;")
289
+ .replace(/'/g, "&#39;");
130
290
  }
131
291
 
132
- setInterval(poll, 3000);
292
+ function escapeHtmlAttr(s) {
293
+ return escapeHtml(s);
294
+ }
133
295
  })();
296
+
297
+ /**
298
+ * Index page: subscribe to SSE and keep the table + cards in sync. Falls
299
+ * back to a 3-second poll if EventSource is unavailable (very old browsers)
300
+ * or if the connection is dropped.
301
+ */
302
+ (function attachIndexLiveRefresh() {
303
+ const banner = document.querySelector("[data-live-refresh]");
304
+ if (!banner) return;
305
+ // Per-task page also has data-live-refresh; skip if it has data-task-id.
306
+ if (banner.hasAttribute("data-task-id")) return;
307
+
308
+ const tbody = document.querySelector(".sessions tbody");
309
+ if (!tbody) return;
310
+
311
+ // Local state.
312
+ const sessions = new Map();
313
+
314
+ let usePollingFallback = typeof EventSource === "undefined";
315
+ let pollTimer = null;
316
+ let source = null;
317
+
318
+ // ---------------------------------------------------------------------
319
+ // Helpers
320
+ // ---------------------------------------------------------------------
321
+
322
+ function setConnectionStatus(state, label) {
323
+ const el = document.querySelector("[data-connection]");
324
+ if (!el) return;
325
+ const dot = el.querySelector(".conn-dot");
326
+ const text = el.querySelector(".conn-label");
327
+ if (dot) {
328
+ dot.className =
329
+ "conn-dot " +
330
+ (state === "connected"
331
+ ? "conn-connected"
332
+ : state === "fallback"
333
+ ? "conn-fallback"
334
+ : "conn-disconnected");
335
+ }
336
+ if (text) text.textContent = label;
337
+ }
338
+
339
+ function renderIndex() {
340
+ const arr = Array.from(sessions.values()).sort(
341
+ (a, b) => b.startedAt - a.startedAt,
342
+ );
343
+ if (arr.length === 0) {
344
+ tbody.innerHTML =
345
+ `<tr><td colspan="6" class="empty">No tasks recorded yet. Trigger a tool call (e.g. send a message in <code>interactive</code> mode) and watch this page update live.</td></tr>`;
346
+ return;
347
+ }
348
+ tbody.innerHTML = arr.map(renderSessionRow).join("");
349
+ }
350
+
351
+ function renderSessionRow(s) {
352
+ const tools = new Set((s.toolCalls || []).map((c) => c.toolName));
353
+ const totalMs = (s.toolCalls || []).reduce((a, c) => a + c.durationMs, 0);
354
+ const status = s.status || "running";
355
+ return (
356
+ `<tr>` +
357
+ `<td><a href="/task/${s.taskId}">#${s.taskId}</a></td>` +
358
+ `<td><span class="badge badge-${status}">${escapeHtml(status)}</span></td>` +
359
+ `<td>${(s.toolCalls || []).length} calls · ${totalMs} ms</td>` +
360
+ `<td>${escapeHtml(s.title || "(no title)")}</td>` +
361
+ `<td>${formatTs(s.startedAt)}</td>` +
362
+ `<td class="tools">${Array.from(tools).map((t) => `<code>${escapeHtml(t)}</code>`).join(" ")}</td>` +
363
+ `</tr>`
364
+ );
365
+ }
366
+
367
+ function upsertSession(session) {
368
+ if (!session || typeof session.taskId !== "number") return;
369
+ sessions.set(session.taskId, session);
370
+ renderIndex();
371
+ }
372
+
373
+ // ---------------------------------------------------------------------
374
+ // SSE path
375
+ // ---------------------------------------------------------------------
376
+
377
+ function openSSE() {
378
+ if (typeof EventSource === "undefined") {
379
+ startPollingFallback();
380
+ return;
381
+ }
382
+ try {
383
+ source = new EventSource("/api/events");
384
+ } catch {
385
+ startPollingFallback();
386
+ return;
387
+ }
388
+
389
+ source.addEventListener("open", () => {
390
+ setConnectionStatus("connected", "Live (SSE)");
391
+ // If we were polling, stop now.
392
+ if (pollTimer) {
393
+ clearInterval(pollTimer);
394
+ pollTimer = null;
395
+ }
396
+ });
397
+
398
+ source.addEventListener("snapshot", (ev) => {
399
+ const data = JSON.parse(ev.data);
400
+ const list = data.data?.sessions || [];
401
+ sessions.clear();
402
+ for (const s of list) sessions.set(s.taskId, s);
403
+ renderIndex();
404
+ });
405
+
406
+ ["task.created", "task.updated", "task.completed"].forEach((type) => {
407
+ source.addEventListener(type, (ev) => {
408
+ const data = JSON.parse(ev.data);
409
+ const session = data.data?.session;
410
+ if (session) upsertSession(session);
411
+ });
412
+ });
413
+
414
+ source.addEventListener("tool.recorded", (ev) => {
415
+ const data = JSON.parse(ev.data);
416
+ const session = data.data?.session;
417
+ if (session) upsertSession(session);
418
+ });
419
+
420
+ source.addEventListener("error", () => {
421
+ // EventSource auto-reconnects, but if it stays down for too long
422
+ // we kick in the polling fallback.
423
+ setConnectionStatus("disconnected", "Reconnecting…");
424
+ if (!pollTimer) {
425
+ setTimeout(() => {
426
+ if (source && source.readyState === EventSource.CLOSED) {
427
+ startPollingFallback();
428
+ }
429
+ }, 3000);
430
+ }
431
+ });
432
+ }
433
+
434
+ // ---------------------------------------------------------------------
435
+ // Polling fallback (3s) — the legacy v0.1.0–v0.4.0 behavior
436
+ // ---------------------------------------------------------------------
437
+
438
+ function startPollingFallback() {
439
+ usePollingFallback = true;
440
+ setConnectionStatus("fallback", "Polling (3s fallback)");
441
+ pollTimer = setInterval(pollOnce, 3000);
442
+ pollOnce();
443
+ }
444
+
445
+ async function pollOnce() {
446
+ try {
447
+ const res = await fetch("/api/sessions", { headers: { accept: "application/json" } });
448
+ if (!res.ok) return;
449
+ const list = await res.json();
450
+ sessions.clear();
451
+ for (const s of list) sessions.set(s.taskId, s);
452
+ renderIndex();
453
+ } catch {
454
+ // network blip — try again next tick
455
+ }
456
+ }
457
+
458
+ // ---------------------------------------------------------------------
459
+ // Initial paint
460
+ // ---------------------------------------------------------------------
461
+
462
+ setConnectionStatus("disconnected", "Connecting…");
463
+ if (!usePollingFallback) {
464
+ openSSE();
465
+ } else {
466
+ startPollingFallback();
467
+ }
468
+
469
+ function formatTs(ms) {
470
+ try {
471
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
472
+ } catch {
473
+ return String(ms);
474
+ }
475
+ }
476
+
477
+ function escapeHtml(s) {
478
+ return String(s)
479
+ .replace(/&/g, "&amp;")
480
+ .replace(/</g, "&lt;")
481
+ .replace(/>/g, "&gt;")
482
+ .replace(/"/g, "&quot;")
483
+ .replace(/'/g, "&#39;");
484
+ }
485
+ })();
package/public/index.html CHANGED
@@ -12,23 +12,41 @@
12
12
  Standalone landing page served by the visualization service. The plugin
13
13
  replaces the index when sessions exist.
14
14
  </p>
15
+ <p class="meta connection-status" data-connection>
16
+ <span class="conn-dot conn-disconnected"></span>
17
+ <span class="conn-label">Connecting…</span>
18
+ </p>
15
19
  </header>
16
20
  <section class="panel">
17
21
  <h2>No sessions yet</h2>
18
22
  <p class="empty">
19
23
  The <code>roy-agent</code> host process has not recorded any tool
20
24
  calls yet. Trigger a session — for example by sending a message in
21
- <code>interactive</code> mode — and refresh this page.
22
- </p>
23
- <p>
24
- Useful endpoints while you wait:
25
- <ul>
26
- <li><a href="/api/sessions">/api/sessions</a> — JSON list of recorded sessions.</li>
27
- <li><a href="/static/style.css">/static/style.css</a> — bundled CSS.</li>
28
- <li><a href="/static/app.js">/static/app.js</a> — bundled JS.</li>
29
- </ul>
25
+ <code>interactive</code> mode — and the page will update live via
26
+ Server-Sent Events.
30
27
  </p>
28
+ <table class="sessions">
29
+ <thead>
30
+ <tr><th>Task ID</th><th>Status</th><th>Stats</th><th>Title</th><th>Started</th><th>Tools</th></tr>
31
+ </thead>
32
+ <tbody></tbody>
33
+ </table>
34
+ </section>
35
+ <section class="panel">
36
+ <h2>How to read this page</h2>
37
+ <ul>
38
+ <li>The page subscribes to <code>/api/events</code> (SSE) for real-time updates.</li>
39
+ <li>If the SSE connection fails, a 3-second polling fallback keeps the page fresh.</li>
40
+ <li>Useful endpoints:
41
+ <ul>
42
+ <li><a href="/api/sessions">/api/sessions</a> — JSON list of recorded sessions.</li>
43
+ <li><a href="/api/events">/api/events</a> — SSE event stream.</li>
44
+ <li><a href="/static/style.css">/static/style.css</a> — bundled CSS.</li>
45
+ <li><a href="/static/app.js">/static/app.js</a> — bundled JS.</li>
46
+ </ul>
47
+ </li>
48
+ </ul>
31
49
  </section>
32
50
  <script src="/static/app.js"></script>
33
51
  </body>
34
- </html>
52
+ </html>