@monster-orange/den 0.4.0 → 0.4.1
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 +409 -0
- package/docs/distributions.md +204 -14
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +9 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +23 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +96 -614
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +94 -10
- package/src/ui/cli/sessions-command.ts +32 -0
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +536 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +323 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +814 -66
- package/src/ui/server/public/agents-page.js +648 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- package/src/ui/server/workspace-routes.ts +13 -7
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `<cli> agents list|start|stop|resume|personas` (AG-2a, AG spec §5c):
|
|
3
3
|
* an agent is a persona and a workspace, run by a runtime in a session of
|
|
4
|
-
* the AG-3a host.
|
|
4
|
+
* the AG-3a host. This is the CLI's front: it parses the arguments and
|
|
5
|
+
* prints; what each operation does is agent-ops.ts, which the Workspaces
|
|
6
|
+
* page's routes (server/agent-routes.ts) call too.
|
|
5
7
|
*
|
|
6
8
|
* start resolves the persona (Den's default pack, then
|
|
7
9
|
* agents.persona_packs) and the runtime, asks the runtime for argv,
|
|
@@ -26,62 +28,40 @@
|
|
|
26
28
|
* Nothing here takes a grant: grants come from the org config only. The
|
|
27
29
|
* host enforces agents.max_concurrent on every start.
|
|
28
30
|
*/
|
|
29
|
-
import
|
|
30
|
-
import
|
|
31
|
+
import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
|
|
32
|
+
import { HostError } from "../../shared/sessions/client.js";
|
|
31
33
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
type
|
|
44
|
-
} from "
|
|
45
|
-
import {
|
|
46
|
-
AGENT_ID_RE,
|
|
47
|
-
agentFilesDir,
|
|
48
|
-
listRecords,
|
|
49
|
-
readRecord,
|
|
50
|
-
writeRecord,
|
|
51
|
-
writeRuntimeFiles,
|
|
52
|
-
type AgentRecord,
|
|
53
|
-
} from "../../shared/agents/records.js";
|
|
54
|
-
import {
|
|
55
|
-
agentSessionEnv,
|
|
56
|
-
DEFAULT_RUNTIME_ID,
|
|
57
|
-
getRuntime,
|
|
58
|
-
LaunchError,
|
|
59
|
-
runtimeIds,
|
|
60
|
-
type AgentMcp,
|
|
61
|
-
type LaunchInput,
|
|
62
|
-
type Runtime,
|
|
63
|
-
} from "../../shared/agents/runtimes.js";
|
|
64
|
-
import { HostError, type HostClient } from "../../shared/sessions/client.js";
|
|
65
|
-
import { DEN_PTY_ID } from "../../shared/sessions/den-pty.js";
|
|
66
|
-
import { inWorkspace, sessionName } from "../../shared/sessions/names.js";
|
|
67
|
-
import type { SessionInfo } from "../../shared/sessions/types.js";
|
|
68
|
-
import { serverSpec } from "../mcp/connect.js";
|
|
34
|
+
AgentError,
|
|
35
|
+
listAgents,
|
|
36
|
+
listPersonas as personaList,
|
|
37
|
+
loadAgentWorkspaces,
|
|
38
|
+
resolveAgentsDeps,
|
|
39
|
+
resumeAgent,
|
|
40
|
+
startAgent,
|
|
41
|
+
stopAgent,
|
|
42
|
+
type AgentsDeps,
|
|
43
|
+
type AgentsResolved,
|
|
44
|
+
type EarlyExit,
|
|
45
|
+
type Started,
|
|
46
|
+
} from "./agent-ops.js";
|
|
69
47
|
import {
|
|
70
48
|
columns,
|
|
71
|
-
contractInfo,
|
|
72
49
|
guarded,
|
|
73
|
-
loadWorkspaces,
|
|
74
50
|
noExtra,
|
|
75
51
|
parseArgs,
|
|
76
|
-
resolveDeps,
|
|
77
|
-
state,
|
|
78
52
|
UsageError,
|
|
79
|
-
withClient,
|
|
80
53
|
workspaceFlag,
|
|
81
|
-
type Resolved,
|
|
82
|
-
type SessionsDeps,
|
|
83
54
|
} from "./sessions-command.js";
|
|
84
55
|
|
|
56
|
+
export {
|
|
57
|
+
agentMcp,
|
|
58
|
+
agentRows,
|
|
59
|
+
EXIT_WATCH_MS,
|
|
60
|
+
plainLines,
|
|
61
|
+
type AgentRow,
|
|
62
|
+
type AgentsDeps,
|
|
63
|
+
} from "./agent-ops.js";
|
|
64
|
+
|
|
85
65
|
export const AGENTS_HELP = {
|
|
86
66
|
usage: "agents <list|start|stop|resume|personas> ...",
|
|
87
67
|
lines: [
|
|
@@ -106,193 +86,13 @@ export const AGENTS_HELP = {
|
|
|
106
86
|
],
|
|
107
87
|
};
|
|
108
88
|
|
|
109
|
-
export interface AgentsDeps extends SessionsDeps {
|
|
110
|
-
agentsConfig?: () => ResolvedAgentsConfig;
|
|
111
|
-
distRoot?: () => string;
|
|
112
|
-
/** A workspace's checkout directory: where its agents run. */
|
|
113
|
-
workspaceDir?: (ws: string) => string;
|
|
114
|
-
/** The MCP server binary and its env; Den adds the scoped args. */
|
|
115
|
-
mcpServer?: () => { command: string[] | string; env: Record<string, string> };
|
|
116
|
-
/**
|
|
117
|
-
* How long start and resume watch a new session for an early non-zero
|
|
118
|
-
* exit (default 3000 ms; 0: not at all).
|
|
119
|
-
*/
|
|
120
|
-
exitWatchMs?: number;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
interface AgentsResolved extends Resolved {
|
|
124
|
-
agentsConfig: () => ResolvedAgentsConfig;
|
|
125
|
-
distRoot: () => string;
|
|
126
|
-
workspaceDir: (ws: string) => string;
|
|
127
|
-
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
128
|
-
exitWatchMs: number;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** The early-exit watch: at most this long, polling the host. */
|
|
132
|
-
export const EXIT_WATCH_MS = 3000;
|
|
133
|
-
const EXIT_POLL_MS = 100;
|
|
134
|
-
/** The most lines of an early exit's scrollback printed. */
|
|
135
|
-
const EXIT_TAIL_LINES = 10;
|
|
136
|
-
|
|
137
|
-
let workspaceDirFn: ((ws: string) => string) | null = null;
|
|
138
|
-
async function loadWorkspaceDir(d: AgentsDeps): Promise<void> {
|
|
139
|
-
if (d.workspaceDir || workspaceDirFn) return;
|
|
140
|
-
const ws = await import("../../shared/environment-manager/workspaces.js");
|
|
141
|
-
const { getReposRoot } = await import("../../shared/den-config.js");
|
|
142
|
-
workspaceDirFn = (name) => {
|
|
143
|
-
const cfg = ws.readWorkspacesFile().workspaces[name];
|
|
144
|
-
if (!cfg) throw new UsageError(`workspace "${name}" does not exist`);
|
|
145
|
-
return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
|
|
150
|
-
function defaultMcpServer(cli: string): {
|
|
151
|
-
command: string[];
|
|
152
|
-
env: Record<string, string>;
|
|
153
|
-
} {
|
|
154
|
-
const s = serverSpec({
|
|
155
|
-
cli,
|
|
156
|
-
distRoot: getDistRoot(),
|
|
157
|
-
nodeBinDir: path.dirname(process.execPath),
|
|
158
|
-
platform: process.platform,
|
|
159
|
-
});
|
|
160
|
-
if (fs.existsSync(s.command)) return { command: [s.command], env: s.env };
|
|
161
|
-
const e = cliEntrypoint();
|
|
162
|
-
return { command: [e.command, ...e.args], env: s.env };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
166
|
-
const base = resolveDeps(d);
|
|
167
|
-
return {
|
|
168
|
-
...base,
|
|
169
|
-
agentsConfig: d.agentsConfig ?? (() => readAgentsConfig()),
|
|
170
|
-
distRoot: d.distRoot ?? getDistRoot,
|
|
171
|
-
workspaceDir:
|
|
172
|
-
d.workspaceDir ??
|
|
173
|
-
((ws) => {
|
|
174
|
-
if (!workspaceDirFn) throw new Error("workspaces not loaded");
|
|
175
|
-
return workspaceDirFn(ws);
|
|
176
|
-
}),
|
|
177
|
-
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
178
|
-
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── the agent's MCP ─────────────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Where the distribution is, from the caller's env (DEN_ROOT,
|
|
186
|
-
* `<PREFIX>_ROOT`, `<PREFIX>_HOME`): the agent's env is an allowlist, so
|
|
187
|
-
* its MCP server gets them in its own config instead, and reaches the same
|
|
188
|
-
* distribution and state dir as the CLI that started it. Paths, never
|
|
189
|
-
* secrets.
|
|
190
|
-
*/
|
|
191
|
-
function distLocationEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
192
|
-
const out: Record<string, string> = {};
|
|
193
|
-
for (const k of new Set(["DEN_ROOT", envKey("ROOT"), envKey("HOME")])) {
|
|
194
|
-
const v = env[k];
|
|
195
|
-
if (v) out[k] = v;
|
|
196
|
-
}
|
|
197
|
-
return out;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** `<cli> mcp --workspace <ws> --actor agent:<id> [--grant <g>…]`. */
|
|
201
|
-
export function agentMcp(
|
|
202
|
-
d: AgentsResolved,
|
|
203
|
-
ws: string,
|
|
204
|
-
id: string,
|
|
205
|
-
grants: string[],
|
|
206
|
-
): AgentMcp {
|
|
207
|
-
const server = d.mcpServer();
|
|
208
|
-
const bin = Array.isArray(server.command) ? server.command : [server.command];
|
|
209
|
-
return {
|
|
210
|
-
name: d.cli,
|
|
211
|
-
command: [
|
|
212
|
-
...bin,
|
|
213
|
-
"mcp",
|
|
214
|
-
"--workspace",
|
|
215
|
-
ws,
|
|
216
|
-
"--actor",
|
|
217
|
-
`agent:${id}`,
|
|
218
|
-
...grants.flatMap((g) => ["--grant", g]),
|
|
219
|
-
],
|
|
220
|
-
env: { ...distLocationEnv(d.env), ...server.env },
|
|
221
|
-
scope: ws,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── rows ────────────────────────────────────────────────────────────────────
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* One row per agent of a workspace: each session joined to the agent record
|
|
229
|
-
* that started it (by session id), and each record whose session is gone,
|
|
230
|
-
* as stopped (after `agents stop`) or ended. A session without a record is
|
|
231
|
-
* a plain one (`sessions start`), not an agent: `sessions list` shows it.
|
|
232
|
-
*/
|
|
233
|
-
export interface AgentRow {
|
|
234
|
-
agent: string;
|
|
235
|
-
persona?: string;
|
|
236
|
-
runtime?: string;
|
|
237
|
-
model?: string;
|
|
238
|
-
workspace?: string;
|
|
239
|
-
cwd?: string;
|
|
240
|
-
/** running, exited (<code>), stopped or ended. */
|
|
241
|
-
state: string;
|
|
242
|
-
session?: SessionInfo;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function rowState(rec: AgentRecord | undefined, s?: SessionInfo): string {
|
|
246
|
-
if (s && !s.exited) return "running";
|
|
247
|
-
if (rec?.stoppedAt) return "stopped";
|
|
248
|
-
return s ? state(s) : "ended";
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export function agentRows(
|
|
252
|
-
sessions: SessionInfo[],
|
|
253
|
-
records: AgentRecord[] = [],
|
|
254
|
-
): AgentRow[] {
|
|
255
|
-
const bySession = new Map(records.map((r) => [r.session.id, r]));
|
|
256
|
-
const rows: AgentRow[] = [];
|
|
257
|
-
const shown = new Set<string>();
|
|
258
|
-
for (const s of sessions) {
|
|
259
|
-
const rec = bySession.get(s.ref.id);
|
|
260
|
-
if (!rec) continue;
|
|
261
|
-
shown.add(rec.id);
|
|
262
|
-
rows.push({
|
|
263
|
-
agent: rec.id,
|
|
264
|
-
...recordFields(rec),
|
|
265
|
-
state: rowState(rec, s),
|
|
266
|
-
session: contractInfo(s),
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
for (const rec of records)
|
|
270
|
-
if (!shown.has(rec.id))
|
|
271
|
-
rows.push({ agent: rec.id, ...recordFields(rec), state: rowState(rec) });
|
|
272
|
-
return rows;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function recordFields(rec: AgentRecord): Partial<AgentRow> {
|
|
276
|
-
return {
|
|
277
|
-
persona: rec.persona,
|
|
278
|
-
runtime: rec.runtime,
|
|
279
|
-
...(rec.model ? { model: rec.model } : {}),
|
|
280
|
-
workspace: rec.workspace,
|
|
281
|
-
cwd: rec.cwd,
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
|
|
285
89
|
async function list(args: string[], d: AgentsResolved): Promise<number> {
|
|
286
90
|
const where = "agents list";
|
|
287
91
|
const p = parseArgs(where, args, { "--workspace": true, "--json": false });
|
|
288
92
|
noExtra(where, p);
|
|
289
93
|
const ws = workspaceFlag(where, p);
|
|
290
94
|
if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
|
|
291
|
-
const
|
|
292
|
-
inWorkspace(s, d.dist, ws),
|
|
293
|
-
);
|
|
294
|
-
const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
|
|
295
|
-
const rows = agentRows(sessions, records);
|
|
95
|
+
const rows = await listAgents(d, ws);
|
|
296
96
|
if (p.flags["--json"]) {
|
|
297
97
|
d.out(JSON.stringify(rows, null, 2));
|
|
298
98
|
return 0;
|
|
@@ -327,210 +127,36 @@ async function list(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
327
127
|
return 0;
|
|
328
128
|
}
|
|
329
129
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
throw new Refused((e as Error).message);
|
|
130
|
+
/** An early exit, as the terminal shows it: its code and last lines. */
|
|
131
|
+
function printEarlyExit(d: AgentsResolved, id: string, e: EarlyExit): void {
|
|
132
|
+
const code = e.exitCode === undefined ? "" : ` (exit ${e.exitCode})`;
|
|
133
|
+
d.err(`✗ agent ${id} exited at once${code}`);
|
|
134
|
+
if (e.tail.length) {
|
|
135
|
+
d.err(` its last ${e.tail.length === 1 ? "line" : "lines"}:`);
|
|
136
|
+
for (const l of e.tail) d.err(` | ${l}`);
|
|
338
137
|
}
|
|
138
|
+
d.err(` All of it: ${d.cli} sessions attach ${e.sessionName}`);
|
|
339
139
|
}
|
|
340
140
|
|
|
341
|
-
/**
|
|
342
|
-
function
|
|
343
|
-
d
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return loadPersonas(resolvePackDirs(cfg.personaPacks, d.distRoot()));
|
|
348
|
-
} catch (e) {
|
|
349
|
-
throw new Refused((e as Error).message);
|
|
141
|
+
/** A start's or resume's warnings and outcome; its exit code. */
|
|
142
|
+
function report(d: AgentsResolved, s: Started, line: string): number {
|
|
143
|
+
for (const w of s.warnings) d.err(w);
|
|
144
|
+
if (s.early) {
|
|
145
|
+
printEarlyExit(d, s.rec.id, s.early);
|
|
146
|
+
return 1;
|
|
350
147
|
}
|
|
148
|
+
d.out(line);
|
|
149
|
+
d.out(` attach: ${d.cli} sessions attach ${s.rec.sessionName}`);
|
|
150
|
+
return 0;
|
|
351
151
|
}
|
|
352
152
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
return
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** The running session of `rec`, if any; else its exited one, if listed. */
|
|
363
|
-
function sessionOf(
|
|
364
|
-
sessions: SessionInfo[],
|
|
365
|
-
rec: AgentRecord,
|
|
366
|
-
): SessionInfo | undefined {
|
|
367
|
-
return sessions.find((s) => s.ref.id === rec.session.id);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** Refuse: exit 1 with the message (and hints). */
|
|
371
|
-
class Refused extends Error {
|
|
372
|
-
constructor(
|
|
373
|
-
message: string,
|
|
374
|
-
readonly hints: string[] = [],
|
|
375
|
-
) {
|
|
376
|
-
super(message);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* The id an agent gets: --id, else the persona's id while it is free (or
|
|
382
|
-
* this workspace's), else `<persona>-<ws>`.
|
|
383
|
-
*/
|
|
384
|
-
function defaultId(stateDir: string, persona: string, ws: string): string {
|
|
385
|
-
const rec = readRecord(stateDir, persona);
|
|
386
|
-
if (!rec || rec.workspace === ws) return persona;
|
|
387
|
-
return `${persona}-${ws}`.slice(0, 64);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function checkId(where: string, id: string): void {
|
|
391
|
-
if (!AGENT_ID_RE.test(id) || AGENTS_RESERVED_KEYS.includes(id))
|
|
392
|
-
throw new UsageError(
|
|
393
|
-
`${where}: --id "${id}" must be letters, digits, ".", "_" or "-" (1-64), ` +
|
|
394
|
-
`and not ${AGENTS_RESERVED_KEYS.join(" or ")}`,
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
interface Launch {
|
|
399
|
-
rec: Omit<AgentRecord, "session" | "startedAt">;
|
|
400
|
-
argv: string[];
|
|
401
|
-
env: Record<string, string>;
|
|
402
|
-
/** The runtime's envPassthrough. */
|
|
403
|
-
passthrough?: readonly string[];
|
|
404
|
-
warnings?: string[];
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** Start `launch` as the agent's session (replacing an exited one) and record it. */
|
|
408
|
-
async function startSession(
|
|
409
|
-
d: AgentsResolved,
|
|
410
|
-
c: HostClient,
|
|
411
|
-
launch: Launch,
|
|
412
|
-
old: SessionInfo | undefined,
|
|
413
|
-
extra: Partial<AgentRecord>,
|
|
414
|
-
): Promise<{ rec: AgentRecord; info: SessionInfo }> {
|
|
415
|
-
if (old?.exited) await c.forget(old.ref.id);
|
|
416
|
-
const tty = d.stdout.isTTY;
|
|
417
|
-
const info = await c.start(
|
|
418
|
-
{
|
|
419
|
-
name: launch.rec.sessionName,
|
|
420
|
-
cwd: launch.rec.cwd,
|
|
421
|
-
argv: launch.argv,
|
|
422
|
-
// Never the caller's whole env: the allowlist, then the runtime's.
|
|
423
|
-
env: agentSessionEnv(
|
|
424
|
-
d.env,
|
|
425
|
-
{ envPassthrough: launch.passthrough },
|
|
426
|
-
launch.env,
|
|
427
|
-
launch.rec.id,
|
|
428
|
-
),
|
|
429
|
-
cols: (tty && d.stdout.columns) || 80,
|
|
430
|
-
rows: (tty && d.stdout.rows) || 24,
|
|
431
|
-
},
|
|
432
|
-
{ limit: d.maxConcurrent() },
|
|
433
|
-
);
|
|
434
|
-
const rec: AgentRecord = {
|
|
435
|
-
...launch.rec,
|
|
436
|
-
session: { backend: info.ref.backend, id: info.ref.id },
|
|
437
|
-
startedAt: info.startedAt,
|
|
438
|
-
...extra,
|
|
439
|
-
};
|
|
440
|
-
delete rec.stoppedAt;
|
|
441
|
-
writeRecord(d.stateDir(), rec);
|
|
442
|
-
for (const w of launch.warnings ?? []) d.err(w);
|
|
443
|
-
return { rec, info };
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** CSI, OSC (BEL or ST ended), and other two-byte escapes. */
|
|
447
|
-
/* eslint-disable no-control-regex */
|
|
448
|
-
const TERMINAL_ESCAPE =
|
|
449
|
-
/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
450
|
-
/* eslint-enable no-control-regex */
|
|
451
|
-
|
|
452
|
-
/** Printable lines of terminal output: escapes and control bytes removed. */
|
|
453
|
-
export function plainLines(text: string): string[] {
|
|
454
|
-
return (
|
|
455
|
-
text
|
|
456
|
-
.replace(TERMINAL_ESCAPE, "")
|
|
457
|
-
.split(/\r?\n/)
|
|
458
|
-
// A carriage return redraws the line: what stays is after the last.
|
|
459
|
-
.map((l) => l.slice(l.lastIndexOf("\r") + 1))
|
|
460
|
-
// eslint-disable-next-line no-control-regex
|
|
461
|
-
.map((l) => l.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").trimEnd())
|
|
462
|
-
.filter((l) => l.trim())
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* Watch a new session for an early exit (d.exitWatchMs, polling the host).
|
|
468
|
-
* A non-zero exit is reported with its code and the last lines it printed
|
|
469
|
-
* (secret-shaped values redacted); true when it did.
|
|
470
|
-
*/
|
|
471
|
-
async function exitedAtOnce(
|
|
472
|
-
d: AgentsResolved,
|
|
473
|
-
c: HostClient,
|
|
474
|
-
id: string,
|
|
475
|
-
info: SessionInfo,
|
|
476
|
-
): Promise<boolean> {
|
|
477
|
-
const deadline = Date.now() + d.exitWatchMs;
|
|
478
|
-
while (Date.now() < deadline) {
|
|
479
|
-
await new Promise((r) =>
|
|
480
|
-
setTimeout(r, Math.min(EXIT_POLL_MS, Math.max(0, deadline - Date.now()))),
|
|
481
|
-
);
|
|
482
|
-
const s = (await c.list()).find((x) => x.ref.id === info.ref.id);
|
|
483
|
-
if (!s) return false;
|
|
484
|
-
if (!s.exited) continue;
|
|
485
|
-
if (s.exitCode === 0) return false;
|
|
486
|
-
const code = s.exitCode === undefined ? "" : ` (exit ${s.exitCode})`;
|
|
487
|
-
d.err(`✗ agent ${id} exited at once${code}`);
|
|
488
|
-
const tail = plainLines(
|
|
489
|
-
(await c.scrollback(s.ref.id, 64 * 1024)).toString("utf-8"),
|
|
490
|
-
).slice(-EXIT_TAIL_LINES);
|
|
491
|
-
if (tail.length) {
|
|
492
|
-
d.err(` its last ${tail.length === 1 ? "line" : "lines"}:`);
|
|
493
|
-
for (const l of tail) d.err(` | ${redactSecrets(l)}`);
|
|
494
|
-
}
|
|
495
|
-
d.err(` All of it: ${d.cli} sessions attach ${s.name}`);
|
|
496
|
-
return true;
|
|
497
|
-
}
|
|
498
|
-
return false;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function launchInput(
|
|
502
|
-
d: AgentsResolved,
|
|
503
|
-
cfg: ResolvedAgentsConfig,
|
|
504
|
-
persona: Persona,
|
|
505
|
-
ws: string,
|
|
506
|
-
id: string,
|
|
507
|
-
model: string | undefined,
|
|
508
|
-
): LaunchInput {
|
|
509
|
-
const grants = cfg.agents[id]?.grants ?? [];
|
|
510
|
-
for (const g of grants)
|
|
511
|
-
if (!d.workspaceExists(g))
|
|
512
|
-
throw new Refused(`agents.${id}.grants: workspace "${g}" does not exist`);
|
|
513
|
-
const dir = d.workspaceDir(ws);
|
|
514
|
-
if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
|
|
515
|
-
throw new Refused(`workspace ${ws}'s directory ${dir} does not exist`);
|
|
516
|
-
return {
|
|
517
|
-
persona,
|
|
518
|
-
workspace: { name: ws, dir },
|
|
519
|
-
agentId: id,
|
|
520
|
-
worktree: dir,
|
|
521
|
-
...(model ? { model } : {}),
|
|
522
|
-
mcp: agentMcp(d, ws, id, grants),
|
|
523
|
-
filesDir: agentFilesDir(d.stateDir(), id),
|
|
524
|
-
env: d.env,
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function ptyOrRefuse(d: AgentsResolved): void {
|
|
529
|
-
const pty = d.pty();
|
|
530
|
-
if (!pty.available)
|
|
531
|
-
throw new Refused(`${DEN_PTY_ID} is unavailable: ${pty.reason}`, [
|
|
532
|
-
`\`${d.cli} doctor\` shows the session backend's state.`,
|
|
533
|
-
]);
|
|
153
|
+
/** A flag's string value, or undefined. */
|
|
154
|
+
function str(
|
|
155
|
+
p: ReturnType<typeof parseArgs>,
|
|
156
|
+
flag: string,
|
|
157
|
+
): string | undefined {
|
|
158
|
+
const v = p.flags[flag];
|
|
159
|
+
return typeof v === "string" ? v : undefined;
|
|
534
160
|
}
|
|
535
161
|
|
|
536
162
|
async function start(args: string[], d: AgentsResolved): Promise<number> {
|
|
@@ -545,188 +171,57 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
545
171
|
noExtra(where, p);
|
|
546
172
|
const ws = workspaceFlag(where, p);
|
|
547
173
|
if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
|
|
548
|
-
const
|
|
549
|
-
if (
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
if (!persona)
|
|
557
|
-
throw new UsageError(
|
|
558
|
-
`${where}: no persona "${personaId}" (personas: ${[...all.keys()].sort().join(", ")})`,
|
|
559
|
-
);
|
|
560
|
-
const idFlag = p.flags["--id"];
|
|
561
|
-
const id =
|
|
562
|
-
typeof idFlag === "string"
|
|
563
|
-
? idFlag
|
|
564
|
-
: defaultId(d.stateDir(), persona.id, ws);
|
|
565
|
-
checkId(where, id);
|
|
566
|
-
const rtFlag = p.flags["--runtime"];
|
|
567
|
-
const rt = runtimeFor(
|
|
568
|
-
typeof rtFlag === "string"
|
|
569
|
-
? rtFlag
|
|
570
|
-
: (persona.runtime ?? DEFAULT_RUNTIME_ID),
|
|
571
|
-
);
|
|
572
|
-
const det = rt.detect(d.env);
|
|
573
|
-
if (!det.available)
|
|
574
|
-
throw new Refused(`${rt.id} is unavailable: ${det.why ?? "unknown"}`);
|
|
575
|
-
const modelFlag = p.flags["--model"];
|
|
576
|
-
const model = typeof modelFlag === "string" ? modelFlag : persona.model;
|
|
577
|
-
|
|
578
|
-
const prev = readRecord(d.stateDir(), id);
|
|
579
|
-
if (prev && prev.workspace !== ws)
|
|
580
|
-
throw new Refused(
|
|
581
|
-
`agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
|
|
582
|
-
);
|
|
583
|
-
ptyOrRefuse(d);
|
|
584
|
-
const input = launchInput(d, cfg, persona, ws, id, model);
|
|
585
|
-
|
|
586
|
-
return withClient(d, async (c) => {
|
|
587
|
-
const sessions = await c.list();
|
|
588
|
-
const old = prev ? sessionOf(sessions, prev) : undefined;
|
|
589
|
-
if (old && !old.exited)
|
|
590
|
-
throw new Refused(`agent ${id} is running (${old.name})`, [
|
|
591
|
-
`Attach: ${d.cli} sessions attach ${old.name}`,
|
|
592
|
-
]);
|
|
593
|
-
const out = rt.launch(input);
|
|
594
|
-
writeRuntimeFiles(input.filesDir, out.files);
|
|
595
|
-
const { rec, info } = await startSession(
|
|
596
|
-
d,
|
|
597
|
-
c,
|
|
598
|
-
{
|
|
599
|
-
rec: {
|
|
600
|
-
id,
|
|
601
|
-
persona: persona.id,
|
|
602
|
-
runtime: rt.id,
|
|
603
|
-
...(model ? { model } : {}),
|
|
604
|
-
workspace: ws,
|
|
605
|
-
cwd: input.worktree,
|
|
606
|
-
sessionName: sessionName(d.dist, ws, id),
|
|
607
|
-
...(out.resumeToken ? { resumeToken: out.resumeToken } : {}),
|
|
608
|
-
},
|
|
609
|
-
argv: out.argv,
|
|
610
|
-
env: out.env,
|
|
611
|
-
passthrough: rt.envPassthrough,
|
|
612
|
-
warnings: out.warnings,
|
|
613
|
-
},
|
|
614
|
-
old,
|
|
615
|
-
{},
|
|
616
|
-
);
|
|
617
|
-
if (await exitedAtOnce(d, c, id, info)) return 1;
|
|
618
|
-
d.out(
|
|
619
|
-
`started agent ${id} (${persona.id}, ${rt.id}) as ${rec.sessionName} in ${rec.cwd}`,
|
|
620
|
-
);
|
|
621
|
-
d.out(` attach: ${d.cli} sessions attach ${rec.sessionName}`);
|
|
622
|
-
return 0;
|
|
174
|
+
const persona = str(p, "--persona");
|
|
175
|
+
if (!persona) throw new UsageError(`${where} needs --persona <p>`);
|
|
176
|
+
const s = await startAgent(d, {
|
|
177
|
+
workspace: ws,
|
|
178
|
+
persona,
|
|
179
|
+
runtime: str(p, "--runtime"),
|
|
180
|
+
model: str(p, "--model"),
|
|
181
|
+
id: str(p, "--id"),
|
|
623
182
|
});
|
|
183
|
+
return report(
|
|
184
|
+
d,
|
|
185
|
+
s,
|
|
186
|
+
`started agent ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) as ${s.rec.sessionName} in ${s.rec.cwd}`,
|
|
187
|
+
);
|
|
624
188
|
}
|
|
625
189
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
function recordFor(
|
|
190
|
+
/** `<id> [-w <ws>]`. */
|
|
191
|
+
function idAndWorkspace(
|
|
629
192
|
where: string,
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
193
|
+
args: string[],
|
|
194
|
+
): { id: string; ws?: string } {
|
|
195
|
+
const p = parseArgs(where, args, { "--workspace": true });
|
|
196
|
+
noExtra(where, p, 1);
|
|
633
197
|
const id = p.positional[0];
|
|
634
198
|
if (!id) throw new UsageError(`${where} needs <id>`);
|
|
635
|
-
|
|
636
|
-
if (!rec) throw new Refused(`no agent "${id}"`);
|
|
637
|
-
const ws = workspaceFlag(where, p);
|
|
638
|
-
if (ws && ws !== rec.workspace)
|
|
639
|
-
throw new Refused(
|
|
640
|
-
`agent ${id} is in workspace ${rec.workspace}, not ${ws}`,
|
|
641
|
-
);
|
|
642
|
-
return rec;
|
|
199
|
+
return { id, ws: workspaceFlag(where, p) };
|
|
643
200
|
}
|
|
644
201
|
|
|
645
202
|
async function stop(args: string[], d: AgentsResolved): Promise<number> {
|
|
646
|
-
const
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
d.out(
|
|
655
|
-
s && !s.exited
|
|
656
|
-
? `stopped agent ${rec.id} (sent TERM to ${s.name}, pid ${s.pid})`
|
|
657
|
-
: `agent ${rec.id} was not running; marked stopped`,
|
|
658
|
-
);
|
|
659
|
-
return 0;
|
|
660
|
-
});
|
|
203
|
+
const { id, ws } = idAndWorkspace("agents stop", args);
|
|
204
|
+
const { rec, session: s } = await stopAgent(d, id, ws);
|
|
205
|
+
d.out(
|
|
206
|
+
s
|
|
207
|
+
? `stopped agent ${rec.id} (sent TERM to ${s.name}, pid ${s.pid})`
|
|
208
|
+
: `agent ${rec.id} was not running; marked stopped`,
|
|
209
|
+
);
|
|
210
|
+
return 0;
|
|
661
211
|
}
|
|
662
212
|
|
|
663
213
|
async function resume(args: string[], d: AgentsResolved): Promise<number> {
|
|
664
|
-
const
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
const rec = recordFor(where, p, d);
|
|
668
|
-
const rt = runtimeFor(rec.runtime);
|
|
669
|
-
if (!rt.resume)
|
|
670
|
-
throw new Refused(`runtime ${rt.id} does not declare resume`, [
|
|
671
|
-
`Start it again: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
|
|
672
|
-
]);
|
|
673
|
-
const cfg = agentsConfig(d);
|
|
674
|
-
const persona = personas(d, cfg).get(rec.persona);
|
|
675
|
-
if (!persona)
|
|
676
|
-
throw new Refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
|
|
677
|
-
ptyOrRefuse(d);
|
|
678
|
-
return withClient(d, async (c) => {
|
|
679
|
-
const old = sessionOf(await c.list(), rec);
|
|
680
|
-
if (old && !old.exited)
|
|
681
|
-
throw new Refused(`agent ${rec.id} is running (${old.name})`, [
|
|
682
|
-
`Attach: ${d.cli} sessions attach ${old.name}`,
|
|
683
|
-
]);
|
|
684
|
-
const input = launchInput(
|
|
685
|
-
d,
|
|
686
|
-
cfg,
|
|
687
|
-
persona,
|
|
688
|
-
rec.workspace,
|
|
689
|
-
rec.id,
|
|
690
|
-
rec.model,
|
|
691
|
-
);
|
|
692
|
-
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
693
|
-
const at = {
|
|
694
|
-
...input,
|
|
695
|
-
worktree: rec.cwd,
|
|
696
|
-
...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
|
|
697
|
-
};
|
|
698
|
-
// A session nobody prompted left no conversation: continuing it fails,
|
|
699
|
-
// so it starts afresh under the same id. A session the host no longer
|
|
700
|
-
// lists is assumed prompted.
|
|
701
|
-
const fresh = Boolean(old && !old.prompted && rec.resumeToken);
|
|
702
|
-
const out = fresh ? rt.launch(at) : rt.resume!(at);
|
|
703
|
-
if (!out) throw new Refused(`agent ${rec.id} has nothing to resume`);
|
|
704
|
-
writeRuntimeFiles(input.filesDir, out.files);
|
|
705
|
-
const { session: _s, startedAt: _t, resumedAt: _r, ...base } = rec;
|
|
706
|
-
if (fresh)
|
|
707
|
-
d.out(
|
|
708
|
-
`agent ${rec.id}: starting fresh: it was never prompted, so there is no conversation to resume`,
|
|
709
|
-
);
|
|
710
|
-
const { rec: next, info } = await startSession(
|
|
711
|
-
d,
|
|
712
|
-
c,
|
|
713
|
-
{
|
|
714
|
-
rec: { ...base, cwd: rec.cwd },
|
|
715
|
-
argv: out.argv,
|
|
716
|
-
env: out.env,
|
|
717
|
-
passthrough: rt.envPassthrough,
|
|
718
|
-
warnings: out.warnings,
|
|
719
|
-
},
|
|
720
|
-
old,
|
|
721
|
-
fresh ? {} : { resumedAt: new Date().toISOString() },
|
|
722
|
-
);
|
|
723
|
-
if (await exitedAtOnce(d, c, rec.id, info)) return 1;
|
|
214
|
+
const { id, ws } = idAndWorkspace("agents resume", args);
|
|
215
|
+
const s = await resumeAgent(d, id, ws);
|
|
216
|
+
if (s.fresh)
|
|
724
217
|
d.out(
|
|
725
|
-
|
|
218
|
+
`agent ${s.rec.id}: starting fresh: it was never prompted, so there is no conversation to resume`,
|
|
726
219
|
);
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
220
|
+
return report(
|
|
221
|
+
d,
|
|
222
|
+
s,
|
|
223
|
+
`${s.fresh ? "started" : "resumed"} agent ${s.rec.id} as ${s.rec.sessionName} in ${s.rec.cwd}`,
|
|
224
|
+
);
|
|
730
225
|
}
|
|
731
226
|
|
|
732
227
|
async function listPersonas(
|
|
@@ -736,17 +231,9 @@ async function listPersonas(
|
|
|
736
231
|
const where = "agents personas";
|
|
737
232
|
const p = parseArgs(where, args, { "--json": false });
|
|
738
233
|
noExtra(where, p);
|
|
739
|
-
const all =
|
|
740
|
-
a.id.localeCompare(b.id),
|
|
741
|
-
);
|
|
234
|
+
const all = personaList(d);
|
|
742
235
|
if (p.flags["--json"]) {
|
|
743
|
-
d.out(
|
|
744
|
-
JSON.stringify(
|
|
745
|
-
all.map(({ prompt: _p, ...rest }) => rest),
|
|
746
|
-
null,
|
|
747
|
-
2,
|
|
748
|
-
),
|
|
749
|
-
);
|
|
236
|
+
d.out(JSON.stringify(all, null, 2));
|
|
750
237
|
return 0;
|
|
751
238
|
}
|
|
752
239
|
for (const l of columns([
|
|
@@ -778,14 +265,12 @@ export async function cmdAgents(
|
|
|
778
265
|
case "list":
|
|
779
266
|
return await list(rest, d);
|
|
780
267
|
case "start":
|
|
781
|
-
await
|
|
782
|
-
await loadWorkspaceDir(deps);
|
|
268
|
+
await loadAgentWorkspaces(deps);
|
|
783
269
|
return await start(rest, d);
|
|
784
270
|
case "stop":
|
|
785
271
|
return await stop(rest, d);
|
|
786
272
|
case "resume":
|
|
787
|
-
await
|
|
788
|
-
await loadWorkspaceDir(deps);
|
|
273
|
+
await loadAgentWorkspaces(deps);
|
|
789
274
|
return await resume(rest, d);
|
|
790
275
|
case "personas":
|
|
791
276
|
return await listPersonas(rest, d);
|
|
@@ -793,17 +278,14 @@ export async function cmdAgents(
|
|
|
793
278
|
throw new UsageError(`unknown agents command "${sub}"`);
|
|
794
279
|
}
|
|
795
280
|
} catch (e) {
|
|
796
|
-
if (e instanceof
|
|
281
|
+
if (e instanceof AgentError) {
|
|
282
|
+
if (e.usage) throw new UsageError(e.message);
|
|
797
283
|
d.err(`✗ ${e.message}`);
|
|
798
|
-
for (const h of e
|
|
284
|
+
for (const h of e.hints) d.err(` ${h}`);
|
|
799
285
|
return 1;
|
|
800
286
|
}
|
|
801
287
|
if (e instanceof HostError) {
|
|
802
288
|
d.err(`✗ ${e.message}`);
|
|
803
|
-
if (e.code === "cap")
|
|
804
|
-
d.err(
|
|
805
|
-
` Stop one first (\`${d.cli} agents stop <id>\`), or raise agents.max_concurrent.`,
|
|
806
|
-
);
|
|
807
289
|
return 1;
|
|
808
290
|
}
|
|
809
291
|
throw e;
|