@monster-orange/den 0.4.24 → 0.4.26

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 CHANGED
@@ -4,6 +4,45 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.26] - 2026-11-10
8
+
9
+ ### Security
10
+
11
+ - **A secret saved to 1Password is never on a command line.** `op`
12
+ wrote the value as an argument (`field[password]=<value>`), which any
13
+ process on the machine can read while `op` runs. Saving the admin
14
+ password (and every other 1Password write) now reads the item as JSON,
15
+ changes the one field and passes it on `op`'s stdin (`op item edit`, or
16
+ `op item create -` for a new item). The item's other fields are kept.
17
+ Nothing to change in config. A sweep of Den's other commands found no
18
+ other secret in argv: sudo and Doppler already read theirs on stdin,
19
+ and agent keys go in the environment.
20
+
21
+ ## [0.4.25] - 2026-11-10
22
+
23
+ ### Added
24
+
25
+ - **Agents can use Hub (HUB-1.5).** With `hub.url` and `hub.token` (a
26
+ secret ref in this install's own store) set, every agent Den launches
27
+ gets Hub's MCP server, so it can search Hub and publish docs and mocks
28
+ there. Hub knows the team from the install's token and the project from
29
+ the workspace's repos. The token stays in the agent's own 0600 MCP
30
+ file. Agents use Hub's tools without asking (`hub.allow_tools`, default
31
+ on; off, they ask like any tool). Settings › Agents › Hub and `doctor`
32
+ say whether Hub is connected, and to which team. Nothing changes
33
+ without `hub.url`. See docs/distributions.md, "Hub".
34
+
35
+ ### Fixed
36
+
37
+ - **Rotation wording.** Add override with no target says "Pick an
38
+ agent", not "Pick a agent". An install select's inherit option is short
39
+ ("inherit (auto)"), with where it comes from in the tag beside it, and
40
+ a select never cuts its words at 375 px.
41
+ - **doctor, an older session host.** A host from an earlier version, with
42
+ no service host behind it, is ℹ "older host (pid N)", not ⚠ "unknown":
43
+ it picks up this version the next time it exits, and nothing needs
44
+ doing.
45
+
7
46
  ## [0.4.24] - 2026-11-10
8
47
 
9
48
  ### Fixed
@@ -911,6 +911,53 @@ later plugins.
911
911
  one line per network. Stacks that are down, and projects with no routes
912
912
  file there, are left alone. (0.3.0-dev.40.)
913
913
 
914
+ ## Hub
915
+
916
+ With `hub.url` set, every agent Den launches gets Hub's tools (HUB-1.5):
917
+ it can search Hub and publish docs and mocks there. Without `hub.url`,
918
+ nothing changes and nothing about Hub shows.
919
+
920
+ ```yaml
921
+ # config/local/user.yml (or the org config; user.yml wins)
922
+ hub:
923
+ url: https://hub.example.com
924
+ token: { vault: Private, item: Acme Hub, field: token }
925
+ # or the name of a secrets.refs entry: token: hub_install
926
+ allow_tools: true # the default: agents use Hub's tools without asking
927
+ ```
928
+
929
+ - `hub.url` must be https. Plain http works only to this machine
930
+ (`localhost`, `*.localhost`).
931
+ - `hub.token` is this install's Hub token (`hubi_…`, one per install,
932
+ made in Hub), as a ref in this install's own secrets store
933
+ (`secrets.provider`). Den never assumes a vault, item or field for it.
934
+ - Each agent's MCP config gets a server named `hub`: Streamable HTTP at
935
+ `<hub.url>/mcp`, with `Authorization: Bearer <token>` and four plain
936
+ headers naming the caller: `X-Den-Agent` (its name), `X-Den-Session`
937
+ (a new opaque id per launch), `X-Den-Workspace` and `X-Den-Repos` (the
938
+ workspace's repos, `owner/repo`, comma-separated). Hub sets the team
939
+ from the token and the project from the repos.
940
+ - Its prompt gets one line: "You can search Hub and publish docs and
941
+ mocks there with the hub\_\* tools; your team and project are set from
942
+ this Den install."
943
+ - The token is only in the agent's own MCP file (mode 0600). It is never
944
+ in a command line, a record, a log, a page or an API answer.
945
+ - `hub.allow_tools` (default `true`): agents use Hub's tools without
946
+ asking (Den allows `mcp__hub` for every agent it launches). The token
947
+ limits writes to this install's team, and Hub keeps every version. Set
948
+ it to `false` and Hub's tools ask before they run, like any tool. An
949
+ explicit deny in the install's own Claude Code permissions still wins.
950
+ Settings › Agents › Hub has the checkbox (it saves to
951
+ `config/local/user.yml`).
952
+ - A Hub that is down, or a token that can't be read, never stops an
953
+ agent: it starts without Hub.
954
+
955
+ Settings › Agents › Hub shows the URL, connected or not, the team and
956
+ this install's label (from Hub's whoami tool), or why it can't connect.
957
+ `GET /api/hub` answers the same. `<cli> doctor` has a Hub row: ✓ when
958
+ connected, ⚠ with the reason when not. A Hub that is down never fails
959
+ doctor.
960
+
914
961
  ## doctor
915
962
 
916
963
  `<cli> doctor` lists every strategy kind in the registry (secrets, tls,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,7 @@ import * as fs from "fs";
50
50
  import * as path from "path";
51
51
  import { MCP_ACTIONS, configuredRuntimeBin } from "../den-config.js";
52
52
  import { rolePrompt } from "./personas.js";
53
+ import { HUB_PROMPT_LINE } from "../hub.js";
53
54
  import { GUARDRAILS_DIR, guardrailHooks, hookCommands } from "./guardrails.js";
54
55
  import { DEFAULT_ROTATE } from "./rotation.js";
55
56
  import { idsInNames } from "./handback.js";
@@ -374,7 +375,17 @@ function common(input: LaunchInput): {
374
375
  )
375
376
  : null;
376
377
  const den = input.denToolsAllowed ? denToolRules(input.mcp.name) : null;
377
- const allow = [...allowRules(input.allow ?? []), ...(den?.allow ?? [])];
378
+ // HUB-1.5: Hub's tools without asking, when hub.allow_tools is on (the
379
+ // default). Claude Code checks deny before allow: an install's deny wins.
380
+ const hubAllow =
381
+ input.mcp.hub?.allowTools === true
382
+ ? [`mcp__${input.mcp.hub.name}`, `mcp__${input.mcp.hub.name}__*`]
383
+ : [];
384
+ const allow = [
385
+ ...allowRules(input.allow ?? []),
386
+ ...(den?.allow ?? []),
387
+ ...hubAllow,
388
+ ];
378
389
  const ask = den?.ask ?? [];
379
390
  const settings =
380
391
  input.stateDir || hooks || allow.length ? settingsPath(input) : null;
@@ -393,7 +404,7 @@ function common(input: LaunchInput): {
393
404
  input.agentId,
394
405
  input.workspace.name,
395
406
  input.mcp.name,
396
- ),
407
+ ) + (input.mcp.hub ? `\n\n${HUB_PROMPT_LINE}` : ""),
397
408
  ],
398
409
  files: {
399
410
  [file]: mcpConfigJson(input.mcp),
@@ -60,6 +60,19 @@ export interface AgentMcp {
60
60
  env: Record<string, string>;
61
61
  /** The workspace it is pinned to. */
62
62
  scope: string;
63
+ /**
64
+ * HUB-1.5: Den Hub's MCP server (Streamable HTTP), when hub.url is set:
65
+ * its base URL and the headers (the bearer token among them). The
66
+ * headers go only into the agent's own 0600 MCP file, never into argv,
67
+ * a record, a log or a page.
68
+ */
69
+ hub?: {
70
+ name: string;
71
+ url: string;
72
+ headers: Record<string, string>;
73
+ /** hub.allow_tools: its tools run without asking (default on). */
74
+ allowTools?: boolean;
75
+ };
63
76
  }
64
77
 
65
78
  export interface LaunchInput {
@@ -322,8 +335,8 @@ export function whichBin(
322
335
  }
323
336
 
324
337
  /**
325
- * A standard MCP config (`mcpServers`, the shape most MCP clients read)
326
- * with Den's server as the only entry.
338
+ * A standard MCP config (`mcpServers`, the shape most MCP clients read):
339
+ * Den's server, and Den Hub's when it is set (HUB-1.5).
327
340
  */
328
341
  export function mcpConfigJson(mcp: AgentMcp): string {
329
342
  const [command, ...args] = mcp.command;
@@ -336,6 +349,15 @@ export function mcpConfigJson(mcp: AgentMcp): string {
336
349
  args,
337
350
  ...(Object.keys(mcp.env).length ? { env: mcp.env } : {}),
338
351
  },
352
+ ...(mcp.hub && mcp.hub.name !== mcp.name
353
+ ? {
354
+ [mcp.hub.name]: {
355
+ type: "http",
356
+ url: `${mcp.hub.url}/mcp`,
357
+ headers: mcp.hub.headers,
358
+ },
359
+ }
360
+ : {}),
339
361
  },
340
362
  },
341
363
  null,
@@ -0,0 +1,345 @@
1
+ /**
2
+ * HUB-1.5: Den Hub for every agent Den launches.
3
+ *
4
+ * hub:
5
+ * url: https://hub.example.com # the Hub's base URL
6
+ * token: { item: …, field: … } # a secret ref (or the name of a
7
+ * # secrets.refs entry), resolved
8
+ * # from this install's own store
9
+ *
10
+ * Read from the org config, then config/local/user.yml (user wins). SEC-0:
11
+ * the token is the install's, from the install's secrets backend; Den
12
+ * never assumes a vault, item or field of its own. With no hub.url,
13
+ * nothing changes and nothing is shown.
14
+ *
15
+ * Every agent gets Hub's MCP server (Streamable HTTP at `<url>/mcp`, with
16
+ * `Authorization: Bearer <token>`) and plain headers naming who calls:
17
+ * X-Den-Agent, X-Den-Session, X-Den-Workspace, X-Den-Repos. Hub picks the
18
+ * team (from the token) and the project (from the repos, then the
19
+ * workspace's name, then the team's default). The token is never logged,
20
+ * never in a page or an API answer: only in the agent's own 0600 MCP file.
21
+ */
22
+ import { readDenConfig, readUserConfig, type DenConfig } from "./den-config.js";
23
+ import {
24
+ getSecretsProvider,
25
+ namedSecretRef,
26
+ readSecretsConfig,
27
+ type SecretRef,
28
+ type SecretsConfig,
29
+ type SecretsProvider,
30
+ } from "./secrets/index.js";
31
+
32
+ /** The server's name in an agent's MCP config. */
33
+ export const HUB_SERVER = "hub";
34
+
35
+ /** The line every agent's prompt gets when Hub is set (generic). */
36
+ export const HUB_PROMPT_LINE =
37
+ "You can search Hub and publish docs and mocks there with the hub_* tools; your team and project are set from this Den install.";
38
+
39
+ export interface HubConfig {
40
+ /** The base URL, no trailing slash. */
41
+ url: string;
42
+ /** The token's ref in this install's store, or null when none is set. */
43
+ tokenRef: SecretRef | null;
44
+ /** Where the ref came from, for messages (never the value). */
45
+ tokenFrom: string;
46
+ /**
47
+ * hub.allow_tools (default true): agents use Hub's tools without asking.
48
+ * Off, they ask like any tool. An install's own deny rule still wins.
49
+ */
50
+ allowTools: boolean;
51
+ }
52
+
53
+ type RawHub =
54
+ | { url?: unknown; token?: unknown; allow_tools?: unknown }
55
+ | undefined;
56
+
57
+ function rawHub(den: unknown, user: unknown): RawHub {
58
+ const d = (den as { hub?: RawHub } | null)?.hub ?? {};
59
+ const u = (user as { hub?: RawHub } | null)?.hub ?? {};
60
+ return { ...d, ...u };
61
+ }
62
+
63
+ /** A URL Den will send a token to: https, or http to this machine. */
64
+ export function hubUrlProblem(url: string): string | null {
65
+ let u: URL;
66
+ try {
67
+ u = new URL(url);
68
+ } catch {
69
+ return `hub.url is not a URL: ${url}`;
70
+ }
71
+ const local =
72
+ ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname) ||
73
+ u.hostname.endsWith(".localhost");
74
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && local))
75
+ return `hub.url must be https (http only to this machine): ${url}`;
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * hub.* (org config, then user.yml), or null without hub.url. A bad value
81
+ * throws naming its key.
82
+ */
83
+ export function readHubConfig(
84
+ den: DenConfig | Record<string, unknown> = readDenConfig(),
85
+ user: Record<string, unknown> = readUserConfig() as Record<string, unknown>,
86
+ secrets: () => SecretsConfig = readSecretsConfig,
87
+ ): HubConfig | null {
88
+ const h = rawHub(den, user);
89
+ if (h?.url === undefined || h.url === null || h.url === "") return null;
90
+ if (typeof h.url !== "string") throw new Error("hub.url must be a string");
91
+ const url = h.url.trim().replace(/\/+$/, "");
92
+ const bad = hubUrlProblem(url);
93
+ if (bad) throw new Error(bad);
94
+ const t = h.token;
95
+ let tokenRef: SecretRef | null = null;
96
+ let tokenFrom = "hub.token";
97
+ if (typeof t === "string" && t.trim()) {
98
+ // The name of a secrets.refs entry.
99
+ tokenFrom = `secrets.refs.${t.trim()}`;
100
+ tokenRef = namedSecretRef(t.trim(), secrets());
101
+ } else if (t && typeof t === "object") {
102
+ const r = t as Record<string, unknown>;
103
+ const s = (v: unknown) => (typeof v === "string" ? v.trim() : "");
104
+ const ref: SecretRef = {
105
+ vault: s(r.vault),
106
+ item: s(r.item),
107
+ field: s(r.field) || s(r.key),
108
+ };
109
+ if (s(r.key)) ref.key = s(r.key);
110
+ tokenRef = ref.item || ref.key || ref.field ? ref : null;
111
+ } else if (t !== undefined && t !== null)
112
+ throw new Error(
113
+ "hub.token must be a secret ref ({ item, field } or { key }) or the name of a secrets.refs entry",
114
+ );
115
+ const at = h.allow_tools;
116
+ if (at !== undefined && at !== null && typeof at !== "boolean")
117
+ throw new Error("hub.allow_tools must be true or false");
118
+ return { url, tokenRef, tokenFrom, allowTools: at !== false };
119
+ }
120
+
121
+ /**
122
+ * Apply a POSTed `hub_allow_tools` to user.yml's hub block (in place): a
123
+ * boolean sets hub.allow_tools, null clears it (back to the default, on).
124
+ */
125
+ export function patchHubAllowTools(
126
+ user: Record<string, unknown>,
127
+ v: unknown,
128
+ ): { ok: true } | { ok: false; error: string } {
129
+ if (v !== null && typeof v !== "boolean")
130
+ return { ok: false, error: "hub_allow_tools must be true, false or null" };
131
+ const hub = { ...((user.hub as Record<string, unknown> | undefined) ?? {}) };
132
+ if (v === null) delete hub.allow_tools;
133
+ else hub.allow_tools = v;
134
+ if (Object.keys(hub).length) user.hub = hub;
135
+ else delete user.hub;
136
+ return { ok: true };
137
+ }
138
+
139
+ /** The token from this install's store, or null (none set, or unreadable). */
140
+ export async function readHubToken(
141
+ cfg: HubConfig,
142
+ provider: () => SecretsProvider = getSecretsProvider,
143
+ ): Promise<string | null> {
144
+ if (!cfg.tokenRef) return null;
145
+ try {
146
+ const v = await provider().tryGet(cfg.tokenRef);
147
+ return v && v.trim() ? v.trim() : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /** Who calls, as Hub reads it: plain names, never a secret. */
154
+ export interface HubCaller {
155
+ agent: string;
156
+ session: string;
157
+ workspace: string;
158
+ repos: string[];
159
+ }
160
+
161
+ /** A header value: one line, printable, bounded. */
162
+ const headerSafe = (v: string) => v.replace(/[^\x20-\x7e]/g, "?").slice(0, 512);
163
+
164
+ export function hubHeaders(
165
+ token: string,
166
+ who: HubCaller,
167
+ ): Record<string, string> {
168
+ return {
169
+ Authorization: `Bearer ${token}`,
170
+ "X-Den-Agent": headerSafe(who.agent),
171
+ "X-Den-Session": headerSafe(who.session),
172
+ "X-Den-Workspace": headerSafe(who.workspace),
173
+ "X-Den-Repos": headerSafe(who.repos.join(",")),
174
+ };
175
+ }
176
+
177
+ /** owner/repo from a catalog entry: its gh slug, else its git remote. */
178
+ export function ownerRepo(
179
+ entry: { gh?: string; remote?: string } | undefined,
180
+ ): string | null {
181
+ if (entry?.gh && /^[^/\s]+\/[^/\s]+$/.test(entry.gh)) return entry.gh;
182
+ const r = entry?.remote?.trim();
183
+ if (!r) return null;
184
+ const m = /[:/]([^/:\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(r);
185
+ return m ? `${m[1]}/${m[2]}` : null;
186
+ }
187
+
188
+ export interface HubWhoami {
189
+ team?: string;
190
+ label?: string;
191
+ }
192
+
193
+ export type HubStatus =
194
+ | { configured: false }
195
+ | {
196
+ configured: true;
197
+ url: string;
198
+ connected: boolean;
199
+ /** hub.allow_tools: agents use Hub's tools without asking. */
200
+ allowTools?: boolean;
201
+ team?: string;
202
+ label?: string;
203
+ /** Why it isn't connected, in words (never the token). */
204
+ problem?: string;
205
+ };
206
+
207
+ /** The first object in a tool result: structured content, else JSON text. */
208
+ function resultObject(r: unknown): Record<string, unknown> | null {
209
+ const o = r as {
210
+ structuredContent?: unknown;
211
+ content?: { type?: string; text?: string }[];
212
+ };
213
+ if (o?.structuredContent && typeof o.structuredContent === "object")
214
+ return o.structuredContent as Record<string, unknown>;
215
+ for (const c of o?.content ?? [])
216
+ if (c?.type === "text" && c.text)
217
+ try {
218
+ const v = JSON.parse(c.text) as unknown;
219
+ if (v && typeof v === "object") return v as Record<string, unknown>;
220
+ } catch {
221
+ /* not JSON */
222
+ }
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Ask Hub who this install is: its whoami tool (the first tool whose name
228
+ * ends in "whoami"), over Streamable HTTP with the token. Bounded by
229
+ * `timeoutMs`; a failure is said in words, without the token.
230
+ */
231
+ export async function hubWhoami(
232
+ url: string,
233
+ token: string,
234
+ opts: { timeoutMs?: number; fetch?: typeof fetch } = {},
235
+ ): Promise<HubWhoami> {
236
+ // Loaded here: only Settings › Hub and doctor ask Hub anything.
237
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
238
+ const { StreamableHTTPClientTransport } =
239
+ await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
240
+ const transport = new StreamableHTTPClientTransport(new URL(`${url}/mcp`), {
241
+ requestInit: { headers: { Authorization: `Bearer ${token}` } },
242
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
243
+ });
244
+ const client = new Client({ name: "den", version: "1" });
245
+ const timeout = opts.timeoutMs ?? 5000;
246
+ let timer: NodeJS.Timeout | undefined;
247
+ const bounded = <T>(p: Promise<T>) =>
248
+ Promise.race([
249
+ p,
250
+ new Promise<never>((_r, rej) => {
251
+ timer = setTimeout(
252
+ () => rej(new Error(`Hub did not answer in ${timeout} ms`)),
253
+ timeout,
254
+ );
255
+ }),
256
+ ]).finally(() => clearTimeout(timer));
257
+ try {
258
+ await bounded(client.connect(transport));
259
+ const tools = await bounded(client.listTools());
260
+ const who = tools.tools.find((t) => /whoami$/.test(t.name));
261
+ if (!who) return {};
262
+ const r = resultObject(
263
+ await bounded(client.callTool({ name: who.name, arguments: {} })),
264
+ );
265
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
266
+ return {
267
+ ...(str(r?.team ?? (r?.team_name as unknown))
268
+ ? { team: str(r?.team ?? r?.team_name)! }
269
+ : {}),
270
+ ...(str(r?.label ?? r?.install ?? r?.install_label)
271
+ ? { label: str(r?.label ?? r?.install ?? r?.install_label)! }
272
+ : {}),
273
+ };
274
+ } finally {
275
+ await client.close().catch(() => undefined);
276
+ }
277
+ }
278
+
279
+ /** A message that is safe to show: the token, if it slipped in, cut out. */
280
+ export function scrubToken(msg: string, token: string | null): string {
281
+ return token ? msg.split(token).join("[token]") : msg;
282
+ }
283
+
284
+ /** Settings › Hub and doctor: set, connected, and to whom. */
285
+ export async function hubStatus(
286
+ deps: {
287
+ config?: () => HubConfig | null;
288
+ token?: (c: HubConfig) => Promise<string | null>;
289
+ whoami?: (url: string, token: string) => Promise<HubWhoami>;
290
+ } = {},
291
+ ): Promise<HubStatus> {
292
+ let cfg: HubConfig | null;
293
+ try {
294
+ cfg = (deps.config ?? (() => readHubConfig()))();
295
+ } catch (e) {
296
+ return {
297
+ configured: true,
298
+ url: "",
299
+ connected: false,
300
+ problem: (e as Error).message,
301
+ };
302
+ }
303
+ if (!cfg) return { configured: false };
304
+ if (!cfg.tokenRef)
305
+ return {
306
+ configured: true,
307
+ url: cfg.url,
308
+ allowTools: cfg.allowTools,
309
+ connected: false,
310
+ problem:
311
+ cfg.tokenFrom === "hub.token"
312
+ ? "no hub.token: set it to the install token's secret ref (Hub › Settings › Den installs makes one)"
313
+ : `hub.token names ${cfg.tokenFrom}, which is not set for this install's secrets backend`,
314
+ };
315
+ const token = await (deps.token ?? ((c) => readHubToken(c)))(cfg);
316
+ if (!token)
317
+ return {
318
+ configured: true,
319
+ url: cfg.url,
320
+ allowTools: cfg.allowTools,
321
+ connected: false,
322
+ problem: `${cfg.tokenFrom} could not be read from this install's secrets store`,
323
+ };
324
+ try {
325
+ const who = await (deps.whoami ?? ((u, t) => hubWhoami(u, t)))(
326
+ cfg.url,
327
+ token,
328
+ );
329
+ return {
330
+ configured: true,
331
+ url: cfg.url,
332
+ allowTools: cfg.allowTools,
333
+ connected: true,
334
+ ...who,
335
+ };
336
+ } catch (e) {
337
+ return {
338
+ configured: true,
339
+ url: cfg.url,
340
+ allowTools: cfg.allowTools,
341
+ connected: false,
342
+ problem: scrubToken(`Hub did not answer: ${(e as Error).message}`, token),
343
+ };
344
+ }
345
+ }
@@ -2,7 +2,7 @@
2
2
  * 1Password strategy — reads/writes item fields with the `op` CLI.
3
3
  * Selected with `secrets.provider: 1password`.
4
4
  */
5
- import { execFile } from "child_process";
5
+ import { execFile, spawn } from "child_process";
6
6
  import { promisify } from "util";
7
7
  import type { PreflightResult, SecretRef, SecretsProvider } from "./types.js";
8
8
  import {
@@ -128,30 +128,108 @@ export class OnePasswordProvider implements SecretsProvider {
128
128
  }
129
129
  }
130
130
 
131
- /** Overwrite the field; an item that isn't there yet is created (a Password item). */
131
+ /**
132
+ * Run `op` with `input` on its stdin. A secret goes this way, never in
133
+ * argv: any process on the machine can read another's command line.
134
+ */
135
+ private async runWithInput(args: string[], input: string): Promise<string> {
136
+ const { token: _t, ...opts } = await this.opOptions();
137
+ return new Promise((resolve, reject) => {
138
+ const child = spawn("op", args, {
139
+ env: opts.env,
140
+ stdio: ["pipe", "pipe", "pipe"],
141
+ });
142
+ let stdout = "";
143
+ let stderr = "";
144
+ let killed = false;
145
+ const timer = setTimeout(() => {
146
+ killed = true;
147
+ child.kill("SIGTERM");
148
+ }, opts.timeout);
149
+ child.stdout.on("data", (d) => (stdout += String(d)));
150
+ child.stderr.on("data", (d) => (stderr += String(d)));
151
+ child.on("error", (e) => {
152
+ clearTimeout(timer);
153
+ reject(e);
154
+ });
155
+ child.on("close", (code) => {
156
+ clearTimeout(timer);
157
+ if (code === 0) resolve(stdout);
158
+ else
159
+ reject(
160
+ Object.assign(new Error(stderr.trim() || `op exited ${code}`), {
161
+ stderr,
162
+ killed,
163
+ code,
164
+ }),
165
+ );
166
+ });
167
+ child.stdin.on("error", () => undefined);
168
+ child.stdin.end(input);
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Overwrite the field; an item that isn't there yet is created (a
174
+ * Password item). The value only ever travels on op's stdin, as the
175
+ * item's JSON (op item edit / op item create -), never in argv.
176
+ */
132
177
  async set(ref: SecretRef, value: string): Promise<void> {
133
178
  const vault = ref.vault ? ["--vault", ref.vault] : [];
134
- const field = `${ref.field}[password]=${value}`;
179
+ type Field = {
180
+ id?: string;
181
+ label?: string;
182
+ type?: string;
183
+ purpose?: string;
184
+ value?: string;
185
+ };
186
+ let item: { id?: string; fields?: Field[] } | null = null;
187
+ let readErr: unknown = null;
135
188
  try {
136
- await this.run(["item", "edit", ref.item, ...vault, field], true);
137
- } catch (err) {
138
- try {
189
+ item = JSON.parse(
139
190
  await this.run(
140
- [
141
- "item",
142
- "create",
143
- "--category",
144
- "password",
145
- "--title",
146
- ref.item,
147
- ...vault,
148
- field,
149
- ],
191
+ ["item", "get", ref.item, ...vault, "--format", "json", "--reveal"],
150
192
  true,
151
- );
152
- } catch {
153
- throw err;
154
- }
193
+ ),
194
+ ) as { id?: string; fields?: Field[] };
195
+ } catch (err) {
196
+ readErr = err;
197
+ }
198
+ if (item) {
199
+ const fields = item.fields ?? [];
200
+ const f = fields.find((x) => x.label === ref.field || x.id === ref.field);
201
+ if (f) {
202
+ f.value = value;
203
+ f.type = "CONCEALED";
204
+ } else fields.push({ label: ref.field, type: "CONCEALED", value });
205
+ item.fields = fields;
206
+ await this.runWithInput(
207
+ ["item", "edit", item.id || ref.item, ...vault],
208
+ JSON.stringify(item),
209
+ );
210
+ return;
211
+ }
212
+ const field: Field =
213
+ ref.field === "password"
214
+ ? {
215
+ id: "password",
216
+ type: "CONCEALED",
217
+ purpose: "PASSWORD",
218
+ label: "password",
219
+ value,
220
+ }
221
+ : { label: ref.field, type: "CONCEALED", value };
222
+ try {
223
+ await this.runWithInput(
224
+ ["item", "create", ...vault, "-"],
225
+ JSON.stringify({
226
+ title: ref.item,
227
+ category: "PASSWORD",
228
+ fields: [field],
229
+ }),
230
+ );
231
+ } catch {
232
+ throw readErr;
155
233
  }
156
234
  }
157
235
 
@@ -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 } : {}),
@@ -473,6 +473,33 @@ export function whereCheck(r: WhereRow): OnboardCheck {
473
473
  };
474
474
  }
475
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
+
476
503
  export async function cmdDoctor(args: string[]): Promise<number> {
477
504
  const probe = args.includes("--probe");
478
505
  const json = args.includes("--json");
@@ -504,6 +531,11 @@ export async function cmdDoctor(args: string[]): Promise<number> {
504
531
  r.checks.push(
505
532
  ...(await whereRows(await defaultWhereDeps())).map(whereCheck),
506
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
+ }
507
539
  printJson(r);
508
540
  return r.ok ? 0 : 1;
509
541
  }
@@ -522,6 +554,12 @@ export async function cmdDoctor(args: string[]): Promise<number> {
522
554
  readBrand().name,
523
555
  ))
524
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
+ }
525
563
  for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
526
564
  const cli = product().cli;
527
565
  if (!probe) {
@@ -115,11 +115,12 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
115
115
  }
116
116
  : host && !host.launchd
117
117
  ? {
118
- // An older host and no service host behind it: what it lacks.
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.
119
120
  what: "session host",
120
- session: `unknown (older host${host.pid ? `, pid ${host.pid}` : ""})`,
121
- status: "warn",
122
- detail: `${older} can't say where it runs, so whether its terminals and agents stop at logout is unknown. It is replaced the next time it exits. ${switchNow}`,
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}`,
123
124
  }
124
125
  : host
125
126
  ? sessionRow("session host", host.launchd, d.cli, loginChosen)
@@ -173,6 +173,8 @@ export interface AgentRouteDeps {
173
173
  instruction?: { quietMs?: number; maxMs?: number };
174
174
  /** AG-18: how "after this turn" polls (tests: shorter). */
175
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>;
176
178
  }
177
179
 
178
180
  const AGENTS_PATH =
@@ -426,6 +428,15 @@ export async function handleAgentRoutes(
426
428
  pathname === "/api/agents/resume-interrupted";
427
429
  if (pathname === "/api/agents/rotation")
428
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
+ }
429
440
  if (
430
441
  pathname !== "/api/personas" &&
431
442
  pathname !== "/api/agents/runtimes" &&
@@ -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);
@@ -748,6 +748,7 @@
748
748
  "ver.usedLatest": "{n} ahora sigue la versión más reciente",
749
749
  "set.agents.personas": "Personas",
750
750
  "set.agents.rotation": "Rotación",
751
+ "set.agents.hub": "Hub",
751
752
  // The Agents tab (AG-4a-term, agents-page.js).
752
753
  "n.agents": "Agentes",
753
754
  "kind.agent": "Agente",
@@ -730,6 +730,7 @@
730
730
  "ver.usedLatest": "{n} は最新バージョンに追従します",
731
731
  "set.agents.personas": "ペルソナ",
732
732
  "set.agents.rotation": "交代",
733
+ "set.agents.hub": "Hub",
733
734
  // The Agents tab (AG-4a-term, agents-page.js).
734
735
  "n.agents": "エージェント",
735
736
  "kind.agent": "エージェント",
@@ -912,6 +912,20 @@ export const DEN_SETTINGS = [
912
912
  read: () => null,
913
913
  },
914
914
 
915
+ {
916
+ id: "agents.hub",
917
+ category: "agents",
918
+ label: "Hub",
919
+ description:
920
+ "Every agent gets Hub's tools: search Hub, publish docs and mocks there. Its team and project come from this install's token and the workspace's repos. Set hub.url and hub.token (a secret ref in this install's store) in config/local/user.yml.",
921
+ keywords: ["hub", "den hub", "mcp", "team", "token", "docs", "mocks"],
922
+ scope: "user",
923
+ control: { type: "custom", render: renderHub },
924
+ keys: ["hub_allow_tools"],
925
+ read: () => null,
926
+ write: (v, ctx) => ctx.post({ hub_allow_tools: v }),
927
+ },
928
+
915
929
  {
916
930
  id: "agents.personas",
917
931
  category: "agents",
@@ -1276,6 +1290,9 @@ function renderRuntimes(host, ctx) {
1276
1290
  void load();
1277
1291
  }
1278
1292
 
1293
+ /** "an agent", "a persona": the article for a lowercase noun. */
1294
+ export const withArticle = (w) => `${/^[aeiou]/.test(w) ? "an" : "a"} ${w}`;
1295
+
1279
1296
  const ROT_SCOPES = [
1280
1297
  ["agent", "Agent", "agent id"],
1281
1298
  ["persona-workspace", "Persona in a workspace", "persona@workspace"],
@@ -1376,7 +1393,7 @@ export function rotationHtml(b) {
1376
1393
  const field = (k, control) =>
1377
1394
  `<div class="rot-f" data-rot-key="${k}"><label for="rot-${k}">${escR(ROT_KEYS.find((x) => x[0] === k)[1])}</label>${control}<span class="set-tag" data-rot-src>${escR(inst[k] !== undefined ? "this install" : inherited(k))}</span></div>`;
1378
1395
  const sel = (k, opts) =>
1379
- `<select id="rot-${k}" data-focus-key="rot-${k}"><option value=""${inst[k] === undefined ? " selected" : ""}>inherit (${escR(inherited(k))})</option>` +
1396
+ `<select id="rot-${k}" data-focus-key="rot-${k}"><option value=""${inst[k] === undefined ? " selected" : ""}>inherit (${escR(rotVal(b.org?.[k] !== undefined ? b.org[k] : b.defaults?.[k]))})</option>` +
1380
1397
  opts
1381
1398
  .map(
1382
1399
  ([v, l]) =>
@@ -1442,6 +1459,68 @@ export function rotationHtml(b) {
1442
1459
  );
1443
1460
  }
1444
1461
 
1462
+ /** HUB-1.5: Settings › Agents › Den Hub (b: GET /api/hub). */
1463
+ export function hubHtml(b) {
1464
+ if (!b || !b.configured) return "";
1465
+ const rows = [
1466
+ ["URL", `<span class="den-mono">${escR(b.url || "not set")}</span>`],
1467
+ [
1468
+ "Status",
1469
+ b.connected
1470
+ ? `<span class="set-tag ok" data-hub-state="connected">Connected</span>`
1471
+ : `<span class="set-tag warn" data-hub-state="down">Not connected</span>`,
1472
+ ],
1473
+ ...(b.team ? [["Team", escR(b.team)]] : []),
1474
+ ...(b.label ? [["This install", escR(b.label)]] : []),
1475
+ ];
1476
+ const tools =
1477
+ `<label class="hub-tools"><input type="checkbox" id="hub-allow-tools"${b.allowTools !== false ? " checked" : ""}> Agents use Hub's tools without asking</label>` +
1478
+ `<p class="set-note" data-hub-tools-note>${
1479
+ b.allowTools !== false
1480
+ ? "Agents can publish and comment in Hub without asking; writes are versioned and stay in this install's team."
1481
+ : "Agents ask before each Hub tool runs."
1482
+ }</p>`;
1483
+ return (
1484
+ `<dl class="hub-st" data-hub>` +
1485
+ rows.map(([k, v]) => `<dt>${escR(k)}</dt><dd>${v}</dd>`).join("") +
1486
+ `</dl>` +
1487
+ tools +
1488
+ (!b.connected && b.problem
1489
+ ? `<p class="set-note" data-hub-problem>${escR(b.problem)}</p>`
1490
+ : "")
1491
+ );
1492
+ }
1493
+
1494
+ /** HUB-1.5: the Den Hub row; with no hub.url, the row isn't shown at all. */
1495
+ function renderHub(host, ctx) {
1496
+ const row = host.closest(".set-row");
1497
+ if (row) row.hidden = true;
1498
+ // Under an invalid config /api/hub answers 422: ask nothing, show nothing.
1499
+ if (ctx.shell?.configInvalid?.() === true) return Promise.resolve();
1500
+ return ctx.shell
1501
+ .api("GET", "/api/hub")
1502
+ .then((b) => {
1503
+ const html = hubHtml(b);
1504
+ host.innerHTML = html;
1505
+ if (row) row.hidden = !html;
1506
+ const box = host.querySelector("#hub-allow-tools");
1507
+ if (box)
1508
+ box.addEventListener("change", () => {
1509
+ box.disabled = true;
1510
+ ctx.shell
1511
+ .api("POST", "/api/settings", { hub_allow_tools: box.checked })
1512
+ .then(() => renderHub(host, ctx))
1513
+ .catch(() => {
1514
+ box.checked = !box.checked;
1515
+ box.disabled = false;
1516
+ });
1517
+ });
1518
+ })
1519
+ .catch(() => {
1520
+ host.innerHTML = "";
1521
+ });
1522
+ }
1523
+
1445
1524
  /** The Rotation row: this install's values, the overrides, + Add override. */
1446
1525
  function renderRotation(host, ctx) {
1447
1526
  const err = () => host.querySelector(".rt-err");
@@ -1562,7 +1641,7 @@ function renderRotation(host, ctx) {
1562
1641
  message:
1563
1642
  scope === "persona-workspace"
1564
1643
  ? "Pick a persona and a workspace."
1565
- : `Pick a ${(ROT_SCOPES.find((x) => x[0] === scope) || [scope, scope])[1].toLowerCase()}.`,
1644
+ : `Pick ${withArticle((ROT_SCOPES.find((x) => x[0] === scope) || [scope, scope])[1].toLowerCase())}.`,
1566
1645
  });
1567
1646
  return;
1568
1647
  }
@@ -451,9 +451,34 @@
451
451
  min-width: 0;
452
452
  max-width: 100%;
453
453
  }
454
+ /* A select never cuts its own words: it wraps to its own line. */
455
+ .rot-f select {
456
+ min-width: min(100%, max-content);
457
+ }
454
458
  .rot-add {
455
459
  margin-top: 10px;
456
460
  }
461
+ /* HUB-1.5: Settings › Agents › Hub. */
462
+ .hub-st {
463
+ display: grid;
464
+ grid-template-columns: max-content 1fr;
465
+ gap: 4px 12px;
466
+ margin: 0;
467
+ }
468
+ .hub-st dt {
469
+ color: var(--muted);
470
+ }
471
+ .hub-st dd {
472
+ margin: 0;
473
+ min-width: 0;
474
+ overflow-wrap: anywhere;
475
+ }
476
+ .hub-tools {
477
+ display: flex;
478
+ gap: 8px;
479
+ align-items: center;
480
+ margin-top: 10px;
481
+ }
457
482
  /* D30: the add form: each control with its label, wrapping. */
458
483
  .rot-pick {
459
484
  display: inline-flex;
@@ -856,6 +856,7 @@
856
856
  "ver.usedLatest": "{n} now tracks the latest version",
857
857
  "set.agents.personas": "Personas",
858
858
  "set.agents.rotation": "Rotation",
859
+ "set.agents.hub": "Hub",
859
860
  // The Agents tab (AG-4a-term, agents-page.js).
860
861
  "n.agents": "Agents",
861
862
  "kind.agent": "Agent",
@@ -388,6 +388,10 @@ export const ROUTES: readonly RouteEntry[] = [
388
388
  "/api/agents/rotation",
389
389
  "D28: rotation settings, the overrides and an agent's effective values",
390
390
  ),
391
+ R(
392
+ "/api/hub",
393
+ "HUB-1.5: Den Hub: its URL, connected or not, the team and the install's label (never the token)",
394
+ ),
391
395
  M(
392
396
  ["PUT"],
393
397
  "/api/agents/rotation",