@monster-orange/den 0.4.26 → 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.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * A read-only transcript view, shared (D32: a helper's transcript; later
3
+ * an agent's own). Items are { at?, kind: prompt|text|tool|result|note,
4
+ * tool?, text, error? }, as the server summarizes them: tool calls in a few
5
+ * words, their results under them, never thinking.
6
+ *
7
+ * openTranscript({ title, subtitle?, note?, load, opener? }) shows it in a
8
+ * modal <dialog> of its own (made once, kept in <body>): labelled, focus on
9
+ * its heading, Escape or Close gives focus back to the opener. `load()`
10
+ * resolves { items, omitted } (or rejects with an Error to show).
11
+ */
12
+
13
+ const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
14
+ const esc = (s) =>
15
+ String(s ?? "").replace(
16
+ /[&<>"']/g,
17
+ (c) =>
18
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
19
+ c
20
+ ],
21
+ );
22
+
23
+ const time = (ms) => {
24
+ if (typeof ms !== "number") return "";
25
+ try {
26
+ return new Date(ms).toLocaleTimeString([], {
27
+ hour: "2-digit",
28
+ minute: "2-digit",
29
+ second: "2-digit",
30
+ });
31
+ } catch {
32
+ return "";
33
+ }
34
+ };
35
+
36
+ /** The items as a list (no dialog): for a page that shows them in place. */
37
+ export function transcriptHtml(items, { omitted = 0 } = {}) {
38
+ const rows = (items ?? []).map((i) => {
39
+ const at = i.at
40
+ ? `<time class="tx-at" datetime="${esc(new Date(i.at).toISOString())}">${esc(time(i.at))}</time>`
41
+ : "";
42
+ if (i.kind === "tool")
43
+ return `<li class="tx-i tx-tool">${at}<span class="tx-k">${esc(i.tool || "tool")}</span><span class="tx-t mono">${esc(i.text)}</span></li>`;
44
+ if (i.kind === "result")
45
+ return `<li class="tx-i tx-res${i.error ? " err" : ""}">${at}<span class="tx-k" aria-label="${esc(t(i.error ? "tx.error" : "tx.result"))}">${i.error ? "✗" : "←"}</span><span class="tx-t mono">${esc(i.text)}</span></li>`;
46
+ if (i.kind === "prompt")
47
+ return `<li class="tx-i tx-prompt">${at}<span class="tx-k">${esc(t("tx.asked"))}</span><span class="tx-t">${esc(i.text)}</span></li>`;
48
+ if (i.kind === "note")
49
+ return `<li class="tx-i tx-note"><span class="tx-t">${esc(i.text)}</span></li>`;
50
+ return `<li class="tx-i tx-text">${at}<span class="tx-t">${esc(i.text)}</span></li>`;
51
+ });
52
+ return (
53
+ (omitted
54
+ ? `<p class="tx-omit">${esc(t("tx.omitted", { n: omitted }))}</p>`
55
+ : "") +
56
+ (rows.length
57
+ ? `<ol class="tx-list">${rows.join("")}</ol>`
58
+ : `<p class="tx-empty">${esc(t("tx.empty"))}</p>`)
59
+ );
60
+ }
61
+
62
+ const CSS = `
63
+ .tx-dlg{font-family:var(--sans);box-shadow:var(--shadow);width:min(860px,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)}
64
+ .tx-dlg::backdrop{background:rgba(0,0,0,.35)}
65
+ .tx-hd{display:flex;gap:8px;align-items:baseline;padding:12px 16px;border-bottom:1px solid var(--border)}
66
+ .tx-hd h2{font-size:15px;margin:0;outline:none;min-width:0;overflow-wrap:anywhere}
67
+ .tx-sub{color:var(--muted);font-size:12px;min-width:0;overflow-wrap:anywhere}
68
+ .tx-hd .x{margin-left:auto;flex:none}
69
+ .tx-body{padding:8px 16px 4px;overflow:auto;max-height:calc(100vh - 200px)}
70
+ .tx-note{margin:0;padding:8px 16px 12px;color:var(--muted);font-size:12px;border-top:1px solid var(--border)}
71
+ .tx-list{list-style:none;margin:0;padding:0}
72
+ .tx-i{display:grid;grid-template-columns:auto auto 1fr;gap:4px 8px;padding:4px 0;font-size:13px;align-items:baseline;border-bottom:1px dashed var(--border)}
73
+ .tx-i:last-child{border-bottom:0}
74
+ .tx-at{color:var(--faint);font-size:11px;font-variant-numeric:tabular-nums}
75
+ .tx-k{color:var(--muted);font-weight:600;font-size:12px}
76
+ .tx-t{grid-column:3;min-width:0;overflow-wrap:anywhere;white-space:pre-wrap}
77
+ .tx-text .tx-t,.tx-prompt .tx-t,.tx-note .tx-t{grid-column:2 / 4}
78
+ .tx-text:not(:has(.tx-at)) .tx-t,.tx-note .tx-t{grid-column:1 / 4}
79
+ .tx-t.mono{font-family:var(--mono);font-size:12px}
80
+ .tx-res .tx-t{color:var(--muted)}
81
+ .tx-res.err .tx-k,.tx-res.err .tx-t{color:var(--bad)}
82
+ .tx-omit,.tx-empty{color:var(--muted);font-size:12px}
83
+ @media (max-width:520px){.tx-i{grid-template-columns:auto 1fr}.tx-at{display:none}.tx-t{grid-column:2}.tx-text .tx-t,.tx-prompt .tx-t{grid-column:1 / 3}}
84
+ `;
85
+
86
+ let dlg = null;
87
+ let opener = null;
88
+ let openerKey = "";
89
+
90
+ const q = (v) =>
91
+ typeof CSS !== "undefined" && CSS.escape
92
+ ? CSS.escape(String(v))
93
+ : String(v).replace(/["\\]/g, "\\$&");
94
+
95
+ /**
96
+ * How to find `el` again once a page redraws it (the Agents page's poll
97
+ * replaces its rows and bars): a helper's row, an agent's "All helpers",
98
+ * a pane's Helpers button. "" for anything else.
99
+ */
100
+ export function refindKey(el) {
101
+ if (!el || !el.matches) return "";
102
+ if (el.matches("button.agh[data-helper]"))
103
+ return `button.agh[data-helper="${q(el.dataset.helper)}"][data-agent="${q(el.dataset.agent)}"]`;
104
+ const g = el.closest?.(".aghs[data-helpers-of]");
105
+ if (g && el.matches(".aghs-all"))
106
+ return `.aghs[data-helpers-of="${q(g.dataset.helpersOf)}"][data-ws="${q(g.dataset.ws)}"] .aghs-all`;
107
+ const pane = el.closest?.(".pane[data-id]");
108
+ if (pane && el.matches('[data-act="helpers"]'))
109
+ return `.pane[data-id="${q(pane.dataset.id)}"] [data-act="helpers"]`;
110
+ return "";
111
+ }
112
+
113
+ /** Focus `el`, or, once it is gone, what `key` finds now (refindKey). */
114
+ export function focusBack(el, key) {
115
+ if (el?.isConnected) return void el.focus?.();
116
+ if (!key) return;
117
+ const now = Array.from(document.querySelectorAll(key)).find(
118
+ (x) => !x.closest("dialog"),
119
+ );
120
+ now?.focus?.();
121
+ }
122
+
123
+ function dialog() {
124
+ if (dlg && dlg.isConnected) return dlg;
125
+ if (!document.getElementById("tx-css")) {
126
+ const s = document.createElement("style");
127
+ s.id = "tx-css";
128
+ s.textContent = CSS;
129
+ document.head.append(s);
130
+ }
131
+ dlg = document.createElement("dialog");
132
+ dlg.className = "tx-dlg";
133
+ dlg.id = "tx-dlg";
134
+ dlg.setAttribute("aria-labelledby", "tx-h");
135
+ dlg.innerHTML =
136
+ `<div class="tx-hd"><h2 id="tx-h" tabindex="-1"></h2><span class="tx-sub" id="tx-sub"></span>` +
137
+ `<button type="button" class="den-btn sm x" data-tx-close></button></div>` +
138
+ `<div class="tx-body" id="tx-body" aria-live="polite"></div>` +
139
+ `<p class="tx-note" id="tx-note" hidden></p>`;
140
+ dlg.querySelector("[data-tx-close]").addEventListener("click", () => close());
141
+ dlg.addEventListener("close", () => {
142
+ const o = opener;
143
+ const k = openerKey;
144
+ opener = null;
145
+ openerKey = "";
146
+ focusBack(o, k);
147
+ });
148
+ document.body.append(dlg);
149
+ return dlg;
150
+ }
151
+
152
+ function close() {
153
+ if (!dlg) return;
154
+ if (typeof dlg.close === "function" && dlg.open) dlg.close();
155
+ else {
156
+ dlg.removeAttribute("open");
157
+ dlg.dispatchEvent(new Event("close"));
158
+ }
159
+ }
160
+
161
+ /** Show a transcript (see the file's comment). Resolves once it is drawn. */
162
+ export async function openTranscript({
163
+ title,
164
+ subtitle = "",
165
+ note = "",
166
+ load,
167
+ opener: from = null,
168
+ }) {
169
+ const d = dialog();
170
+ opener = from ?? document.activeElement;
171
+ openerKey = refindKey(opener);
172
+ d.querySelector("#tx-h").textContent = title;
173
+ d.querySelector("#tx-sub").textContent = subtitle;
174
+ const x = d.querySelector("[data-tx-close]");
175
+ x.textContent = t("tx.close");
176
+ const n = d.querySelector("#tx-note");
177
+ n.textContent = note;
178
+ n.hidden = !note;
179
+ const body = d.querySelector("#tx-body");
180
+ body.setAttribute("aria-busy", "true");
181
+ body.innerHTML = `<p class="tx-empty">${esc(t("tx.loading"))}</p>`;
182
+ if (!d.open) {
183
+ if (typeof d.showModal === "function") d.showModal();
184
+ else d.setAttribute("open", "");
185
+ }
186
+ d.querySelector("#tx-h").focus();
187
+ try {
188
+ const r = await load();
189
+ body.innerHTML = transcriptHtml(r?.items, { omitted: r?.omitted ?? 0 });
190
+ // The newest lines are what a person opens it for.
191
+ body.scrollTop = body.scrollHeight;
192
+ } catch (e) {
193
+ body.innerHTML = `<p class="tx-empty" role="alert">${esc(t("tx.failed", { err: e?.message || String(e) }))}</p>`;
194
+ } finally {
195
+ body.removeAttribute("aria-busy");
196
+ }
197
+ }
198
+
199
+ /** Close the transcript view, if open (tests, page changes). */
200
+ export function closeTranscript() {
201
+ close();
202
+ }
@@ -707,7 +707,9 @@ export function agentRefusal(
707
707
  AGENT_READ_ONLY.some((r) => pathMatches(r, pathname)))
708
708
  )
709
709
  return `${p.actor} can't use ${pathname}: signing in, web.secret and the admin password are a person's.`;
710
- const m = SESSION_ROUTE.exec(pathname);
710
+ // D32's helpers count is the list's, no session's: read-grade.
711
+ const m =
712
+ pathname === "/api/sessions/helpers" ? null : SESSION_ROUTE.exec(pathname);
711
713
  if (m) {
712
714
  let id = m[1];
713
715
  try {
@@ -172,6 +172,18 @@ export const ROUTES: readonly RouteEntry[] = [
172
172
  "/api/workspaces/:ws/agents/:id/paths",
173
173
  "which paths an agent printed exist in its workspace (stat only, AG-4b)",
174
174
  ),
175
+ R(
176
+ "/api/workspaces/:ws/agents/:id/helpers",
177
+ "D32: the helpers an agent started in its own session, read from the runtime's files (never written)",
178
+ ),
179
+ R(
180
+ "/api/workspaces/:ws/agents/:id/helpers/:helper",
181
+ "D32: one helper's transcript, tool calls summarized, read-only (no stop or steer)",
182
+ ),
183
+ R(
184
+ "/api/sessions/helpers",
185
+ "D32: how many helpers the agents have running (a count, for the rail's badge)",
186
+ ),
175
187
  R(
176
188
  "/api/sessions",
177
189
  "lists the sessions and agents (names and states, never their output)",
@@ -392,6 +404,20 @@ export const ROUTES: readonly RouteEntry[] = [
392
404
  "/api/hub",
393
405
  "HUB-1.5: Den Hub: its URL, connected or not, the team and the install's label (never the token)",
394
406
  ),
407
+ M(
408
+ ["POST"],
409
+ "/api/hub/connect",
410
+ "HUB-1.7: start Connect to Hub: a PKCE verifier and single-use state kept here; answers Hub's /connect URL",
411
+ ),
412
+ M(
413
+ ["POST"],
414
+ "/api/hub/disconnect",
415
+ "HUB-1.7: revoke this install's Hub token on Hub, then remove it here",
416
+ ),
417
+ R(
418
+ "/hub/callback",
419
+ "HUB-1.7: Hub sends the person back here. Sign-in free: the session cookie is SameSite=Strict, so Hub's redirect doesn't carry it. It acts only for a state this server issued to a signed-in Connect (single-use, 10 min), with the PKCE verifier only this server holds; anything else changes nothing",
420
+ ),
395
421
  M(
396
422
  ["PUT"],
397
423
  "/api/agents/rotation",
@@ -488,6 +514,9 @@ export const SIGNIN_FREE: readonly string[] = [
488
514
  // SEC-2a: approve this computer with a code, from the signed-out page.
489
515
  "/api/signin/code",
490
516
  "/api/signin/code/:id",
517
+ // HUB-1.7: Hub's redirect back (no session cookie on it): the state
518
+ // this server issued is the proof (hub-connect.ts).
519
+ "/hub/callback",
491
520
  ];
492
521
 
493
522
  /**
@@ -533,6 +562,10 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
533
562
  "agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
534
563
  "agent-routes.ts:PERSONA_PATH": ["/api/personas/:id"],
535
564
  "agent-routes.ts:PLAIN_PATH": ["/api/workspaces/:ws/sessions"],
565
+ "agent-routes.ts:HELPERS_PATH": [
566
+ "/api/workspaces/:ws/agents/:id/helpers",
567
+ "/api/workspaces/:ws/agents/:id/helpers/:helper",
568
+ ],
536
569
  "agent-routes.ts:CONVERSATIONS_PATH": [
537
570
  "/api/workspaces/:ws/conversations",
538
571
  "/api/workspaces/:ws/conversations/:id/import",
@@ -64,7 +64,16 @@ import { parseSessionName } from "../../shared/sessions/names.js";
64
64
  import { sessionPaths } from "../../shared/sessions/paths.js";
65
65
  import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
66
66
  import type { SessionInfo } from "../../shared/sessions/types.js";
67
- import { agentRows, newerOf, noteEnded } from "../cli/agent-ops.js";
67
+ import type { HelperSettings } from "../../shared/agents/helpers.js";
68
+ import { readAgentsConfig } from "../../shared/den-config.js";
69
+ import {
70
+ agentRows,
71
+ isStoppedState,
72
+ newerOf,
73
+ noteEnded,
74
+ rowHelpers,
75
+ type AgentRow,
76
+ } from "../cli/agent-ops.js";
68
77
  import type { AgentRecord, Override } from "../../shared/agents/records.js";
69
78
  import { state as sessionState } from "../cli/sessions-command.js";
70
79
  import { recordAction } from "./audit.js";
@@ -91,6 +100,8 @@ export interface SessionRouteDeps {
91
100
  waitingAgents?: () => ReadonlySet<string>;
92
101
  /** D28: each agent's effective rotation settings (one resolver per list). */
93
102
  rotationPolicy?: () => (rec: AgentRecord) => RotationPolicy | undefined;
103
+ /** D32: agents.helpers (default: the agents config's, else its defaults). */
104
+ helperSettings?: () => HelperSettings | undefined;
94
105
  }
95
106
 
96
107
  interface Resolved {
@@ -102,6 +113,7 @@ interface Resolved {
102
113
  pty: () => PtyLoad;
103
114
  waitingAgents: () => ReadonlySet<string>;
104
115
  rotationPolicy?: SessionRouteDeps["rotationPolicy"];
116
+ helperSettings: () => HelperSettings | undefined;
105
117
  }
106
118
 
107
119
  /** A workspace name as `?ws=` may give it. */
@@ -122,6 +134,15 @@ function resolve(d: SessionRouteDeps = {}): Resolved {
122
134
  throw e;
123
135
  }
124
136
  }),
137
+ helperSettings:
138
+ d.helperSettings ??
139
+ (() => {
140
+ try {
141
+ return readAgentsConfig().helpers;
142
+ } catch {
143
+ return undefined;
144
+ }
145
+ }),
125
146
  activeWorkspaces: d.activeWorkspaces ?? (() => getActiveWorkspaces()),
126
147
  workspaceExists:
127
148
  d.workspaceExists ??
@@ -209,6 +230,8 @@ export interface ListedSession {
209
230
  rotation?: RotationInfo;
210
231
  /** D28: the last relaunch from a handoff (the page toasts one Den did by itself). */
211
232
  lastRotation?: RotationEntry;
233
+ /** D32: its helpers (counts and the recent ones). */
234
+ helpers?: AgentRow["helpers"];
212
235
  }
213
236
 
214
237
  /** The list, from the host's sessions and the agent records. */
@@ -216,6 +239,7 @@ export function listedSessions(
216
239
  sessions: SessionInfo[],
217
240
  d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces"> &
218
241
  Partial<Pick<Resolved, "waitingAgents">> &
242
+ Partial<Pick<Resolved, "helperSettings">> &
219
243
  Pick<SessionRouteDeps, "rotationPolicy">,
220
244
  /** A workspace asked for by name (`?ws=`), listed even when inactive. */
221
245
  also?: string,
@@ -237,6 +261,7 @@ export function listedSessions(
237
261
  const out: ListedSession[] = [];
238
262
  const agentSessions = new Set<string>();
239
263
  noteEnded(d.stateDir(), mine, records);
264
+ const helperSettings = d.helperSettings?.();
240
265
  for (const r of agentRows(
241
266
  mine,
242
267
  records,
@@ -283,6 +308,11 @@ export function listedSessions(
283
308
  ...(r.lastRotation ? { lastRotation: r.lastRotation } : {}),
284
309
  ...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
285
310
  ...(waiting.has(rec.id) && s && !s.exited ? { waiting: true } : {}),
311
+ // D32: the helpers it started in its own session.
312
+ ...(() => {
313
+ const h = rowHelpers(rec, !isStoppedState(r.state), helperSettings);
314
+ return h ? { helpers: h } : {};
315
+ })(),
286
316
  });
287
317
  }
288
318
  for (const s of mine) {
@@ -311,6 +341,9 @@ export function listedSessions(
311
341
  );
312
342
  }
313
343
 
344
+ /** D32: the helpers count, under /api/sessions/ but no session's route. */
345
+ export const SESSION_HELPERS_PATH = "/api/sessions/helpers";
346
+
314
347
  /**
315
348
  * `GET /api/sessions`; false when `pathname` isn't it. `ioAllowed` is what
316
349
  * the request checks say about this browser: beyond loopback without a
@@ -323,6 +356,8 @@ export async function handleSessionList(
323
356
  ioAllowed: boolean,
324
357
  deps: SessionRouteDeps = {},
325
358
  ): Promise<boolean> {
359
+ if (pathname === "/api/sessions/helpers")
360
+ return helpersRunning(req, res, deps);
326
361
  if (pathname !== "/api/sessions") return false;
327
362
  if (req.method !== "GET" && req.method !== "HEAD") {
328
363
  res.setHeader("Allow", "GET");
@@ -660,3 +695,38 @@ export async function handleSessionUpgrade(
660
695
  detach();
661
696
  });
662
697
  }
698
+
699
+ /**
700
+ * D32: `GET /api/sessions/helpers`: how many helpers the active
701
+ * workspaces' agents have running now, `{ running }` (the rail's Agents
702
+ * badge on every page). Names and counts only, never a transcript.
703
+ */
704
+ async function helpersRunning(
705
+ req: http.IncomingMessage,
706
+ res: http.ServerResponse,
707
+ deps: SessionRouteDeps,
708
+ ): Promise<true> {
709
+ if (req.method !== "GET" && req.method !== "HEAD") {
710
+ res.setHeader("Allow", "GET");
711
+ sendJson(res, { ok: false, error: "method not allowed" }, 405);
712
+ return true;
713
+ }
714
+ const d = resolve(deps);
715
+ let sessions: SessionInfo[] = [];
716
+ try {
717
+ const c = await d.connectExisting();
718
+ if (c)
719
+ try {
720
+ sessions = await c.list();
721
+ } finally {
722
+ c.close();
723
+ }
724
+ } catch {
725
+ /* no host: nothing runs, so no helper runs */
726
+ }
727
+ let running = 0;
728
+ for (const r of listedSessions(sessions, d))
729
+ running += r.helpers?.running ?? 0;
730
+ sendJson(res, { running });
731
+ return true;
732
+ }
@@ -34,6 +34,9 @@ export const UI_MODULES: readonly string[] = [
34
34
  "/error-page.js",
35
35
  "/agents-section.js",
36
36
  "/agents-page.js",
37
+ // D32: an agent's helpers, and the shared read-only transcript view.
38
+ "/agent-helpers.js",
39
+ "/transcript-view.js",
37
40
  // The Start agent form's choices, shared by both (AG-10.0).
38
41
  "/agent-start.js",
39
42
  // AG-10.4-10.7: the persona picker, editor and Settings › Personas.