@monster-orange/den 0.3.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 +593 -0
- package/docs/distributions.md +429 -18
- package/package.json +6 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +271 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +199 -0
- package/src/shared/agents/runtimes.ts +54 -0
- package/src/shared/den-config.ts +116 -0
- package/src/shared/engine.ts +23 -0
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/environment-manager/workspaces.ts +19 -0
- package/src/shared/profile.ts +15 -4
- package/src/shared/secret-env.ts +70 -0
- package/src/shared/sessions/client.ts +284 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +89 -0
- package/src/shared/sessions/host.ts +690 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -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 +294 -0
- package/src/ui/cli/doctor-command.ts +138 -9
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +32 -3
- package/src/ui/cli/index.ts +128 -12
- package/src/ui/cli/sessions-command.ts +750 -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/scope.ts +123 -19
- package/src/ui/mcp/server.ts +100 -7
- package/src/ui/mcp/tools.ts +44 -5
- package/src/ui/server/agent-routes.ts +323 -0
- package/src/ui/server/audit.ts +111 -22
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +820 -65
- 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/readonly-routes.ts +25 -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 +16 -22
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent operation does (AG-2a, AG spec §5c), once, for both of its
|
|
3
|
+
* fronts: `<cli> agents` (agents-command.ts) and the Workspaces page's
|
|
4
|
+
* Agents section (server/agent-routes.ts, AG-4a). Neither front keeps state
|
|
5
|
+
* of its own: the agent record (shared/agents/records.ts) and the session
|
|
6
|
+
* host are the whole of it. Nothing here prints: each operation returns
|
|
7
|
+
* what happened, or throws an AgentError saying why it didn't.
|
|
8
|
+
*
|
|
9
|
+
* start resolves the persona (Den's default pack, then
|
|
10
|
+
* agents.persona_packs) and the runtime, asks the runtime for argv,
|
|
11
|
+
* env and files (its MCP config: `<cli> mcp --workspace <ws>
|
|
12
|
+
* --actor agent:<id>`, plus a `--grant` per agents.<id>.grants),
|
|
13
|
+
* starts `<dist>-<ws>:<id>` in the workspace's dir, and writes the
|
|
14
|
+
* agent record (<stateDir>/agents/<id>.json, 0600; no env, no argv).
|
|
15
|
+
* SEC-1c: it mints the agent's actor token (den_at_…, its hash in
|
|
16
|
+
* <stateDir>/actor-tokens.json) and hands it to the session as
|
|
17
|
+
* DEN_ACTOR_TOKEN; any earlier token of the agent stops working.
|
|
18
|
+
* The agent's env is an allowlist plus what its runtime declares,
|
|
19
|
+
* less every runtime's scrubEnv (sessions/env.ts), never the
|
|
20
|
+
* caller's whole environment; no --keep-env. A session that exits
|
|
21
|
+
* non-zero in its first moments (exitWatchMs) is returned as an
|
|
22
|
+
* early exit with its last lines.
|
|
23
|
+
* list the workspace's agents (sessions with an agent record), with
|
|
24
|
+
* persona, runtime and model; a record whose session is gone is
|
|
25
|
+
* listed as stopped or ended. A plain session is not an agent.
|
|
26
|
+
* stop ends its session (TERM), marks the record stopped and revokes
|
|
27
|
+
* its actor token.
|
|
28
|
+
* resume only when its runtime declares resume and its session is not
|
|
29
|
+
* running: a new session, in the same cwd, continuing the last run;
|
|
30
|
+
* when that session was never prompted, a fresh start under the
|
|
31
|
+
* same conversation id (there is nothing to continue). A fresh
|
|
32
|
+
* actor token; the old one stops working.
|
|
33
|
+
*
|
|
34
|
+
* Nothing here takes a grant: grants come from the org config only. The
|
|
35
|
+
* host enforces agents.max_concurrent on every start.
|
|
36
|
+
*/
|
|
37
|
+
import * as fs from "fs";
|
|
38
|
+
import * as path from "path";
|
|
39
|
+
import {
|
|
40
|
+
AGENTS_RESERVED_KEYS,
|
|
41
|
+
readAgentsConfig,
|
|
42
|
+
type ResolvedAgentsConfig,
|
|
43
|
+
} from "../../shared/den-config.js";
|
|
44
|
+
import { cliEntrypoint } from "../../shared/engine.js";
|
|
45
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
46
|
+
import { envKey } from "../../shared/product.js";
|
|
47
|
+
import { redactSecrets } from "../../shared/redact.js";
|
|
48
|
+
import {
|
|
49
|
+
loadPersonas,
|
|
50
|
+
resolvePackDirs,
|
|
51
|
+
type Persona,
|
|
52
|
+
} from "../../shared/agents/personas.js";
|
|
53
|
+
import {
|
|
54
|
+
AGENT_ID_RE,
|
|
55
|
+
agentFilesDir,
|
|
56
|
+
listRecords,
|
|
57
|
+
readRecord,
|
|
58
|
+
writeRecord,
|
|
59
|
+
writeRuntimeFiles,
|
|
60
|
+
type AgentRecord,
|
|
61
|
+
} from "../../shared/agents/records.js";
|
|
62
|
+
import {
|
|
63
|
+
agentSessionEnv,
|
|
64
|
+
DEFAULT_RUNTIME_ID,
|
|
65
|
+
getRuntime,
|
|
66
|
+
LaunchError,
|
|
67
|
+
runtimeIds,
|
|
68
|
+
type AgentMcp,
|
|
69
|
+
type LaunchInput,
|
|
70
|
+
type Runtime,
|
|
71
|
+
} from "../../shared/agents/runtimes.js";
|
|
72
|
+
import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
|
|
73
|
+
import {
|
|
74
|
+
bindActorSession,
|
|
75
|
+
mintActorToken,
|
|
76
|
+
revokeActorToken,
|
|
77
|
+
revokeAgentTokens,
|
|
78
|
+
} from "../../shared/actor-token.js";
|
|
79
|
+
import { HostError, type HostClient } from "../../shared/sessions/client.js";
|
|
80
|
+
import { DEN_PTY_ID } from "../../shared/sessions/den-pty.js";
|
|
81
|
+
import { inWorkspace, sessionName } from "../../shared/sessions/names.js";
|
|
82
|
+
import type { SessionInfo } from "../../shared/sessions/types.js";
|
|
83
|
+
import { serverSpec } from "../mcp/connect.js";
|
|
84
|
+
import {
|
|
85
|
+
contractInfo,
|
|
86
|
+
loadWorkspaces,
|
|
87
|
+
resolveDeps,
|
|
88
|
+
state,
|
|
89
|
+
withClient,
|
|
90
|
+
type Resolved,
|
|
91
|
+
type SessionsDeps,
|
|
92
|
+
} from "./sessions-command.js";
|
|
93
|
+
|
|
94
|
+
export interface AgentsDeps extends SessionsDeps {
|
|
95
|
+
agentsConfig?: () => ResolvedAgentsConfig;
|
|
96
|
+
distRoot?: () => string;
|
|
97
|
+
/** A workspace's checkout directory: where its agents run. */
|
|
98
|
+
workspaceDir?: (ws: string) => string;
|
|
99
|
+
/** The MCP server binary and its env; Den adds the scoped args. */
|
|
100
|
+
mcpServer?: () => { command: string[] | string; env: Record<string, string> };
|
|
101
|
+
/**
|
|
102
|
+
* How long start and resume watch a new session for an early non-zero
|
|
103
|
+
* exit (default 3000 ms; 0: not at all).
|
|
104
|
+
*/
|
|
105
|
+
exitWatchMs?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AgentsResolved extends Resolved {
|
|
109
|
+
agentsConfig: () => ResolvedAgentsConfig;
|
|
110
|
+
distRoot: () => string;
|
|
111
|
+
workspaceDir: (ws: string) => string;
|
|
112
|
+
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
113
|
+
exitWatchMs: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The early-exit watch: at most this long, polling the host. */
|
|
117
|
+
export const EXIT_WATCH_MS = 3000;
|
|
118
|
+
const EXIT_POLL_MS = 100;
|
|
119
|
+
/** The most lines of an early exit's scrollback returned. */
|
|
120
|
+
const EXIT_TAIL_LINES = 10;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Why an operation didn't happen. `usage` marks a request that was wrong
|
|
124
|
+
* as asked (the CLI exits 2 with its usage line; others exit 1); `status`
|
|
125
|
+
* is the HTTP answer the page gets, `code` its R26 code when the status's
|
|
126
|
+
* own is not specific enough.
|
|
127
|
+
*/
|
|
128
|
+
export class AgentError extends Error {
|
|
129
|
+
readonly status: number;
|
|
130
|
+
readonly usage: boolean;
|
|
131
|
+
readonly code?: string;
|
|
132
|
+
readonly hints: string[];
|
|
133
|
+
constructor(
|
|
134
|
+
message: string,
|
|
135
|
+
o: {
|
|
136
|
+
status?: number;
|
|
137
|
+
usage?: boolean;
|
|
138
|
+
code?: string;
|
|
139
|
+
hints?: string[];
|
|
140
|
+
} = {},
|
|
141
|
+
) {
|
|
142
|
+
super(message);
|
|
143
|
+
this.name = "AgentError";
|
|
144
|
+
this.status = o.status ?? (o.usage ? 400 : 409);
|
|
145
|
+
this.usage = o.usage ?? false;
|
|
146
|
+
this.code = o.code;
|
|
147
|
+
this.hints = o.hints ?? [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A request that was wrong as asked (CLI exit 2; HTTP 400 unless given). */
|
|
152
|
+
const usage = (message: string, status = 400) =>
|
|
153
|
+
new AgentError(message, { usage: true, status });
|
|
154
|
+
/** A refusal (CLI exit 1; HTTP 409 unless given). */
|
|
155
|
+
const refused = (message: string, hints: string[] = [], status = 409) =>
|
|
156
|
+
new AgentError(message, { status, hints });
|
|
157
|
+
|
|
158
|
+
let workspaceDirFn: ((ws: string) => string) | null = null;
|
|
159
|
+
/** The workspaces config, for start and resume (loaded on first use). */
|
|
160
|
+
export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
|
|
161
|
+
await loadWorkspaces(d);
|
|
162
|
+
if (d.workspaceDir || workspaceDirFn) return;
|
|
163
|
+
const ws = await import("../../shared/environment-manager/workspaces.js");
|
|
164
|
+
const { getReposRoot } = await import("../../shared/den-config.js");
|
|
165
|
+
workspaceDirFn = (name) => {
|
|
166
|
+
const cfg = ws.readWorkspacesFile().workspaces[name];
|
|
167
|
+
if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
|
|
168
|
+
return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
|
|
173
|
+
function defaultMcpServer(cli: string): {
|
|
174
|
+
command: string[];
|
|
175
|
+
env: Record<string, string>;
|
|
176
|
+
} {
|
|
177
|
+
const s = serverSpec({
|
|
178
|
+
cli,
|
|
179
|
+
distRoot: getDistRoot(),
|
|
180
|
+
nodeBinDir: path.dirname(process.execPath),
|
|
181
|
+
platform: process.platform,
|
|
182
|
+
});
|
|
183
|
+
if (fs.existsSync(s.command)) return { command: [s.command], env: s.env };
|
|
184
|
+
const e = cliEntrypoint();
|
|
185
|
+
return { command: [e.command, ...e.args], env: s.env };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
189
|
+
const base = resolveDeps(d);
|
|
190
|
+
return {
|
|
191
|
+
...base,
|
|
192
|
+
agentsConfig: d.agentsConfig ?? (() => readAgentsConfig()),
|
|
193
|
+
distRoot: d.distRoot ?? getDistRoot,
|
|
194
|
+
workspaceDir:
|
|
195
|
+
d.workspaceDir ??
|
|
196
|
+
((ws) => {
|
|
197
|
+
if (!workspaceDirFn) throw new Error("workspaces not loaded");
|
|
198
|
+
return workspaceDirFn(ws);
|
|
199
|
+
}),
|
|
200
|
+
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
201
|
+
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── the agent's MCP ─────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Where the distribution is, from the caller's env (DEN_ROOT,
|
|
209
|
+
* `<PREFIX>_ROOT`, `<PREFIX>_HOME`): the agent's env is an allowlist, so
|
|
210
|
+
* its MCP server gets them in its own config instead, and reaches the same
|
|
211
|
+
* distribution and state dir as the CLI that started it. Paths, never
|
|
212
|
+
* secrets.
|
|
213
|
+
*/
|
|
214
|
+
function distLocationEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
215
|
+
const out: Record<string, string> = {};
|
|
216
|
+
for (const k of new Set(["DEN_ROOT", envKey("ROOT"), envKey("HOME")])) {
|
|
217
|
+
const v = env[k];
|
|
218
|
+
if (v) out[k] = v;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `<cli> mcp --workspace <ws> --actor agent:<id> [--grant <g>…]`. */
|
|
224
|
+
export function agentMcp(
|
|
225
|
+
d: AgentsResolved,
|
|
226
|
+
ws: string,
|
|
227
|
+
id: string,
|
|
228
|
+
grants: string[],
|
|
229
|
+
): AgentMcp {
|
|
230
|
+
const server = d.mcpServer();
|
|
231
|
+
const bin = Array.isArray(server.command) ? server.command : [server.command];
|
|
232
|
+
return {
|
|
233
|
+
name: d.cli,
|
|
234
|
+
command: [
|
|
235
|
+
...bin,
|
|
236
|
+
"mcp",
|
|
237
|
+
"--workspace",
|
|
238
|
+
ws,
|
|
239
|
+
"--actor",
|
|
240
|
+
`agent:${id}`,
|
|
241
|
+
...grants.flatMap((g) => ["--grant", g]),
|
|
242
|
+
],
|
|
243
|
+
env: { ...distLocationEnv(d.env), ...server.env },
|
|
244
|
+
scope: ws,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── rows ────────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* One row per agent of a workspace: each session joined to the agent record
|
|
252
|
+
* that started it (by session id), and each record whose session is gone,
|
|
253
|
+
* as stopped (after `agents stop`) or ended. A session without a record is
|
|
254
|
+
* a plain one (`sessions start`), not an agent: `sessions list` shows it.
|
|
255
|
+
*/
|
|
256
|
+
export interface AgentRow {
|
|
257
|
+
agent: string;
|
|
258
|
+
persona?: string;
|
|
259
|
+
runtime?: string;
|
|
260
|
+
model?: string;
|
|
261
|
+
workspace?: string;
|
|
262
|
+
cwd?: string;
|
|
263
|
+
/** running, stopping (stopped, its session not yet gone), exited
|
|
264
|
+
* (<code>), stopped or ended. */
|
|
265
|
+
state: string;
|
|
266
|
+
/** When its current (or last) session started (ISO 8601). */
|
|
267
|
+
startedAt?: string;
|
|
268
|
+
/** Resume applies: its runtime declares resume and it isn't running. */
|
|
269
|
+
resumable: boolean;
|
|
270
|
+
session?: SessionInfo;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function rowState(rec: AgentRecord | undefined, s?: SessionInfo): string {
|
|
274
|
+
// Stopped (TERM sent) while its session still runs: on its way out.
|
|
275
|
+
if (s && !s.exited)
|
|
276
|
+
return rec?.stoppedAt && rec.stoppedAt >= s.startedAt
|
|
277
|
+
? "stopping"
|
|
278
|
+
: "running";
|
|
279
|
+
if (rec?.stoppedAt) return "stopped";
|
|
280
|
+
return s ? state(s) : "ended";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Whether `rec` may be resumed now, in `rowState`. */
|
|
284
|
+
function canResume(rec: AgentRecord, st: string): boolean {
|
|
285
|
+
return (
|
|
286
|
+
st !== "running" &&
|
|
287
|
+
st !== "stopping" &&
|
|
288
|
+
Boolean(getRuntime(rec.runtime)?.resume)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function agentRows(
|
|
293
|
+
sessions: SessionInfo[],
|
|
294
|
+
records: AgentRecord[] = [],
|
|
295
|
+
): AgentRow[] {
|
|
296
|
+
const bySession = new Map(records.map((r) => [r.session.id, r]));
|
|
297
|
+
const rows: AgentRow[] = [];
|
|
298
|
+
const shown = new Set<string>();
|
|
299
|
+
const row = (rec: AgentRecord, s?: SessionInfo): AgentRow => {
|
|
300
|
+
const st = rowState(rec, s);
|
|
301
|
+
return {
|
|
302
|
+
agent: rec.id,
|
|
303
|
+
...recordFields(rec),
|
|
304
|
+
state: st,
|
|
305
|
+
startedAt: s?.startedAt ?? rec.startedAt,
|
|
306
|
+
resumable: canResume(rec, st),
|
|
307
|
+
...(s ? { session: contractInfo(s) } : {}),
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
for (const s of sessions) {
|
|
311
|
+
const rec = bySession.get(s.ref.id);
|
|
312
|
+
if (!rec) continue;
|
|
313
|
+
shown.add(rec.id);
|
|
314
|
+
rows.push(row(rec, s));
|
|
315
|
+
}
|
|
316
|
+
for (const rec of records) if (!shown.has(rec.id)) rows.push(row(rec));
|
|
317
|
+
return rows;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function recordFields(rec: AgentRecord): Partial<AgentRow> {
|
|
321
|
+
return {
|
|
322
|
+
persona: rec.persona,
|
|
323
|
+
runtime: rec.runtime,
|
|
324
|
+
...(rec.model ? { model: rec.model } : {}),
|
|
325
|
+
workspace: rec.workspace,
|
|
326
|
+
cwd: rec.cwd,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A workspace name as given, checked (a usage error otherwise). */
|
|
331
|
+
export function checkWorkspaceName(where: string, ws: unknown): string {
|
|
332
|
+
if (typeof ws !== "string" || !WORKSPACE_NAME_RE.test(ws))
|
|
333
|
+
throw usage(`${where}: "${String(ws)}" is not a workspace name`);
|
|
334
|
+
return ws;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The workspace's agents, and only its own: its records joined to its
|
|
339
|
+
* sessions. `startHost: false` (the page) never starts a host to ask:
|
|
340
|
+
* with none running, every agent's session is gone.
|
|
341
|
+
*/
|
|
342
|
+
export async function listAgents(
|
|
343
|
+
d: AgentsResolved,
|
|
344
|
+
ws: string,
|
|
345
|
+
opts: { startHost?: boolean } = {},
|
|
346
|
+
): Promise<AgentRow[]> {
|
|
347
|
+
checkWorkspaceName("agents list", ws);
|
|
348
|
+
let all: SessionInfo[];
|
|
349
|
+
if (opts.startHost === false) {
|
|
350
|
+
const c = await d.connectExisting();
|
|
351
|
+
try {
|
|
352
|
+
all = c ? await c.list() : [];
|
|
353
|
+
} finally {
|
|
354
|
+
c?.close();
|
|
355
|
+
}
|
|
356
|
+
} else all = await withClient(d, (c) => c.list());
|
|
357
|
+
const sessions = all.filter((s) => inWorkspace(s, d.dist, ws));
|
|
358
|
+
const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
|
|
359
|
+
return agentRows(sessions, records);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── personas, runtimes ──────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
/** The agents config; an invalid one is a refusal naming the key. */
|
|
365
|
+
function agentsConfig(d: AgentsResolved): ResolvedAgentsConfig {
|
|
366
|
+
try {
|
|
367
|
+
return d.agentsConfig();
|
|
368
|
+
} catch (e) {
|
|
369
|
+
throw refused((e as Error).message, [], 422);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Every persona; a bad pack or persona file is a refusal naming it. */
|
|
374
|
+
function personas(
|
|
375
|
+
d: AgentsResolved,
|
|
376
|
+
cfg: ResolvedAgentsConfig,
|
|
377
|
+
): Map<string, Persona> {
|
|
378
|
+
try {
|
|
379
|
+
return loadPersonas(resolvePackDirs(cfg.personaPacks, d.distRoot()));
|
|
380
|
+
} catch (e) {
|
|
381
|
+
throw refused((e as Error).message, [], 422);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** A persona as listed: everything but its role prompt. */
|
|
386
|
+
export type ListedPersona = Omit<Persona, "prompt">;
|
|
387
|
+
|
|
388
|
+
/** Den's default pack, then agents.persona_packs, by id. */
|
|
389
|
+
export function listPersonas(d: AgentsResolved): ListedPersona[] {
|
|
390
|
+
return [...personas(d, agentsConfig(d)).values()]
|
|
391
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
392
|
+
.map(({ prompt: _p, ...rest }) => rest);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface RuntimeChoice {
|
|
396
|
+
id: string;
|
|
397
|
+
available: boolean;
|
|
398
|
+
/** Why it is unavailable. */
|
|
399
|
+
why?: string;
|
|
400
|
+
version?: string;
|
|
401
|
+
/** It declares resume. */
|
|
402
|
+
resume: boolean;
|
|
403
|
+
default: boolean;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Every registered runtime, and whether it can run here (detect runs nothing). */
|
|
407
|
+
export function listRuntimes(d: AgentsResolved): RuntimeChoice[] {
|
|
408
|
+
return runtimeIds().map((id) => {
|
|
409
|
+
const rt = getRuntime(id)!;
|
|
410
|
+
const det = rt.detect(d.env);
|
|
411
|
+
return {
|
|
412
|
+
id,
|
|
413
|
+
available: det.available,
|
|
414
|
+
...(det.available ? {} : { why: det.why ?? "unavailable" }),
|
|
415
|
+
...(det.version ? { version: det.version } : {}),
|
|
416
|
+
resume: Boolean(rt.resume),
|
|
417
|
+
default: id === DEFAULT_RUNTIME_ID,
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function runtimeFor(id: string): Runtime {
|
|
423
|
+
const rt = getRuntime(id);
|
|
424
|
+
if (!rt)
|
|
425
|
+
throw usage(`no runtime "${id}" (runtimes: ${runtimeIds().join(", ")})`);
|
|
426
|
+
return rt;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The running session of `rec`, if any; else its exited one, if listed. */
|
|
430
|
+
function sessionOf(
|
|
431
|
+
sessions: SessionInfo[],
|
|
432
|
+
rec: AgentRecord,
|
|
433
|
+
): SessionInfo | undefined {
|
|
434
|
+
return sessions.find((s) => s.ref.id === rec.session.id);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The id an agent gets: --id, else the persona's id while it is free (or
|
|
439
|
+
* this workspace's), else `<persona>-<ws>`.
|
|
440
|
+
*/
|
|
441
|
+
function defaultId(stateDir: string, persona: string, ws: string): string {
|
|
442
|
+
const rec = readRecord(stateDir, persona);
|
|
443
|
+
if (!rec || rec.workspace === ws) return persona;
|
|
444
|
+
return `${persona}-${ws}`.slice(0, 64);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function checkId(where: string, id: string): void {
|
|
448
|
+
if (!AGENT_ID_RE.test(id) || AGENTS_RESERVED_KEYS.includes(id))
|
|
449
|
+
throw usage(
|
|
450
|
+
`${where}: --id "${id}" must be letters, digits, ".", "_" or "-" (1-64), ` +
|
|
451
|
+
`and not ${AGENTS_RESERVED_KEYS.join(" or ")}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
interface Launch {
|
|
456
|
+
rec: Omit<AgentRecord, "session" | "startedAt">;
|
|
457
|
+
argv: string[];
|
|
458
|
+
env: Record<string, string>;
|
|
459
|
+
/** The runtime's envPassthrough. */
|
|
460
|
+
passthrough?: readonly string[];
|
|
461
|
+
/** agents.<id>.grants, recorded with its actor token. */
|
|
462
|
+
grants: readonly string[];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The host's cap refusal as an AgentError (its hint says what to do). */
|
|
466
|
+
function capOrThrow(d: AgentsResolved, e: unknown): never {
|
|
467
|
+
if (e instanceof HostError && e.code === "cap")
|
|
468
|
+
throw new AgentError(e.message, {
|
|
469
|
+
status: 429,
|
|
470
|
+
code: "cap",
|
|
471
|
+
hints: [
|
|
472
|
+
`Stop one first (\`${d.cli} agents stop <id>\`), or raise agents.max_concurrent.`,
|
|
473
|
+
],
|
|
474
|
+
});
|
|
475
|
+
throw e;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Start `launch` as the agent's session (replacing an exited one) and record
|
|
480
|
+
* it. SEC-1c: a fresh actor token for the session (every earlier one of the
|
|
481
|
+
* agent revoked), bound to the session id once the host has started it, and
|
|
482
|
+
* revoked again when the start fails.
|
|
483
|
+
*/
|
|
484
|
+
async function startSession(
|
|
485
|
+
d: AgentsResolved,
|
|
486
|
+
c: HostClient,
|
|
487
|
+
launch: Launch,
|
|
488
|
+
old: SessionInfo | undefined,
|
|
489
|
+
extra: Partial<AgentRecord>,
|
|
490
|
+
): Promise<{ rec: AgentRecord; info: SessionInfo }> {
|
|
491
|
+
if (old?.exited) await c.forget(old.ref.id);
|
|
492
|
+
const tty = d.stdout.isTTY;
|
|
493
|
+
const minted = mintActorToken(d.stateDir(), {
|
|
494
|
+
agentId: launch.rec.id,
|
|
495
|
+
workspace: launch.rec.workspace,
|
|
496
|
+
grants: launch.grants,
|
|
497
|
+
sessionName: launch.rec.sessionName,
|
|
498
|
+
});
|
|
499
|
+
let info: SessionInfo;
|
|
500
|
+
try {
|
|
501
|
+
info = await c
|
|
502
|
+
.start(
|
|
503
|
+
{
|
|
504
|
+
name: launch.rec.sessionName,
|
|
505
|
+
cwd: launch.rec.cwd,
|
|
506
|
+
argv: launch.argv,
|
|
507
|
+
// Never the caller's whole env: the allowlist, then the runtime's.
|
|
508
|
+
env: agentSessionEnv(
|
|
509
|
+
d.env,
|
|
510
|
+
{ envPassthrough: launch.passthrough },
|
|
511
|
+
launch.env,
|
|
512
|
+
launch.rec.id,
|
|
513
|
+
minted.token,
|
|
514
|
+
),
|
|
515
|
+
cols: (tty && d.stdout.columns) || 80,
|
|
516
|
+
rows: (tty && d.stdout.rows) || 24,
|
|
517
|
+
},
|
|
518
|
+
{ limit: d.maxConcurrent() },
|
|
519
|
+
)
|
|
520
|
+
.catch((e: unknown) => capOrThrow(d, e));
|
|
521
|
+
} catch (e) {
|
|
522
|
+
revokeActorToken(d.stateDir(), minted.key);
|
|
523
|
+
throw e;
|
|
524
|
+
}
|
|
525
|
+
bindActorSession(d.stateDir(), minted.key, info.ref.id);
|
|
526
|
+
const rec: AgentRecord = {
|
|
527
|
+
...launch.rec,
|
|
528
|
+
session: { backend: info.ref.backend, id: info.ref.id },
|
|
529
|
+
startedAt: info.startedAt,
|
|
530
|
+
...extra,
|
|
531
|
+
};
|
|
532
|
+
delete rec.stoppedAt;
|
|
533
|
+
writeRecord(d.stateDir(), rec);
|
|
534
|
+
return { rec, info };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** CSI, OSC (BEL or ST ended), and other two-byte escapes. */
|
|
538
|
+
/* eslint-disable no-control-regex */
|
|
539
|
+
const TERMINAL_ESCAPE =
|
|
540
|
+
/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
541
|
+
/* eslint-enable no-control-regex */
|
|
542
|
+
|
|
543
|
+
/** Printable lines of terminal output: escapes and control bytes removed. */
|
|
544
|
+
export function plainLines(text: string): string[] {
|
|
545
|
+
return (
|
|
546
|
+
text
|
|
547
|
+
.replace(TERMINAL_ESCAPE, "")
|
|
548
|
+
.split(/\r?\n/)
|
|
549
|
+
// A carriage return redraws the line: what stays is after the last.
|
|
550
|
+
.map((l) => l.slice(l.lastIndexOf("\r") + 1))
|
|
551
|
+
// eslint-disable-next-line no-control-regex
|
|
552
|
+
.map((l) => l.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").trimEnd())
|
|
553
|
+
.filter((l) => l.trim())
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** A new session that exited non-zero at once. */
|
|
558
|
+
export interface EarlyExit {
|
|
559
|
+
exitCode?: number;
|
|
560
|
+
/** Its last printed lines, secret-shaped values redacted. */
|
|
561
|
+
tail: string[];
|
|
562
|
+
sessionName: string;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Watch a new session for an early exit (d.exitWatchMs, polling the host):
|
|
567
|
+
* a non-zero one, with its code and the last lines it printed.
|
|
568
|
+
*/
|
|
569
|
+
async function exitedAtOnce(
|
|
570
|
+
d: AgentsResolved,
|
|
571
|
+
c: HostClient,
|
|
572
|
+
info: SessionInfo,
|
|
573
|
+
): Promise<EarlyExit | null> {
|
|
574
|
+
const deadline = Date.now() + d.exitWatchMs;
|
|
575
|
+
while (Date.now() < deadline) {
|
|
576
|
+
await new Promise((r) =>
|
|
577
|
+
setTimeout(r, Math.min(EXIT_POLL_MS, Math.max(0, deadline - Date.now()))),
|
|
578
|
+
);
|
|
579
|
+
const s = (await c.list()).find((x) => x.ref.id === info.ref.id);
|
|
580
|
+
if (!s) return null;
|
|
581
|
+
if (!s.exited) continue;
|
|
582
|
+
if (s.exitCode === 0) return null;
|
|
583
|
+
const tail = plainLines(
|
|
584
|
+
(await c.scrollback(s.ref.id, 64 * 1024)).toString("utf-8"),
|
|
585
|
+
)
|
|
586
|
+
.slice(-EXIT_TAIL_LINES)
|
|
587
|
+
.map((l) => redactSecrets(l));
|
|
588
|
+
return {
|
|
589
|
+
...(s.exitCode === undefined ? {} : { exitCode: s.exitCode }),
|
|
590
|
+
tail,
|
|
591
|
+
sessionName: s.name,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function launchInput(
|
|
598
|
+
d: AgentsResolved,
|
|
599
|
+
cfg: ResolvedAgentsConfig,
|
|
600
|
+
persona: Persona,
|
|
601
|
+
ws: string,
|
|
602
|
+
id: string,
|
|
603
|
+
model: string | undefined,
|
|
604
|
+
): LaunchInput {
|
|
605
|
+
const grants = cfg.agents[id]?.grants ?? [];
|
|
606
|
+
for (const g of grants)
|
|
607
|
+
if (!d.workspaceExists(g))
|
|
608
|
+
throw refused(
|
|
609
|
+
`agents.${id}.grants: workspace "${g}" does not exist`,
|
|
610
|
+
[],
|
|
611
|
+
422,
|
|
612
|
+
);
|
|
613
|
+
const dir = d.workspaceDir(ws);
|
|
614
|
+
if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
|
|
615
|
+
throw refused(`workspace ${ws}'s directory ${dir} does not exist`);
|
|
616
|
+
return {
|
|
617
|
+
persona,
|
|
618
|
+
workspace: { name: ws, dir },
|
|
619
|
+
agentId: id,
|
|
620
|
+
worktree: dir,
|
|
621
|
+
...(model ? { model } : {}),
|
|
622
|
+
mcp: agentMcp(d, ws, id, grants),
|
|
623
|
+
filesDir: agentFilesDir(d.stateDir(), id),
|
|
624
|
+
stateDir: d.stateDir(),
|
|
625
|
+
env: d.env,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function ptyOrRefuse(d: AgentsResolved): void {
|
|
630
|
+
const pty = d.pty();
|
|
631
|
+
if (!pty.available)
|
|
632
|
+
throw refused(
|
|
633
|
+
`${DEN_PTY_ID} is unavailable: ${pty.reason}`,
|
|
634
|
+
[`\`${d.cli} doctor\` shows the session backend's state.`],
|
|
635
|
+
503,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** A runtime's refusal of a launch (a bad persona key) as an AgentError. */
|
|
640
|
+
function launchOrThrow<T>(fn: () => T): T {
|
|
641
|
+
try {
|
|
642
|
+
return fn();
|
|
643
|
+
} catch (e) {
|
|
644
|
+
if (e instanceof LaunchError) throw refused(e.message, [], 422);
|
|
645
|
+
throw e;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** A started (or resumed) agent. */
|
|
650
|
+
export interface Started {
|
|
651
|
+
rec: AgentRecord;
|
|
652
|
+
info: SessionInfo;
|
|
653
|
+
warnings: string[];
|
|
654
|
+
/** Set when its session exited non-zero at once. */
|
|
655
|
+
early: EarlyExit | null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export interface StartRequest {
|
|
659
|
+
workspace: string;
|
|
660
|
+
persona: string;
|
|
661
|
+
runtime?: string;
|
|
662
|
+
model?: string;
|
|
663
|
+
id?: string;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export async function startAgent(
|
|
667
|
+
d: AgentsResolved,
|
|
668
|
+
req: StartRequest,
|
|
669
|
+
): Promise<Started> {
|
|
670
|
+
const where = "agents start";
|
|
671
|
+
const ws = checkWorkspaceName(where, req.workspace);
|
|
672
|
+
if (!d.workspaceExists(ws))
|
|
673
|
+
throw usage(`${where}: workspace "${ws}" does not exist`, 404);
|
|
674
|
+
const cfg = agentsConfig(d);
|
|
675
|
+
const all = personas(d, cfg);
|
|
676
|
+
const persona = all.get(req.persona);
|
|
677
|
+
if (!persona)
|
|
678
|
+
throw usage(
|
|
679
|
+
`${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
|
|
680
|
+
);
|
|
681
|
+
const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
|
|
682
|
+
checkId(where, id);
|
|
683
|
+
const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
|
|
684
|
+
const det = rt.detect(d.env);
|
|
685
|
+
if (!det.available)
|
|
686
|
+
throw refused(`${rt.id} is unavailable: ${det.why ?? "unknown"}`, [], 503);
|
|
687
|
+
const model = req.model ?? persona.model;
|
|
688
|
+
|
|
689
|
+
const prev = readRecord(d.stateDir(), id);
|
|
690
|
+
if (prev && prev.workspace !== ws)
|
|
691
|
+
throw refused(
|
|
692
|
+
`agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
|
|
693
|
+
);
|
|
694
|
+
ptyOrRefuse(d);
|
|
695
|
+
const input = launchInput(d, cfg, persona, ws, id, model);
|
|
696
|
+
|
|
697
|
+
return withClient(d, async (c) => {
|
|
698
|
+
const sessions = await c.list();
|
|
699
|
+
const old = prev ? sessionOf(sessions, prev) : undefined;
|
|
700
|
+
if (old && !old.exited)
|
|
701
|
+
throw refused(`agent ${id} is running (${old.name})`, [
|
|
702
|
+
`Attach: ${d.cli} sessions attach ${old.name}`,
|
|
703
|
+
]);
|
|
704
|
+
const out = launchOrThrow(() => rt.launch(input));
|
|
705
|
+
writeRuntimeFiles(input.filesDir, out.files);
|
|
706
|
+
const { rec, info } = await startSession(
|
|
707
|
+
d,
|
|
708
|
+
c,
|
|
709
|
+
{
|
|
710
|
+
rec: {
|
|
711
|
+
id,
|
|
712
|
+
persona: persona.id,
|
|
713
|
+
runtime: rt.id,
|
|
714
|
+
...(model ? { model } : {}),
|
|
715
|
+
workspace: ws,
|
|
716
|
+
cwd: input.worktree,
|
|
717
|
+
sessionName: sessionName(d.dist, ws, id),
|
|
718
|
+
...(out.resumeToken ? { resumeToken: out.resumeToken } : {}),
|
|
719
|
+
},
|
|
720
|
+
argv: out.argv,
|
|
721
|
+
env: out.env,
|
|
722
|
+
passthrough: rt.envPassthrough,
|
|
723
|
+
grants: cfg.agents[id]?.grants ?? [],
|
|
724
|
+
},
|
|
725
|
+
old,
|
|
726
|
+
{},
|
|
727
|
+
);
|
|
728
|
+
const early = await exitedAtOnce(d, c, info);
|
|
729
|
+
return { rec, info, warnings: out.warnings ?? [], early };
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ── stop, resume ────────────────────────────────────────────────────────────
|
|
734
|
+
|
|
735
|
+
/** The code of a refusal naming another workspace's agent. */
|
|
736
|
+
export const NOT_IN_WORKSPACE = "not_in_workspace";
|
|
737
|
+
|
|
738
|
+
/** Agent `id`'s record; with `ws`, only when it is that workspace's. */
|
|
739
|
+
export function agentRecord(
|
|
740
|
+
d: AgentsResolved,
|
|
741
|
+
id: string,
|
|
742
|
+
ws?: string,
|
|
743
|
+
): AgentRecord {
|
|
744
|
+
const rec = readRecord(d.stateDir(), id);
|
|
745
|
+
// Another workspace's agent is, from this one, no agent at all.
|
|
746
|
+
if (!rec) throw refused(`no agent "${id}"`, [], 404);
|
|
747
|
+
if (ws && ws !== rec.workspace)
|
|
748
|
+
throw new AgentError(
|
|
749
|
+
`agent ${id} is in workspace ${rec.workspace}, not ${ws}`,
|
|
750
|
+
{ status: 404, code: NOT_IN_WORKSPACE },
|
|
751
|
+
);
|
|
752
|
+
return rec;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export interface Stopped {
|
|
756
|
+
rec: AgentRecord;
|
|
757
|
+
/** The session it sent TERM to; undefined when it wasn't running. */
|
|
758
|
+
session?: SessionInfo;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export async function stopAgent(
|
|
762
|
+
d: AgentsResolved,
|
|
763
|
+
id: string,
|
|
764
|
+
ws?: string,
|
|
765
|
+
): Promise<Stopped> {
|
|
766
|
+
const rec = agentRecord(d, id, ws);
|
|
767
|
+
return withClient(d, async (c) => {
|
|
768
|
+
const s = sessionOf(await c.list(), rec);
|
|
769
|
+
// Its token stops working before the session is asked to end.
|
|
770
|
+
revokeAgentTokens(d.stateDir(), rec.id);
|
|
771
|
+
if (s && !s.exited) await c.kill(s.ref.id, "TERM");
|
|
772
|
+
const next = { ...rec, stoppedAt: new Date().toISOString() };
|
|
773
|
+
writeRecord(d.stateDir(), next);
|
|
774
|
+
return { rec: next, ...(s && !s.exited ? { session: s } : {}) };
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export interface Resumed extends Started {
|
|
779
|
+
/** Never prompted: started afresh under the same conversation id. */
|
|
780
|
+
fresh: boolean;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export async function resumeAgent(
|
|
784
|
+
d: AgentsResolved,
|
|
785
|
+
id: string,
|
|
786
|
+
ws?: string,
|
|
787
|
+
): Promise<Resumed> {
|
|
788
|
+
const rec = agentRecord(d, id, ws);
|
|
789
|
+
const rt = runtimeFor(rec.runtime);
|
|
790
|
+
if (!rt.resume)
|
|
791
|
+
throw refused(`runtime ${rt.id} does not declare resume`, [
|
|
792
|
+
`Start it again: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
|
|
793
|
+
]);
|
|
794
|
+
const cfg = agentsConfig(d);
|
|
795
|
+
const persona = personas(d, cfg).get(rec.persona);
|
|
796
|
+
if (!persona)
|
|
797
|
+
throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
|
|
798
|
+
ptyOrRefuse(d);
|
|
799
|
+
return withClient(d, async (c) => {
|
|
800
|
+
const old = sessionOf(await c.list(), rec);
|
|
801
|
+
if (old && !old.exited)
|
|
802
|
+
throw refused(`agent ${rec.id} is running (${old.name})`, [
|
|
803
|
+
`Attach: ${d.cli} sessions attach ${old.name}`,
|
|
804
|
+
]);
|
|
805
|
+
const input = launchInput(
|
|
806
|
+
d,
|
|
807
|
+
cfg,
|
|
808
|
+
persona,
|
|
809
|
+
rec.workspace,
|
|
810
|
+
rec.id,
|
|
811
|
+
rec.model,
|
|
812
|
+
);
|
|
813
|
+
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
814
|
+
const at = {
|
|
815
|
+
...input,
|
|
816
|
+
worktree: rec.cwd,
|
|
817
|
+
...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
|
|
818
|
+
};
|
|
819
|
+
// A session nobody prompted left no conversation: continuing it fails,
|
|
820
|
+
// so it starts afresh under the same id. A session the host no longer
|
|
821
|
+
// lists is assumed prompted.
|
|
822
|
+
const fresh = Boolean(old && !old.prompted && rec.resumeToken);
|
|
823
|
+
const out = launchOrThrow(() => (fresh ? rt.launch(at) : rt.resume!(at)));
|
|
824
|
+
if (!out) throw refused(`agent ${rec.id} has nothing to resume`);
|
|
825
|
+
writeRuntimeFiles(input.filesDir, out.files);
|
|
826
|
+
const { session: _s, startedAt: _t, resumedAt: _r, ...base } = rec;
|
|
827
|
+
const { rec: next, info } = await startSession(
|
|
828
|
+
d,
|
|
829
|
+
c,
|
|
830
|
+
{
|
|
831
|
+
rec: { ...base, cwd: rec.cwd },
|
|
832
|
+
argv: out.argv,
|
|
833
|
+
env: out.env,
|
|
834
|
+
passthrough: rt.envPassthrough,
|
|
835
|
+
grants: cfg.agents[rec.id]?.grants ?? [],
|
|
836
|
+
},
|
|
837
|
+
old,
|
|
838
|
+
fresh ? {} : { resumedAt: new Date().toISOString() },
|
|
839
|
+
);
|
|
840
|
+
const early = await exitedAtOnce(d, c, info);
|
|
841
|
+
return { rec: next, info, warnings: out.warnings ?? [], early, fresh };
|
|
842
|
+
});
|
|
843
|
+
}
|