@monster-orange/den 0.4.3 → 0.4.4
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 +179 -0
- package/README.md +9 -9
- package/docs/distributions.md +215 -65
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +57 -3
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/ui/cli/agent-ops.ts +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- package/src/ui/server/index.ts +77 -6
- package/src/ui/server/public/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +15 -13
- package/src/ui/server/public/i18n/ja.js +14 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +28 -17
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +3 -2
- package/src/ui/server/public/settings-registry.js +225 -0
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +28 -23
- package/src/ui/server/public/topbar.js +166 -206
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +1 -1
- package/src/ui/server/route-classes.ts +14 -2
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -86,6 +86,10 @@ export function spawnDaemon(
|
|
|
86
86
|
// Stamp the log so the tail starts reading from a clean boundary.
|
|
87
87
|
const ts = new Date().toISOString();
|
|
88
88
|
fs.appendFileSync(logPath, `\n\n─── ${product().cli} start ${ts} ───\n`);
|
|
89
|
+
// Where this run's output starts. The tail reads from here, not from "the
|
|
90
|
+
// end when tail opened": a fast command (a refused sudo, a typo) can write
|
|
91
|
+
// and exit before tail starts, and `-n 0` would drop those lines.
|
|
92
|
+
const startOffset = fs.statSync(logPath).size;
|
|
89
93
|
|
|
90
94
|
// Open the log fd in the parent so we can hand it to the child.
|
|
91
95
|
// MUST stay "a" (O_APPEND): rotation truncates this file in place, and only
|
|
@@ -120,9 +124,10 @@ export function spawnDaemon(
|
|
|
120
124
|
fs.closeSync(logFd); // parent closes its copy; child keeps its own fd
|
|
121
125
|
|
|
122
126
|
// Tail the log for streaming back to the caller.
|
|
123
|
-
// -F follows by name (handles log rotation). -
|
|
124
|
-
// after
|
|
125
|
-
|
|
127
|
+
// -F follows by name (handles log rotation). -c +N starts at this run's
|
|
128
|
+
// first byte (just after the stamp), so nothing the service wrote before
|
|
129
|
+
// tail opened the file is lost.
|
|
130
|
+
const tailProc = spawn("tail", ["-F", "-c", `+${startOffset + 1}`, logPath], {
|
|
126
131
|
stdio: ["ignore", "pipe", "ignore"],
|
|
127
132
|
});
|
|
128
133
|
|
|
@@ -76,7 +76,8 @@ export class HostClient extends EventEmitter {
|
|
|
76
76
|
const h = this.attachments.get(ev.aid);
|
|
77
77
|
if (!h) return;
|
|
78
78
|
if (ev.ev === "data") h.onData(Buffer.from(ev.data, "base64"));
|
|
79
|
-
else
|
|
79
|
+
else if (ev.ev === "size") h.onSize?.(ev.cols, ev.rows);
|
|
80
|
+
else if (ev.ev === "exit") {
|
|
80
81
|
this.attachments.delete(ev.aid);
|
|
81
82
|
h.onExit?.(ev.exitCode);
|
|
82
83
|
}
|
|
@@ -174,6 +175,9 @@ export class HostClient extends EventEmitter {
|
|
|
174
175
|
ref: id,
|
|
175
176
|
aid,
|
|
176
177
|
...(opts.replay !== undefined ? { replay: opts.replay } : {}),
|
|
178
|
+
// Only a client that handles them is sent size events: an older
|
|
179
|
+
// client would read an unknown event as the session's exit.
|
|
180
|
+
...(handlers.onSize ? { sizes: true } : {}),
|
|
177
181
|
}).then(
|
|
178
182
|
(r) => r.info as SessionInfo,
|
|
179
183
|
(e: Error) => {
|
|
@@ -63,6 +63,10 @@ interface Session {
|
|
|
63
63
|
log: ScrollbackLog;
|
|
64
64
|
/** aid → its connection. */
|
|
65
65
|
attachments: Map<string, Conn>;
|
|
66
|
+
/** The attachments that asked for size events (attach `sizes: true`). */
|
|
67
|
+
sizeAids?: Set<string>;
|
|
68
|
+
/** The PTY's size: the start's, then the last resize's (any client's). */
|
|
69
|
+
size?: { cols: number; rows: number };
|
|
66
70
|
/** The cap it counts against (start's `pool`), when one was named. */
|
|
67
71
|
pool?: SessionPool;
|
|
68
72
|
}
|
|
@@ -453,7 +457,15 @@ export class SessionHost {
|
|
|
453
457
|
const s = this.session(req.ref);
|
|
454
458
|
if (!isInt(req.cols, 1, 10_000) || !isInt(req.rows, 1, 10_000))
|
|
455
459
|
throw new Refusal("bad-request", "resize needs whole cols and rows");
|
|
456
|
-
if (s.pty && !s.info.exited)
|
|
460
|
+
if (s.pty && !s.info.exited) {
|
|
461
|
+
s.pty.resize(req.cols, req.rows);
|
|
462
|
+
// Last writer wins: every viewer that asked hears the new size,
|
|
463
|
+
// so one out of step knows to say its own again.
|
|
464
|
+
if (s.size?.cols !== req.cols || s.size?.rows !== req.rows) {
|
|
465
|
+
s.size = { cols: req.cols, rows: req.rows };
|
|
466
|
+
this.sendSize(s);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
457
469
|
return {};
|
|
458
470
|
}
|
|
459
471
|
case "signal": {
|
|
@@ -606,6 +618,7 @@ export class SessionHost {
|
|
|
606
618
|
},
|
|
607
619
|
),
|
|
608
620
|
attachments: new Map(),
|
|
621
|
+
size: { cols, rows },
|
|
609
622
|
...(pool !== undefined ? { pool: pool as SessionPool } : {}),
|
|
610
623
|
};
|
|
611
624
|
this.sessions.set(id, s);
|
|
@@ -626,6 +639,7 @@ export class SessionHost {
|
|
|
626
639
|
for (const [aid, c] of s.attachments)
|
|
627
640
|
c.send({ ev: "exit", aid, exitCode: s.info.exitCode });
|
|
628
641
|
s.attachments.clear();
|
|
642
|
+
s.sizeAids?.clear();
|
|
629
643
|
this.log(`session ${id} (${name}) exited (${s.info.exitCode})`);
|
|
630
644
|
}, EXIT_SETTLE_MS);
|
|
631
645
|
});
|
|
@@ -654,10 +668,24 @@ export class SessionHost {
|
|
|
654
668
|
} else {
|
|
655
669
|
conn.aids.add(aid);
|
|
656
670
|
s.attachments.set(aid, conn);
|
|
671
|
+
if (req.sizes === true) {
|
|
672
|
+
(s.sizeAids ??= new Set()).add(aid);
|
|
673
|
+
if (s.size) conn.send({ ev: "size", aid, ...s.size });
|
|
674
|
+
}
|
|
657
675
|
}
|
|
658
676
|
return s.info;
|
|
659
677
|
}
|
|
660
678
|
|
|
679
|
+
/** Tell each attachment that asked the session's current size. */
|
|
680
|
+
private sendSize(s: Session): void {
|
|
681
|
+
if (!s.size || !s.sizeAids?.size) return;
|
|
682
|
+
for (const aid of s.sizeAids) {
|
|
683
|
+
const c = s.attachments.get(aid);
|
|
684
|
+
if (c) c.send({ ev: "size", aid, ...s.size });
|
|
685
|
+
else s.sizeAids.delete(aid);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
661
689
|
/** Stop serving: running sessions get SIGHUP, scrollback is flushed. */
|
|
662
690
|
async close(): Promise<void> {
|
|
663
691
|
if (this.closed) return;
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* {id, ok: false, code, error}
|
|
8
8
|
* event {ev: "data", aid, data} host → client, per attachment
|
|
9
9
|
* {ev: "exit", aid, exitCode}
|
|
10
|
+
* {ev: "size", aid, cols, rows} only to an attachment that
|
|
11
|
+
* asked (attach `sizes: true`):
|
|
12
|
+
* the PTY's size on attach, and
|
|
13
|
+
* each time it changes
|
|
10
14
|
*
|
|
11
15
|
* ops: hello, start, list, attach, detach, write, resize, signal,
|
|
12
16
|
* scrollback, forget, shutdown. A request whose `v` is not the host's is
|
|
@@ -47,7 +51,8 @@ export type Reply =
|
|
|
47
51
|
|
|
48
52
|
export type HostEvent =
|
|
49
53
|
| { ev: "data"; aid: string; data: string }
|
|
50
|
-
| { ev: "exit"; aid: string; exitCode?: number }
|
|
54
|
+
| { ev: "exit"; aid: string; exitCode?: number }
|
|
55
|
+
| { ev: "size"; aid: string; cols: number; rows: number };
|
|
51
56
|
|
|
52
57
|
/** Splits a byte stream into lines; refuses one that grows too long. */
|
|
53
58
|
export class LineReader {
|
|
@@ -55,6 +55,12 @@ export interface AttachHandlers {
|
|
|
55
55
|
onData(data: Buffer): void;
|
|
56
56
|
/** The session ended (at once, after the replay, when it already had). */
|
|
57
57
|
onExit?(exitCode: number | undefined): void;
|
|
58
|
+
/**
|
|
59
|
+
* The PTY's size: once on attach (after the replay), then each time any
|
|
60
|
+
* client resizes it. The size is last-writer-wins across every viewer,
|
|
61
|
+
* so a viewer learns here that another one changed it.
|
|
62
|
+
*/
|
|
63
|
+
onSize?(cols: number, rows: number): void;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/** One attachment; a session has any number, each gets all the output. */
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* The CLI prints it; the web server serves it at /api/stacks (and through it
|
|
4
4
|
* the MCP `stacks` tool).
|
|
5
5
|
*/
|
|
6
|
+
import { product } from "../product.js";
|
|
6
7
|
import { spawnSync } from "child_process";
|
|
7
8
|
import * as fs from "fs";
|
|
8
9
|
import * as path from "path";
|
|
@@ -125,6 +126,29 @@ export function rejoinRunningStacks(
|
|
|
125
126
|
return rejoinEdgeNetworks(contexts, onLine);
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Why a workspace has no stacks (MCP-2.4): an empty list with no reason
|
|
131
|
+
* reads as "docker is broken". A stack is a `stacks:` entry in the org
|
|
132
|
+
* config, or a repo of the workspace whose manifest has a `compose:` block.
|
|
133
|
+
*/
|
|
134
|
+
export function emptyStacksReason(o: {
|
|
135
|
+
workspace: string;
|
|
136
|
+
workspaceExists: boolean;
|
|
137
|
+
/** How many stacks the org config itself declares. */
|
|
138
|
+
configStacks: number;
|
|
139
|
+
}): string {
|
|
140
|
+
const cfg = `the org config (${product().configFile}) declares no stacks:`;
|
|
141
|
+
if (!o.workspaceExists)
|
|
142
|
+
return (
|
|
143
|
+
`no stacks: workspace "${o.workspace}" does not exist here, so no ` +
|
|
144
|
+
`repo manifest was read, and ${cfg}`
|
|
145
|
+
);
|
|
146
|
+
return (
|
|
147
|
+
`no stacks: ${cfg}, and no repo of workspace "${o.workspace}" has a ` +
|
|
148
|
+
`compose: block in its ${product().manifestYml}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
128
152
|
/** Each stack's name, project, routes and (optionally) container health. */
|
|
129
153
|
export function stackStatuses(
|
|
130
154
|
ctx: StackContext,
|
|
@@ -44,6 +44,11 @@ export function registerStrategy<K extends string, C>(
|
|
|
44
44
|
byId.set(entry.id, entry as StrategyEntry);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Remove strategy `id` of `kind` (no-op when it isn't there). */
|
|
48
|
+
export function unregisterStrategy(kind: string, id: string): void {
|
|
49
|
+
registries.get(kind)?.delete(id);
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
/** The strategy `id` of `kind`, or undefined. */
|
|
48
53
|
export function getStrategy<K extends string, C>(
|
|
49
54
|
kind: K,
|
|
@@ -667,6 +667,12 @@ export interface WorkspaceServiceEntry {
|
|
|
667
667
|
}
|
|
668
668
|
|
|
669
669
|
export interface WorkspaceConfig {
|
|
670
|
+
/**
|
|
671
|
+
* Agents in this workspace, on this machine (AG-0.5):
|
|
672
|
+
* `agents.runtimes.<id>.bin`, a runtime's CLI for this workspace's
|
|
673
|
+
* agents, which wins over user.yml's and the search.
|
|
674
|
+
*/
|
|
675
|
+
agents?: { runtimes?: Record<string, { bin?: unknown } | null> };
|
|
670
676
|
/**
|
|
671
677
|
* Service entries keyed by service address.
|
|
672
678
|
* Single-service repos: "tasks-runner"
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -42,10 +42,22 @@ import * as path from "path";
|
|
|
42
42
|
import {
|
|
43
43
|
AGENTS_RESERVED_KEYS,
|
|
44
44
|
configuredRuntimeBin,
|
|
45
|
+
configuredRuntimeCatalog,
|
|
45
46
|
readAgentsConfig,
|
|
47
|
+
setUserRuntimeBin,
|
|
46
48
|
userConfigPath,
|
|
47
49
|
type ResolvedAgentsConfig,
|
|
48
50
|
} from "../../shared/den-config.js";
|
|
51
|
+
import {
|
|
52
|
+
setWorkspaceRuntimeBin,
|
|
53
|
+
workspaceBinKey,
|
|
54
|
+
workspaceRuntimeBin,
|
|
55
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
56
|
+
import {
|
|
57
|
+
BUILTIN_CATALOG,
|
|
58
|
+
type CatalogEntry,
|
|
59
|
+
} from "../../shared/agents/catalog.js";
|
|
60
|
+
import { checkRuntimeBin } from "../../shared/agents/catalog-runtime.js";
|
|
49
61
|
import { cliEntrypoint } from "../../shared/engine.js";
|
|
50
62
|
import { getDistRoot } from "../../shared/paths.js";
|
|
51
63
|
import { envKey } from "../../shared/product.js";
|
|
@@ -70,6 +82,7 @@ import {
|
|
|
70
82
|
getRuntime,
|
|
71
83
|
LaunchError,
|
|
72
84
|
runtimeIds,
|
|
85
|
+
syncConfiguredRuntimes,
|
|
73
86
|
type AgentMcp,
|
|
74
87
|
type LaunchInput,
|
|
75
88
|
type Runtime,
|
|
@@ -77,7 +90,10 @@ import {
|
|
|
77
90
|
import type { RuntimeDetect } from "../../shared/agents/runtime.js";
|
|
78
91
|
import {
|
|
79
92
|
binSettingKey,
|
|
93
|
+
ensureInteractivePath,
|
|
94
|
+
interactivePathAsked,
|
|
80
95
|
runtimeVersion,
|
|
96
|
+
type BinSetting,
|
|
81
97
|
type BinSource,
|
|
82
98
|
} from "../../shared/agents/runtime-search.js";
|
|
83
99
|
import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
|
|
@@ -116,6 +132,20 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
116
132
|
exitWatchMs?: number;
|
|
117
133
|
/** `agents.runtimes.<id>.bin` (default: user.yml's; undefined: unset). */
|
|
118
134
|
runtimeBin?: (id: string) => unknown;
|
|
135
|
+
/**
|
|
136
|
+
* Workspace `ws`'s `agents.runtimes.<id>.bin` (default: workspaces.yml's;
|
|
137
|
+
* undefined: unset). It wins over runtimeBin (AG-0.5).
|
|
138
|
+
*/
|
|
139
|
+
workspaceRuntimeBin?: (ws: string, id: string) => unknown;
|
|
140
|
+
/** user.yml's runtime catalog entries, checked (default: user.yml's). */
|
|
141
|
+
userCatalog?: (taken: readonly string[]) => CatalogEntry[];
|
|
142
|
+
/** Save a checked path (null clears it): global or the workspace's. */
|
|
143
|
+
saveRuntimeBin?: (
|
|
144
|
+
scope: "global" | "workspace",
|
|
145
|
+
ws: string | undefined,
|
|
146
|
+
id: string,
|
|
147
|
+
bin: string | null,
|
|
148
|
+
) => void;
|
|
119
149
|
/** The user.yml a person edits to set it (default: config/local/user.yml). */
|
|
120
150
|
userConfigFile?: () => string;
|
|
121
151
|
}
|
|
@@ -127,6 +157,9 @@ export interface AgentsResolved extends Resolved {
|
|
|
127
157
|
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
128
158
|
exitWatchMs: number;
|
|
129
159
|
runtimeBin: (id: string) => unknown;
|
|
160
|
+
workspaceRuntimeBin: (ws: string, id: string) => unknown;
|
|
161
|
+
userCatalog: (taken: readonly string[]) => CatalogEntry[];
|
|
162
|
+
saveRuntimeBin: NonNullable<AgentsDeps["saveRuntimeBin"]>;
|
|
130
163
|
userConfigFile: () => string;
|
|
131
164
|
}
|
|
132
165
|
|
|
@@ -217,6 +250,15 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
|
217
250
|
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
218
251
|
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
219
252
|
runtimeBin: d.runtimeBin ?? ((id) => configuredRuntimeBin(id)),
|
|
253
|
+
workspaceRuntimeBin:
|
|
254
|
+
d.workspaceRuntimeBin ?? ((ws, id) => workspaceRuntimeBin(ws, id)),
|
|
255
|
+
userCatalog: d.userCatalog ?? ((taken) => configuredRuntimeCatalog(taken)),
|
|
256
|
+
saveRuntimeBin:
|
|
257
|
+
d.saveRuntimeBin ??
|
|
258
|
+
((scope, ws, id, bin) =>
|
|
259
|
+
scope === "workspace" && ws
|
|
260
|
+
? setWorkspaceRuntimeBin(ws, id, bin)
|
|
261
|
+
: setUserRuntimeBin(id, bin)),
|
|
220
262
|
userConfigFile: d.userConfigFile ?? userConfigPath,
|
|
221
263
|
};
|
|
222
264
|
}
|
|
@@ -415,44 +457,137 @@ export interface RuntimeChoice {
|
|
|
415
457
|
id: string;
|
|
416
458
|
/** Its name for people ("Claude Code"). */
|
|
417
459
|
label?: string;
|
|
460
|
+
/** Who makes it ("Anthropic"): a catalog runtime (AG-0.5). */
|
|
461
|
+
vendor?: string;
|
|
462
|
+
/**
|
|
463
|
+
* false: it runs as its own CLI, without Den's tools yet ("Den tools:
|
|
464
|
+
* coming"). Absent for a runtime that isn't from the catalog.
|
|
465
|
+
*/
|
|
466
|
+
denTools?: boolean;
|
|
418
467
|
available: boolean;
|
|
419
468
|
/** Why it is unavailable. */
|
|
420
469
|
why?: string;
|
|
421
470
|
version?: string;
|
|
422
471
|
/** The CLI it runs (AG-0.2). */
|
|
423
472
|
path?: string;
|
|
424
|
-
/**
|
|
473
|
+
/**
|
|
474
|
+
* Where that path came from: workspace (its workspaces.yml entry),
|
|
475
|
+
* setting (user.yml), login-shell, PATH, well-known, interactive-shell.
|
|
476
|
+
*/
|
|
425
477
|
source?: BinSource;
|
|
426
478
|
/** The directories searched, in order (a runtime with a CLI to find). */
|
|
427
479
|
searched?: string[];
|
|
428
480
|
/** The user.yml key that sets its path (a runtime with a CLI to find). */
|
|
429
481
|
setting?: string;
|
|
482
|
+
/** The workspace's key for it, when listed for a workspace. */
|
|
483
|
+
workspaceSetting?: string;
|
|
430
484
|
/** It declares resume. */
|
|
431
485
|
resume: boolean;
|
|
432
486
|
default: boolean;
|
|
433
487
|
}
|
|
434
488
|
|
|
435
489
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
|
|
490
|
+
* The path set for runtime `id` (AG-0.5): the workspace's (when `ws` is
|
|
491
|
+
* given and it sets one), else user.yml's; null when neither does.
|
|
492
|
+
*/
|
|
493
|
+
export function runtimeSetting(
|
|
494
|
+
d: AgentsResolved,
|
|
495
|
+
id: string,
|
|
496
|
+
ws?: string,
|
|
497
|
+
): BinSetting | null {
|
|
498
|
+
if (ws) {
|
|
499
|
+
const v = d.workspaceRuntimeBin(ws, id);
|
|
500
|
+
if (v !== undefined && v !== null)
|
|
501
|
+
return { value: v, source: "workspace", key: workspaceBinKey(ws, id) };
|
|
502
|
+
}
|
|
503
|
+
const g = d.runtimeBin(id);
|
|
504
|
+
return g === undefined || g === null ? null : { value: g, source: "setting" };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Whether runtime `rt` can run here (AG-0.1, AG-0.5): a path set for the
|
|
509
|
+
* workspace, else user.yml's (`agents.runtimes.<id>.bin`), else the
|
|
510
|
+
* search. Runs nothing.
|
|
511
|
+
*/
|
|
512
|
+
export function detectRuntime(
|
|
513
|
+
d: AgentsResolved,
|
|
514
|
+
rt: Runtime,
|
|
515
|
+
ws?: string,
|
|
516
|
+
): RuntimeDetect {
|
|
517
|
+
return rt.detect(d.env, { bin: runtimeSetting(d, rt.id, ws) });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The catalog entries the distribution and user.yml add, registered as
|
|
522
|
+
* runtimes (AG-0.5). A malformed entry is a refusal naming its key.
|
|
523
|
+
*/
|
|
524
|
+
export function syncCatalog(d: AgentsResolved): void {
|
|
525
|
+
const cfg = agentsConfig(d);
|
|
526
|
+
let extra: CatalogEntry[];
|
|
527
|
+
try {
|
|
528
|
+
extra = [
|
|
529
|
+
...cfg.runtimeCatalog,
|
|
530
|
+
...d.userCatalog([
|
|
531
|
+
...BUILTIN_CATALOG.map((e) => e.id),
|
|
532
|
+
...cfg.runtimeCatalog.map((e) => e.id),
|
|
533
|
+
]),
|
|
534
|
+
];
|
|
535
|
+
} catch (e) {
|
|
536
|
+
throw refused((e as Error).message, [], 422);
|
|
537
|
+
}
|
|
538
|
+
syncConfiguredRuntimes(extra);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* When a catalog runtime is still missing (nothing set, not found on the
|
|
543
|
+
* login PATH, PATH or its install directories), ask the interactive shell
|
|
544
|
+
* for its PATH: once per process, then kept (AG-0.5).
|
|
545
|
+
*/
|
|
546
|
+
async function fallbackSearch(
|
|
547
|
+
d: AgentsResolved,
|
|
548
|
+
rts: Runtime[],
|
|
549
|
+
ws?: string,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
if (interactivePathAsked()) return;
|
|
552
|
+
const missing = rts.some(
|
|
553
|
+
(rt) =>
|
|
554
|
+
rt.catalog &&
|
|
555
|
+
!runtimeSetting(d, rt.id, ws) &&
|
|
556
|
+
!detectRuntime(d, rt, ws).available,
|
|
557
|
+
);
|
|
558
|
+
if (missing) await ensureInteractivePath({ env: d.env });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The catalog's installed runtimes first, then the ones not found, then
|
|
563
|
+
* the rest (generic: no CLI of its own to find).
|
|
439
564
|
*/
|
|
440
|
-
|
|
441
|
-
|
|
565
|
+
function rank(r: RuntimeChoice): number {
|
|
566
|
+
if (r.denTools === undefined) return 2;
|
|
567
|
+
return r.available ? 0 : 1;
|
|
442
568
|
}
|
|
443
569
|
|
|
444
570
|
/**
|
|
445
|
-
* Every registered runtime, and whether it can run here
|
|
446
|
-
*
|
|
447
|
-
*
|
|
571
|
+
* Every registered runtime, and whether it can run here (for an agent of
|
|
572
|
+
* `ws`, when given: its workspace's path wins), with the path it runs and
|
|
573
|
+
* its version: its version command (`claude --version`), run once under a
|
|
574
|
+
* timeout and kept, else what its install says. Installed ones first.
|
|
448
575
|
*/
|
|
449
576
|
export async function listRuntimes(
|
|
450
577
|
d: AgentsResolved,
|
|
578
|
+
ws?: string,
|
|
451
579
|
): Promise<RuntimeChoice[]> {
|
|
452
|
-
|
|
453
|
-
|
|
580
|
+
syncCatalog(d);
|
|
581
|
+
const ids = runtimeIds();
|
|
582
|
+
await fallbackSearch(
|
|
583
|
+
d,
|
|
584
|
+
ids.map((id) => getRuntime(id)!),
|
|
585
|
+
ws,
|
|
586
|
+
);
|
|
587
|
+
const rows = await Promise.all(
|
|
588
|
+
ids.map(async (id) => {
|
|
454
589
|
const rt = getRuntime(id)!;
|
|
455
|
-
const det = detectRuntime(d, rt);
|
|
590
|
+
const det = detectRuntime(d, rt, ws);
|
|
456
591
|
const asked =
|
|
457
592
|
det.available && det.path && rt.find?.versionArgs
|
|
458
593
|
? await runtimeVersion(det.path, rt.find.versionArgs, d.env)
|
|
@@ -461,24 +596,72 @@ export async function listRuntimes(
|
|
|
461
596
|
return {
|
|
462
597
|
id,
|
|
463
598
|
...(rt.label ? { label: rt.label } : {}),
|
|
599
|
+
...(rt.vendor ? { vendor: rt.vendor } : {}),
|
|
600
|
+
...(rt.catalog ? { denTools: rt.denTools === true } : {}),
|
|
464
601
|
available: det.available,
|
|
465
602
|
...(det.available ? {} : { why: det.why ?? "unavailable" }),
|
|
466
603
|
...(version ? { version } : {}),
|
|
467
604
|
...(det.path ? { path: det.path } : {}),
|
|
468
605
|
...(det.source ? { source: det.source } : {}),
|
|
469
606
|
...(rt.find
|
|
470
|
-
? {
|
|
607
|
+
? {
|
|
608
|
+
searched: det.searched ?? [],
|
|
609
|
+
setting: binSettingKey(id),
|
|
610
|
+
...(ws ? { workspaceSetting: workspaceBinKey(ws, id) } : {}),
|
|
611
|
+
}
|
|
471
612
|
: {}),
|
|
472
613
|
resume: Boolean(rt.resume),
|
|
473
614
|
default: id === DEFAULT_RUNTIME_ID,
|
|
474
615
|
};
|
|
475
616
|
}),
|
|
476
617
|
);
|
|
618
|
+
return rows
|
|
619
|
+
.map((r, i) => ({ r, i }))
|
|
620
|
+
.sort((a, b) => rank(a.r) - rank(b.r) || a.i - b.i)
|
|
621
|
+
.map((x) => x.r);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Set (or, with null, clear) runtime `id`'s path, global or for workspace
|
|
626
|
+
* `ws` (AG-0.5): its version command runs first, and a path that isn't
|
|
627
|
+
* that runtime is refused. Returns its row as listed after.
|
|
628
|
+
*/
|
|
629
|
+
export async function setRuntimePath(
|
|
630
|
+
d: AgentsResolved,
|
|
631
|
+
req: {
|
|
632
|
+
id: string;
|
|
633
|
+
scope: "global" | "workspace";
|
|
634
|
+
workspace?: string;
|
|
635
|
+
bin: string | null;
|
|
636
|
+
},
|
|
637
|
+
): Promise<RuntimeChoice> {
|
|
638
|
+
syncCatalog(d);
|
|
639
|
+
const rt = runtimeFor(req.id);
|
|
640
|
+
if (!rt.catalog)
|
|
641
|
+
throw usage(`${req.id} has no CLI to find: its path can't be set`);
|
|
642
|
+
let ws: string | undefined;
|
|
643
|
+
if (req.scope === "workspace") {
|
|
644
|
+
ws = checkWorkspaceName("runtime path", req.workspace);
|
|
645
|
+
if (!d.workspaceExists(ws))
|
|
646
|
+
throw usage(`workspace "${ws}" does not exist`, 404);
|
|
647
|
+
}
|
|
648
|
+
if (req.bin !== null) {
|
|
649
|
+
const c = await checkRuntimeBin(rt.catalog, req.bin, d.env);
|
|
650
|
+
if (!c.ok) throw refused(c.why, [], 422);
|
|
651
|
+
d.saveRuntimeBin(req.scope, ws, rt.id, c.path);
|
|
652
|
+
} else d.saveRuntimeBin(req.scope, ws, rt.id, null);
|
|
653
|
+
const rows = await listRuntimes(d, ws);
|
|
654
|
+
return rows.find((r) => r.id === rt.id)!;
|
|
477
655
|
}
|
|
478
656
|
|
|
479
657
|
/** Refused unless `rt` can run here; its detect result when it can. */
|
|
480
|
-
function availableOrRefuse(
|
|
481
|
-
|
|
658
|
+
async function availableOrRefuse(
|
|
659
|
+
d: AgentsResolved,
|
|
660
|
+
rt: Runtime,
|
|
661
|
+
ws?: string,
|
|
662
|
+
): Promise<RuntimeDetect> {
|
|
663
|
+
await fallbackSearch(d, [rt], ws);
|
|
664
|
+
const det = detectRuntime(d, rt, ws);
|
|
482
665
|
if (!det.available)
|
|
483
666
|
throw refused(
|
|
484
667
|
`${rt.id} is unavailable: ${det.why ?? "unknown"}`,
|
|
@@ -538,9 +721,15 @@ interface Launch {
|
|
|
538
721
|
binDir?: string;
|
|
539
722
|
}
|
|
540
723
|
|
|
541
|
-
/**
|
|
724
|
+
/**
|
|
725
|
+
* A CLI's directory when the session's PATH wouldn't have it (a path set
|
|
726
|
+
* for it, or found by the interactive shell), for its own tools.
|
|
727
|
+
*/
|
|
542
728
|
function binDirOf(det: RuntimeDetect): { binDir?: string } {
|
|
543
|
-
return det.source === "setting"
|
|
729
|
+
return (det.source === "setting" ||
|
|
730
|
+
det.source === "workspace" ||
|
|
731
|
+
det.source === "interactive-shell") &&
|
|
732
|
+
det.path
|
|
544
733
|
? { binDir: path.dirname(det.path) }
|
|
545
734
|
: {};
|
|
546
735
|
}
|
|
@@ -766,8 +955,9 @@ export async function startAgent(
|
|
|
766
955
|
);
|
|
767
956
|
const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
|
|
768
957
|
checkId(where, id);
|
|
958
|
+
syncCatalog(d);
|
|
769
959
|
const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
|
|
770
|
-
const det = availableOrRefuse(d, rt);
|
|
960
|
+
const det = await availableOrRefuse(d, rt, ws);
|
|
771
961
|
const model = req.model ?? persona.model;
|
|
772
962
|
|
|
773
963
|
const prev = readRecord(d.stateDir(), id);
|
|
@@ -871,6 +1061,7 @@ export async function resumeAgent(
|
|
|
871
1061
|
ws?: string,
|
|
872
1062
|
): Promise<Resumed> {
|
|
873
1063
|
const rec = agentRecord(d, id, ws);
|
|
1064
|
+
syncCatalog(d);
|
|
874
1065
|
const rt = runtimeFor(rec.runtime);
|
|
875
1066
|
if (!rt.resume)
|
|
876
1067
|
throw refused(`runtime ${rt.id} does not declare resume`, [
|
|
@@ -880,7 +1071,7 @@ export async function resumeAgent(
|
|
|
880
1071
|
const persona = personas(d, cfg).get(rec.persona);
|
|
881
1072
|
if (!persona)
|
|
882
1073
|
throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
|
|
883
|
-
const det = availableOrRefuse(d, rt);
|
|
1074
|
+
const det = await availableOrRefuse(d, rt, rec.workspace);
|
|
884
1075
|
ptyOrRefuse(d);
|
|
885
1076
|
return withClient(d, async (c) => {
|
|
886
1077
|
const old = sessionOf(await c.list(), rec);
|