@monster-orange/den 0.4.1 → 0.4.3
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 +142 -0
- package/README.md +88 -158
- package/docs/distributions.md +151 -28
- package/docs/reference.md +177 -0
- package/package.json +1 -1
- package/src/shared/agents/den-claude.ts +57 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +416 -0
- package/src/shared/agents/runtime.ts +26 -1
- package/src/shared/agents/runtimes.ts +30 -3
- package/src/shared/den-config.ts +88 -3
- package/src/shared/edge/render.ts +31 -0
- package/src/shared/sessions/client.ts +7 -0
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/host.ts +33 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/ui/cli/agent-ops.ts +109 -21
- package/src/ui/cli/agents-command.ts +100 -4
- package/src/ui/cli/index.ts +33 -1
- package/src/ui/cli/sessions-command.ts +26 -9
- package/src/ui/cli/web-command.ts +60 -9
- package/src/ui/server/agent-routes.ts +11 -3
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +105 -23
- package/src/ui/server/public/agent-start.js +202 -0
- package/src/ui/server/public/agents-page.js +90 -18
- package/src/ui/server/public/agents-section.js +6 -18
- package/src/ui/server/public/agents.html +70 -0
- package/src/ui/server/public/i18n/es.js +11 -0
- package/src/ui/server/public/i18n/ja.js +11 -0
- package/src/ui/server/public/local-dev.html +43 -14
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +40 -0
- package/src/ui/server/public/topbar.js +127 -28
- package/src/ui/server/request-guard.ts +122 -7
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
AttachHandlers,
|
|
17
17
|
Attachment,
|
|
18
18
|
SessionInfo,
|
|
19
|
+
SessionPool,
|
|
19
20
|
SessionSignal,
|
|
20
21
|
SessionSpec,
|
|
21
22
|
} from "./types.js";
|
|
@@ -125,6 +126,11 @@ export class HostClient extends EventEmitter {
|
|
|
125
126
|
spec: SessionSpec,
|
|
126
127
|
opts: {
|
|
127
128
|
limit?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Which running sessions `limit` counts: agents (agents.max_concurrent)
|
|
131
|
+
* or plain ones (sessions.max_concurrent). Without it, all of them.
|
|
132
|
+
*/
|
|
133
|
+
pool?: SessionPool;
|
|
128
134
|
/** `--keep-env` names, for the host's audit line (never values). */
|
|
129
135
|
keptEnv?: { name: string; credentialShaped: boolean }[];
|
|
130
136
|
/** Who asked, for that line. */
|
|
@@ -139,6 +145,7 @@ export class HostClient extends EventEmitter {
|
|
|
139
145
|
const r = await this.request("start", {
|
|
140
146
|
spec,
|
|
141
147
|
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
148
|
+
...(opts.pool !== undefined ? { pool: opts.pool } : {}),
|
|
142
149
|
...(opts.keptEnv?.length || opts.refusedEnv?.length
|
|
143
150
|
? { actor: opts.actor ?? "unknown" }
|
|
144
151
|
: {}),
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
Attachment,
|
|
22
22
|
SessionBackend,
|
|
23
23
|
SessionInfo,
|
|
24
|
+
SessionPool,
|
|
24
25
|
SessionRef,
|
|
25
26
|
SessionSignal,
|
|
26
27
|
SessionSpec,
|
|
@@ -32,8 +33,10 @@ export { BACKEND_ID as DEN_PTY_ID };
|
|
|
32
33
|
export interface DenPtyOptions {
|
|
33
34
|
/** A client of the running host (ensureHost). */
|
|
34
35
|
client: HostClient;
|
|
35
|
-
/** Applied to every start
|
|
36
|
+
/** Applied to every start, counting `pool`'s running sessions. */
|
|
36
37
|
limit?: number;
|
|
38
|
+
/** agents (agents.max_concurrent) or sessions (sessions.max_concurrent). */
|
|
39
|
+
pool?: SessionPool;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export class DenPtyBackend implements SessionBackend {
|
|
@@ -49,7 +52,12 @@ export class DenPtyBackend implements SessionBackend {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
async start(spec: SessionSpec): Promise<SessionRef> {
|
|
52
|
-
return (
|
|
55
|
+
return (
|
|
56
|
+
await this.opts.client.start(spec, {
|
|
57
|
+
limit: this.opts.limit,
|
|
58
|
+
...(this.opts.pool ? { pool: this.opts.pool } : {}),
|
|
59
|
+
})
|
|
60
|
+
).ref;
|
|
53
61
|
}
|
|
54
62
|
list(): Promise<SessionInfo[]> {
|
|
55
63
|
return this.opts.client.list();
|
|
@@ -29,7 +29,12 @@ import {
|
|
|
29
29
|
type Request,
|
|
30
30
|
} from "./protocol.js";
|
|
31
31
|
import type { PtyLoad, PtyProcess } from "./pty.js";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
SESSION_POOLS,
|
|
34
|
+
SESSION_SIGNALS,
|
|
35
|
+
type SessionInfo,
|
|
36
|
+
type SessionPool,
|
|
37
|
+
} from "./types.js";
|
|
33
38
|
|
|
34
39
|
export const BACKEND_ID = "den-pty";
|
|
35
40
|
|
|
@@ -58,6 +63,8 @@ interface Session {
|
|
|
58
63
|
log: ScrollbackLog;
|
|
59
64
|
/** aid → its connection. */
|
|
60
65
|
attachments: Map<string, Conn>;
|
|
66
|
+
/** The cap it counts against (start's `pool`), when one was named. */
|
|
67
|
+
pool?: SessionPool;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
class Conn {
|
|
@@ -82,6 +89,17 @@ class Refusal extends Error {
|
|
|
82
89
|
const isInt = (n: unknown, min: number, max: number): n is number =>
|
|
83
90
|
typeof n === "number" && Number.isInteger(n) && n >= min && n <= max;
|
|
84
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The cap refusal: "2 agents are running and agents.max_concurrent is 2".
|
|
94
|
+
* With no pool (an older client's limit) every session counts, and the key
|
|
95
|
+
* is agents.max_concurrent, as it was.
|
|
96
|
+
*/
|
|
97
|
+
function capMessage(live: number, limit: number, pool?: unknown): string {
|
|
98
|
+
const noun = pool === "agents" ? "agent" : "session";
|
|
99
|
+
const key = pool === "sessions" ? "sessions" : "agents";
|
|
100
|
+
return `${live} ${noun}${live === 1 ? " is" : "s are"} running and ${key}.max_concurrent is ${limit}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
function pidAlive(pid: number): boolean {
|
|
86
104
|
try {
|
|
87
105
|
process.kill(pid, 0);
|
|
@@ -528,20 +546,26 @@ export class SessionHost {
|
|
|
528
546
|
);
|
|
529
547
|
if (same.some((s) => !s.info.exited))
|
|
530
548
|
throw new Refusal("exists", `${name} is already running`);
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
).
|
|
549
|
+
// A cap counts its own pool's running sessions: agents.max_concurrent
|
|
550
|
+
// the agents, sessions.max_concurrent the plain ones. A limit with no
|
|
551
|
+
// pool (an older client) counts every running session, as before.
|
|
552
|
+
const pool = req.pool;
|
|
553
|
+
if (pool !== undefined && !SESSION_POOLS.includes(pool as SessionPool))
|
|
554
|
+
throw new Refusal(
|
|
555
|
+
"bad-request",
|
|
556
|
+
`pool must be ${SESSION_POOLS.join(" or ")}`,
|
|
557
|
+
);
|
|
534
558
|
if (req.limit !== undefined) {
|
|
535
559
|
if (!isInt(req.limit, 1, 100_000))
|
|
536
560
|
throw new Refusal(
|
|
537
561
|
"bad-request",
|
|
538
562
|
"limit must be a whole number of at least 1",
|
|
539
563
|
);
|
|
564
|
+
const live = [...this.sessions.values()].filter(
|
|
565
|
+
(s) => !s.info.exited && (pool === undefined || s.pool === pool),
|
|
566
|
+
).length;
|
|
540
567
|
if (live >= req.limit)
|
|
541
|
-
throw new Refusal(
|
|
542
|
-
"cap",
|
|
543
|
-
`${live} session${live === 1 ? " is" : "s are"} running and agents.max_concurrent is ${req.limit}`,
|
|
544
|
-
);
|
|
568
|
+
throw new Refusal("cap", capMessage(live, req.limit, pool));
|
|
545
569
|
}
|
|
546
570
|
const load = this.opts.pty();
|
|
547
571
|
if (!load.available) throw new Refusal("unavailable", load.reason);
|
|
@@ -582,6 +606,7 @@ export class SessionHost {
|
|
|
582
606
|
},
|
|
583
607
|
),
|
|
584
608
|
attachments: new Map(),
|
|
609
|
+
...(pool !== undefined ? { pool: pool as SessionPool } : {}),
|
|
585
610
|
};
|
|
586
611
|
this.sessions.set(id, s);
|
|
587
612
|
this.persist(s);
|
|
@@ -32,6 +32,14 @@ export interface SessionInfo {
|
|
|
32
32
|
prompted?: boolean;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The cap a start counts against: `agents` (agents.max_concurrent) or
|
|
37
|
+
* `sessions` (sessions.max_concurrent, the plain ones), each counting only
|
|
38
|
+
* its own running sessions.
|
|
39
|
+
*/
|
|
40
|
+
export const SESSION_POOLS = ["agents", "sessions"] as const;
|
|
41
|
+
export type SessionPool = (typeof SESSION_POOLS)[number];
|
|
42
|
+
|
|
35
43
|
/** What to run. `env` is the whole environment: nothing is inherited. */
|
|
36
44
|
export interface SessionSpec {
|
|
37
45
|
name: string;
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* what happened, or throws an AgentError saying why it didn't.
|
|
8
8
|
*
|
|
9
9
|
* start resolves the persona (Den's default pack, then
|
|
10
|
-
* agents.persona_packs) and the runtime
|
|
10
|
+
* agents.persona_packs) and the runtime (its CLI from
|
|
11
|
+
* agents.runtimes.<id>.bin in user.yml, else the search:
|
|
12
|
+
* runtime-search.ts, AG-0.1; unavailable, it is refused with the
|
|
13
|
+
* fix), asks the runtime for argv,
|
|
11
14
|
* env and files (its MCP config: `<cli> mcp --workspace <ws>
|
|
12
15
|
* --actor agent:<id>`, plus a `--grant` per agents.<id>.grants),
|
|
13
16
|
* starts `<dist>-<ws>:<id>` in the workspace's dir, and writes the
|
|
@@ -32,13 +35,15 @@
|
|
|
32
35
|
* actor token; the old one stops working.
|
|
33
36
|
*
|
|
34
37
|
* Nothing here takes a grant: grants come from the org config only. The
|
|
35
|
-
* host enforces agents.max_concurrent on every start.
|
|
38
|
+
* host enforces agents.max_concurrent on every start, counting agents only.
|
|
36
39
|
*/
|
|
37
40
|
import * as fs from "fs";
|
|
38
41
|
import * as path from "path";
|
|
39
42
|
import {
|
|
40
43
|
AGENTS_RESERVED_KEYS,
|
|
44
|
+
configuredRuntimeBin,
|
|
41
45
|
readAgentsConfig,
|
|
46
|
+
userConfigPath,
|
|
42
47
|
type ResolvedAgentsConfig,
|
|
43
48
|
} from "../../shared/den-config.js";
|
|
44
49
|
import { cliEntrypoint } from "../../shared/engine.js";
|
|
@@ -69,6 +74,12 @@ import {
|
|
|
69
74
|
type LaunchInput,
|
|
70
75
|
type Runtime,
|
|
71
76
|
} from "../../shared/agents/runtimes.js";
|
|
77
|
+
import type { RuntimeDetect } from "../../shared/agents/runtime.js";
|
|
78
|
+
import {
|
|
79
|
+
binSettingKey,
|
|
80
|
+
runtimeVersion,
|
|
81
|
+
type BinSource,
|
|
82
|
+
} from "../../shared/agents/runtime-search.js";
|
|
72
83
|
import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
|
|
73
84
|
import {
|
|
74
85
|
bindActorSession,
|
|
@@ -103,6 +114,10 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
103
114
|
* exit (default 3000 ms; 0: not at all).
|
|
104
115
|
*/
|
|
105
116
|
exitWatchMs?: number;
|
|
117
|
+
/** `agents.runtimes.<id>.bin` (default: user.yml's; undefined: unset). */
|
|
118
|
+
runtimeBin?: (id: string) => unknown;
|
|
119
|
+
/** The user.yml a person edits to set it (default: config/local/user.yml). */
|
|
120
|
+
userConfigFile?: () => string;
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
export interface AgentsResolved extends Resolved {
|
|
@@ -111,6 +126,8 @@ export interface AgentsResolved extends Resolved {
|
|
|
111
126
|
workspaceDir: (ws: string) => string;
|
|
112
127
|
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
113
128
|
exitWatchMs: number;
|
|
129
|
+
runtimeBin: (id: string) => unknown;
|
|
130
|
+
userConfigFile: () => string;
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
/** The early-exit watch: at most this long, polling the host. */
|
|
@@ -199,6 +216,8 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
|
199
216
|
}),
|
|
200
217
|
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
201
218
|
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
219
|
+
runtimeBin: d.runtimeBin ?? ((id) => configuredRuntimeBin(id)),
|
|
220
|
+
userConfigFile: d.userConfigFile ?? userConfigPath,
|
|
202
221
|
};
|
|
203
222
|
}
|
|
204
223
|
|
|
@@ -394,29 +413,84 @@ export function listPersonas(d: AgentsResolved): ListedPersona[] {
|
|
|
394
413
|
|
|
395
414
|
export interface RuntimeChoice {
|
|
396
415
|
id: string;
|
|
416
|
+
/** Its name for people ("Claude Code"). */
|
|
417
|
+
label?: string;
|
|
397
418
|
available: boolean;
|
|
398
419
|
/** Why it is unavailable. */
|
|
399
420
|
why?: string;
|
|
400
421
|
version?: string;
|
|
422
|
+
/** The CLI it runs (AG-0.2). */
|
|
423
|
+
path?: string;
|
|
424
|
+
/** Where that path came from: setting, login-shell, well-known or PATH. */
|
|
425
|
+
source?: BinSource;
|
|
426
|
+
/** The directories searched, in order (a runtime with a CLI to find). */
|
|
427
|
+
searched?: string[];
|
|
428
|
+
/** The user.yml key that sets its path (a runtime with a CLI to find). */
|
|
429
|
+
setting?: string;
|
|
401
430
|
/** It declares resume. */
|
|
402
431
|
resume: boolean;
|
|
403
432
|
default: boolean;
|
|
404
433
|
}
|
|
405
434
|
|
|
406
|
-
/**
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
435
|
+
/**
|
|
436
|
+
* Whether runtime `rt` can run here (AG-0.1): its setting
|
|
437
|
+
* (`agents.runtimes.<id>.bin`, from d.runtimeBin), else the search. Runs
|
|
438
|
+
* nothing.
|
|
439
|
+
*/
|
|
440
|
+
export function detectRuntime(d: AgentsResolved, rt: Runtime): RuntimeDetect {
|
|
441
|
+
return rt.detect(d.env, { bin: d.runtimeBin(rt.id) ?? null });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Every registered runtime, and whether it can run here, with the path it
|
|
446
|
+
* runs and its version: its version command (`claude --version`), run once
|
|
447
|
+
* under a timeout and kept, else what its install says.
|
|
448
|
+
*/
|
|
449
|
+
export async function listRuntimes(
|
|
450
|
+
d: AgentsResolved,
|
|
451
|
+
): Promise<RuntimeChoice[]> {
|
|
452
|
+
return Promise.all(
|
|
453
|
+
runtimeIds().map(async (id) => {
|
|
454
|
+
const rt = getRuntime(id)!;
|
|
455
|
+
const det = detectRuntime(d, rt);
|
|
456
|
+
const asked =
|
|
457
|
+
det.available && det.path && rt.find?.versionArgs
|
|
458
|
+
? await runtimeVersion(det.path, rt.find.versionArgs, d.env)
|
|
459
|
+
: undefined;
|
|
460
|
+
const version = asked ?? det.version;
|
|
461
|
+
return {
|
|
462
|
+
id,
|
|
463
|
+
...(rt.label ? { label: rt.label } : {}),
|
|
464
|
+
available: det.available,
|
|
465
|
+
...(det.available ? {} : { why: det.why ?? "unavailable" }),
|
|
466
|
+
...(version ? { version } : {}),
|
|
467
|
+
...(det.path ? { path: det.path } : {}),
|
|
468
|
+
...(det.source ? { source: det.source } : {}),
|
|
469
|
+
...(rt.find
|
|
470
|
+
? { searched: det.searched ?? [], setting: binSettingKey(id) }
|
|
471
|
+
: {}),
|
|
472
|
+
resume: Boolean(rt.resume),
|
|
473
|
+
default: id === DEFAULT_RUNTIME_ID,
|
|
474
|
+
};
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Refused unless `rt` can run here; its detect result when it can. */
|
|
480
|
+
function availableOrRefuse(d: AgentsResolved, rt: Runtime): RuntimeDetect {
|
|
481
|
+
const det = detectRuntime(d, rt);
|
|
482
|
+
if (!det.available)
|
|
483
|
+
throw refused(
|
|
484
|
+
`${rt.id} is unavailable: ${det.why ?? "unknown"}`,
|
|
485
|
+
rt.find
|
|
486
|
+
? [
|
|
487
|
+
`Install it, or set ${binSettingKey(rt.id)} to its absolute path in ${d.userConfigFile()}.`,
|
|
488
|
+
`\`${d.cli} agents doctor\` shows where it looked.`,
|
|
489
|
+
]
|
|
490
|
+
: [],
|
|
491
|
+
503,
|
|
492
|
+
);
|
|
493
|
+
return det;
|
|
420
494
|
}
|
|
421
495
|
|
|
422
496
|
function runtimeFor(id: string): Runtime {
|
|
@@ -460,6 +534,15 @@ interface Launch {
|
|
|
460
534
|
passthrough?: readonly string[];
|
|
461
535
|
/** agents.<id>.grants, recorded with its actor token. */
|
|
462
536
|
grants: readonly string[];
|
|
537
|
+
/** Added to the session's PATH: a configured CLI's own directory. */
|
|
538
|
+
binDir?: string;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** A configured CLI's directory, for its session's PATH (its own tools). */
|
|
542
|
+
function binDirOf(det: RuntimeDetect): { binDir?: string } {
|
|
543
|
+
return det.source === "setting" && det.path
|
|
544
|
+
? { binDir: path.dirname(det.path) }
|
|
545
|
+
: {};
|
|
463
546
|
}
|
|
464
547
|
|
|
465
548
|
/** The host's cap refusal as an AgentError (its hint says what to do). */
|
|
@@ -511,11 +594,12 @@ async function startSession(
|
|
|
511
594
|
launch.env,
|
|
512
595
|
launch.rec.id,
|
|
513
596
|
minted.token,
|
|
597
|
+
launch.binDir ? [launch.binDir] : [],
|
|
514
598
|
),
|
|
515
599
|
cols: (tty && d.stdout.columns) || 80,
|
|
516
600
|
rows: (tty && d.stdout.rows) || 24,
|
|
517
601
|
},
|
|
518
|
-
{ limit: d.maxConcurrent() },
|
|
602
|
+
{ limit: d.maxConcurrent(), pool: "agents" },
|
|
519
603
|
)
|
|
520
604
|
.catch((e: unknown) => capOrThrow(d, e));
|
|
521
605
|
} catch (e) {
|
|
@@ -601,6 +685,7 @@ function launchInput(
|
|
|
601
685
|
ws: string,
|
|
602
686
|
id: string,
|
|
603
687
|
model: string | undefined,
|
|
688
|
+
bin?: string,
|
|
604
689
|
): LaunchInput {
|
|
605
690
|
const grants = cfg.agents[id]?.grants ?? [];
|
|
606
691
|
for (const g of grants)
|
|
@@ -623,6 +708,7 @@ function launchInput(
|
|
|
623
708
|
filesDir: agentFilesDir(d.stateDir(), id),
|
|
624
709
|
stateDir: d.stateDir(),
|
|
625
710
|
env: d.env,
|
|
711
|
+
...(bin ? { bin } : {}),
|
|
626
712
|
};
|
|
627
713
|
}
|
|
628
714
|
|
|
@@ -681,9 +767,7 @@ export async function startAgent(
|
|
|
681
767
|
const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
|
|
682
768
|
checkId(where, id);
|
|
683
769
|
const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
|
|
684
|
-
const det =
|
|
685
|
-
if (!det.available)
|
|
686
|
-
throw refused(`${rt.id} is unavailable: ${det.why ?? "unknown"}`, [], 503);
|
|
770
|
+
const det = availableOrRefuse(d, rt);
|
|
687
771
|
const model = req.model ?? persona.model;
|
|
688
772
|
|
|
689
773
|
const prev = readRecord(d.stateDir(), id);
|
|
@@ -692,7 +776,7 @@ export async function startAgent(
|
|
|
692
776
|
`agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
|
|
693
777
|
);
|
|
694
778
|
ptyOrRefuse(d);
|
|
695
|
-
const input = launchInput(d, cfg, persona, ws, id, model);
|
|
779
|
+
const input = launchInput(d, cfg, persona, ws, id, model, det.path);
|
|
696
780
|
|
|
697
781
|
return withClient(d, async (c) => {
|
|
698
782
|
const sessions = await c.list();
|
|
@@ -721,6 +805,7 @@ export async function startAgent(
|
|
|
721
805
|
env: out.env,
|
|
722
806
|
passthrough: rt.envPassthrough,
|
|
723
807
|
grants: cfg.agents[id]?.grants ?? [],
|
|
808
|
+
...binDirOf(det),
|
|
724
809
|
},
|
|
725
810
|
old,
|
|
726
811
|
{},
|
|
@@ -795,6 +880,7 @@ export async function resumeAgent(
|
|
|
795
880
|
const persona = personas(d, cfg).get(rec.persona);
|
|
796
881
|
if (!persona)
|
|
797
882
|
throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
|
|
883
|
+
const det = availableOrRefuse(d, rt);
|
|
798
884
|
ptyOrRefuse(d);
|
|
799
885
|
return withClient(d, async (c) => {
|
|
800
886
|
const old = sessionOf(await c.list(), rec);
|
|
@@ -809,6 +895,7 @@ export async function resumeAgent(
|
|
|
809
895
|
rec.workspace,
|
|
810
896
|
rec.id,
|
|
811
897
|
rec.model,
|
|
898
|
+
det.path,
|
|
812
899
|
);
|
|
813
900
|
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
814
901
|
const at = {
|
|
@@ -833,6 +920,7 @@ export async function resumeAgent(
|
|
|
833
920
|
env: out.env,
|
|
834
921
|
passthrough: rt.envPassthrough,
|
|
835
922
|
grants: cfg.agents[rec.id]?.grants ?? [],
|
|
923
|
+
...binDirOf(det),
|
|
836
924
|
},
|
|
837
925
|
old,
|
|
838
926
|
fresh ? {} : { resumedAt: new Date().toISOString() },
|
|
@@ -24,16 +24,25 @@
|
|
|
24
24
|
* running: a new session, in the same cwd, continuing the last run;
|
|
25
25
|
* when that session was never prompted, a fresh start under the
|
|
26
26
|
* same conversation id (there is nothing to continue).
|
|
27
|
+
* doctor (AG-0.3) each runtime: its status, the CLI it runs and where
|
|
28
|
+
* that came from (setting, login-shell, PATH, well-known), its
|
|
29
|
+
* version (its version command, once, under a timeout), the
|
|
30
|
+
* directories searched (names only, never an env value), and the
|
|
31
|
+
* fix when it is missing. Exit 1 when the default runtime is
|
|
32
|
+
* unavailable. It resolves the login shell's PATH first, as the
|
|
33
|
+
* web server does at start.
|
|
27
34
|
*
|
|
28
35
|
* Nothing here takes a grant: grants come from the org config only. The
|
|
29
|
-
* host enforces agents.max_concurrent on every start.
|
|
36
|
+
* host enforces agents.max_concurrent on every start, counting agents only.
|
|
30
37
|
*/
|
|
31
38
|
import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
|
|
39
|
+
import { initLoginPath } from "../../shared/agents/runtime-search.js";
|
|
32
40
|
import { HostError } from "../../shared/sessions/client.js";
|
|
33
41
|
import {
|
|
34
42
|
AgentError,
|
|
35
43
|
listAgents,
|
|
36
44
|
listPersonas as personaList,
|
|
45
|
+
listRuntimes,
|
|
37
46
|
loadAgentWorkspaces,
|
|
38
47
|
resolveAgentsDeps,
|
|
39
48
|
resumeAgent,
|
|
@@ -42,6 +51,7 @@ import {
|
|
|
42
51
|
type AgentsDeps,
|
|
43
52
|
type AgentsResolved,
|
|
44
53
|
type EarlyExit,
|
|
54
|
+
type RuntimeChoice,
|
|
45
55
|
type Started,
|
|
46
56
|
} from "./agent-ops.js";
|
|
47
57
|
import {
|
|
@@ -63,7 +73,7 @@ export {
|
|
|
63
73
|
} from "./agent-ops.js";
|
|
64
74
|
|
|
65
75
|
export const AGENTS_HELP = {
|
|
66
|
-
usage: "agents <list|start|stop|resume|personas> ...",
|
|
76
|
+
usage: "agents <list|start|stop|resume|personas|doctor> ...",
|
|
67
77
|
lines: [
|
|
68
78
|
"Agents: a persona and a workspace, run by a runtime in a session that",
|
|
69
79
|
"outlives the web server (see `{cli} sessions`).",
|
|
@@ -78,9 +88,16 @@ export const AGENTS_HELP = {
|
|
|
78
88
|
" a session never prompted starts fresh under the same conversation id",
|
|
79
89
|
"{cli} agents personas [--json]",
|
|
80
90
|
" Den's default pack, then agents.persona_packs (a later pack wins by id)",
|
|
91
|
+
"{cli} agents doctor [--json]",
|
|
92
|
+
" each runtime: status, the CLI it runs and where it came from, its version,",
|
|
93
|
+
" the directories searched, and the fix; exit 1 when the default is missing",
|
|
81
94
|
"",
|
|
82
|
-
"Runtimes: den-claude (default;
|
|
95
|
+
"Runtimes: den-claude (default; Claude Code's claude) and generic (the",
|
|
83
96
|
"persona's argv template). Attach with `{cli} sessions attach <ws>:<id>`.",
|
|
97
|
+
"A runtime's CLI is found as your terminal finds it: agents.runtimes.<id>.bin",
|
|
98
|
+
"in config/local/user.yml (an absolute path) wins, then the login shell's PATH,",
|
|
99
|
+
"this PATH, and its install directories (~/.local/bin, ~/.claude/local,",
|
|
100
|
+
"/opt/homebrew/bin, /usr/local/bin, npm's global bin).",
|
|
84
101
|
"An agent's env is PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG,",
|
|
85
102
|
"LC_*, TZ and TMPDIR, plus what its runtime declares: nothing else of yours.",
|
|
86
103
|
],
|
|
@@ -250,10 +267,87 @@ async function listPersonas(
|
|
|
250
267
|
return 0;
|
|
251
268
|
}
|
|
252
269
|
|
|
270
|
+
export interface AgentsDoctorDeps {
|
|
271
|
+
/** Resolve the login shell's PATH first (default: initLoginPath). */
|
|
272
|
+
loginPath?: () => Promise<void>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const SOURCE_WORD: Record<string, string> = {
|
|
276
|
+
setting: "agents.runtimes setting",
|
|
277
|
+
"login-shell": "login shell PATH",
|
|
278
|
+
PATH: "PATH",
|
|
279
|
+
"well-known": "install directory",
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/** `agents doctor`'s lines for one runtime (names and paths, never env values). */
|
|
283
|
+
export function doctorLines(
|
|
284
|
+
r: RuntimeChoice,
|
|
285
|
+
cli: string,
|
|
286
|
+
userConfig: string,
|
|
287
|
+
): string[] {
|
|
288
|
+
const lines = [
|
|
289
|
+
`${r.id}${r.label ? ` (${r.label})` : ""}${r.default ? " [default]" : ""}`,
|
|
290
|
+
r.available
|
|
291
|
+
? " status ✓ available"
|
|
292
|
+
: ` status ✗ unavailable: ${r.why ?? "unknown"}`,
|
|
293
|
+
];
|
|
294
|
+
if (r.path)
|
|
295
|
+
lines.push(
|
|
296
|
+
` path ${r.path}${r.source ? ` (${SOURCE_WORD[r.source] ?? r.source})` : ""}`,
|
|
297
|
+
);
|
|
298
|
+
if (r.available && r.setting)
|
|
299
|
+
lines.push(` version ${r.version ?? "unknown"}`);
|
|
300
|
+
if (!r.setting) lines.push(" path none to find (the persona's argv)");
|
|
301
|
+
else if (r.searched?.length)
|
|
302
|
+
r.searched.forEach((dir, i) =>
|
|
303
|
+
lines.push(` ${i === 0 ? "searched " : " "}${dir}`),
|
|
304
|
+
);
|
|
305
|
+
else if (r.source !== "setting") lines.push(" searched (no directories)");
|
|
306
|
+
if (!r.available && r.setting)
|
|
307
|
+
lines.push(
|
|
308
|
+
` fix install ${r.label ?? r.id}, or set ${r.setting} to its absolute path in ${userConfig}`,
|
|
309
|
+
` then run \`${cli} agents doctor\` again`,
|
|
310
|
+
);
|
|
311
|
+
return lines;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function doctor(
|
|
315
|
+
args: string[],
|
|
316
|
+
d: AgentsResolved,
|
|
317
|
+
deps: AgentsDoctorDeps,
|
|
318
|
+
): Promise<number> {
|
|
319
|
+
const where = "agents doctor";
|
|
320
|
+
const p = parseArgs(where, args, { "--json": false });
|
|
321
|
+
noExtra(where, p);
|
|
322
|
+
await (deps.loginPath ?? (() => initLoginPath({ env: d.env })))();
|
|
323
|
+
const rts = await listRuntimes(d);
|
|
324
|
+
const def = rts.find((r) => r.default);
|
|
325
|
+
const code = def?.available ? 0 : 1;
|
|
326
|
+
if (p.flags["--json"]) {
|
|
327
|
+
d.out(
|
|
328
|
+
JSON.stringify(
|
|
329
|
+
{ runtimes: rts, userConfig: d.userConfigFile() },
|
|
330
|
+
null,
|
|
331
|
+
2,
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
return code;
|
|
335
|
+
}
|
|
336
|
+
rts.forEach((r, i) => {
|
|
337
|
+
if (i) d.out("");
|
|
338
|
+
for (const l of doctorLines(r, d.cli, d.userConfigFile())) d.out(l);
|
|
339
|
+
});
|
|
340
|
+
d.out("");
|
|
341
|
+
d.out(
|
|
342
|
+
`\`${d.cli} doctor --probe\` checks the env each runtime's session gets.`,
|
|
343
|
+
);
|
|
344
|
+
return code;
|
|
345
|
+
}
|
|
346
|
+
|
|
253
347
|
/** `<cli> agents ...`; returns the exit code. */
|
|
254
348
|
export async function cmdAgents(
|
|
255
349
|
args: string[],
|
|
256
|
-
deps: AgentsDeps = {},
|
|
350
|
+
deps: AgentsDeps & AgentsDoctorDeps = {},
|
|
257
351
|
): Promise<number> {
|
|
258
352
|
const d = resolveAgentsDeps(deps);
|
|
259
353
|
const sub =
|
|
@@ -274,6 +368,8 @@ export async function cmdAgents(
|
|
|
274
368
|
return await resume(rest, d);
|
|
275
369
|
case "personas":
|
|
276
370
|
return await listPersonas(rest, d);
|
|
371
|
+
case "doctor":
|
|
372
|
+
return await doctor(rest, d, deps);
|
|
277
373
|
default:
|
|
278
374
|
throw new UsageError(`unknown agents command "${sub}"`);
|
|
279
375
|
}
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -2711,8 +2711,11 @@ class DenCLI {
|
|
|
2711
2711
|
);
|
|
2712
2712
|
console.log();
|
|
2713
2713
|
console.log(chalk.bold(" Flags:"));
|
|
2714
|
+
// Two spaces at least between a flag and its text, a long one too.
|
|
2714
2715
|
const row = (f: string, d: string) =>
|
|
2715
|
-
console.log(
|
|
2716
|
+
console.log(
|
|
2717
|
+
` ${chalk.cyan(f.padEnd(Math.max(20, f.length + 2)))}${d}`,
|
|
2718
|
+
);
|
|
2716
2719
|
row(
|
|
2717
2720
|
"-x, --exclude",
|
|
2718
2721
|
"start only the dependencies, not the current/seed repo",
|
|
@@ -4660,6 +4663,35 @@ class DenCLI {
|
|
|
4660
4663
|
if (!d.available) warn(`edge.tls ${edge.tls.strategy}: ${d.reason}`);
|
|
4661
4664
|
}
|
|
4662
4665
|
}
|
|
4666
|
+
// The web keys: a wrong value would stop `web up`; one read and
|
|
4667
|
+
// ignored (the distribution's web.allow_plain_http_remote: only
|
|
4668
|
+
// config/local/user.yml counts) is flagged, as `web up` says it.
|
|
4669
|
+
{
|
|
4670
|
+
const { ignoredConfigWarning, readWebAuthConfig } =
|
|
4671
|
+
await import("../../shared/web-session.js");
|
|
4672
|
+
try {
|
|
4673
|
+
const w = ignoredConfigWarning(
|
|
4674
|
+
readWebAuthConfig(getDistRoot(), product().configFile),
|
|
4675
|
+
);
|
|
4676
|
+
if (w) console.warn(chalk.yellow(`\n ⚠ ${w}`));
|
|
4677
|
+
} catch (e) {
|
|
4678
|
+
console.error(chalk.red(`\n ✗ web: ${(e as Error).message}`));
|
|
4679
|
+
process.exitCode = 1;
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
// The caps: agents.max_concurrent (agents) and sessions.max_concurrent
|
|
4683
|
+
// (plain sessions). A wrong value would refuse every start.
|
|
4684
|
+
{
|
|
4685
|
+
const { readAgentsConfig, readSessionsConfig } =
|
|
4686
|
+
await import("../../shared/den-config.js");
|
|
4687
|
+
for (const read of [readAgentsConfig, readSessionsConfig])
|
|
4688
|
+
try {
|
|
4689
|
+
read();
|
|
4690
|
+
} catch (e) {
|
|
4691
|
+
console.error(chalk.red(`\n ✗ ${(e as Error).message}`));
|
|
4692
|
+
process.exitCode = 1;
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4663
4695
|
const positional = args.filter((a) => !a.startsWith("-"));
|
|
4664
4696
|
const held = args.includes("--held");
|
|
4665
4697
|
// Bare `validate` in the distribution root (which has no manifest of its
|