@monster-orange/den 0.4.23 → 0.4.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/docs/distributions.md +47 -0
- package/package.json +1 -1
- package/service/install.sh +5 -3
- 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 +171 -3
- package/src/ui/cli/doctor-command.ts +62 -1
- package/src/ui/cli/doctor-where.ts +60 -13
- package/src/ui/server/agent-routes.ts +24 -1
- package/src/ui/server/index.ts +13 -0
- package/src/ui/server/public/combobox.js +150 -0
- package/src/ui/server/public/error-page.js +26 -14
- package/src/ui/server/public/i18n/es.js +4 -2
- package/src/ui/server/public/i18n/ja.js +4 -2
- package/src/ui/server/public/settings-registry.js +218 -25
- package/src/ui/server/public/settings.html +149 -1
- package/src/ui/server/public/shell.js +21 -14
- package/src/ui/server/public/svc-panel.js +54 -15
- package/src/ui/server/route-classes.ts +4 -0
- package/src/ui/server/ui-assets.ts +2 -0
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 } : {}),
|
|
@@ -3369,6 +3472,71 @@ export async function resumeOrRelaunch(
|
|
|
3369
3472
|
}
|
|
3370
3473
|
|
|
3371
3474
|
/** D28: the rotation settings, and agent `agent`'s effective values. */
|
|
3475
|
+
/** D30: what the rotation overrides can name, with names to show. */
|
|
3476
|
+
export interface RotationChoices {
|
|
3477
|
+
agents: { id: string; name: string; workspace?: string; state: string }[];
|
|
3478
|
+
personas: { id: string; name: string; category: string }[];
|
|
3479
|
+
workspaces: { id: string; name: string }[];
|
|
3480
|
+
services: { id: string; name: string; workspace: string }[];
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
/**
|
|
3484
|
+
* D30: the overrides form's pickers: every agent (its name, workspace and
|
|
3485
|
+
* state), persona (name and category), workspace and workspace service.
|
|
3486
|
+
* Never starts a session host to ask (a stopped one: agents from their
|
|
3487
|
+
* records). A part that can't be read is empty, not an error.
|
|
3488
|
+
*/
|
|
3489
|
+
export async function rotationChoices(
|
|
3490
|
+
d: AgentsResolved,
|
|
3491
|
+
workspaces: string[],
|
|
3492
|
+
): Promise<RotationChoices> {
|
|
3493
|
+
let sessions: SessionInfo[] = [];
|
|
3494
|
+
try {
|
|
3495
|
+
const c = await d.connectExisting();
|
|
3496
|
+
try {
|
|
3497
|
+
sessions = c ? await c.list() : [];
|
|
3498
|
+
} finally {
|
|
3499
|
+
c?.close();
|
|
3500
|
+
}
|
|
3501
|
+
} catch {
|
|
3502
|
+
sessions = [];
|
|
3503
|
+
}
|
|
3504
|
+
const records = listRecords(d.stateDir());
|
|
3505
|
+
const agents = agentRows(sessions, records, d.stateDir())
|
|
3506
|
+
.filter((r) => r.agent)
|
|
3507
|
+
.map((r) => ({
|
|
3508
|
+
id: r.agent,
|
|
3509
|
+
name: r.displayName || r.sessionTitle || r.agent,
|
|
3510
|
+
...(r.workspace ? { workspace: r.workspace } : {}),
|
|
3511
|
+
state: r.state,
|
|
3512
|
+
}));
|
|
3513
|
+
let personaList: RotationChoices["personas"] = [];
|
|
3514
|
+
try {
|
|
3515
|
+
personaList = listPersonas(d).map((p) => ({
|
|
3516
|
+
id: p.id,
|
|
3517
|
+
name: p.displayName ? `${p.displayName} (${p.id})` : p.id,
|
|
3518
|
+
category: p.category,
|
|
3519
|
+
}));
|
|
3520
|
+
} catch {
|
|
3521
|
+
personaList = [];
|
|
3522
|
+
}
|
|
3523
|
+
const services: RotationChoices["services"] = [];
|
|
3524
|
+
for (const ws of workspaces)
|
|
3525
|
+
try {
|
|
3526
|
+
for (const sv of d.workspaceServices(ws))
|
|
3527
|
+
if (!services.some((x) => x.id === sv.address))
|
|
3528
|
+
services.push({ id: sv.address, name: sv.address, workspace: ws });
|
|
3529
|
+
} catch {
|
|
3530
|
+
/* a workspace whose services can't be read: none */
|
|
3531
|
+
}
|
|
3532
|
+
return {
|
|
3533
|
+
agents,
|
|
3534
|
+
personas: personaList,
|
|
3535
|
+
workspaces: workspaces.map((w) => ({ id: w, name: w })),
|
|
3536
|
+
services,
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3372
3540
|
export function rotationSettings(
|
|
3373
3541
|
d: AgentsResolved,
|
|
3374
3542
|
agent?: string,
|
|
@@ -69,11 +69,13 @@ import { printJson } from "./json-out.js";
|
|
|
69
69
|
import { ownerCheck, ownerLine } from "./doctor-owner.js";
|
|
70
70
|
import {
|
|
71
71
|
formatWhere,
|
|
72
|
+
launchdJobPid,
|
|
72
73
|
serviceJobState,
|
|
73
74
|
whereRows,
|
|
74
75
|
type WhereDeps,
|
|
75
76
|
type WhereRow,
|
|
76
77
|
} from "./doctor-where.js";
|
|
78
|
+
import { hostLabel } from "./service-command.js";
|
|
77
79
|
|
|
78
80
|
export interface DoctorRow {
|
|
79
81
|
kind: string;
|
|
@@ -420,6 +422,8 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
|
|
|
420
422
|
try {
|
|
421
423
|
return {
|
|
422
424
|
launchd: c.hostLaunchd,
|
|
425
|
+
pid: c.hostPid,
|
|
426
|
+
startedAt: c.hostStartedAt || undefined,
|
|
423
427
|
access: await c.access().catch(() => null),
|
|
424
428
|
};
|
|
425
429
|
} finally {
|
|
@@ -438,6 +442,24 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
|
|
|
438
442
|
return null;
|
|
439
443
|
}
|
|
440
444
|
},
|
|
445
|
+
hostJob: () => {
|
|
446
|
+
try {
|
|
447
|
+
const { label } = readWebServiceConfig(
|
|
448
|
+
cfg(),
|
|
449
|
+
getDistRoot(),
|
|
450
|
+
os.homedir(),
|
|
451
|
+
);
|
|
452
|
+
const host = hostLabel(label);
|
|
453
|
+
const job = serviceJobState(
|
|
454
|
+
host,
|
|
455
|
+
os.homedir(),
|
|
456
|
+
process.getuid?.() ?? 0,
|
|
457
|
+
);
|
|
458
|
+
return job?.state === "loaded" ? launchdJobPid(job.domain, host) : null;
|
|
459
|
+
} catch {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
},
|
|
441
463
|
};
|
|
442
464
|
}
|
|
443
465
|
|
|
@@ -445,11 +467,39 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
|
|
|
445
467
|
export function whereCheck(r: WhereRow): OnboardCheck {
|
|
446
468
|
return {
|
|
447
469
|
id: `where:${r.what.replace(" ", "-")}`,
|
|
448
|
-
|
|
470
|
+
// info is as chosen: not a problem.
|
|
471
|
+
status: r.status === "info" ? "ok" : r.status,
|
|
449
472
|
detail: `${r.session}${r.detail ? `: ${r.detail}` : ""}`,
|
|
450
473
|
};
|
|
451
474
|
}
|
|
452
475
|
|
|
476
|
+
/**
|
|
477
|
+
* HUB-1.5: doctor's Hub row, or none when hub.url isn't set. Never the
|
|
478
|
+
* token: hubStatus doesn't have it to give.
|
|
479
|
+
*/
|
|
480
|
+
export function hubLines(
|
|
481
|
+
st: import("../../shared/hub.js").HubStatus,
|
|
482
|
+
title: string,
|
|
483
|
+
): { lines: string[]; problem: boolean; check?: OnboardCheck } {
|
|
484
|
+
if (!st.configured) return { lines: [], problem: false };
|
|
485
|
+
const who = [st.team && `team ${st.team}`, st.label && `install ${st.label}`]
|
|
486
|
+
.filter(Boolean)
|
|
487
|
+
.join(", ");
|
|
488
|
+
const detail = st.connected
|
|
489
|
+
? `${st.url}: connected${who ? ` (${who})` : ""}`
|
|
490
|
+
: `${st.url || "hub.url"}: not connected: ${st.problem ?? "no answer"}`;
|
|
491
|
+
return {
|
|
492
|
+
lines: [
|
|
493
|
+
"",
|
|
494
|
+
`${title} doctor: Hub`,
|
|
495
|
+
"",
|
|
496
|
+
` hub ${st.connected ? "✓" : "⚠"} ${detail}`,
|
|
497
|
+
],
|
|
498
|
+
problem: false, // a Hub that is down never fails doctor: agents still run
|
|
499
|
+
check: { id: "hub", status: st.connected ? "ok" : "warn", detail },
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
453
503
|
export async function cmdDoctor(args: string[]): Promise<number> {
|
|
454
504
|
const probe = args.includes("--probe");
|
|
455
505
|
const json = args.includes("--json");
|
|
@@ -481,6 +531,11 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
481
531
|
r.checks.push(
|
|
482
532
|
...(await whereRows(await defaultWhereDeps())).map(whereCheck),
|
|
483
533
|
);
|
|
534
|
+
{
|
|
535
|
+
const { hubStatus } = await import("../../shared/hub.js");
|
|
536
|
+
const h = hubLines(await hubStatus(), readBrand().name);
|
|
537
|
+
if (h.check) r.checks.push(h.check);
|
|
538
|
+
}
|
|
484
539
|
printJson(r);
|
|
485
540
|
return r.ok ? 0 : 1;
|
|
486
541
|
}
|
|
@@ -499,6 +554,12 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
499
554
|
readBrand().name,
|
|
500
555
|
))
|
|
501
556
|
console.log(l);
|
|
557
|
+
{
|
|
558
|
+
// HUB-1.5: only when hub.url is set.
|
|
559
|
+
const { hubStatus } = await import("../../shared/hub.js");
|
|
560
|
+
for (const l of hubLines(await hubStatus(), readBrand().name).lines)
|
|
561
|
+
console.log(l);
|
|
562
|
+
}
|
|
502
563
|
for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
|
|
503
564
|
const cli = product().cli;
|
|
504
565
|
if (!probe) {
|
|
@@ -41,15 +41,22 @@ export interface WhereDeps {
|
|
|
41
41
|
/** The running session host's; null when none runs. */
|
|
42
42
|
host: () => Promise<{
|
|
43
43
|
launchd: string | null;
|
|
44
|
+
/** The pid that answers on the socket. */
|
|
45
|
+
pid?: number;
|
|
46
|
+
/** When it started (ISO). */
|
|
47
|
+
startedAt?: string;
|
|
44
48
|
access?: { exe: string; fullDiskAccess: boolean | null } | null;
|
|
45
49
|
} | null>;
|
|
46
50
|
service: () => ServiceJob | null;
|
|
51
|
+
/** The service's own host job's pid (it may stand by), or null. */
|
|
52
|
+
hostJob?: () => number | null;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export interface WhereRow {
|
|
50
56
|
what: "server" | "session host" | "full disk access" | "service";
|
|
51
57
|
session: string;
|
|
52
|
-
|
|
58
|
+
/** info: as chosen, worth knowing (login mode ends at logout). */
|
|
59
|
+
status: "ok" | "info" | "warn";
|
|
53
60
|
detail?: string;
|
|
54
61
|
}
|
|
55
62
|
|
|
@@ -57,12 +64,15 @@ function sessionRow(
|
|
|
57
64
|
what: "server" | "session host",
|
|
58
65
|
launchd: string | null | undefined,
|
|
59
66
|
cli: string,
|
|
67
|
+
loginChosen = false,
|
|
60
68
|
): WhereRow {
|
|
61
69
|
const session = launchd || "unknown";
|
|
62
70
|
return {
|
|
63
71
|
what,
|
|
64
72
|
session,
|
|
65
|
-
|
|
73
|
+
// The service installed in login mode: ending at logout is what was
|
|
74
|
+
// chosen (the default), so it is said, not warned about.
|
|
75
|
+
status: endsAtLogout(launchd) ? (loginChosen ? "info" : "warn") : "ok",
|
|
66
76
|
...(sessionWarning(launchd, cli, what)
|
|
67
77
|
? { detail: sessionWarning(launchd, cli, what)! }
|
|
68
78
|
: {}),
|
|
@@ -72,10 +82,12 @@ function sessionRow(
|
|
|
72
82
|
export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
|
|
73
83
|
if (d.platform !== "darwin") return [];
|
|
74
84
|
const rows: WhereRow[] = [];
|
|
85
|
+
const svc = d.service();
|
|
86
|
+
const loginChosen = svc?.mode === "login" && svc.state !== "not-loaded";
|
|
75
87
|
const server = await d.server().catch(() => null);
|
|
76
88
|
rows.push(
|
|
77
89
|
server
|
|
78
|
-
? sessionRow("server", server.launchd, d.cli)
|
|
90
|
+
? sessionRow("server", server.launchd, d.cli, loginChosen)
|
|
79
91
|
: {
|
|
80
92
|
// Not started yet is not a problem (a fresh install).
|
|
81
93
|
what: "server",
|
|
@@ -85,15 +97,39 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
|
|
|
85
97
|
},
|
|
86
98
|
);
|
|
87
99
|
const host = await d.host().catch(() => null);
|
|
100
|
+
const jobPid = host ? (d.hostJob?.() ?? null) : null;
|
|
101
|
+
// The service's host stands by while another host holds the socket, and
|
|
102
|
+
// takes over when that one exits: nothing to do.
|
|
103
|
+
const standsBy =
|
|
104
|
+
jobPid !== null && host?.pid !== undefined && jobPid !== host.pid;
|
|
105
|
+
const since = host?.startedAt ? `, since ${host.startedAt.slice(0, 10)}` : "";
|
|
106
|
+
const older = `an older session host (pid ${host?.pid ?? "?"}${since})`;
|
|
107
|
+
const switchNow = `To switch now (ends running terminals and agents): ${d.cli} sessions host --stop`;
|
|
88
108
|
rows.push(
|
|
89
|
-
host
|
|
90
|
-
?
|
|
91
|
-
: {
|
|
109
|
+
host && standsBy
|
|
110
|
+
? {
|
|
92
111
|
what: "session host",
|
|
93
|
-
session:
|
|
94
|
-
status: "
|
|
95
|
-
detail:
|
|
96
|
-
}
|
|
112
|
+
session: host.launchd || `older host (pid ${host.pid})`,
|
|
113
|
+
status: "info",
|
|
114
|
+
detail: `${older} is serving; the service's host (pid ${jobPid}) takes over when it exits. Nothing to do. ${switchNow}`,
|
|
115
|
+
}
|
|
116
|
+
: host && !host.launchd
|
|
117
|
+
? {
|
|
118
|
+
// An older host and no service host behind it: from an earlier
|
|
119
|
+
// version, so it can't say where it runs. Nothing is wrong.
|
|
120
|
+
what: "session host",
|
|
121
|
+
session: `older host${host.pid ? ` (pid ${host.pid})` : ""}`,
|
|
122
|
+
status: "info",
|
|
123
|
+
detail: `${older} is serving, from before this version, so it can't say whether its terminals and agents stop at logout. It picks up this version the next time it exits. Nothing to do. ${switchNow}`,
|
|
124
|
+
}
|
|
125
|
+
: host
|
|
126
|
+
? sessionRow("session host", host.launchd, d.cli, loginChosen)
|
|
127
|
+
: {
|
|
128
|
+
what: "session host",
|
|
129
|
+
session: "not running",
|
|
130
|
+
status: "ok",
|
|
131
|
+
detail: "it starts when the first terminal or agent does",
|
|
132
|
+
},
|
|
97
133
|
);
|
|
98
134
|
const fda = host?.access;
|
|
99
135
|
if (fda && fda.fullDiskAccess !== null) {
|
|
@@ -116,7 +152,6 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
|
|
|
116
152
|
},
|
|
117
153
|
);
|
|
118
154
|
}
|
|
119
|
-
const svc = d.service();
|
|
120
155
|
if (!svc)
|
|
121
156
|
rows.push({
|
|
122
157
|
what: "service",
|
|
@@ -145,6 +180,17 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
|
|
|
145
180
|
return rows;
|
|
146
181
|
}
|
|
147
182
|
|
|
183
|
+
/** The pid launchd runs `<domain>/<label>` as, or null (launchctl print). */
|
|
184
|
+
export function launchdJobPid(domain: string, label: string): number | null {
|
|
185
|
+
const r = spawnSync("launchctl", ["print", `${domain}/${label}`], {
|
|
186
|
+
encoding: "utf-8",
|
|
187
|
+
timeout: 3000,
|
|
188
|
+
});
|
|
189
|
+
if (r.status !== 0) return null;
|
|
190
|
+
const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
|
|
191
|
+
return m ? Number(m[1]) : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
148
194
|
export interface JobProbe {
|
|
149
195
|
exists: (p: string) => boolean;
|
|
150
196
|
/** `launchctl print <domain>/<label>`'s exit status (127: no launchctl). */
|
|
@@ -195,9 +241,10 @@ export function formatWhere(rows: WhereRow[], title: string): string[] {
|
|
|
195
241
|
const lines = ["", `${title} doctor: where it runs`, ""];
|
|
196
242
|
const w = Math.max(...rows.map((r) => r.session.length));
|
|
197
243
|
for (const r of rows) {
|
|
198
|
-
const mark = r.status === "ok" ? "✓" : "⚠";
|
|
244
|
+
const mark = r.status === "ok" ? "✓" : r.status === "info" ? "ℹ" : "⚠";
|
|
199
245
|
lines.push(` ${r.what.padEnd(16)} ${r.session.padEnd(w)} ${mark}`);
|
|
200
|
-
if (r.detail)
|
|
246
|
+
if (r.detail)
|
|
247
|
+
lines.push(` ${r.status === "warn" ? "⚠ " : ""}${r.detail}`);
|
|
201
248
|
}
|
|
202
249
|
return lines;
|
|
203
250
|
}
|
|
@@ -138,6 +138,7 @@ import {
|
|
|
138
138
|
resumeOrRelaunch,
|
|
139
139
|
keepAgentConversation,
|
|
140
140
|
askForHandoff,
|
|
141
|
+
rotationChoices,
|
|
141
142
|
rotationSettings,
|
|
142
143
|
setRotationSetting,
|
|
143
144
|
rotationTick,
|
|
@@ -172,6 +173,8 @@ export interface AgentRouteDeps {
|
|
|
172
173
|
instruction?: { quietMs?: number; maxMs?: number };
|
|
173
174
|
/** AG-18: how "after this turn" polls (tests: shorter). */
|
|
174
175
|
personaWait?: { pollMs?: number; maxMs?: number };
|
|
176
|
+
/** HUB-1.5: GET /api/hub's answer (tests: a fake Hub). */
|
|
177
|
+
hubStatus?: () => Promise<import("../../shared/hub.js").HubStatus>;
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
const AGENTS_PATH =
|
|
@@ -425,6 +428,15 @@ export async function handleAgentRoutes(
|
|
|
425
428
|
pathname === "/api/agents/resume-interrupted";
|
|
426
429
|
if (pathname === "/api/agents/rotation")
|
|
427
430
|
return rotationRoute(req, res, webDeps(deps.agents));
|
|
431
|
+
// HUB-1.5: Settings › Hub. Never the token: hubStatus doesn't return it.
|
|
432
|
+
if (pathname === "/api/hub") {
|
|
433
|
+
const method = req.method ?? "GET";
|
|
434
|
+
if (method !== "GET" && method !== "HEAD")
|
|
435
|
+
return methodNotAllowed(res, "GET");
|
|
436
|
+
const { hubStatus } = await import("../../shared/hub.js");
|
|
437
|
+
sendJson(res, await (deps.hubStatus ?? (() => hubStatus()))());
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
428
440
|
if (
|
|
429
441
|
pathname !== "/api/personas" &&
|
|
430
442
|
pathname !== "/api/agents/runtimes" &&
|
|
@@ -1328,7 +1340,18 @@ async function rotationRoute(
|
|
|
1328
1340
|
const agent = q.get("agent") ?? undefined;
|
|
1329
1341
|
if ((ws && !NAME_RE.test(ws)) || (agent && !NAME_RE.test(agent)))
|
|
1330
1342
|
throw new AgentError("not a name", { usage: true });
|
|
1331
|
-
|
|
1343
|
+
// D30: ?choices=1 adds what the overrides form can pick, by name.
|
|
1344
|
+
sendJson(res, {
|
|
1345
|
+
...rotationSettings(d, agent, ws),
|
|
1346
|
+
...(q.get("choices") === "1"
|
|
1347
|
+
? {
|
|
1348
|
+
choices: await rotationChoices(
|
|
1349
|
+
d,
|
|
1350
|
+
Object.keys(readWorkspacesFile().workspaces ?? {}),
|
|
1351
|
+
),
|
|
1352
|
+
}
|
|
1353
|
+
: {}),
|
|
1354
|
+
});
|
|
1332
1355
|
return true;
|
|
1333
1356
|
}
|
|
1334
1357
|
if (method !== "PUT") return methodNotAllowed(res, "GET, PUT");
|
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);
|