@monster-orange/den 0.4.24 → 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.
- package/CHANGELOG.md +25 -0
- package/docs/distributions.md +47 -0
- package/package.json +1 -1
- package/src/shared/agents/den-claude.ts +13 -2
- package/src/shared/agents/runtime.ts +24 -2
- package/src/shared/hub.ts +345 -0
- package/src/ui/cli/agent-ops.ts +106 -3
- package/src/ui/cli/doctor-command.ts +38 -0
- package/src/ui/cli/doctor-where.ts +5 -4
- package/src/ui/server/agent-routes.ts +11 -0
- package/src/ui/server/index.ts +13 -0
- package/src/ui/server/public/i18n/es.js +1 -0
- package/src/ui/server/public/i18n/ja.js +1 -0
- package/src/ui/server/public/settings-registry.js +81 -2
- package/src/ui/server/public/settings.html +25 -0
- package/src/ui/server/public/shell.js +1 -0
- package/src/ui/server/route-classes.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ 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.25] - 2026-11-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Agents can use Hub (HUB-1.5).** With `hub.url` and `hub.token` (a
|
|
12
|
+
secret ref in this install's own store) set, every agent Den launches
|
|
13
|
+
gets Hub's MCP server, so it can search Hub and publish docs and mocks
|
|
14
|
+
there. Hub knows the team from the install's token and the project from
|
|
15
|
+
the workspace's repos. The token stays in the agent's own 0600 MCP
|
|
16
|
+
file. Agents use Hub's tools without asking (`hub.allow_tools`, default
|
|
17
|
+
on; off, they ask like any tool). Settings › Agents › Hub and `doctor`
|
|
18
|
+
say whether Hub is connected, and to which team. Nothing changes
|
|
19
|
+
without `hub.url`. See docs/distributions.md, "Hub".
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Rotation wording.** Add override with no target says "Pick an
|
|
24
|
+
agent", not "Pick a agent". An install select's inherit option is short
|
|
25
|
+
("inherit (auto)"), with where it comes from in the tag beside it, and
|
|
26
|
+
a select never cuts its words at 375 px.
|
|
27
|
+
- **doctor, an older session host.** A host from an earlier version, with
|
|
28
|
+
no service host behind it, is ℹ "older host (pid N)", not ⚠ "unknown":
|
|
29
|
+
it picks up this version the next time it exits, and nothing needs
|
|
30
|
+
doing.
|
|
31
|
+
|
|
7
32
|
## [0.4.24] - 2026-11-10
|
|
8
33
|
|
|
9
34
|
### Fixed
|
package/docs/distributions.md
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -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
|
|
1803
|
-
|
|
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:
|
|
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: `
|
|
121
|
-
status: "
|
|
122
|
-
detail: `${older}
|
|
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" &&
|
package/src/ui/server/index.ts
CHANGED
|
@@ -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",
|
|
@@ -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(
|
|
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
|
|
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",
|