@monster-orange/den 0.4.27 → 0.4.28

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.
@@ -109,13 +109,25 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
109
109
  ...(toolScope ? { scope: toolScope } : {}),
110
110
  };
111
111
 
112
+ // A pinned tool with no workspace parameter (no grants) still takes a
113
+ // `workspace` someone names, so that Den refuses it (callTool, scope.ts)
114
+ // instead of the SDK dropping the key and answering about this workspace
115
+ // (0.4.28-dev.0's gate: agent_helpers {workspace:"west"} answered for
116
+ // "default"). Not advertised; the one-shot path does the same.
117
+ const unpinned = new Map(toolsFor(opts.cfg).map((t) => [t.name, t]));
118
+ const schemaOf = (t: (typeof tools)[number]) =>
119
+ toolScope &&
120
+ !("workspace" in t.shape) &&
121
+ "workspace" in (unpinned.get(t.name)?.shape ?? {})
122
+ ? z.object(t.shape).passthrough()
123
+ : t.shape;
112
124
  for (const t of tools) {
113
125
  server.registerTool(
114
126
  t.name,
115
127
  {
116
128
  title: t.title,
117
129
  description: t.description,
118
- inputSchema: t.shape,
130
+ inputSchema: schemaOf(t),
119
131
  annotations: {
120
132
  readOnlyHint: !t.action,
121
133
  destructiveHint: t.action,
@@ -187,6 +187,55 @@ const read = (
187
187
  ): DenTool => ({ name, title, description, action: false, shape, run });
188
188
 
189
189
  const READ_TOOLS: DenTool[] = [
190
+ read(
191
+ "agent_helpers",
192
+ "An agent's helpers",
193
+ "D32: the helpers an agent started inside its own session (Claude Code's Agent and Workflow subagents), running first: id, description, type, model, branch, status (running, idle, done, failed, stopped), statusFrom (how that was told), startedAt, lastActivityAt, durationMs, workflow, and `earlier` for those from a conversation before a relaunch. With `helper`, that one helper's transcript, tool calls summarized (`items`, the last `limit` lines). Reads only: helpers belong to the agent's session, so nothing here stops or steers one.",
194
+ {
195
+ agent: z.string().describe("The agent's id"),
196
+ helper: z
197
+ .string()
198
+ .optional()
199
+ .describe("A helper's id: its transcript instead of the list"),
200
+ limit: z
201
+ .number()
202
+ .int()
203
+ .min(1)
204
+ .max(5000)
205
+ .optional()
206
+ .describe("With helper: how many of its last lines (default 500)"),
207
+ workspace: workspaceArg,
208
+ },
209
+ async (a, { api, scope }) => {
210
+ const agent = str(a.agent);
211
+ if (!agent || !NAME_RE.test(agent))
212
+ throw new Refusal("agent_helpers: agent must be an agent id");
213
+ const helper = str(a.helper);
214
+ if (helper !== undefined && !/^[A-Za-z0-9_-]{1,128}$/.test(helper))
215
+ throw new Refusal("agent_helpers: helper must be a helper id");
216
+ let ws = str(a.workspace) ?? scope?.workspace;
217
+ if (!ws) {
218
+ const focused = (await api.get("/api/workspaces")) as {
219
+ focus?: unknown;
220
+ };
221
+ ws = typeof focused?.focus === "string" ? focused.focus : undefined;
222
+ }
223
+ if (!ws || !NAME_RE.test(ws))
224
+ throw new Refusal("agent_helpers: name the agent's workspace");
225
+ if (scope && ws !== scope.workspace && !(scope.grants ?? []).includes(ws))
226
+ throw new ScopeRefusal(
227
+ `agent_helpers: workspace "${ws}" is outside this agent's scope ("${scope.workspace}")`,
228
+ );
229
+ const base = `/api/workspaces/${encodeURIComponent(ws)}/agents/${encodeURIComponent(agent)}/helpers`;
230
+ if (!helper) return api.get(base);
231
+ // The limit as a query, never in the path (the client's allowlist
232
+ // matches the path alone).
233
+ return api.get(
234
+ `${base}/${encodeURIComponent(helper)}`,
235
+ typeof a.limit === "number" ? { limit: String(a.limit) } : {},
236
+ );
237
+ },
238
+ ),
190
239
  read(
191
240
  "approval_check",
192
241
  "Check an approval",
@@ -1327,6 +1376,7 @@ const WORKSPACE_TOOLS = new Set([
1327
1376
  "run_tests",
1328
1377
  "agent_resume",
1329
1378
  "agent_import",
1379
+ "agent_helpers",
1330
1380
  ]);
1331
1381
 
1332
1382
  /**
@@ -113,6 +113,8 @@ import {
113
113
  createPersona,
114
114
  deletePersona,
115
115
  listAgents,
116
+ agentHelpers,
117
+ agentHelperTranscript,
116
118
  listPersonas,
117
119
  personaDetail,
118
120
  registries,
@@ -183,6 +185,9 @@ export interface AgentRouteDeps {
183
185
  const AGENTS_PATH =
184
186
  /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona|keep|handoff))?$/;
185
187
 
188
+ /** D32: an agent's helpers, and one helper's transcript (read-only). */
189
+ const HELPERS_PATH =
190
+ /^\/api\/workspaces\/([^/]+)\/agents\/([^/]+)\/helpers(?:\/([^/]+))?$/;
186
191
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
187
192
  const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
188
193
  const PLAIN_PATH = /^\/api\/workspaces\/([^/]+)\/sessions$/;
@@ -431,6 +436,8 @@ export async function handleAgentRoutes(
431
436
  pathname === "/api/agents/resume-interrupted";
432
437
  if (pathname === "/api/agents/rotation")
433
438
  return rotationRoute(req, res, webDeps(deps.agents));
439
+ const hp = HELPERS_PATH.exec(pathname);
440
+ if (hp) return helpersRoute(req, res, hp, webDeps(deps.agents));
434
441
  // HUB-1.5: Settings › Hub. Never the token: hubStatus doesn't return it.
435
442
  if (pathname === "/api/hub") {
436
443
  const method = req.method ?? "GET";
@@ -438,8 +445,14 @@ export async function handleAgentRoutes(
438
445
  return methodNotAllowed(res, "GET");
439
446
  const { hubStatus } = await import("../../shared/hub.js");
440
447
  const st = await (deps.hubStatus ?? (() => hubStatus()))();
441
- // HUB-1.7: what the last Connect came to, once (words only).
442
- const last = (deps.hubConnect ?? defaultConnect)?.lastResult();
448
+ // HUB-1.7: what the last Connect came to, once (words only). The
449
+ // rail's look (?rail=1) leaves it for Settings, which shows it.
450
+ const rail =
451
+ new URL(req.url ?? "/", "http://localhost").searchParams.get("rail") ===
452
+ "1";
453
+ const last = rail
454
+ ? null
455
+ : (deps.hubConnect ?? defaultConnect)?.lastResult();
443
456
  sendJson(res, last ? { ...st, connect: last } : st);
444
457
  return true;
445
458
  }
@@ -1459,3 +1472,44 @@ async function hubConnectRoute(
1459
1472
  sendJson(res, r, r.ok ? 200 : 502);
1460
1473
  return true;
1461
1474
  }
1475
+
1476
+ /**
1477
+ * D32: GET .../agents/:id/helpers: the helpers agent :id started in its
1478
+ * own session; .../helpers/:helper: one helper's transcript (?limit=).
1479
+ * Read-only: nothing here stops or steers a helper.
1480
+ */
1481
+ async function helpersRoute(
1482
+ req: http.IncomingMessage,
1483
+ res: http.ServerResponse,
1484
+ hp: RegExpExecArray,
1485
+ d: AgentsResolved,
1486
+ ): Promise<true> {
1487
+ const method = req.method ?? "GET";
1488
+ if (method !== "GET" && method !== "HEAD")
1489
+ return methodNotAllowed(res, "GET");
1490
+ const ws = safeDecode(hp[1]);
1491
+ const id = safeDecode(hp[2]);
1492
+ const helper = hp[3] === undefined ? undefined : safeDecode(hp[3]);
1493
+ try {
1494
+ if (!d.workspaceExists(ws))
1495
+ throw new AgentError(`workspace "${ws}" does not exist`, { status: 404 });
1496
+ if (helper === undefined) {
1497
+ const r = await agentHelpers(d, id, ws);
1498
+ sendJson(res, {
1499
+ agent: r.rec.id,
1500
+ workspace: ws,
1501
+ supported: r.supported,
1502
+ live: r.live,
1503
+ helpers: r.helpers,
1504
+ note: r.note,
1505
+ });
1506
+ return true;
1507
+ }
1508
+ const q = new URL(req.url ?? "/", "http://localhost").searchParams;
1509
+ const limit = Number(q.get("limit") ?? "") || undefined;
1510
+ sendJson(res, await agentHelperTranscript(d, id, helper, ws, limit));
1511
+ } catch (e) {
1512
+ sendError(res, e, ws);
1513
+ }
1514
+ return true;
1515
+ }
@@ -40,6 +40,8 @@ import {
40
40
  type HubGrant,
41
41
  } from "../../shared/hub.js";
42
42
  import { readUserConfig, writeUserConfig } from "../../shared/den-config.js";
43
+ import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
44
+ import { webRouteHosts } from "../../shared/edge/render.js";
43
45
  import { getStateDir } from "../../shared/paths.js";
44
46
  import { envKey, product } from "../../shared/product.js";
45
47
  import {
@@ -81,6 +83,60 @@ export interface HubConnectDeps {
81
83
  writeUser: (u: Record<string, unknown>) => void;
82
84
  /** This install's label on Hub (1–40 characters). */
83
85
  installLabel: () => string;
86
+ /**
87
+ * This Den's https edge address, when it has one (Connect's den_url:
88
+ * where Hub links back to it when the browser came over loopback).
89
+ */
90
+ denUrl?: () => string | undefined;
91
+ }
92
+
93
+ /** A `<name>.localhost` host: this machine (RFC 6761). */
94
+ export const isDotLocalhost = (host: string) =>
95
+ host.toLowerCase().endsWith(".localhost");
96
+
97
+ /**
98
+ * `u` as an origin Hub takes for den_url, else null: plain (no user, path,
99
+ * query or hash), and https or http to a `<name>.localhost`.
100
+ */
101
+ export function denUrlOrigin(u: string): string | null {
102
+ let x: URL;
103
+ try {
104
+ x = new URL(u);
105
+ } catch {
106
+ return null;
107
+ }
108
+ if (
109
+ !(
110
+ x.protocol === "https:" ||
111
+ (x.protocol === "http:" && isDotLocalhost(x.hostname))
112
+ ) ||
113
+ x.username ||
114
+ x.password ||
115
+ (x.pathname !== "/" && x.pathname !== "") ||
116
+ x.search ||
117
+ x.hash
118
+ )
119
+ return null;
120
+ return x.origin;
121
+ }
122
+
123
+ /** Of edge addresses, the den_url to send: https first, else a .localhost one. */
124
+ export function pickDenUrl(urls: readonly string[]): string | undefined {
125
+ const all = urls.map((u) => denUrlOrigin(u)).filter((o): o is string => !!o);
126
+ return all.find((o) => o.startsWith("https:")) ?? all[0];
127
+ }
128
+
129
+ /** This server's edge address for den_url (pickDenUrl of its edge routes). */
130
+ export function edgeDenUrl(
131
+ port = Number(process.env.PORT) || 0,
132
+ ): string | undefined {
133
+ try {
134
+ const ports = new Set([readWebPort(), ...(port ? [port] : [])]);
135
+ return pickDenUrl(webRouteHosts(readEdgeConfig(), ports).map((r) => r.url));
136
+ } catch {
137
+ /* no edge config: none */
138
+ }
139
+ return undefined;
84
140
  }
85
141
 
86
142
  /** `<cli> on <machine>`: how Hub lists this install (Hub's label rule). */
@@ -199,10 +255,11 @@ export function defaultHubConnectDeps(): HubConnectDeps {
199
255
  readUser: () => readUserConfig() as Record<string, unknown>,
200
256
  writeUser: (u) => writeUserConfig(u as never),
201
257
  installLabel: () => defaultInstallLabel(),
258
+ denUrl: () => edgeDenUrl(),
202
259
  };
203
260
  }
204
261
 
205
- /** An origin Hub will send the person back to: https, or loopback http. */
262
+ /** An origin Hub will send the person back to: https, or loopback http (incl. *.localhost). */
206
263
  export function callbackOriginProblem(origin: string): string | null {
207
264
  let u: URL;
208
265
  try {
@@ -210,9 +267,13 @@ export function callbackOriginProblem(origin: string): string | null {
210
267
  } catch {
211
268
  return "this page's address isn't one Hub can send you back to";
212
269
  }
213
- const loop = u.hostname === "localhost" || u.hostname === "127.0.0.1";
270
+ // Loopback: localhost, 127.0.0.1, and any <name>.localhost (RFC 6761:
271
+ // it never leaves this machine, and browsers count it a secure context),
272
+ // which is how an edge on this machine is often reached over http.
273
+ const h = u.hostname.toLowerCase();
274
+ const loop = h === "localhost" || h === "127.0.0.1" || isDotLocalhost(h);
214
275
  if (u.protocol === "https:" || (u.protocol === "http:" && loop)) return null;
215
- return `Hub can only send you back to https or to localhost; open ${product().title} over https (its edge address) or on this machine, then Connect`;
276
+ return `Hub can only send you back to https or to this machine (localhost or a .localhost name); open ${product().title} over https (its edge address) or on this machine, then Connect`;
216
277
  }
217
278
 
218
279
  export type ConnectResult =
@@ -268,6 +329,11 @@ export class HubConnect {
268
329
  code_challenge: challenge,
269
330
  code_challenge_method: "S256",
270
331
  });
332
+ // Hub takes only a plain https or <name>.localhost address (anything
333
+ // else is a 400), and uses it only when redirect_uri is loopback. A
334
+ // reconnect sends it again.
335
+ const denUrl = denUrlOrigin(this.deps.denUrl?.() ?? "");
336
+ if (denUrl) q.set("den_url", denUrl);
271
337
  return { ok: true, location: `${hubUrl}/connect?${q}` };
272
338
  }
273
339
 
@@ -895,6 +895,7 @@ import {
895
895
  handleSessionUpgrade,
896
896
  isSessionIoPath,
897
897
  sendSessionHttp,
898
+ SESSION_HELPERS_PATH,
898
899
  } from "./session-routes.js";
899
900
  import { refuseUpgrade } from "./websocket.js";
900
901
  import {
@@ -2124,7 +2125,12 @@ async function handleRequest(
2124
2125
  // AG-4a-term's session routes (`/api/sessions/<id>/io`): shell-grade, and
2125
2126
  // an agent's token reaches its own session's only (request-guard). The io
2126
2127
  // is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
2127
- if (pathname.startsWith("/api/sessions/")) {
2128
+ // D32's /api/sessions/helpers (a count, read-grade) is not a session's:
2129
+ // the list's handler below answers it.
2130
+ if (
2131
+ pathname.startsWith("/api/sessions/") &&
2132
+ pathname !== SESSION_HELPERS_PATH
2133
+ ) {
2128
2134
  await sendSessionHttp(req, res, pathname);
2129
2135
  return;
2130
2136
  }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * D32: the helpers an agent starts in its own session (Claude Code's Agent
3
+ * and Workflow subagents), read-only. Den shows them; they belong to the
4
+ * agent's session, so nothing here stops or steers one (and the views say
5
+ * so).
6
+ *
7
+ * helperRowsHtml(row): under an agent's row on the Agents page, its
8
+ * recent helpers (the row's `helpers.recent`: running, or ended a short
9
+ * while ago) and "All helpers (n)".
10
+ * openHelpers({ ws, agent, name, opener }): every helper, this
11
+ * conversation's first and earlier ones folded under "Earlier (n)";
12
+ * each opens its transcript (transcript-view.js).
13
+ * openHelperTranscript({ ws, agent, helper, opener }).
14
+ */
15
+ import { focusBack, openTranscript, refindKey } from "/transcript-view.js";
16
+
17
+ const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
18
+ const esc = (s) =>
19
+ String(s ?? "").replace(
20
+ /[&<>"']/g,
21
+ (c) =>
22
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
23
+ c
24
+ ],
25
+ );
26
+ const api = (method, url) => window.denShell.api(method, url);
27
+ const base = (ws, agent) =>
28
+ `/api/workspaces/${encodeURIComponent(ws)}/agents/${encodeURIComponent(agent)}/helpers`;
29
+
30
+ /** A dot's tone for a helper's status (the shell's .den-dot tones). */
31
+ export function helperTone(status) {
32
+ return status === "running"
33
+ ? "ok"
34
+ : status === "failed"
35
+ ? "bad"
36
+ : status === "idle" || status === "stopped"
37
+ ? "warn"
38
+ : "off";
39
+ }
40
+
41
+ /** 65 s → "1m 5s". */
42
+ export function dur(ms) {
43
+ if (typeof ms !== "number" || ms < 0) return "";
44
+ const s = Math.round(ms / 1000);
45
+ if (s < 60) return `${s}s`;
46
+ const m = Math.floor(s / 60);
47
+ if (m < 60) return `${m}m ${s % 60}s`;
48
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
49
+ }
50
+
51
+ const label = (h) => h.description || h.workflow?.label || h.id;
52
+
53
+ /** One helper as a button row (opens its transcript). */
54
+ /**
55
+ * How far each helper of `list` (in sortHelpers' order) is indented: one
56
+ * step under a parent shown just above it, none when its parent isn't in
57
+ * the list (folded away, or another conversation's).
58
+ */
59
+ export function shownDepths(list) {
60
+ const at = new Map();
61
+ for (const h of list || [])
62
+ at.set(h.id, h.parentId && at.has(h.parentId) ? at.get(h.parentId) + 1 : 0);
63
+ return at;
64
+ }
65
+
66
+ /** A list of helpers as rows, each indented under a parent it shows. */
67
+ export function helperListHtml(list, ctx) {
68
+ const d = shownDepths(list);
69
+ return (list || [])
70
+ .map((h) => helperHtml(h, { ...ctx, depth: d.get(h.id) ?? 0 }))
71
+ .join("");
72
+ }
73
+
74
+ export function helperHtml(h, { ws, agent, depth }) {
75
+ const meta = [
76
+ h.workflow
77
+ ? t("hp.workflow", { name: h.workflow.name || h.workflow.runId })
78
+ : h.type,
79
+ h.model,
80
+ h.branch,
81
+ ]
82
+ .filter(Boolean)
83
+ .join(" · ");
84
+ const st = t(`hp.st.${h.status}`);
85
+ const when = [
86
+ h.durationMs !== undefined ? dur(h.durationMs) : "",
87
+ h.lastTool && h.status === "running" ? h.lastTool : "",
88
+ ]
89
+ .filter(Boolean)
90
+ .join(" · ");
91
+ return (
92
+ `<button type="button" class="agh" data-helper="${esc(h.id)}" data-agent="${esc(agent)}" data-ws="${esc(ws)}" data-status="${esc(h.status)}"` +
93
+ ` style="--hp-depth:${Math.max(0, Math.min(8, depth ?? (h.depth || 1) - 1))}"` +
94
+ ` aria-label="${esc(t("hp.open", { what: label(h), status: st }))}" title="${esc(h.statusFrom ? `${st}: ${h.statusFrom}` : st)}">` +
95
+ `<span class="den-dot ${helperTone(h.status)}" aria-hidden="true"></span>` +
96
+ `<span class="agh-n">${esc(label(h))}</span>` +
97
+ `<span class="agh-s">${esc(st)}${when ? ` · ${esc(when)}` : ""}</span>` +
98
+ (meta ? `<span class="agh-m mono">${esc(meta)}</span>` : "") +
99
+ `</button>`
100
+ );
101
+ }
102
+
103
+ /** Under an agent's row: its recent helpers and "All helpers (n)". */
104
+ export function helperRowsHtml(r) {
105
+ const h = r?.helpers;
106
+ if (!h || !h.total) return "";
107
+ const ctx = { ws: r.workspace, agent: r.agent };
108
+ return (
109
+ `<div class="aghs" data-helpers-of="${esc(r.agent)}" data-ws="${esc(r.workspace)}" role="group" aria-label="${esc(t("hp.groupOf", { name: r.displayName || r.agent }))}">` +
110
+ helperListHtml(h.recent, ctx) +
111
+ `<button type="button" class="lnk aghs-all">` +
112
+ esc(t("hp.all", { n: h.total })) +
113
+ (h.running
114
+ ? ` <span class="hpbdg" aria-label="${esc(t("hp.runningN", { n: h.running }))}">${esc(h.running)}</span>`
115
+ : "") +
116
+ `</button></div>`
117
+ );
118
+ }
119
+
120
+ /** "Helpers (n)" for a pane's bar: a count badge while any run. */
121
+ export function helperBarButtonHtml(r) {
122
+ const h = r?.helpers;
123
+ if (!h || !h.total) return "";
124
+ return (
125
+ `<button type="button" class="den-btn sm" data-act="helpers" aria-haspopup="dialog"` +
126
+ ` aria-label="${esc(t(h.running ? "hp.barRunning" : "hp.bar", { n: h.total, r: h.running }))}">` +
127
+ esc(t("hp.title", { n: h.total })) +
128
+ (h.running
129
+ ? ` <span class="hpbdg" aria-hidden="true">${esc(h.running)}</span>`
130
+ : "") +
131
+ `</button>`
132
+ );
133
+ }
134
+
135
+ const CSS = `
136
+ .hp-dlg{font-family:var(--sans);width:min(760px,calc(100vw - 32px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--border-strong);border-radius:10px;background:var(--raised, var(--surface));color:var(--text);box-shadow:var(--shadow)}
137
+ .hp-dlg::backdrop{background:rgba(0,0,0,.35)}
138
+ .hp-hd{display:flex;gap:8px;align-items:baseline;padding:12px 16px;border-bottom:1px solid var(--border)}
139
+ .hp-hd h2{font-size:15px;margin:0;outline:none;min-width:0;overflow-wrap:anywhere}
140
+ .hp-hd .x{margin-left:auto;flex:none}
141
+ .hp-body{padding:8px 12px;overflow:auto;max-height:calc(100vh - 200px)}
142
+ .hp-note{margin:0;padding:8px 16px 12px;color:var(--muted);font-size:12px;border-top:1px solid var(--border)}
143
+ .hp-none{color:var(--muted);font-size:13px;padding:8px 4px}
144
+ .hp-earlier{margin-top:8px}
145
+ .hp-earlier summary{cursor:pointer;color:var(--muted);font-size:13px;padding:4px}
146
+ .aghs{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px 18px;padding-left:8px;border-left:2px solid var(--border)}
147
+ .agh{all:unset;box-sizing:border-box;display:grid;grid-template-columns:auto 1fr auto;gap:2px 8px;align-items:center;width:100%;padding:4px 6px 4px calc(6px + var(--hp-depth,0) * 14px);border-radius:var(--radius-button,6px);cursor:pointer;font-size:12px;color:var(--text)}
148
+ .agh:hover{background:var(--sunken)}
149
+ .agh:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
150
+ .agh-n{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
151
+ .agh-s{color:var(--muted);white-space:nowrap}
152
+ .agh-m{grid-column:2 / 4;color:var(--faint);font-size:11px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
153
+ .agh[data-status="running"] .agh-s{color:var(--text)}
154
+ .aghs-all{align-self:flex-start;font-size:12px;margin:2px 6px}
155
+ .hpbdg{display:inline-block;min-width:16px;padding:0 5px;border-radius:8px;background:var(--accent);color:var(--accent-text);font-size:11px;line-height:16px;text-align:center}
156
+ `;
157
+
158
+ let dlg = null;
159
+ let opener = null;
160
+ let openerKey = "";
161
+
162
+ function ensureCss() {
163
+ if (document.getElementById("hp-css")) return;
164
+ const s = document.createElement("style");
165
+ s.id = "hp-css";
166
+ s.textContent = CSS;
167
+ document.head.append(s);
168
+ }
169
+
170
+ function dialog() {
171
+ ensureCss();
172
+ if (dlg && dlg.isConnected) return dlg;
173
+ dlg = document.createElement("dialog");
174
+ dlg.className = "hp-dlg";
175
+ dlg.id = "hp-dlg";
176
+ dlg.setAttribute("aria-labelledby", "hp-h");
177
+ dlg.innerHTML =
178
+ `<div class="hp-hd"><h2 id="hp-h" tabindex="-1"></h2>` +
179
+ `<button type="button" class="den-btn sm x" data-hp-close></button></div>` +
180
+ `<div class="hp-body" id="hp-body" aria-live="polite"></div>` +
181
+ `<p class="hp-note" id="hp-note"></p>`;
182
+ dlg.querySelector("[data-hp-close]").addEventListener("click", () => {
183
+ if (dlg.open && typeof dlg.close === "function") dlg.close();
184
+ else dlg.removeAttribute("open");
185
+ });
186
+ dlg.addEventListener("close", () => {
187
+ const o = opener;
188
+ const k = openerKey;
189
+ opener = null;
190
+ openerKey = "";
191
+ // The Agents page's poll may have replaced it meanwhile: find it again.
192
+ focusBack(o, k);
193
+ });
194
+ // A helper opens its transcript over this list.
195
+ dlg.addEventListener("click", (e) => {
196
+ const b = e.target.closest("button.agh");
197
+ if (b)
198
+ void openHelperTranscript({
199
+ ws: b.dataset.ws,
200
+ agent: b.dataset.agent,
201
+ helper: b.dataset.helper,
202
+ opener: b,
203
+ });
204
+ });
205
+ document.body.append(dlg);
206
+ return dlg;
207
+ }
208
+
209
+ /** Every helper of agent `agent` (see the file's comment). */
210
+ export async function openHelpers({ ws, agent, name, opener: from = null }) {
211
+ const d = dialog();
212
+ opener = from ?? document.activeElement;
213
+ openerKey = refindKey(opener);
214
+ d.querySelector("#hp-h").textContent = t("hp.dlg", { name: name || agent });
215
+ d.querySelector("[data-hp-close]").textContent = t("tx.close");
216
+ d.querySelector("#hp-note").textContent = t("hp.note");
217
+ const body = d.querySelector("#hp-body");
218
+ body.setAttribute("aria-busy", "true");
219
+ body.innerHTML = `<p class="hp-none">${esc(t("tx.loading"))}</p>`;
220
+ if (!d.open) {
221
+ if (typeof d.showModal === "function") d.showModal();
222
+ else d.setAttribute("open", "");
223
+ }
224
+ d.querySelector("#hp-h").focus();
225
+ try {
226
+ const r = await api("GET", base(ws, agent));
227
+ const all = Array.isArray(r?.helpers) ? r.helpers : [];
228
+ const now = all.filter((h) => !h.earlier);
229
+ const before = all.filter((h) => h.earlier);
230
+ const ctx = { ws, agent };
231
+ body.innerHTML =
232
+ (r?.supported === false
233
+ ? `<p class="hp-none">${esc(t("hp.unsupported"))}</p>`
234
+ : now.length
235
+ ? helperListHtml(now, ctx)
236
+ : `<p class="hp-none">${esc(t("hp.none"))}</p>`) +
237
+ (before.length
238
+ ? `<details class="hp-earlier"><summary>${esc(t("hp.earlier", { n: before.length }))}</summary>` +
239
+ helperListHtml(before, ctx) +
240
+ `</details>`
241
+ : "");
242
+ } catch (e) {
243
+ body.innerHTML = `<p class="hp-none" role="alert">${esc(t("tx.failed", { err: e?.message || String(e) }))}</p>`;
244
+ } finally {
245
+ body.removeAttribute("aria-busy");
246
+ }
247
+ }
248
+
249
+ /** One helper's transcript, in the shared transcript view. */
250
+ export function openHelperTranscript({ ws, agent, helper, opener: from }) {
251
+ let head = null;
252
+ return openTranscript({
253
+ title: t("tx.loading"),
254
+ note: t("hp.note"),
255
+ opener: from,
256
+ load: async () => {
257
+ const r = await api(
258
+ "GET",
259
+ `${base(ws, agent)}/${encodeURIComponent(helper)}`,
260
+ );
261
+ head = r?.helper;
262
+ const h2 = document.getElementById("tx-h");
263
+ const sub = document.getElementById("tx-sub");
264
+ if (h2 && head) h2.textContent = label(head);
265
+ if (sub && head)
266
+ sub.textContent = [
267
+ t(`hp.st.${head.status}`),
268
+ head.workflow
269
+ ? t("hp.workflow", {
270
+ name: head.workflow.name || head.workflow.runId,
271
+ })
272
+ : head.type,
273
+ head.model,
274
+ head.branch,
275
+ head.durationMs !== undefined ? dur(head.durationMs) : "",
276
+ ]
277
+ .filter(Boolean)
278
+ .join(" · ");
279
+ return { items: r?.items ?? [], omitted: r?.omitted ?? 0 };
280
+ },
281
+ });
282
+ }
283
+
284
+ /** Wire helper rows and "All helpers" inside `root` (a list's container). */
285
+ export function wireHelperRows(root, nameOf = (a) => a) {
286
+ ensureCss();
287
+ root.addEventListener("click", (e) => {
288
+ const row = e.target.closest("button.agh");
289
+ if (row && root.contains(row)) {
290
+ e.stopPropagation();
291
+ void openHelperTranscript({
292
+ ws: row.dataset.ws,
293
+ agent: row.dataset.agent,
294
+ helper: row.dataset.helper,
295
+ opener: row,
296
+ });
297
+ return;
298
+ }
299
+ const all = e.target.closest(".aghs-all");
300
+ if (all && root.contains(all)) {
301
+ e.stopPropagation();
302
+ const g = all.closest(".aghs");
303
+ void openHelpers({
304
+ ws: g.dataset.ws,
305
+ agent: g.dataset.helpersOf,
306
+ name: nameOf(g.dataset.helpersOf, g.dataset.ws),
307
+ opener: all,
308
+ });
309
+ }
310
+ });
311
+ }