@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.
- package/CHANGELOG.md +47 -0
- package/docs/distributions.md +73 -2
- package/package.json +1 -1
- package/src/shared/agents/claude-helpers.ts +623 -0
- package/src/shared/agents/den-claude.ts +54 -0
- package/src/shared/agents/helpers.ts +175 -0
- package/src/shared/agents/runtime.ts +13 -0
- package/src/shared/den-config.ts +42 -1
- package/src/shared/hub.ts +10 -0
- package/src/ui/cli/agent-ops.ts +144 -0
- package/src/ui/cli/agents-command.ts +127 -1
- package/src/ui/mcp/api.ts +8 -2
- package/src/ui/mcp/server.ts +13 -1
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-routes.ts +56 -2
- package/src/ui/server/hub-connect.ts +69 -3
- package/src/ui/server/index.ts +7 -1
- package/src/ui/server/public/agent-helpers.js +311 -0
- package/src/ui/server/public/agents-page.js +76 -9
- package/src/ui/server/public/i18n/es.js +39 -0
- package/src/ui/server/public/i18n/ja.js +37 -0
- package/src/ui/server/public/settings-registry.js +37 -1
- package/src/ui/server/public/shell.js +200 -0
- package/src/ui/server/public/transcript-view.js +202 -0
- package/src/ui/server/request-guard.ts +3 -1
- package/src/ui/server/route-classes.ts +16 -0
- package/src/ui/server/session-routes.ts +71 -1
- package/src/ui/server/ui-assets.ts +3 -0
package/src/ui/mcp/server.ts
CHANGED
|
@@ -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
|
|
130
|
+
inputSchema: schemaOf(t),
|
|
119
131
|
annotations: {
|
|
120
132
|
readOnlyHint: !t.action,
|
|
121
133
|
destructiveHint: t.action,
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/ui/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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
|
+
}
|