@monster-orange/den 0.4.23 → 0.4.25

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.
@@ -256,6 +256,18 @@ export interface AgentsDeps extends SessionsDeps {
256
256
  userConfigFile?: () => string;
257
257
  /** D28: workspace `ws`'s services and their folders (the service scope). */
258
258
  workspaceServices?: (ws: string) => { address: string; path: string }[];
259
+ /**
260
+ * HUB-1.5: Den Hub's URL and this install's token (from its own store),
261
+ * or null when hub.url isn't set or the token can't be read (default:
262
+ * hub.* and the install's secrets backend).
263
+ */
264
+ hub?: () => Promise<{
265
+ url: string;
266
+ token: string;
267
+ allowTools?: boolean;
268
+ } | null>;
269
+ /** HUB-1.5: workspace `ws`'s repos as owner/repo (X-Den-Repos). */
270
+ workspaceRepos?: (ws: string) => string[];
259
271
  }
260
272
 
261
273
  export interface AgentsResolved extends Resolved {
@@ -272,6 +284,12 @@ export interface AgentsResolved extends Resolved {
272
284
  saveRuntimeBin: NonNullable<AgentsDeps["saveRuntimeBin"]>;
273
285
  userConfigFile: () => string;
274
286
  workspaceServices: (ws: string) => { address: string; path: string }[];
287
+ hub: () => Promise<{
288
+ url: string;
289
+ token: string;
290
+ allowTools?: boolean;
291
+ } | null>;
292
+ workspaceRepos: (ws: string) => string[];
275
293
  }
276
294
 
277
295
  /** The early-exit watch: at most this long, polling the host. */
@@ -327,6 +345,7 @@ let servicesFn: ((ws: string) => { address: string; path: string }[]) | null =
327
345
  null;
328
346
  let foldersFn: ((ws: string) => { under: string[]; exact?: string[] }) | null =
329
347
  null;
348
+ let reposFn: ((ws: string) => string[]) | null = null;
330
349
  /** The workspaces config, for start and resume (loaded on first use). */
331
350
  export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
332
351
  await loadWorkspaces(d);
@@ -353,6 +372,50 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
353
372
  .listWorkspaceServices(cfg, readCatalog(), name)
354
373
  .map((r) => ({ address: r.address, path: r.repoPath }));
355
374
  };
375
+ const { ownerRepo } = await import("../../shared/hub.js");
376
+ reposFn = (name) => {
377
+ const cfg = ws.readWorkspacesFile().workspaces[name];
378
+ if (!cfg) return [];
379
+ const cat = readCatalog();
380
+ const out: string[] = [];
381
+ for (const r of ws.listWorkspaceServices(cfg, cat, name)) {
382
+ const slug = ownerRepo(cat[r.repoName]);
383
+ if (slug && !out.includes(slug)) out.push(slug);
384
+ }
385
+ return out;
386
+ };
387
+ }
388
+
389
+ /**
390
+ * HUB-1.5: Den Hub's MCP server for this agent, when hub.url is set and
391
+ * its token is readable: who calls (its name, a session id, the workspace
392
+ * and its repos), as headers. Hub sets the team and project from them.
393
+ */
394
+ export async function agentHub(
395
+ d: AgentsResolved,
396
+ ws: string,
397
+ name: string,
398
+ ): Promise<AgentMcp["hub"] | undefined> {
399
+ const h = await d.hub().catch(() => null);
400
+ if (!h) return undefined;
401
+ const { HUB_SERVER, hubHeaders } = await import("../../shared/hub.js");
402
+ let repos: string[] = [];
403
+ try {
404
+ repos = d.workspaceRepos(ws);
405
+ } catch {
406
+ repos = [];
407
+ }
408
+ return {
409
+ name: HUB_SERVER,
410
+ url: h.url,
411
+ allowTools: h.allowTools !== false,
412
+ headers: hubHeaders(h.token, {
413
+ agent: name,
414
+ session: crypto.randomUUID(),
415
+ workspace: ws,
416
+ repos,
417
+ }),
418
+ };
356
419
  }
357
420
 
358
421
  /** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
@@ -407,9 +470,29 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
407
470
  userConfigFile: d.userConfigFile ?? userConfigPath,
408
471
  workspaceServices:
409
472
  d.workspaceServices ?? ((ws) => (servicesFn ? servicesFn(ws) : [])),
473
+ hub: d.hub ?? defaultHub,
474
+ workspaceRepos: d.workspaceRepos ?? ((ws) => (reposFn ? reposFn(ws) : [])),
410
475
  });
411
476
  }
412
477
 
478
+ /** HUB-1.5: hub.* and the token from this install's store; null without. */
479
+ async function defaultHub(): Promise<{
480
+ url: string;
481
+ token: string;
482
+ allowTools: boolean;
483
+ } | null> {
484
+ const { readHubConfig, readHubToken } = await import("../../shared/hub.js");
485
+ let cfg;
486
+ try {
487
+ cfg = readHubConfig();
488
+ } catch {
489
+ return null; // a bad hub.* is doctor's and Settings' to say
490
+ }
491
+ if (!cfg) return null;
492
+ const token = await readHubToken(cfg);
493
+ return token ? { url: cfg.url, token, allowTools: cfg.allowTools } : null;
494
+ }
495
+
413
496
  /**
414
497
  * ON-6: the server agent's scope is no workspace, but its agent is a
415
498
  * normal one: it "exists", and its folder is an empty one under the state
@@ -1799,11 +1882,29 @@ export async function startAgent(
1799
1882
  id,
1800
1883
  displayName: given ?? prev?.displayName ?? persona.displayName,
1801
1884
  });
1802
- const input = {
1803
- ...launchInput(d, cfg, persona, ws, id, model, det.path, undefined, {
1885
+ const base = launchInput(
1886
+ d,
1887
+ cfg,
1888
+ persona,
1889
+ ws,
1890
+ id,
1891
+ model,
1892
+ det.path,
1893
+ undefined,
1894
+ {
1804
1895
  ...(ch.permission ? { permission: ch.permission } : {}),
1805
1896
  ...(rt.permissions ? { allow } : {}),
1806
- }),
1897
+ },
1898
+ );
1899
+ // HUB-1.5: Den Hub's MCP server, when hub.url is set.
1900
+ const hub = await agentHub(
1901
+ d,
1902
+ ws,
1903
+ given ?? prev?.displayName ?? persona.displayName ?? id,
1904
+ );
1905
+ const input = {
1906
+ ...base,
1907
+ ...(hub ? { mcp: { ...base.mcp, hub } } : {}),
1807
1908
  sessionLabel: peerName,
1808
1909
  ...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
1809
1910
  // 0.4.18: Start agent here: in a folder of the workspace's.
@@ -2342,8 +2443,10 @@ export async function resumeAgent(
2342
2443
  // Resume in the cwd the agent had, wherever the workspace points now.
2343
2444
  // D24 (and D25: a rename since) the name peers see it by.
2344
2445
  const peerName = peerSessionName(d.cli, rec.workspace, rec);
2446
+ const hub = await agentHub(d, rec.workspace, rec.displayName ?? rec.id);
2345
2447
  const at = {
2346
2448
  ...input,
2449
+ ...(hub ? { mcp: { ...input.mcp, hub } } : {}),
2347
2450
  sessionLabel: peerName,
2348
2451
  worktree: rec.cwd,
2349
2452
  ...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
@@ -3369,6 +3472,71 @@ export async function resumeOrRelaunch(
3369
3472
  }
3370
3473
 
3371
3474
  /** D28: the rotation settings, and agent `agent`'s effective values. */
3475
+ /** D30: what the rotation overrides can name, with names to show. */
3476
+ export interface RotationChoices {
3477
+ agents: { id: string; name: string; workspace?: string; state: string }[];
3478
+ personas: { id: string; name: string; category: string }[];
3479
+ workspaces: { id: string; name: string }[];
3480
+ services: { id: string; name: string; workspace: string }[];
3481
+ }
3482
+
3483
+ /**
3484
+ * D30: the overrides form's pickers: every agent (its name, workspace and
3485
+ * state), persona (name and category), workspace and workspace service.
3486
+ * Never starts a session host to ask (a stopped one: agents from their
3487
+ * records). A part that can't be read is empty, not an error.
3488
+ */
3489
+ export async function rotationChoices(
3490
+ d: AgentsResolved,
3491
+ workspaces: string[],
3492
+ ): Promise<RotationChoices> {
3493
+ let sessions: SessionInfo[] = [];
3494
+ try {
3495
+ const c = await d.connectExisting();
3496
+ try {
3497
+ sessions = c ? await c.list() : [];
3498
+ } finally {
3499
+ c?.close();
3500
+ }
3501
+ } catch {
3502
+ sessions = [];
3503
+ }
3504
+ const records = listRecords(d.stateDir());
3505
+ const agents = agentRows(sessions, records, d.stateDir())
3506
+ .filter((r) => r.agent)
3507
+ .map((r) => ({
3508
+ id: r.agent,
3509
+ name: r.displayName || r.sessionTitle || r.agent,
3510
+ ...(r.workspace ? { workspace: r.workspace } : {}),
3511
+ state: r.state,
3512
+ }));
3513
+ let personaList: RotationChoices["personas"] = [];
3514
+ try {
3515
+ personaList = listPersonas(d).map((p) => ({
3516
+ id: p.id,
3517
+ name: p.displayName ? `${p.displayName} (${p.id})` : p.id,
3518
+ category: p.category,
3519
+ }));
3520
+ } catch {
3521
+ personaList = [];
3522
+ }
3523
+ const services: RotationChoices["services"] = [];
3524
+ for (const ws of workspaces)
3525
+ try {
3526
+ for (const sv of d.workspaceServices(ws))
3527
+ if (!services.some((x) => x.id === sv.address))
3528
+ services.push({ id: sv.address, name: sv.address, workspace: ws });
3529
+ } catch {
3530
+ /* a workspace whose services can't be read: none */
3531
+ }
3532
+ return {
3533
+ agents,
3534
+ personas: personaList,
3535
+ workspaces: workspaces.map((w) => ({ id: w, name: w })),
3536
+ services,
3537
+ };
3538
+ }
3539
+
3372
3540
  export function rotationSettings(
3373
3541
  d: AgentsResolved,
3374
3542
  agent?: string,
@@ -69,11 +69,13 @@ import { printJson } from "./json-out.js";
69
69
  import { ownerCheck, ownerLine } from "./doctor-owner.js";
70
70
  import {
71
71
  formatWhere,
72
+ launchdJobPid,
72
73
  serviceJobState,
73
74
  whereRows,
74
75
  type WhereDeps,
75
76
  type WhereRow,
76
77
  } from "./doctor-where.js";
78
+ import { hostLabel } from "./service-command.js";
77
79
 
78
80
  export interface DoctorRow {
79
81
  kind: string;
@@ -420,6 +422,8 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
420
422
  try {
421
423
  return {
422
424
  launchd: c.hostLaunchd,
425
+ pid: c.hostPid,
426
+ startedAt: c.hostStartedAt || undefined,
423
427
  access: await c.access().catch(() => null),
424
428
  };
425
429
  } finally {
@@ -438,6 +442,24 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
438
442
  return null;
439
443
  }
440
444
  },
445
+ hostJob: () => {
446
+ try {
447
+ const { label } = readWebServiceConfig(
448
+ cfg(),
449
+ getDistRoot(),
450
+ os.homedir(),
451
+ );
452
+ const host = hostLabel(label);
453
+ const job = serviceJobState(
454
+ host,
455
+ os.homedir(),
456
+ process.getuid?.() ?? 0,
457
+ );
458
+ return job?.state === "loaded" ? launchdJobPid(job.domain, host) : null;
459
+ } catch {
460
+ return null;
461
+ }
462
+ },
441
463
  };
442
464
  }
443
465
 
@@ -445,11 +467,39 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
445
467
  export function whereCheck(r: WhereRow): OnboardCheck {
446
468
  return {
447
469
  id: `where:${r.what.replace(" ", "-")}`,
448
- status: r.status,
470
+ // info is as chosen: not a problem.
471
+ status: r.status === "info" ? "ok" : r.status,
449
472
  detail: `${r.session}${r.detail ? `: ${r.detail}` : ""}`,
450
473
  };
451
474
  }
452
475
 
476
+ /**
477
+ * HUB-1.5: doctor's Hub row, or none when hub.url isn't set. Never the
478
+ * token: hubStatus doesn't have it to give.
479
+ */
480
+ export function hubLines(
481
+ st: import("../../shared/hub.js").HubStatus,
482
+ title: string,
483
+ ): { lines: string[]; problem: boolean; check?: OnboardCheck } {
484
+ if (!st.configured) return { lines: [], problem: false };
485
+ const who = [st.team && `team ${st.team}`, st.label && `install ${st.label}`]
486
+ .filter(Boolean)
487
+ .join(", ");
488
+ const detail = st.connected
489
+ ? `${st.url}: connected${who ? ` (${who})` : ""}`
490
+ : `${st.url || "hub.url"}: not connected: ${st.problem ?? "no answer"}`;
491
+ return {
492
+ lines: [
493
+ "",
494
+ `${title} doctor: Hub`,
495
+ "",
496
+ ` hub ${st.connected ? "✓" : "⚠"} ${detail}`,
497
+ ],
498
+ problem: false, // a Hub that is down never fails doctor: agents still run
499
+ check: { id: "hub", status: st.connected ? "ok" : "warn", detail },
500
+ };
501
+ }
502
+
453
503
  export async function cmdDoctor(args: string[]): Promise<number> {
454
504
  const probe = args.includes("--probe");
455
505
  const json = args.includes("--json");
@@ -481,6 +531,11 @@ export async function cmdDoctor(args: string[]): Promise<number> {
481
531
  r.checks.push(
482
532
  ...(await whereRows(await defaultWhereDeps())).map(whereCheck),
483
533
  );
534
+ {
535
+ const { hubStatus } = await import("../../shared/hub.js");
536
+ const h = hubLines(await hubStatus(), readBrand().name);
537
+ if (h.check) r.checks.push(h.check);
538
+ }
484
539
  printJson(r);
485
540
  return r.ok ? 0 : 1;
486
541
  }
@@ -499,6 +554,12 @@ export async function cmdDoctor(args: string[]): Promise<number> {
499
554
  readBrand().name,
500
555
  ))
501
556
  console.log(l);
557
+ {
558
+ // HUB-1.5: only when hub.url is set.
559
+ const { hubStatus } = await import("../../shared/hub.js");
560
+ for (const l of hubLines(await hubStatus(), readBrand().name).lines)
561
+ console.log(l);
562
+ }
502
563
  for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
503
564
  const cli = product().cli;
504
565
  if (!probe) {
@@ -41,15 +41,22 @@ export interface WhereDeps {
41
41
  /** The running session host's; null when none runs. */
42
42
  host: () => Promise<{
43
43
  launchd: string | null;
44
+ /** The pid that answers on the socket. */
45
+ pid?: number;
46
+ /** When it started (ISO). */
47
+ startedAt?: string;
44
48
  access?: { exe: string; fullDiskAccess: boolean | null } | null;
45
49
  } | null>;
46
50
  service: () => ServiceJob | null;
51
+ /** The service's own host job's pid (it may stand by), or null. */
52
+ hostJob?: () => number | null;
47
53
  }
48
54
 
49
55
  export interface WhereRow {
50
56
  what: "server" | "session host" | "full disk access" | "service";
51
57
  session: string;
52
- status: "ok" | "warn";
58
+ /** info: as chosen, worth knowing (login mode ends at logout). */
59
+ status: "ok" | "info" | "warn";
53
60
  detail?: string;
54
61
  }
55
62
 
@@ -57,12 +64,15 @@ function sessionRow(
57
64
  what: "server" | "session host",
58
65
  launchd: string | null | undefined,
59
66
  cli: string,
67
+ loginChosen = false,
60
68
  ): WhereRow {
61
69
  const session = launchd || "unknown";
62
70
  return {
63
71
  what,
64
72
  session,
65
- status: endsAtLogout(launchd) ? "warn" : "ok",
73
+ // The service installed in login mode: ending at logout is what was
74
+ // chosen (the default), so it is said, not warned about.
75
+ status: endsAtLogout(launchd) ? (loginChosen ? "info" : "warn") : "ok",
66
76
  ...(sessionWarning(launchd, cli, what)
67
77
  ? { detail: sessionWarning(launchd, cli, what)! }
68
78
  : {}),
@@ -72,10 +82,12 @@ function sessionRow(
72
82
  export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
73
83
  if (d.platform !== "darwin") return [];
74
84
  const rows: WhereRow[] = [];
85
+ const svc = d.service();
86
+ const loginChosen = svc?.mode === "login" && svc.state !== "not-loaded";
75
87
  const server = await d.server().catch(() => null);
76
88
  rows.push(
77
89
  server
78
- ? sessionRow("server", server.launchd, d.cli)
90
+ ? sessionRow("server", server.launchd, d.cli, loginChosen)
79
91
  : {
80
92
  // Not started yet is not a problem (a fresh install).
81
93
  what: "server",
@@ -85,15 +97,39 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
85
97
  },
86
98
  );
87
99
  const host = await d.host().catch(() => null);
100
+ const jobPid = host ? (d.hostJob?.() ?? null) : null;
101
+ // The service's host stands by while another host holds the socket, and
102
+ // takes over when that one exits: nothing to do.
103
+ const standsBy =
104
+ jobPid !== null && host?.pid !== undefined && jobPid !== host.pid;
105
+ const since = host?.startedAt ? `, since ${host.startedAt.slice(0, 10)}` : "";
106
+ const older = `an older session host (pid ${host?.pid ?? "?"}${since})`;
107
+ const switchNow = `To switch now (ends running terminals and agents): ${d.cli} sessions host --stop`;
88
108
  rows.push(
89
- host
90
- ? sessionRow("session host", host.launchd, d.cli)
91
- : {
109
+ host && standsBy
110
+ ? {
92
111
  what: "session host",
93
- session: "not running",
94
- status: "ok",
95
- detail: "it starts when the first terminal or agent does",
96
- },
112
+ session: host.launchd || `older host (pid ${host.pid})`,
113
+ status: "info",
114
+ detail: `${older} is serving; the service's host (pid ${jobPid}) takes over when it exits. Nothing to do. ${switchNow}`,
115
+ }
116
+ : host && !host.launchd
117
+ ? {
118
+ // An older host and no service host behind it: from an earlier
119
+ // version, so it can't say where it runs. Nothing is wrong.
120
+ what: "session host",
121
+ session: `older host${host.pid ? ` (pid ${host.pid})` : ""}`,
122
+ status: "info",
123
+ detail: `${older} is serving, from before this version, so it can't say whether its terminals and agents stop at logout. It picks up this version the next time it exits. Nothing to do. ${switchNow}`,
124
+ }
125
+ : host
126
+ ? sessionRow("session host", host.launchd, d.cli, loginChosen)
127
+ : {
128
+ what: "session host",
129
+ session: "not running",
130
+ status: "ok",
131
+ detail: "it starts when the first terminal or agent does",
132
+ },
97
133
  );
98
134
  const fda = host?.access;
99
135
  if (fda && fda.fullDiskAccess !== null) {
@@ -116,7 +152,6 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
116
152
  },
117
153
  );
118
154
  }
119
- const svc = d.service();
120
155
  if (!svc)
121
156
  rows.push({
122
157
  what: "service",
@@ -145,6 +180,17 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
145
180
  return rows;
146
181
  }
147
182
 
183
+ /** The pid launchd runs `<domain>/<label>` as, or null (launchctl print). */
184
+ export function launchdJobPid(domain: string, label: string): number | null {
185
+ const r = spawnSync("launchctl", ["print", `${domain}/${label}`], {
186
+ encoding: "utf-8",
187
+ timeout: 3000,
188
+ });
189
+ if (r.status !== 0) return null;
190
+ const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
191
+ return m ? Number(m[1]) : null;
192
+ }
193
+
148
194
  export interface JobProbe {
149
195
  exists: (p: string) => boolean;
150
196
  /** `launchctl print <domain>/<label>`'s exit status (127: no launchctl). */
@@ -195,9 +241,10 @@ export function formatWhere(rows: WhereRow[], title: string): string[] {
195
241
  const lines = ["", `${title} doctor: where it runs`, ""];
196
242
  const w = Math.max(...rows.map((r) => r.session.length));
197
243
  for (const r of rows) {
198
- const mark = r.status === "ok" ? "✓" : "⚠";
244
+ const mark = r.status === "ok" ? "✓" : r.status === "info" ? "ℹ" : "⚠";
199
245
  lines.push(` ${r.what.padEnd(16)} ${r.session.padEnd(w)} ${mark}`);
200
- if (r.detail) lines.push(` ${r.status === "ok" ? "" : "⚠ "}${r.detail}`);
246
+ if (r.detail)
247
+ lines.push(` ${r.status === "warn" ? "⚠ " : ""}${r.detail}`);
201
248
  }
202
249
  return lines;
203
250
  }
@@ -138,6 +138,7 @@ import {
138
138
  resumeOrRelaunch,
139
139
  keepAgentConversation,
140
140
  askForHandoff,
141
+ rotationChoices,
141
142
  rotationSettings,
142
143
  setRotationSetting,
143
144
  rotationTick,
@@ -172,6 +173,8 @@ export interface AgentRouteDeps {
172
173
  instruction?: { quietMs?: number; maxMs?: number };
173
174
  /** AG-18: how "after this turn" polls (tests: shorter). */
174
175
  personaWait?: { pollMs?: number; maxMs?: number };
176
+ /** HUB-1.5: GET /api/hub's answer (tests: a fake Hub). */
177
+ hubStatus?: () => Promise<import("../../shared/hub.js").HubStatus>;
175
178
  }
176
179
 
177
180
  const AGENTS_PATH =
@@ -425,6 +428,15 @@ export async function handleAgentRoutes(
425
428
  pathname === "/api/agents/resume-interrupted";
426
429
  if (pathname === "/api/agents/rotation")
427
430
  return rotationRoute(req, res, webDeps(deps.agents));
431
+ // HUB-1.5: Settings › Hub. Never the token: hubStatus doesn't return it.
432
+ if (pathname === "/api/hub") {
433
+ const method = req.method ?? "GET";
434
+ if (method !== "GET" && method !== "HEAD")
435
+ return methodNotAllowed(res, "GET");
436
+ const { hubStatus } = await import("../../shared/hub.js");
437
+ sendJson(res, await (deps.hubStatus ?? (() => hubStatus()))());
438
+ return true;
439
+ }
428
440
  if (
429
441
  pathname !== "/api/personas" &&
430
442
  pathname !== "/api/agents/runtimes" &&
@@ -1328,7 +1340,18 @@ async function rotationRoute(
1328
1340
  const agent = q.get("agent") ?? undefined;
1329
1341
  if ((ws && !NAME_RE.test(ws)) || (agent && !NAME_RE.test(agent)))
1330
1342
  throw new AgentError("not a name", { usage: true });
1331
- sendJson(res, rotationSettings(d, agent, ws));
1343
+ // D30: ?choices=1 adds what the overrides form can pick, by name.
1344
+ sendJson(res, {
1345
+ ...rotationSettings(d, agent, ws),
1346
+ ...(q.get("choices") === "1"
1347
+ ? {
1348
+ choices: await rotationChoices(
1349
+ d,
1350
+ Object.keys(readWorkspacesFile().workspaces ?? {}),
1351
+ ),
1352
+ }
1353
+ : {}),
1354
+ });
1332
1355
  return true;
1333
1356
  }
1334
1357
  if (method !== "PUT") return methodNotAllowed(res, "GET, PUT");
@@ -283,6 +283,7 @@ import {
283
283
  } from "../../shared/cli-command.js";
284
284
  import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
285
285
  import { agentsLayout, patchAgentsLayout } from "./agents-layout-pref.js";
286
+ import { patchHubAllowTools } from "../../shared/hub.js";
286
287
  import { editorOpen, patchEditorOpen } from "./editor-open-pref.js";
287
288
  import { graphPageHtml } from "./graph-page.js";
288
289
  import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
@@ -5017,6 +5018,8 @@ async function handleRequest(
5017
5018
  // pane opens (AG-4b, editor-open-pref.ts); null clears it (none)
5018
5019
  // language: the UI's language, en | ja | es (ui-language.ts); null or
5019
5020
  // "en" clears it (English is the default)
5021
+ // hub_allow_tools: true | false, hub.allow_tools (HUB-1.5: agents use
5022
+ // Hub's tools without asking); null clears it (on)
5020
5023
  // It writes config/local/user.yml only. `distribution` is read-only: what
5021
5024
  // the org config (tracked) sets, for the Settings page's Distribution rows.
5022
5025
  // Every key POST accepts has a row in the Settings page's registry
@@ -5091,8 +5094,18 @@ async function handleRequest(
5091
5094
  machine_name?: unknown;
5092
5095
  agents_layout?: unknown;
5093
5096
  editor_open?: unknown;
5097
+ hub_allow_tools?: unknown;
5094
5098
  };
5095
5099
  const user = readUserConfig();
5100
+ // HUB-1.5: hub.allow_tools, Hub's tools without asking (Settings ›
5101
+ // Agents › Hub); null clears it (the default, on).
5102
+ if (body.hub_allow_tools !== undefined) {
5103
+ const h = patchHubAllowTools(
5104
+ user as Record<string, unknown>,
5105
+ body.hub_allow_tools,
5106
+ );
5107
+ if (!h.ok) return send400(res, h.error);
5108
+ }
5096
5109
  // AG-4b: the Agents tab's layout, a view preference only.
5097
5110
  if (body.agents_layout !== undefined) {
5098
5111
  const l = patchAgentsLayout(user, body.agents_layout);