@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.
@@ -50,6 +50,15 @@
50
50
  * Nothing here takes a grant: grants come from the org config only. The
51
51
  * host enforces agents.max_concurrent on every start, counting agents only.
52
52
  */
53
+ import {
54
+ capHelpers,
55
+ HELPER_DEFAULTS,
56
+ HELPERS_READ_ONLY_NOTE,
57
+ helperCounts,
58
+ type Helper,
59
+ type HelperSettings,
60
+ type HelperTranscript,
61
+ } from "../../shared/agents/helpers.js";
53
62
  import * as fs from "fs";
54
63
  import * as path from "path";
55
64
  import {
@@ -265,6 +274,8 @@ export interface AgentsDeps extends SessionsDeps {
265
274
  url: string;
266
275
  token: string;
267
276
  allowTools?: boolean;
277
+ /** HUB-1.7: Hub's MCP endpoint (default <url>/mcp). */
278
+ mcpUrl?: string;
268
279
  } | null>;
269
280
  /** HUB-1.5: workspace `ws`'s repos as owner/repo (X-Den-Repos). */
270
281
  workspaceRepos?: (ws: string) => string[];
@@ -288,6 +299,7 @@ export interface AgentsResolved extends Resolved {
288
299
  url: string;
289
300
  token: string;
290
301
  allowTools?: boolean;
302
+ mcpUrl?: string;
291
303
  } | null>;
292
304
  workspaceRepos: (ws: string) => string[];
293
305
  }
@@ -395,6 +407,7 @@ export async function agentHub(
395
407
  d: AgentsResolved,
396
408
  ws: string,
397
409
  name: string,
410
+ agentId?: string,
398
411
  ): Promise<AgentMcp["hub"] | undefined> {
399
412
  const h = await d.hub().catch(() => null);
400
413
  if (!h) return undefined;
@@ -407,10 +420,11 @@ export async function agentHub(
407
420
  }
408
421
  return {
409
422
  name: HUB_SERVER,
410
- url: h.url,
423
+ url: h.mcpUrl || `${h.url}/mcp`,
411
424
  allowTools: h.allowTools !== false,
412
425
  headers: hubHeaders(h.token, {
413
426
  agent: name,
427
+ ...(agentId ? { agentId } : {}),
414
428
  session: crypto.randomUUID(),
415
429
  workspace: ws,
416
430
  repos,
@@ -480,6 +494,7 @@ async function defaultHub(): Promise<{
480
494
  url: string;
481
495
  token: string;
482
496
  allowTools: boolean;
497
+ mcpUrl: string;
483
498
  } | null> {
484
499
  const { readHubConfig, readHubToken } = await import("../../shared/hub.js");
485
500
  let cfg;
@@ -490,7 +505,9 @@ async function defaultHub(): Promise<{
490
505
  }
491
506
  if (!cfg) return null;
492
507
  const token = await readHubToken(cfg);
493
- return token ? { url: cfg.url, token, allowTools: cfg.allowTools } : null;
508
+ return token
509
+ ? { url: cfg.url, token, allowTools: cfg.allowTools, mcpUrl: cfg.mcpUrl }
510
+ : null;
494
511
  }
495
512
 
496
513
  /**
@@ -630,6 +647,12 @@ export interface AgentRow {
630
647
  /** D28: the last one (when, why, the old conversation, by itself or not). */
631
648
  lastRotation?: RotationEntry;
632
649
  session?: SessionInfo;
650
+ /**
651
+ * D32: the helpers it started in its own session: how many, how many
652
+ * run now, and the ones worth showing under it (running, or ended
653
+ * within agents.helpers.collapse_done_minutes; at most 8).
654
+ */
655
+ helpers?: { running: number; total: number; recent: Helper[] };
633
656
  }
634
657
 
635
658
  /** Its state as a row shows it (AG-14.1: stopped, exited or interrupted). */
@@ -823,9 +846,138 @@ export async function listAgents(
823
846
  const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
824
847
  noteEnded(d.stateDir(), sessions, records);
825
848
  const rows = agentRows(sessions, records, d.stateDir(), policyResolver(d));
849
+ addHelperCounts(d, rows, records);
826
850
  return opts.stopped ? rows.filter((r) => isStoppedState(r.state)) : rows;
827
851
  }
828
852
 
853
+ /** At most this many helpers ride along on an agent's row. */
854
+ const ROW_HELPERS = 8;
855
+
856
+ /** D32: agents.helpers, or its defaults when the config can't be read. */
857
+ function helperSettings(d: AgentsResolved): HelperSettings {
858
+ try {
859
+ return d.agentsConfig().helpers ?? HELPER_DEFAULTS;
860
+ } catch {
861
+ return HELPER_DEFAULTS;
862
+ }
863
+ }
864
+
865
+ /** D32: each row's helper counts and recent helpers (never fails a list). */
866
+ function addHelperCounts(
867
+ d: AgentsResolved,
868
+ rows: AgentRow[],
869
+ records: AgentRecord[],
870
+ ): void {
871
+ const settings = helperSettings(d);
872
+ const byId = new Map(records.map((r) => [r.id, r]));
873
+ for (const row of rows) {
874
+ const rec = byId.get(row.agent);
875
+ if (!rec) continue;
876
+ const h = rowHelpers(rec, !isStoppedState(row.state), settings, d.env);
877
+ if (h) row.helpers = h;
878
+ }
879
+ }
880
+
881
+ /**
882
+ * D32: what an agent's row shows of its helpers: counts, and the ones
883
+ * worth showing (running, or this conversation's ended within
884
+ * collapse_done_minutes; at most 8). Undefined: none, or its runtime
885
+ * can't tell. Never throws.
886
+ */
887
+ export function rowHelpers(
888
+ rec: AgentRecord,
889
+ live: boolean,
890
+ settings: HelperSettings = HELPER_DEFAULTS,
891
+ env: NodeJS.ProcessEnv = process.env,
892
+ now = Date.now(),
893
+ ): AgentRow["helpers"] {
894
+ const rt = getRuntime(rec.runtime);
895
+ if (!rt?.helpers) return undefined;
896
+ let hs: Helper[] = [];
897
+ try {
898
+ hs = rt.helpers(rec, { env, now, settings, agentLive: live });
899
+ } catch {
900
+ return undefined;
901
+ }
902
+ if (!hs.length) return undefined;
903
+ const keep = settings.collapseDoneMinutes * 60_000;
904
+ const shown = hs.filter(
905
+ (h) =>
906
+ h.status === "running" ||
907
+ (!h.earlier && now - (h.lastActivityAt ?? h.startedAt ?? 0) <= keep),
908
+ );
909
+ return { ...helperCounts(hs), recent: capHelpers(shown, ROW_HELPERS) };
910
+ }
911
+
912
+ /** D32: an agent's helpers, read-only, and whether its runtime can tell. */
913
+ export async function agentHelpers(
914
+ d: AgentsResolved,
915
+ id: string,
916
+ ws?: string,
917
+ ): Promise<{
918
+ rec: AgentRecord;
919
+ supported: boolean;
920
+ live: boolean;
921
+ helpers: Helper[];
922
+ note: string;
923
+ }> {
924
+ const rec = agentRecord(d, id, ws);
925
+ const rt = getRuntime(rec.runtime);
926
+ const live = await agentLive(d, rec);
927
+ const helpers =
928
+ rt?.helpers?.(rec, {
929
+ env: d.env,
930
+ settings: helperSettings(d),
931
+ agentLive: live,
932
+ }) ?? [];
933
+ return {
934
+ rec,
935
+ supported: !!rt?.helpers,
936
+ live,
937
+ helpers,
938
+ note: HELPERS_READ_ONLY_NOTE,
939
+ };
940
+ }
941
+
942
+ /** D32: one of agent `id`'s helpers' transcript; a 404 when there's none. */
943
+ export async function agentHelperTranscript(
944
+ d: AgentsResolved,
945
+ id: string,
946
+ helperId: string,
947
+ ws?: string,
948
+ limit?: number,
949
+ ): Promise<HelperTranscript & { note: string }> {
950
+ const rec = agentRecord(d, id, ws);
951
+ const rt = getRuntime(rec.runtime);
952
+ const t = rt?.helperTranscript?.(rec, helperId, {
953
+ env: d.env,
954
+ settings: helperSettings(d),
955
+ agentLive: await agentLive(d, rec),
956
+ ...(limit ? { limit } : {}),
957
+ });
958
+ if (!t) throw refused(`agent ${id} has no helper "${helperId}"`, [], 404);
959
+ return { ...t, note: HELPERS_READ_ONLY_NOTE };
960
+ }
961
+
962
+ /** Whether agent `rec`'s session runs now (an unreachable host: no). */
963
+ async function agentLive(
964
+ d: AgentsResolved,
965
+ rec: AgentRecord,
966
+ ): Promise<boolean> {
967
+ try {
968
+ const c = await d.connectExisting();
969
+ if (!c) return false;
970
+ try {
971
+ const s = sessionOf(await c.list(), rec);
972
+ return Boolean(s && !s.exited);
973
+ } finally {
974
+ c.close();
975
+ }
976
+ } catch {
977
+ return false;
978
+ }
979
+ }
980
+
829
981
  // ── personas, runtimes ──────────────────────────────────────────────────────
830
982
 
831
983
  /** The agents config; an invalid one is a refusal naming the key. */
@@ -1901,6 +2053,7 @@ export async function startAgent(
1901
2053
  d,
1902
2054
  ws,
1903
2055
  given ?? prev?.displayName ?? persona.displayName ?? id,
2056
+ id,
1904
2057
  );
1905
2058
  const input = {
1906
2059
  ...base,
@@ -2443,7 +2596,12 @@ export async function resumeAgent(
2443
2596
  // Resume in the cwd the agent had, wherever the workspace points now.
2444
2597
  // D24 (and D25: a rename since) the name peers see it by.
2445
2598
  const peerName = peerSessionName(d.cli, rec.workspace, rec);
2446
- const hub = await agentHub(d, rec.workspace, rec.displayName ?? rec.id);
2599
+ const hub = await agentHub(
2600
+ d,
2601
+ rec.workspace,
2602
+ rec.displayName ?? rec.id,
2603
+ rec.id,
2604
+ );
2447
2605
  const at = {
2448
2606
  ...input,
2449
2607
  ...(hub ? { mcp: { ...input.mcp, hub } } : {}),
@@ -70,6 +70,7 @@ import { HostError } from "../../shared/sessions/client.js";
70
70
  import { checkHandback } from "../../shared/agents/handback.js";
71
71
  import { checkHandoff } from "../../shared/agents/handoff.js";
72
72
  import { handoffFile, reasonText } from "../../shared/agents/rotation.js";
73
+ import { shownDepths } from "../../shared/agents/helpers.js";
73
74
  import {
74
75
  parseRotateValue,
75
76
  type OverrideScope,
@@ -84,6 +85,8 @@ import {
84
85
  importAgent,
85
86
  changePersona,
86
87
  listAgents,
88
+ agentHelpers,
89
+ agentHelperTranscript,
87
90
  listConversations,
88
91
  listPersonas as personaList,
89
92
  listRuntimes,
@@ -129,7 +132,7 @@ export {
129
132
 
130
133
  export const AGENTS_HELP = {
131
134
  usage:
132
- "agents <list|start|stop|resume|relaunch|rotate|keep|handoff|import|persona|rename|rm|personas|doctor|handback-check|handoff-check> ...",
135
+ "agents <list|start|stop|resume|relaunch|rotate|keep|handoff|import|persona|helpers|rename|rm|personas|doctor|handback-check|handoff-check> ...",
133
136
  lines: [
134
137
  "Agents: a persona and a workspace, run by a runtime in a session that",
135
138
  "outlives the web server (see `{cli} sessions`).",
@@ -167,6 +170,13 @@ export const AGENTS_HELP = {
167
170
  " agent never rotates. A person's only",
168
171
  "{cli} agents handoff <id> [-w <ws>]",
169
172
  " ask a running agent, at idle, to write its handoff",
173
+ "{cli} agents helpers <id> [-w <ws>] [--all] [--json]",
174
+ " the helpers it started in its own session (Claude Code's Agent and",
175
+ " Workflow subagents): what each does, its type, model, branch, state and",
176
+ " times; running first. --all: its earlier conversations' too. Read-only:",
177
+ " they belong to the agent's session, so Den can't stop or steer them",
178
+ "{cli} agents helpers <id> <helper> [-w <ws>] [--limit <n>] [--json]",
179
+ " one helper's transcript, tool calls summarized (the last 500 lines)",
170
180
  "{cli} agents import -w <ws> [--runtime <r>] [--json]",
171
181
  "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>] [--model <m>]",
172
182
  " bring in a conversation started outside Den: the first lists the runtime's",
@@ -456,6 +466,120 @@ async function persona(args: string[], d: AgentsResolved): Promise<number> {
456
466
  }
457
467
 
458
468
  /** `<id> [-w <ws>]`. */
469
+ /** D32: `agents helpers <id> [<helper>]`, read-only. */
470
+ async function helpers(args: string[], d: AgentsResolved): Promise<number> {
471
+ const where = "agents helpers";
472
+ const p = parseArgs(where, args, {
473
+ "--workspace": true,
474
+ "--json": false,
475
+ "--all": false,
476
+ "--limit": true,
477
+ });
478
+ noExtra(where, p, 2);
479
+ const [id, helperId] = p.positional;
480
+ if (!id) throw new UsageError(`${where} needs <id>`);
481
+ const ws = workspaceFlag(where, p);
482
+ const ago = (ms?: number) =>
483
+ ms === undefined ? "-" : `${duration(Date.now() - ms)} ago`;
484
+ if (helperId) {
485
+ const raw = p.flags["--limit"];
486
+ const limit = raw === undefined ? undefined : Number(raw);
487
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
488
+ throw new UsageError(
489
+ `${where}: --limit must be a whole number of at least 1`,
490
+ );
491
+ const t = await agentHelperTranscript(d, id, helperId, ws, limit);
492
+ if (p.flags["--json"]) {
493
+ d.out(JSON.stringify(t, null, 2));
494
+ return 0;
495
+ }
496
+ d.out(
497
+ `${t.helper.description ?? t.helper.id} (${t.helper.status}, ${t.helper.type ?? "helper"})`,
498
+ );
499
+ if (t.omitted) d.out(`… ${t.omitted} earlier lines`);
500
+ for (const i of t.items) {
501
+ const lead =
502
+ i.kind === "tool"
503
+ ? `→ ${i.tool}: `
504
+ : i.kind === "result"
505
+ ? i.error
506
+ ? " ✗ "
507
+ : " ← "
508
+ : i.kind === "prompt"
509
+ ? "asked: "
510
+ : i.kind === "note"
511
+ ? "… "
512
+ : "";
513
+ d.out(`${lead}${i.text}`);
514
+ }
515
+ d.out("");
516
+ d.out(t.note);
517
+ return 0;
518
+ }
519
+ const r = await agentHelpers(d, id, ws);
520
+ const shown = p.flags["--all"]
521
+ ? r.helpers
522
+ : r.helpers.filter((h) => !h.earlier);
523
+ if (p.flags["--json"]) {
524
+ d.out(
525
+ JSON.stringify(
526
+ {
527
+ agent: r.rec.id,
528
+ supported: r.supported,
529
+ live: r.live,
530
+ helpers: shown,
531
+ note: r.note,
532
+ },
533
+ null,
534
+ 2,
535
+ ),
536
+ );
537
+ return 0;
538
+ }
539
+ if (!r.supported) {
540
+ d.out(`${r.rec.runtime} can't tell which helpers ${r.rec.id} started`);
541
+ return 0;
542
+ }
543
+ const earlier = r.helpers.length - r.helpers.filter((h) => !h.earlier).length;
544
+ if (!shown.length) {
545
+ d.out(
546
+ `${r.rec.id} has started no helpers${earlier && !p.flags["--all"] ? ` in this conversation (${earlier} earlier: --all)` : ""}`,
547
+ );
548
+ return 0;
549
+ }
550
+ const depth = shownDepths(shown);
551
+ for (const l of columns([
552
+ ["HELPER", "WHAT", "TYPE", "MODEL", "STATE", "STARTED", "FOR", "BRANCH"],
553
+ ...shown.map((h) => [
554
+ `${" ".repeat(depth.get(h.id) ?? 0)}${h.id}`,
555
+ (h.description ?? "-").slice(0, 48),
556
+ h.workflow
557
+ ? `${h.type ?? "-"} (${h.workflow.name ?? "workflow"})`
558
+ : (h.type ?? "-"),
559
+ h.model ?? "-",
560
+ `${h.status}${h.earlier ? " (earlier)" : ""}`,
561
+ ago(h.startedAt),
562
+ h.durationMs === undefined ? "-" : duration(h.durationMs),
563
+ h.branch ?? "-",
564
+ ]),
565
+ ]))
566
+ d.out(l);
567
+ if (earlier && !p.flags["--all"])
568
+ d.out(`${earlier} more from earlier conversations: --all`);
569
+ d.out("");
570
+ d.out(r.note);
571
+ return 0;
572
+ }
573
+
574
+ /** 65 s → "1m 5s"; 2 h → "2h 0m". */
575
+ function duration(ms: number): string {
576
+ const s = Math.max(0, Math.round(ms / 1000));
577
+ if (s < 60) return `${s}s`;
578
+ const m = Math.floor(s / 60);
579
+ if (m < 60) return `${m}m ${s % 60}s`;
580
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
581
+ }
582
+
459
583
  function idAndWorkspace(
460
584
  where: string,
461
585
  args: string[],
@@ -962,6 +1086,8 @@ export async function cmdAgents(
962
1086
  case "persona":
963
1087
  await loadAgentWorkspaces(deps);
964
1088
  return await persona(rest, d);
1089
+ case "helpers":
1090
+ return await helpers(rest, d);
965
1091
  case "rename":
966
1092
  return rename(rest, d);
967
1093
  case "rm":
@@ -482,12 +482,21 @@ export function hubLines(
482
482
  title: string,
483
483
  ): { lines: string[]; problem: boolean; check?: OnboardCheck } {
484
484
  if (!st.configured) return { lines: [], problem: false };
485
- const who = [st.team && `team ${st.team}`, st.label && `install ${st.label}`]
485
+ const who = [
486
+ st.team && `company ${st.team}`,
487
+ st.label && `install ${st.label}`,
488
+ st.role && `role ${st.role}`,
489
+ ]
486
490
  .filter(Boolean)
487
491
  .join(", ");
492
+ // HUB-1.7: what to do about it is in Settings (Connect, Reconnect).
493
+ const fix =
494
+ st.state === "no_token" || st.state === "rejected"
495
+ ? ` (Settings › Agents › Hub: ${st.state === "rejected" ? "Reconnect" : "Connect"})`
496
+ : "";
488
497
  const detail = st.connected
489
498
  ? `${st.url}: connected${who ? ` (${who})` : ""}`
490
- : `${st.url || "hub.url"}: not connected: ${st.problem ?? "no answer"}`;
499
+ : `${st.url || "hub.url"}: not connected: ${st.problem ?? "no answer"}${fix}`;
491
500
  return {
492
501
  lines: [
493
502
  "",
package/src/ui/mcp/api.ts CHANGED
@@ -147,8 +147,9 @@ const ACTION_ROUTES: Record<string, string> = {
147
147
  };
148
148
 
149
149
  /**
150
- * Action routes with a parameter: an agent's resume (MCP agent_resume),
151
- * and Bring in's list and import (MCP agent_import, AG-17.1).
150
+ * Routes with a parameter: an agent's resume (MCP agent_resume), Bring
151
+ * in's list and import (MCP agent_import, AG-17.1), and an agent's helpers
152
+ * (MCP agent_helpers, D32, GET only).
152
153
  */
153
154
  const ACTION_ROUTE_PATTERNS: { re: RegExp; method: string }[] = [
154
155
  // One held request's state (its own, the server checks). Never /decide.
@@ -161,6 +162,11 @@ const ACTION_ROUTE_PATTERNS: { re: RegExp; method: string }[] = [
161
162
  re: /^\/api\/workspaces\/[A-Za-z0-9._-]{1,64}\/conversations$/,
162
163
  method: "GET",
163
164
  },
165
+ // D32 (MCP agent_helpers): an agent's helpers, or one's transcript. Reads.
166
+ {
167
+ re: /^\/api\/workspaces\/[A-Za-z0-9._-]{1,64}\/agents\/[A-Za-z0-9._-]{1,64}\/helpers(?:\/[A-Za-z0-9_-]{1,128})?$/,
168
+ method: "GET",
169
+ },
164
170
  {
165
171
  re: /^\/api\/workspaces\/[A-Za-z0-9._-]{1,64}\/conversations\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\/import$/,
166
172
  method: "POST",
@@ -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
  /**