@monster-orange/den 0.4.6 → 0.4.8
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 +160 -0
- package/README.md +7 -7
- package/docs/distributions.md +244 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/skills/install-den/SKILL.md +2 -2
- package/src/shared/agents/den-claude.ts +38 -5
- package/src/shared/agents/guardrails/hooks.json +27 -0
- package/src/shared/agents/guardrails/instructions.md +39 -0
- package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
- package/src/shared/agents/guardrails/stop-hook.ts +66 -0
- package/src/shared/agents/guardrails.ts +114 -0
- package/src/shared/agents/handback.ts +241 -0
- package/src/shared/agents/personas.ts +66 -7
- package/src/shared/agents/records.ts +114 -0
- package/src/shared/agents/runtime.ts +12 -0
- package/src/shared/den-config.ts +97 -6
- package/src/shared/dev-metrics/emit.ts +31 -4
- package/src/shared/dev-metrics/polling.ts +135 -0
- package/src/shared/engine.ts +14 -2
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +283 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +262 -0
- package/src/ui/cli/agent-ops.ts +495 -53
- package/src/ui/cli/agents-command.ts +170 -14
- package/src/ui/cli/doctor-command.ts +20 -0
- package/src/ui/cli/flag-parser.ts +1 -0
- package/src/ui/cli/index.ts +119 -2
- package/src/ui/cli/usage-command.ts +256 -0
- package/src/ui/cli/web-command.ts +3 -1
- package/src/ui/mcp/api.ts +10 -1
- package/src/ui/mcp/server.ts +34 -0
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-resume-grant.ts +53 -0
- package/src/ui/server/agent-routes.ts +230 -9
- package/src/ui/server/audit.ts +68 -1
- package/src/ui/server/index.ts +16 -5
- package/src/ui/server/public/agents-page.js +333 -11
- package/src/ui/server/public/agents-section.js +17 -3
- package/src/ui/server/public/agents.html +44 -0
- package/src/ui/server/public/i18n/es.js +28 -0
- package/src/ui/server/public/i18n/ja.js +25 -0
- package/src/ui/server/public/shell.js +66 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +23 -0
- package/src/ui/server/session-routes.ts +52 -4
- package/src/ui/server/sudo-store.ts +36 -7
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -28,11 +28,24 @@
|
|
|
28
28
|
* listed as stopped or ended. A plain session is not an agent.
|
|
29
29
|
* stop ends its session (TERM), marks the record stopped and revokes
|
|
30
30
|
* its actor token.
|
|
31
|
-
* resume only when its runtime declares resume
|
|
32
|
-
*
|
|
31
|
+
* resume only when its runtime declares resume, the record has its
|
|
32
|
+
* resume token, and its session is not running: a new session,
|
|
33
|
+
* in the same cwd and workspace with the grants it had (never
|
|
34
|
+
* more than the config still gives), continuing the last run with
|
|
35
|
+
* the runtime's own resume (den-claude: `--resume <token>`);
|
|
33
36
|
* when that session was never prompted, a fresh start under the
|
|
34
37
|
* same conversation id (there is nothing to continue). A fresh
|
|
35
38
|
* actor token; the old one stops working.
|
|
39
|
+
* fresh (AG-14) a new conversation with the same persona, runtime,
|
|
40
|
+
* model and workspace, under the same id.
|
|
41
|
+
* remove (AG-14.1b) forgets a stopped, interrupted or exited agent: its
|
|
42
|
+
* record moves aside for Undo (restore). Never a running one.
|
|
43
|
+
* The runtime's own history stays.
|
|
44
|
+
* interrupted (AG-14.3) an agent whose session is gone though nobody
|
|
45
|
+
* stopped it and it never exited: Den or the machine went down.
|
|
46
|
+
* markInterrupted stamps them when the web server starts, and
|
|
47
|
+
* resumes the standing personas and those with
|
|
48
|
+
* `resume_on_restart: true`.
|
|
36
49
|
*
|
|
37
50
|
* Nothing here takes a grant: grants come from the org config only. The
|
|
38
51
|
* host enforces agents.max_concurrent on every start, counting agents only.
|
|
@@ -71,7 +84,10 @@ import {
|
|
|
71
84
|
AGENT_ID_RE,
|
|
72
85
|
agentFilesDir,
|
|
73
86
|
listRecords,
|
|
87
|
+
purgeRemoved,
|
|
74
88
|
readRecord,
|
|
89
|
+
removeRecord,
|
|
90
|
+
restoreRecord,
|
|
75
91
|
writeRecord,
|
|
76
92
|
writeRuntimeFiles,
|
|
77
93
|
type AgentRecord,
|
|
@@ -130,6 +146,11 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
130
146
|
* exit (default 3000 ms; 0: not at all).
|
|
131
147
|
*/
|
|
132
148
|
exitWatchMs?: number;
|
|
149
|
+
/**
|
|
150
|
+
* How long stop waits for the session to end after TERM (default
|
|
151
|
+
* 5000 ms; 0: it doesn't wait, as `--no-wait`).
|
|
152
|
+
*/
|
|
153
|
+
stopWaitMs?: number;
|
|
133
154
|
/** `agents.runtimes.<id>.bin` (default: user.yml's; undefined: unset). */
|
|
134
155
|
runtimeBin?: (id: string) => unknown;
|
|
135
156
|
/**
|
|
@@ -156,6 +177,7 @@ export interface AgentsResolved extends Resolved {
|
|
|
156
177
|
workspaceDir: (ws: string) => string;
|
|
157
178
|
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
158
179
|
exitWatchMs: number;
|
|
180
|
+
stopWaitMs: number;
|
|
159
181
|
runtimeBin: (id: string) => unknown;
|
|
160
182
|
workspaceRuntimeBin: (ws: string, id: string) => unknown;
|
|
161
183
|
userCatalog: (taken: readonly string[]) => CatalogEntry[];
|
|
@@ -166,6 +188,8 @@ export interface AgentsResolved extends Resolved {
|
|
|
166
188
|
/** The early-exit watch: at most this long, polling the host. */
|
|
167
189
|
export const EXIT_WATCH_MS = 3000;
|
|
168
190
|
const EXIT_POLL_MS = 100;
|
|
191
|
+
/** Stop waits at most this long for the session to end (0.4.8). */
|
|
192
|
+
export const STOP_WAIT_MS = 5000;
|
|
169
193
|
/** The most lines of an early exit's scrollback returned. */
|
|
170
194
|
const EXIT_TAIL_LINES = 10;
|
|
171
195
|
|
|
@@ -249,6 +273,7 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
|
249
273
|
}),
|
|
250
274
|
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
251
275
|
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
276
|
+
stopWaitMs: d.stopWaitMs ?? STOP_WAIT_MS,
|
|
252
277
|
runtimeBin: d.runtimeBin ?? ((id) => configuredRuntimeBin(id)),
|
|
253
278
|
workspaceRuntimeBin:
|
|
254
279
|
d.workspaceRuntimeBin ?? ((ws, id) => workspaceRuntimeBin(ws, id)),
|
|
@@ -322,32 +347,73 @@ export interface AgentRow {
|
|
|
322
347
|
workspace?: string;
|
|
323
348
|
cwd?: string;
|
|
324
349
|
/** running, stopping (stopped, its session not yet gone), exited
|
|
325
|
-
* (<code>), stopped or
|
|
350
|
+
* (<code>), stopped or interrupted. */
|
|
326
351
|
state: string;
|
|
327
352
|
/** When its current (or last) session started (ISO 8601). */
|
|
328
353
|
startedAt?: string;
|
|
329
|
-
/**
|
|
354
|
+
/**
|
|
355
|
+
* Resume applies: its runtime declares resume, its record has the
|
|
356
|
+
* runtime's resume token, and it isn't running.
|
|
357
|
+
*/
|
|
330
358
|
resumable: boolean;
|
|
359
|
+
/**
|
|
360
|
+
* Why Resume doesn't apply to a stopped agent (AG-14.2), e.g. "codex
|
|
361
|
+
* can't resume a conversation": only Start fresh is offered.
|
|
362
|
+
*/
|
|
363
|
+
noResume?: string;
|
|
364
|
+
/** When `agents stop` stopped it. */
|
|
365
|
+
stoppedAt?: string;
|
|
366
|
+
/** When its session was seen to end on its own. */
|
|
367
|
+
endedAt?: string;
|
|
368
|
+
/** When Den found it interrupted by a restart. */
|
|
369
|
+
interruptedAt?: string;
|
|
370
|
+
/** Its persona resumes on its own after a restart (AG-14.3). */
|
|
371
|
+
autoResume?: boolean;
|
|
372
|
+
outcome?: AgentRecord["outcome"];
|
|
373
|
+
usage?: AgentRecord["usage"];
|
|
331
374
|
session?: SessionInfo;
|
|
332
375
|
}
|
|
333
376
|
|
|
334
|
-
|
|
377
|
+
/** Its state as a row shows it (AG-14.1: stopped, exited or interrupted). */
|
|
378
|
+
export function rowState(
|
|
379
|
+
rec: AgentRecord | undefined,
|
|
380
|
+
s?: SessionInfo,
|
|
381
|
+
): string {
|
|
335
382
|
// Stopped (TERM sent) while its session still runs: on its way out.
|
|
336
383
|
if (s && !s.exited)
|
|
337
384
|
return rec?.stoppedAt && rec.stoppedAt >= s.startedAt
|
|
338
385
|
? "stopping"
|
|
339
386
|
: "running";
|
|
340
387
|
if (rec?.stoppedAt) return "stopped";
|
|
341
|
-
|
|
388
|
+
if (rec?.endedAt)
|
|
389
|
+
return rec.exitCode === undefined ? "exited" : `exited (${rec.exitCode})`;
|
|
390
|
+
// A host sees every exit of its own sessions, with its code. A session
|
|
391
|
+
// listed as ended with no code ended with an earlier host (the host
|
|
392
|
+
// loads them so: AG-3a); one not listed at all, the same. Nobody stopped
|
|
393
|
+
// it and it never exited: the host (Den, or the machine) went down.
|
|
394
|
+
if (s && s.exitCode !== undefined) return state(s);
|
|
395
|
+
return "interrupted";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** A state an agent can be removed or resumed in: not running. */
|
|
399
|
+
export function isStoppedState(st: string): boolean {
|
|
400
|
+
return st !== "running" && st !== "stopping";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Why `rec` can't be resumed (AG-14.2), or null when it can be: its
|
|
405
|
+
* runtime can't resume a conversation, or has no token to resume with.
|
|
406
|
+
*/
|
|
407
|
+
export function resumeRefusal(rec: AgentRecord): string | null {
|
|
408
|
+
const rt = getRuntime(rec.runtime);
|
|
409
|
+
if (!rt?.resume) return `${rec.runtime} can't resume a conversation`;
|
|
410
|
+
if (!rec.resumeToken) return `${rec.id} has no conversation to resume`;
|
|
411
|
+
return null;
|
|
342
412
|
}
|
|
343
413
|
|
|
344
414
|
/** Whether `rec` may be resumed now, in `rowState`. */
|
|
345
415
|
function canResume(rec: AgentRecord, st: string): boolean {
|
|
346
|
-
return (
|
|
347
|
-
st !== "running" &&
|
|
348
|
-
st !== "stopping" &&
|
|
349
|
-
Boolean(getRuntime(rec.runtime)?.resume)
|
|
350
|
-
);
|
|
416
|
+
return isStoppedState(st) && resumeRefusal(rec) === null;
|
|
351
417
|
}
|
|
352
418
|
|
|
353
419
|
export function agentRows(
|
|
@@ -359,12 +425,20 @@ export function agentRows(
|
|
|
359
425
|
const shown = new Set<string>();
|
|
360
426
|
const row = (rec: AgentRecord, s?: SessionInfo): AgentRow => {
|
|
361
427
|
const st = rowState(rec, s);
|
|
428
|
+
const resumable = canResume(rec, st);
|
|
429
|
+
const why = isStoppedState(st) && !resumable ? resumeRefusal(rec) : null;
|
|
362
430
|
return {
|
|
363
431
|
agent: rec.id,
|
|
364
432
|
...recordFields(rec),
|
|
365
433
|
state: st,
|
|
366
434
|
startedAt: s?.startedAt ?? rec.startedAt,
|
|
367
|
-
resumable
|
|
435
|
+
resumable,
|
|
436
|
+
...(why ? { noResume: why } : {}),
|
|
437
|
+
...(rec.stoppedAt ? { stoppedAt: rec.stoppedAt } : {}),
|
|
438
|
+
...(rec.endedAt ? { endedAt: rec.endedAt } : {}),
|
|
439
|
+
...(rec.interruptedAt ? { interruptedAt: rec.interruptedAt } : {}),
|
|
440
|
+
...(rec.outcome ? { outcome: rec.outcome } : {}),
|
|
441
|
+
...(rec.usage ? { usage: rec.usage } : {}),
|
|
368
442
|
...(s ? { session: contractInfo(s) } : {}),
|
|
369
443
|
};
|
|
370
444
|
};
|
|
@@ -388,6 +462,28 @@ function recordFields(rec: AgentRecord): Partial<AgentRow> {
|
|
|
388
462
|
};
|
|
389
463
|
}
|
|
390
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Note in each record whose session the host lists as exited that it
|
|
467
|
+
* ended, with its code (AG-14.1: "Exited (code n)"), so it still says so
|
|
468
|
+
* once the host forgets the session (a restart). Only what the host says.
|
|
469
|
+
*/
|
|
470
|
+
export function noteEnded(
|
|
471
|
+
stateDir: string,
|
|
472
|
+
sessions: SessionInfo[],
|
|
473
|
+
records: AgentRecord[],
|
|
474
|
+
): void {
|
|
475
|
+
const byId = new Map(sessions.map((s) => [s.ref.id, s]));
|
|
476
|
+
for (const rec of records) {
|
|
477
|
+
const s = byId.get(rec.session.id);
|
|
478
|
+
// An exit with no code is a host that went down: interrupted, not ended.
|
|
479
|
+
if (!s?.exited || s.exitCode === undefined) continue;
|
|
480
|
+
if (rec.endedAt || rec.stoppedAt) continue;
|
|
481
|
+
rec.endedAt = new Date().toISOString();
|
|
482
|
+
rec.exitCode = s.exitCode;
|
|
483
|
+
writeRecord(stateDir, rec);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
391
487
|
/** A workspace name as given, checked (a usage error otherwise). */
|
|
392
488
|
export function checkWorkspaceName(where: string, ws: unknown): string {
|
|
393
489
|
if (typeof ws !== "string" || !WORKSPACE_NAME_RE.test(ws))
|
|
@@ -403,7 +499,7 @@ export function checkWorkspaceName(where: string, ws: unknown): string {
|
|
|
403
499
|
export async function listAgents(
|
|
404
500
|
d: AgentsResolved,
|
|
405
501
|
ws: string,
|
|
406
|
-
opts: { startHost?: boolean } = {},
|
|
502
|
+
opts: { startHost?: boolean; stopped?: boolean } = {},
|
|
407
503
|
): Promise<AgentRow[]> {
|
|
408
504
|
checkWorkspaceName("agents list", ws);
|
|
409
505
|
let all: SessionInfo[];
|
|
@@ -417,7 +513,9 @@ export async function listAgents(
|
|
|
417
513
|
} else all = await withClient(d, (c) => c.list());
|
|
418
514
|
const sessions = all.filter((s) => inWorkspace(s, d.dist, ws));
|
|
419
515
|
const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
|
|
420
|
-
|
|
516
|
+
noteEnded(d.stateDir(), sessions, records);
|
|
517
|
+
const rows = agentRows(sessions, records);
|
|
518
|
+
return opts.stopped ? rows.filter((r) => isStoppedState(r.state)) : rows;
|
|
421
519
|
}
|
|
422
520
|
|
|
423
521
|
// ── personas, runtimes ──────────────────────────────────────────────────────
|
|
@@ -437,20 +535,24 @@ function personas(
|
|
|
437
535
|
cfg: ResolvedAgentsConfig,
|
|
438
536
|
): Map<string, Persona> {
|
|
439
537
|
try {
|
|
440
|
-
return loadPersonas(
|
|
538
|
+
return loadPersonas(
|
|
539
|
+
resolvePackDirs(cfg.personaPacks, d.distRoot()),
|
|
540
|
+
undefined,
|
|
541
|
+
{ defaultPack: cfg.defaultPackGuardrails, packs: cfg.packGuardrails },
|
|
542
|
+
);
|
|
441
543
|
} catch (e) {
|
|
442
544
|
throw refused((e as Error).message, [], 422);
|
|
443
545
|
}
|
|
444
546
|
}
|
|
445
547
|
|
|
446
548
|
/** A persona as listed: everything but its role prompt. */
|
|
447
|
-
export type ListedPersona = Omit<Persona, "prompt">;
|
|
549
|
+
export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet">;
|
|
448
550
|
|
|
449
551
|
/** Den's default pack, then agents.persona_packs, by id. */
|
|
450
552
|
export function listPersonas(d: AgentsResolved): ListedPersona[] {
|
|
451
553
|
return [...personas(d, agentsConfig(d)).values()]
|
|
452
554
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
453
|
-
.map(({ prompt: _p, ...rest }) => rest);
|
|
555
|
+
.map(({ prompt: _p, guardrailsSet: _g, ...rest }) => rest);
|
|
454
556
|
}
|
|
455
557
|
|
|
456
558
|
export interface RuntimeChoice {
|
|
@@ -800,17 +902,26 @@ async function startSession(
|
|
|
800
902
|
...launch.rec,
|
|
801
903
|
session: { backend: info.ref.backend, id: info.ref.id },
|
|
802
904
|
startedAt: info.startedAt,
|
|
905
|
+
grants: [...launch.grants],
|
|
803
906
|
...extra,
|
|
804
907
|
};
|
|
908
|
+
// A new session: whatever ended the last one no longer applies.
|
|
805
909
|
delete rec.stoppedAt;
|
|
910
|
+
delete rec.endedAt;
|
|
911
|
+
delete rec.exitCode;
|
|
912
|
+
delete rec.interruptedAt;
|
|
806
913
|
writeRecord(d.stateDir(), rec);
|
|
807
914
|
return { rec, info };
|
|
808
915
|
}
|
|
809
916
|
|
|
810
|
-
/**
|
|
917
|
+
/**
|
|
918
|
+
* CSI, OSC (BEL or ST ended), charset designations (`ESC ( B`), and other
|
|
919
|
+
* two-byte escapes, save and restore (`ESC 7`, `ESC 8`) and keypad modes
|
|
920
|
+
* included: Claude Code ends with `ESC ( B ESC 7 ESC 8`.
|
|
921
|
+
*/
|
|
811
922
|
/* eslint-disable no-control-regex */
|
|
812
923
|
const TERMINAL_ESCAPE =
|
|
813
|
-
/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[
|
|
924
|
+
/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()*+\-./][ -~]|\x1b[0-9:;<=>?@-Z\\-_]/g;
|
|
814
925
|
/* eslint-enable no-control-regex */
|
|
815
926
|
|
|
816
927
|
/** Printable lines of terminal output: escapes and control bytes removed. */
|
|
@@ -832,6 +943,8 @@ export interface EarlyExit {
|
|
|
832
943
|
exitCode?: number;
|
|
833
944
|
/** Its last printed lines, secret-shaped values redacted. */
|
|
834
945
|
tail: string[];
|
|
946
|
+
/** All it printed (the last 64 KB), as plain text, to match against. */
|
|
947
|
+
said: string;
|
|
835
948
|
sessionName: string;
|
|
836
949
|
}
|
|
837
950
|
|
|
@@ -853,14 +966,14 @@ async function exitedAtOnce(
|
|
|
853
966
|
if (!s) return null;
|
|
854
967
|
if (!s.exited) continue;
|
|
855
968
|
if (s.exitCode === 0) return null;
|
|
856
|
-
const
|
|
969
|
+
const lines = plainLines(
|
|
857
970
|
(await c.scrollback(s.ref.id, 64 * 1024)).toString("utf-8"),
|
|
858
|
-
)
|
|
859
|
-
|
|
860
|
-
.map((l) => redactSecrets(l));
|
|
971
|
+
);
|
|
972
|
+
const tail = lines.slice(-EXIT_TAIL_LINES).map((l) => redactSecrets(l));
|
|
861
973
|
return {
|
|
862
974
|
...(s.exitCode === undefined ? {} : { exitCode: s.exitCode }),
|
|
863
975
|
tail,
|
|
976
|
+
said: lines.join("\n"),
|
|
864
977
|
sessionName: s.name,
|
|
865
978
|
};
|
|
866
979
|
}
|
|
@@ -875,8 +988,10 @@ function launchInput(
|
|
|
875
988
|
id: string,
|
|
876
989
|
model: string | undefined,
|
|
877
990
|
bin?: string,
|
|
991
|
+
/** The grants to serve (a resume's, narrowed); default the config's. */
|
|
992
|
+
only?: readonly string[],
|
|
878
993
|
): LaunchInput {
|
|
879
|
-
const grants = cfg.agents[id]?.grants ?? [];
|
|
994
|
+
const grants = only ? [...only] : (cfg.agents[id]?.grants ?? []);
|
|
880
995
|
for (const g of grants)
|
|
881
996
|
if (!d.workspaceExists(g))
|
|
882
997
|
throw refused(
|
|
@@ -989,7 +1104,10 @@ export async function startAgent(
|
|
|
989
1104
|
workspace: ws,
|
|
990
1105
|
cwd: input.worktree,
|
|
991
1106
|
sessionName: sessionName(d.dist, ws, id),
|
|
992
|
-
|
|
1107
|
+
// 0.4.8: launched on this token, so a resume continues it.
|
|
1108
|
+
...(out.resumeToken
|
|
1109
|
+
? { resumeToken: out.resumeToken, launched: true }
|
|
1110
|
+
: {}),
|
|
993
1111
|
},
|
|
994
1112
|
argv: out.argv,
|
|
995
1113
|
env: out.env,
|
|
@@ -1031,22 +1149,49 @@ export interface Stopped {
|
|
|
1031
1149
|
rec: AgentRecord;
|
|
1032
1150
|
/** The session it sent TERM to; undefined when it wasn't running. */
|
|
1033
1151
|
session?: SessionInfo;
|
|
1152
|
+
/** Its session was still running when the wait ran out. */
|
|
1153
|
+
stillStopping?: boolean;
|
|
1034
1154
|
}
|
|
1035
1155
|
|
|
1156
|
+
/**
|
|
1157
|
+
* Stop: TERM to the agent's session, then wait (up to d.stopWaitMs) until
|
|
1158
|
+
* the session has ended, so a resume right after it never finds the agent
|
|
1159
|
+
* still running. Still running at the bound: `stillStopping`. With
|
|
1160
|
+
* `wait: false` (or d.stopWaitMs 0) it returns once TERM is sent.
|
|
1161
|
+
*/
|
|
1036
1162
|
export async function stopAgent(
|
|
1037
1163
|
d: AgentsResolved,
|
|
1038
1164
|
id: string,
|
|
1039
1165
|
ws?: string,
|
|
1166
|
+
opts: { wait?: boolean } = {},
|
|
1040
1167
|
): Promise<Stopped> {
|
|
1041
1168
|
const rec = agentRecord(d, id, ws);
|
|
1042
1169
|
return withClient(d, async (c) => {
|
|
1043
1170
|
const s = sessionOf(await c.list(), rec);
|
|
1044
1171
|
// Its token stops working before the session is asked to end.
|
|
1045
1172
|
revokeAgentTokens(d.stateDir(), rec.id);
|
|
1046
|
-
|
|
1173
|
+
const live = Boolean(s && !s.exited);
|
|
1174
|
+
if (live) await c.kill(s!.ref.id, "TERM");
|
|
1047
1175
|
const next = { ...rec, stoppedAt: new Date().toISOString() };
|
|
1048
1176
|
writeRecord(d.stateDir(), next);
|
|
1049
|
-
|
|
1177
|
+
let stillStopping = false;
|
|
1178
|
+
if (live && opts.wait !== false && d.stopWaitMs > 0) {
|
|
1179
|
+
const deadline = Date.now() + d.stopWaitMs;
|
|
1180
|
+
for (;;) {
|
|
1181
|
+
const now = (await c.list()).find((x) => x.ref.id === s!.ref.id);
|
|
1182
|
+
if (!now || now.exited) break;
|
|
1183
|
+
if (Date.now() >= deadline) {
|
|
1184
|
+
stillStopping = true;
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
|
+
await new Promise((r) => setTimeout(r, EXIT_POLL_MS));
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return {
|
|
1191
|
+
rec: next,
|
|
1192
|
+
...(live ? { session: s! } : {}),
|
|
1193
|
+
...(stillStopping ? { stillStopping } : {}),
|
|
1194
|
+
};
|
|
1050
1195
|
});
|
|
1051
1196
|
}
|
|
1052
1197
|
|
|
@@ -1055,6 +1200,20 @@ export interface Resumed extends Started {
|
|
|
1055
1200
|
fresh: boolean;
|
|
1056
1201
|
}
|
|
1057
1202
|
|
|
1203
|
+
/**
|
|
1204
|
+
* The grants a resume serves (AG-14): the ones the agent had, never more
|
|
1205
|
+
* than agents.<id>.grants gives now (a grant taken out of the config is
|
|
1206
|
+
* gone). A record from before 0.4.8 has none noted: the config's.
|
|
1207
|
+
*/
|
|
1208
|
+
export function resumeGrants(
|
|
1209
|
+
rec: AgentRecord,
|
|
1210
|
+
cfg: ResolvedAgentsConfig,
|
|
1211
|
+
): string[] {
|
|
1212
|
+
const now = cfg.agents[rec.id]?.grants ?? [];
|
|
1213
|
+
if (!rec.grants) return [...now];
|
|
1214
|
+
return rec.grants.filter((g) => now.includes(g));
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1058
1217
|
export async function resumeAgent(
|
|
1059
1218
|
d: AgentsResolved,
|
|
1060
1219
|
id: string,
|
|
@@ -1063,11 +1222,14 @@ export async function resumeAgent(
|
|
|
1063
1222
|
const rec = agentRecord(d, id, ws);
|
|
1064
1223
|
syncCatalog(d);
|
|
1065
1224
|
const rt = runtimeFor(rec.runtime);
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1225
|
+
// AG-14.2: never a resume that would start a new conversation instead.
|
|
1226
|
+
const why = resumeRefusal(rec);
|
|
1227
|
+
if (why)
|
|
1228
|
+
throw refused(why, [
|
|
1229
|
+
`Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
|
|
1069
1230
|
]);
|
|
1070
1231
|
const cfg = agentsConfig(d);
|
|
1232
|
+
const grants = resumeGrants(rec, cfg);
|
|
1071
1233
|
const persona = personas(d, cfg).get(rec.persona);
|
|
1072
1234
|
if (!persona)
|
|
1073
1235
|
throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
|
|
@@ -1087,6 +1249,7 @@ export async function resumeAgent(
|
|
|
1087
1249
|
rec.id,
|
|
1088
1250
|
rec.model,
|
|
1089
1251
|
det.path,
|
|
1252
|
+
grants,
|
|
1090
1253
|
);
|
|
1091
1254
|
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
1092
1255
|
const at = {
|
|
@@ -1094,29 +1257,308 @@ export async function resumeAgent(
|
|
|
1094
1257
|
worktree: rec.cwd,
|
|
1095
1258
|
...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
|
|
1096
1259
|
};
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
// lists
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1260
|
+
// 0.4.8: the runtime's own resume first, whenever this token was
|
|
1261
|
+
// launched (the record says so), its conversation is on disk, the last
|
|
1262
|
+
// session was prompted, or the host no longer lists it. Only a record
|
|
1263
|
+
// from before 0.4.8 whose session nobody prompted starts afresh under
|
|
1264
|
+
// the same id.
|
|
1265
|
+
const token = rec.resumeToken;
|
|
1266
|
+
// A runtime that can tell (den-claude: its transcript) decides before
|
|
1267
|
+
// launching: a conversation on disk, or a last session someone typed
|
|
1268
|
+
// into, resumes; neither starts fresh at once. Without that, the record
|
|
1269
|
+
// decides: launched on this token, or a session the host lost.
|
|
1270
|
+
const known = rt.conversationExists
|
|
1271
|
+
? Boolean(token && rt.conversationExists(token, d.env)) ||
|
|
1272
|
+
Boolean(old?.prompted)
|
|
1273
|
+
: Boolean(rec.launched) || !old || Boolean(old.prompted);
|
|
1274
|
+
const launchAs = (asFresh: boolean) => {
|
|
1275
|
+
const out = launchOrThrow(() =>
|
|
1276
|
+
asFresh ? rt.launch(at) : rt.resume!(at),
|
|
1277
|
+
);
|
|
1278
|
+
if (!out) throw refused(`agent ${rec.id} has nothing to resume`);
|
|
1279
|
+
writeRuntimeFiles(input.filesDir, out.files);
|
|
1280
|
+
return out;
|
|
1281
|
+
};
|
|
1104
1282
|
const { session: _s, startedAt: _t, resumedAt: _r, ...base } = rec;
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1283
|
+
const run = async (asFresh: boolean, prior: SessionInfo | undefined) => {
|
|
1284
|
+
const out = launchAs(asFresh);
|
|
1285
|
+
const { rec: next, info } = await startSession(
|
|
1286
|
+
d,
|
|
1287
|
+
c,
|
|
1288
|
+
{
|
|
1289
|
+
rec: { ...base, cwd: rec.cwd, ...(token ? { launched: true } : {}) },
|
|
1290
|
+
argv: out.argv,
|
|
1291
|
+
env: out.env,
|
|
1292
|
+
passthrough: rt.envPassthrough,
|
|
1293
|
+
grants,
|
|
1294
|
+
...binDirOf(det),
|
|
1295
|
+
},
|
|
1296
|
+
prior,
|
|
1297
|
+
asFresh ? {} : { resumedAt: new Date().toISOString() },
|
|
1298
|
+
);
|
|
1299
|
+
const early = await exitedAtOnce(d, c, info);
|
|
1300
|
+
return { out, next, info, early };
|
|
1301
|
+
};
|
|
1302
|
+
let fresh = !known && Boolean(token);
|
|
1303
|
+
let r = await run(fresh, old);
|
|
1304
|
+
// It exited at once saying the other way was the right one: try that,
|
|
1305
|
+
// once. A resume with nothing to continue starts fresh under the same
|
|
1306
|
+
// id; a fresh start whose id is taken resumes it.
|
|
1307
|
+
const said = r.early?.said ?? "";
|
|
1308
|
+
const other = fresh
|
|
1309
|
+
? rt.launchFailures?.idInUse?.test(said)
|
|
1310
|
+
: rt.launchFailures?.noConversation?.test(said);
|
|
1311
|
+
if (r.early && other && token) {
|
|
1312
|
+
const first = r;
|
|
1313
|
+
fresh = !fresh;
|
|
1314
|
+
// The first try's token goes with its session.
|
|
1315
|
+
revokeAgentTokens(d.stateDir(), rec.id);
|
|
1316
|
+
r = await run(
|
|
1317
|
+
fresh,
|
|
1318
|
+
(await c.list()).find((x) => x.ref.id === first.info.ref.id),
|
|
1319
|
+
);
|
|
1320
|
+
if (r.early)
|
|
1321
|
+
throw new AgentError(
|
|
1322
|
+
`agent ${rec.id}: conversation ${token} could not be continued or started (${rt.id} exited at once both ways: ${[...first.early!.tail, ...r.early.tail].slice(-4).join(" / ") || "no output"})`,
|
|
1323
|
+
{
|
|
1324
|
+
status: 502,
|
|
1325
|
+
code: "resume_failed",
|
|
1326
|
+
hints: [
|
|
1327
|
+
`Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id} (a new conversation)`,
|
|
1328
|
+
],
|
|
1329
|
+
},
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
return {
|
|
1333
|
+
rec: r.next,
|
|
1334
|
+
info: r.info,
|
|
1335
|
+
warnings: r.out.warnings ?? [],
|
|
1336
|
+
early: r.early,
|
|
1337
|
+
fresh,
|
|
1338
|
+
};
|
|
1121
1339
|
});
|
|
1122
1340
|
}
|
|
1341
|
+
|
|
1342
|
+
// ── AG-14: start fresh, remove, restart ─────────────────────────────────────
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* A new conversation for agent `id` (AG-14.1 Start fresh): the same
|
|
1346
|
+
* persona, runtime, model and workspace, under the same id. Its last
|
|
1347
|
+
* conversation stays in the runtime's own history.
|
|
1348
|
+
*/
|
|
1349
|
+
export async function startFresh(
|
|
1350
|
+
d: AgentsResolved,
|
|
1351
|
+
id: string,
|
|
1352
|
+
ws?: string,
|
|
1353
|
+
): Promise<Started> {
|
|
1354
|
+
const rec = agentRecord(d, id, ws);
|
|
1355
|
+
return startAgent(d, {
|
|
1356
|
+
workspace: rec.workspace,
|
|
1357
|
+
persona: rec.persona,
|
|
1358
|
+
runtime: rec.runtime,
|
|
1359
|
+
...(rec.model ? { model: rec.model } : {}),
|
|
1360
|
+
id: rec.id,
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** The host's sessions, never starting a host (none running: none). */
|
|
1365
|
+
async function hostSessions(d: AgentsResolved): Promise<SessionInfo[]> {
|
|
1366
|
+
const c = await d.connectExisting();
|
|
1367
|
+
try {
|
|
1368
|
+
return c ? await c.list() : [];
|
|
1369
|
+
} finally {
|
|
1370
|
+
c?.close();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** An agent removed (AG-14.1b): what Undo brings back. */
|
|
1375
|
+
export interface Removed {
|
|
1376
|
+
agent: string;
|
|
1377
|
+
workspace: string;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Forget agents `ids` (AG-14.1b): each record moves aside for Undo, its
|
|
1382
|
+
* actor tokens are revoked and the host forgets its ended session. A
|
|
1383
|
+
* running agent is refused: stop it first. Never asks a host to start.
|
|
1384
|
+
*/
|
|
1385
|
+
async function removeRecords(
|
|
1386
|
+
d: AgentsResolved,
|
|
1387
|
+
recs: AgentRecord[],
|
|
1388
|
+
): Promise<Removed[]> {
|
|
1389
|
+
const c = await d.connectExisting();
|
|
1390
|
+
try {
|
|
1391
|
+
const sessions = c ? await c.list() : [];
|
|
1392
|
+
for (const rec of recs) {
|
|
1393
|
+
const s = sessionOf(sessions, rec);
|
|
1394
|
+
if (!isStoppedState(rowState(rec, s)))
|
|
1395
|
+
throw refused(`agent ${rec.id} is running: stop it first`, [
|
|
1396
|
+
`${d.cli} agents stop ${rec.id}`,
|
|
1397
|
+
]);
|
|
1398
|
+
}
|
|
1399
|
+
const out: Removed[] = [];
|
|
1400
|
+
for (const rec of recs) {
|
|
1401
|
+
const s = sessionOf(sessions, rec);
|
|
1402
|
+
revokeAgentTokens(d.stateDir(), rec.id);
|
|
1403
|
+
if (c && s?.exited) await c.forget(s.ref.id).catch(() => undefined);
|
|
1404
|
+
if (removeRecord(d.stateDir(), rec.id))
|
|
1405
|
+
out.push({ agent: rec.id, workspace: rec.workspace });
|
|
1406
|
+
}
|
|
1407
|
+
purgeRemoved(d.stateDir());
|
|
1408
|
+
return out;
|
|
1409
|
+
} finally {
|
|
1410
|
+
c?.close();
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
export async function removeAgent(
|
|
1415
|
+
d: AgentsResolved,
|
|
1416
|
+
id: string,
|
|
1417
|
+
ws?: string,
|
|
1418
|
+
): Promise<Removed[]> {
|
|
1419
|
+
return removeRecords(d, [agentRecord(d, id, ws)]);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Remove every stopped, interrupted or exited agent of `workspaces`
|
|
1424
|
+
* (AG-14.1b Clear stopped); running ones stay.
|
|
1425
|
+
*/
|
|
1426
|
+
export async function removeStopped(
|
|
1427
|
+
d: AgentsResolved,
|
|
1428
|
+
workspaces: readonly string[],
|
|
1429
|
+
): Promise<Removed[]> {
|
|
1430
|
+
const sessions = await hostSessions(d);
|
|
1431
|
+
const recs = listRecords(d.stateDir()).filter(
|
|
1432
|
+
(r) =>
|
|
1433
|
+
workspaces.includes(r.workspace) &&
|
|
1434
|
+
isStoppedState(rowState(r, sessionOf(sessions, r))),
|
|
1435
|
+
);
|
|
1436
|
+
return removeRecords(d, recs);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** Undo: bring removed agents back; the ones that came back. */
|
|
1440
|
+
export function restoreAgents(
|
|
1441
|
+
d: AgentsResolved,
|
|
1442
|
+
ids: readonly string[],
|
|
1443
|
+
): string[] {
|
|
1444
|
+
return ids.filter((id) => restoreRecord(d.stateDir(), id));
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Forget stopped and exited agents older than `days` (AG-14.1b,
|
|
1449
|
+
* agents.remove_stopped_after in user.yml; 0: never). Interrupted ones
|
|
1450
|
+
* are never forgotten. Returns the ids forgotten.
|
|
1451
|
+
*/
|
|
1452
|
+
export async function pruneStopped(
|
|
1453
|
+
d: AgentsResolved,
|
|
1454
|
+
days: number,
|
|
1455
|
+
now = Date.now(),
|
|
1456
|
+
): Promise<string[]> {
|
|
1457
|
+
if (!(days > 0)) return [];
|
|
1458
|
+
const sessions = await hostSessions(d);
|
|
1459
|
+
const cutoff = now - days * 86_400_000;
|
|
1460
|
+
const old = listRecords(d.stateDir()).filter((r) => {
|
|
1461
|
+
const st = rowState(r, sessionOf(sessions, r));
|
|
1462
|
+
if (st === "interrupted" || !isStoppedState(st)) return false;
|
|
1463
|
+
const at = Date.parse(r.stoppedAt ?? r.endedAt ?? r.startedAt);
|
|
1464
|
+
return Number.isFinite(at) && at < cutoff;
|
|
1465
|
+
});
|
|
1466
|
+
for (const r of old) {
|
|
1467
|
+
revokeAgentTokens(d.stateDir(), r.id);
|
|
1468
|
+
removeRecord(d.stateDir(), r.id);
|
|
1469
|
+
}
|
|
1470
|
+
purgeRemoved(d.stateDir(), 0, now);
|
|
1471
|
+
return old.map((r) => r.id);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** What a restart found (AG-14.3). */
|
|
1475
|
+
export interface RestartFound {
|
|
1476
|
+
/** Agents newly marked interrupted. */
|
|
1477
|
+
interrupted: string[];
|
|
1478
|
+
/** Of those, the ones resumed on their own (standing, resume_on_restart). */
|
|
1479
|
+
resumed: string[];
|
|
1480
|
+
/** Ones that should have resumed but couldn't, with why. */
|
|
1481
|
+
failed: { agent: string; why: string }[];
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* When the web server starts (AG-14.3): every agent whose session is gone
|
|
1486
|
+
* though nobody stopped it and it never exited is marked interrupted. A
|
|
1487
|
+
* session host that survived a web-server-only restart still lists its
|
|
1488
|
+
* sessions, so nothing is marked. Then the standing personas, and those
|
|
1489
|
+
* with `resume_on_restart: true`, resume (when their runtime can); the
|
|
1490
|
+
* rest wait for someone to click Resume.
|
|
1491
|
+
*/
|
|
1492
|
+
export async function markInterrupted(
|
|
1493
|
+
d: AgentsResolved,
|
|
1494
|
+
opts: { autoResume?: boolean } = {},
|
|
1495
|
+
): Promise<RestartFound> {
|
|
1496
|
+
const sessions = await hostSessions(d);
|
|
1497
|
+
const found: RestartFound = { interrupted: [], resumed: [], failed: [] };
|
|
1498
|
+
const now = new Date().toISOString();
|
|
1499
|
+
const marked: AgentRecord[] = [];
|
|
1500
|
+
for (const rec of listRecords(d.stateDir())) {
|
|
1501
|
+
if (rec.interruptedAt) continue;
|
|
1502
|
+
if (rowState(rec, sessionOf(sessions, rec)) !== "interrupted") continue;
|
|
1503
|
+
const next = { ...rec, interruptedAt: now };
|
|
1504
|
+
writeRecord(d.stateDir(), next);
|
|
1505
|
+
marked.push(next);
|
|
1506
|
+
found.interrupted.push(rec.id);
|
|
1507
|
+
}
|
|
1508
|
+
if (opts.autoResume === false || !marked.length) return found;
|
|
1509
|
+
let all: Map<string, Persona>;
|
|
1510
|
+
try {
|
|
1511
|
+
all = personas(d, agentsConfig(d));
|
|
1512
|
+
} catch {
|
|
1513
|
+
return found;
|
|
1514
|
+
}
|
|
1515
|
+
for (const rec of marked) {
|
|
1516
|
+
const p = all.get(rec.persona);
|
|
1517
|
+
if (!p || !(p.standing || p.resumeOnRestart)) continue;
|
|
1518
|
+
if (resumeRefusal(rec)) continue;
|
|
1519
|
+
try {
|
|
1520
|
+
const r = await resumeAgent(d, rec.id);
|
|
1521
|
+
if (r.early) found.failed.push({ agent: rec.id, why: "exited at once" });
|
|
1522
|
+
else found.resumed.push(rec.id);
|
|
1523
|
+
} catch (e) {
|
|
1524
|
+
found.failed.push({ agent: rec.id, why: (e as Error).message });
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
return found;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Resume every interrupted agent of `workspaces` that can resume
|
|
1532
|
+
* (AG-14.3 "Resume all", `agents resume --all-interrupted`).
|
|
1533
|
+
*/
|
|
1534
|
+
export async function resumeInterrupted(
|
|
1535
|
+
d: AgentsResolved,
|
|
1536
|
+
workspaces?: readonly string[],
|
|
1537
|
+
): Promise<{
|
|
1538
|
+
resumed: Resumed[];
|
|
1539
|
+
failed: { agent: string; why: string }[];
|
|
1540
|
+
}> {
|
|
1541
|
+
const sessions = await hostSessions(d);
|
|
1542
|
+
const recs = listRecords(d.stateDir()).filter(
|
|
1543
|
+
(r) =>
|
|
1544
|
+
(!workspaces || workspaces.includes(r.workspace)) &&
|
|
1545
|
+
rowState(r, sessionOf(sessions, r)) === "interrupted",
|
|
1546
|
+
);
|
|
1547
|
+
const resumed: Resumed[] = [];
|
|
1548
|
+
const failed: { agent: string; why: string }[] = [];
|
|
1549
|
+
for (const rec of recs) {
|
|
1550
|
+
const why = resumeRefusal(rec);
|
|
1551
|
+
if (why) {
|
|
1552
|
+
failed.push({ agent: rec.id, why });
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
try {
|
|
1556
|
+
const r = await resumeAgent(d, rec.id);
|
|
1557
|
+
if (r.early) failed.push({ agent: rec.id, why: "exited at once" });
|
|
1558
|
+
else resumed.push(r);
|
|
1559
|
+
} catch (e) {
|
|
1560
|
+
failed.push({ agent: rec.id, why: (e as Error).message });
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return { resumed, failed };
|
|
1564
|
+
}
|