@monster-orange/den 0.4.7 → 0.4.9
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 +174 -0
- package/README.md +7 -7
- package/docs/distributions.md +243 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/src/shared/agents/den-claude.ts +57 -3
- package/src/shared/agents/guardrails/hooks.json +23 -0
- package/src/shared/agents/guardrails/instructions.md +23 -0
- package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
- package/src/shared/agents/guardrails/stop-hook.ts +48 -2
- package/src/shared/agents/guardrails.ts +38 -7
- package/src/shared/agents/handoff.ts +118 -0
- package/src/shared/agents/personas.ts +11 -1
- package/src/shared/agents/records.ts +122 -1
- package/src/shared/agents/rotation.ts +362 -0
- package/src/shared/agents/runtime.ts +27 -0
- package/src/shared/den-config.ts +72 -1
- package/src/shared/git/gh-pr-state.ts +32 -0
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +377 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +319 -0
- package/src/ui/cli/agent-ops.ts +678 -51
- package/src/ui/cli/agents-command.ts +192 -22
- package/src/ui/cli/index.ts +13 -1
- package/src/ui/cli/usage-command.ts +323 -0
- 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 +247 -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 +400 -13
- 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 +40 -0
- package/src/ui/server/public/i18n/ja.js +36 -0
- package/src/ui/server/public/shell.js +79 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +29 -0
- package/src/ui/server/session-routes.ts +60 -5
- 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,
|
|
@@ -88,6 +104,20 @@ import {
|
|
|
88
104
|
type Runtime,
|
|
89
105
|
} from "../../shared/agents/runtimes.js";
|
|
90
106
|
import type { RuntimeDetect } from "../../shared/agents/runtime.js";
|
|
107
|
+
import {
|
|
108
|
+
archiveHandoff,
|
|
109
|
+
DEFAULT_ROTATE,
|
|
110
|
+
handoffFile,
|
|
111
|
+
handoffPrompt,
|
|
112
|
+
launchRotation,
|
|
113
|
+
readHandoff,
|
|
114
|
+
readRotation,
|
|
115
|
+
writeRotation,
|
|
116
|
+
reasonText,
|
|
117
|
+
rotateAfter,
|
|
118
|
+
rotationInfo,
|
|
119
|
+
type RotationInfo,
|
|
120
|
+
} from "../../shared/agents/rotation.js";
|
|
91
121
|
import {
|
|
92
122
|
binSettingKey,
|
|
93
123
|
ensureInteractivePath,
|
|
@@ -130,6 +160,11 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
130
160
|
* exit (default 3000 ms; 0: not at all).
|
|
131
161
|
*/
|
|
132
162
|
exitWatchMs?: number;
|
|
163
|
+
/**
|
|
164
|
+
* How long stop waits for the session to end after TERM (default
|
|
165
|
+
* 5000 ms; 0: it doesn't wait, as `--no-wait`).
|
|
166
|
+
*/
|
|
167
|
+
stopWaitMs?: number;
|
|
133
168
|
/** `agents.runtimes.<id>.bin` (default: user.yml's; undefined: unset). */
|
|
134
169
|
runtimeBin?: (id: string) => unknown;
|
|
135
170
|
/**
|
|
@@ -156,6 +191,7 @@ export interface AgentsResolved extends Resolved {
|
|
|
156
191
|
workspaceDir: (ws: string) => string;
|
|
157
192
|
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
158
193
|
exitWatchMs: number;
|
|
194
|
+
stopWaitMs: number;
|
|
159
195
|
runtimeBin: (id: string) => unknown;
|
|
160
196
|
workspaceRuntimeBin: (ws: string, id: string) => unknown;
|
|
161
197
|
userCatalog: (taken: readonly string[]) => CatalogEntry[];
|
|
@@ -166,6 +202,8 @@ export interface AgentsResolved extends Resolved {
|
|
|
166
202
|
/** The early-exit watch: at most this long, polling the host. */
|
|
167
203
|
export const EXIT_WATCH_MS = 3000;
|
|
168
204
|
const EXIT_POLL_MS = 100;
|
|
205
|
+
/** Stop waits at most this long for the session to end (0.4.8). */
|
|
206
|
+
export const STOP_WAIT_MS = 5000;
|
|
169
207
|
/** The most lines of an early exit's scrollback returned. */
|
|
170
208
|
const EXIT_TAIL_LINES = 10;
|
|
171
209
|
|
|
@@ -249,6 +287,7 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
|
249
287
|
}),
|
|
250
288
|
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
251
289
|
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
290
|
+
stopWaitMs: d.stopWaitMs ?? STOP_WAIT_MS,
|
|
252
291
|
runtimeBin: d.runtimeBin ?? ((id) => configuredRuntimeBin(id)),
|
|
253
292
|
workspaceRuntimeBin:
|
|
254
293
|
d.workspaceRuntimeBin ?? ((ws, id) => workspaceRuntimeBin(ws, id)),
|
|
@@ -322,49 +361,119 @@ export interface AgentRow {
|
|
|
322
361
|
workspace?: string;
|
|
323
362
|
cwd?: string;
|
|
324
363
|
/** running, stopping (stopped, its session not yet gone), exited
|
|
325
|
-
* (<code>), stopped or
|
|
364
|
+
* (<code>), stopped or interrupted. */
|
|
326
365
|
state: string;
|
|
327
366
|
/** When its current (or last) session started (ISO 8601). */
|
|
328
367
|
startedAt?: string;
|
|
329
|
-
/**
|
|
368
|
+
/**
|
|
369
|
+
* Resume applies: its runtime declares resume, its record has the
|
|
370
|
+
* runtime's resume token, and it isn't running.
|
|
371
|
+
*/
|
|
330
372
|
resumable: boolean;
|
|
373
|
+
/**
|
|
374
|
+
* Why Resume doesn't apply to a stopped agent (AG-14.2), e.g. "codex
|
|
375
|
+
* can't resume a conversation": only Start fresh is offered.
|
|
376
|
+
*/
|
|
377
|
+
noResume?: string;
|
|
378
|
+
/** When `agents stop` stopped it. */
|
|
379
|
+
stoppedAt?: string;
|
|
380
|
+
/** When its session was seen to end on its own. */
|
|
381
|
+
endedAt?: string;
|
|
382
|
+
/** When Den found it interrupted by a restart. */
|
|
383
|
+
interruptedAt?: string;
|
|
384
|
+
/** Its persona resumes on its own after a restart (AG-14.3). */
|
|
385
|
+
autoResume?: boolean;
|
|
386
|
+
outcome?: AgentRecord["outcome"];
|
|
387
|
+
usage?: AgentRecord["usage"];
|
|
388
|
+
/**
|
|
389
|
+
* Session rotation (den#4): its compactions, whether a rotation is due
|
|
390
|
+
* and why, and its handoff.md. `ready`: Relaunch from handoff, not Resume.
|
|
391
|
+
*/
|
|
392
|
+
rotation?: RotationInfo;
|
|
393
|
+
/** How many times Den relaunched it from a handoff. */
|
|
394
|
+
rotations?: number;
|
|
331
395
|
session?: SessionInfo;
|
|
332
396
|
}
|
|
333
397
|
|
|
334
|
-
|
|
398
|
+
/** Its state as a row shows it (AG-14.1: stopped, exited or interrupted). */
|
|
399
|
+
export function rowState(
|
|
400
|
+
rec: AgentRecord | undefined,
|
|
401
|
+
s?: SessionInfo,
|
|
402
|
+
): string {
|
|
335
403
|
// Stopped (TERM sent) while its session still runs: on its way out.
|
|
336
404
|
if (s && !s.exited)
|
|
337
405
|
return rec?.stoppedAt && rec.stoppedAt >= s.startedAt
|
|
338
406
|
? "stopping"
|
|
339
407
|
: "running";
|
|
340
408
|
if (rec?.stoppedAt) return "stopped";
|
|
341
|
-
|
|
409
|
+
if (rec?.endedAt)
|
|
410
|
+
return rec.exitCode === undefined ? "exited" : `exited (${rec.exitCode})`;
|
|
411
|
+
// A host sees every exit of its own sessions, with its code. A session
|
|
412
|
+
// listed as ended with no code ended with an earlier host (the host
|
|
413
|
+
// loads them so: AG-3a); one not listed at all, the same. Nobody stopped
|
|
414
|
+
// it and it never exited: the host (Den, or the machine) went down.
|
|
415
|
+
if (s && s.exitCode !== undefined) return state(s);
|
|
416
|
+
return "interrupted";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** A state an agent can be removed or resumed in: not running. */
|
|
420
|
+
export function isStoppedState(st: string): boolean {
|
|
421
|
+
return st !== "running" && st !== "stopping";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Why `rec` can't be resumed (AG-14.2), or null when it can be: its
|
|
426
|
+
* runtime can't resume a conversation, or has no token to resume with.
|
|
427
|
+
*/
|
|
428
|
+
export function resumeRefusal(rec: AgentRecord): string | null {
|
|
429
|
+
const rt = getRuntime(rec.runtime);
|
|
430
|
+
if (!rt?.resume) return `${rec.runtime} can't resume a conversation`;
|
|
431
|
+
if (!rec.resumeToken) return `${rec.id} has no conversation to resume`;
|
|
432
|
+
return null;
|
|
342
433
|
}
|
|
343
434
|
|
|
344
435
|
/** Whether `rec` may be resumed now, in `rowState`. */
|
|
345
436
|
function canResume(rec: AgentRecord, st: string): boolean {
|
|
346
|
-
return (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
437
|
+
return isStoppedState(st) && resumeRefusal(rec) === null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Why a rotated agent isn't resumed: its conversation ended there (den#4). */
|
|
441
|
+
export function rotatedRefusal(rec: AgentRecord, r: RotationInfo): string {
|
|
442
|
+
return `${rec.id} rotated (${reasonText(r.due!, r.compactions)}): relaunch it from its handoff`;
|
|
351
443
|
}
|
|
352
444
|
|
|
353
445
|
export function agentRows(
|
|
354
446
|
sessions: SessionInfo[],
|
|
355
447
|
records: AgentRecord[] = [],
|
|
448
|
+
/** Den's state dir: each agent's rotation is read there (den#4). */
|
|
449
|
+
stateDir?: string,
|
|
356
450
|
): AgentRow[] {
|
|
357
451
|
const bySession = new Map(records.map((r) => [r.session.id, r]));
|
|
358
452
|
const rows: AgentRow[] = [];
|
|
359
453
|
const shown = new Set<string>();
|
|
360
454
|
const row = (rec: AgentRecord, s?: SessionInfo): AgentRow => {
|
|
361
455
|
const st = rowState(rec, s);
|
|
456
|
+
const rot = stateDir ? rotationInfo(stateDir, rec) : null;
|
|
457
|
+
// A rotated conversation is not resumed: it relaunches from its handoff.
|
|
458
|
+
const resumable = canResume(rec, st) && !rot?.ready;
|
|
459
|
+
const why =
|
|
460
|
+
isStoppedState(st) && !resumable
|
|
461
|
+
? (resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null))
|
|
462
|
+
: null;
|
|
362
463
|
return {
|
|
363
464
|
agent: rec.id,
|
|
364
465
|
...recordFields(rec),
|
|
365
466
|
state: st,
|
|
366
467
|
startedAt: s?.startedAt ?? rec.startedAt,
|
|
367
|
-
resumable
|
|
468
|
+
resumable,
|
|
469
|
+
...(why ? { noResume: why } : {}),
|
|
470
|
+
...(rec.stoppedAt ? { stoppedAt: rec.stoppedAt } : {}),
|
|
471
|
+
...(rec.endedAt ? { endedAt: rec.endedAt } : {}),
|
|
472
|
+
...(rec.interruptedAt ? { interruptedAt: rec.interruptedAt } : {}),
|
|
473
|
+
...(rec.outcome ? { outcome: rec.outcome } : {}),
|
|
474
|
+
...(rec.usage ? { usage: rec.usage } : {}),
|
|
475
|
+
...(rot ? { rotation: rot } : {}),
|
|
476
|
+
...(rec.rotations?.length ? { rotations: rec.rotations.length } : {}),
|
|
368
477
|
...(s ? { session: contractInfo(s) } : {}),
|
|
369
478
|
};
|
|
370
479
|
};
|
|
@@ -388,6 +497,28 @@ function recordFields(rec: AgentRecord): Partial<AgentRow> {
|
|
|
388
497
|
};
|
|
389
498
|
}
|
|
390
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Note in each record whose session the host lists as exited that it
|
|
502
|
+
* ended, with its code (AG-14.1: "Exited (code n)"), so it still says so
|
|
503
|
+
* once the host forgets the session (a restart). Only what the host says.
|
|
504
|
+
*/
|
|
505
|
+
export function noteEnded(
|
|
506
|
+
stateDir: string,
|
|
507
|
+
sessions: SessionInfo[],
|
|
508
|
+
records: AgentRecord[],
|
|
509
|
+
): void {
|
|
510
|
+
const byId = new Map(sessions.map((s) => [s.ref.id, s]));
|
|
511
|
+
for (const rec of records) {
|
|
512
|
+
const s = byId.get(rec.session.id);
|
|
513
|
+
// An exit with no code is a host that went down: interrupted, not ended.
|
|
514
|
+
if (!s?.exited || s.exitCode === undefined) continue;
|
|
515
|
+
if (rec.endedAt || rec.stoppedAt) continue;
|
|
516
|
+
rec.endedAt = new Date().toISOString();
|
|
517
|
+
rec.exitCode = s.exitCode;
|
|
518
|
+
writeRecord(stateDir, rec);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
391
522
|
/** A workspace name as given, checked (a usage error otherwise). */
|
|
392
523
|
export function checkWorkspaceName(where: string, ws: unknown): string {
|
|
393
524
|
if (typeof ws !== "string" || !WORKSPACE_NAME_RE.test(ws))
|
|
@@ -403,7 +534,7 @@ export function checkWorkspaceName(where: string, ws: unknown): string {
|
|
|
403
534
|
export async function listAgents(
|
|
404
535
|
d: AgentsResolved,
|
|
405
536
|
ws: string,
|
|
406
|
-
opts: { startHost?: boolean } = {},
|
|
537
|
+
opts: { startHost?: boolean; stopped?: boolean } = {},
|
|
407
538
|
): Promise<AgentRow[]> {
|
|
408
539
|
checkWorkspaceName("agents list", ws);
|
|
409
540
|
let all: SessionInfo[];
|
|
@@ -417,7 +548,9 @@ export async function listAgents(
|
|
|
417
548
|
} else all = await withClient(d, (c) => c.list());
|
|
418
549
|
const sessions = all.filter((s) => inWorkspace(s, d.dist, ws));
|
|
419
550
|
const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
|
|
420
|
-
|
|
551
|
+
noteEnded(d.stateDir(), sessions, records);
|
|
552
|
+
const rows = agentRows(sessions, records, d.stateDir());
|
|
553
|
+
return opts.stopped ? rows.filter((r) => isStoppedState(r.state)) : rows;
|
|
421
554
|
}
|
|
422
555
|
|
|
423
556
|
// ── personas, runtimes ──────────────────────────────────────────────────────
|
|
@@ -804,17 +937,26 @@ async function startSession(
|
|
|
804
937
|
...launch.rec,
|
|
805
938
|
session: { backend: info.ref.backend, id: info.ref.id },
|
|
806
939
|
startedAt: info.startedAt,
|
|
940
|
+
grants: [...launch.grants],
|
|
807
941
|
...extra,
|
|
808
942
|
};
|
|
943
|
+
// A new session: whatever ended the last one no longer applies.
|
|
809
944
|
delete rec.stoppedAt;
|
|
945
|
+
delete rec.endedAt;
|
|
946
|
+
delete rec.exitCode;
|
|
947
|
+
delete rec.interruptedAt;
|
|
810
948
|
writeRecord(d.stateDir(), rec);
|
|
811
949
|
return { rec, info };
|
|
812
950
|
}
|
|
813
951
|
|
|
814
|
-
/**
|
|
952
|
+
/**
|
|
953
|
+
* CSI, OSC (BEL or ST ended), charset designations (`ESC ( B`), and other
|
|
954
|
+
* two-byte escapes, save and restore (`ESC 7`, `ESC 8`) and keypad modes
|
|
955
|
+
* included: Claude Code ends with `ESC ( B ESC 7 ESC 8`.
|
|
956
|
+
*/
|
|
815
957
|
/* eslint-disable no-control-regex */
|
|
816
958
|
const TERMINAL_ESCAPE =
|
|
817
|
-
/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[
|
|
959
|
+
/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()*+\-./][ -~]|\x1b[0-9:;<=>?@-Z\\-_]/g;
|
|
818
960
|
/* eslint-enable no-control-regex */
|
|
819
961
|
|
|
820
962
|
/** Printable lines of terminal output: escapes and control bytes removed. */
|
|
@@ -836,6 +978,8 @@ export interface EarlyExit {
|
|
|
836
978
|
exitCode?: number;
|
|
837
979
|
/** Its last printed lines, secret-shaped values redacted. */
|
|
838
980
|
tail: string[];
|
|
981
|
+
/** All it printed (the last 64 KB), as plain text, to match against. */
|
|
982
|
+
said: string;
|
|
839
983
|
sessionName: string;
|
|
840
984
|
}
|
|
841
985
|
|
|
@@ -857,14 +1001,14 @@ async function exitedAtOnce(
|
|
|
857
1001
|
if (!s) return null;
|
|
858
1002
|
if (!s.exited) continue;
|
|
859
1003
|
if (s.exitCode === 0) return null;
|
|
860
|
-
const
|
|
1004
|
+
const lines = plainLines(
|
|
861
1005
|
(await c.scrollback(s.ref.id, 64 * 1024)).toString("utf-8"),
|
|
862
|
-
)
|
|
863
|
-
|
|
864
|
-
.map((l) => redactSecrets(l));
|
|
1006
|
+
);
|
|
1007
|
+
const tail = lines.slice(-EXIT_TAIL_LINES).map((l) => redactSecrets(l));
|
|
865
1008
|
return {
|
|
866
1009
|
...(s.exitCode === undefined ? {} : { exitCode: s.exitCode }),
|
|
867
1010
|
tail,
|
|
1011
|
+
said: lines.join("\n"),
|
|
868
1012
|
sessionName: s.name,
|
|
869
1013
|
};
|
|
870
1014
|
}
|
|
@@ -879,8 +1023,10 @@ function launchInput(
|
|
|
879
1023
|
id: string,
|
|
880
1024
|
model: string | undefined,
|
|
881
1025
|
bin?: string,
|
|
1026
|
+
/** The grants to serve (a resume's, narrowed); default the config's. */
|
|
1027
|
+
only?: readonly string[],
|
|
882
1028
|
): LaunchInput {
|
|
883
|
-
const grants = cfg.agents[id]?.grants ?? [];
|
|
1029
|
+
const grants = only ? [...only] : (cfg.agents[id]?.grants ?? []);
|
|
884
1030
|
for (const g of grants)
|
|
885
1031
|
if (!d.workspaceExists(g))
|
|
886
1032
|
throw refused(
|
|
@@ -891,6 +1037,10 @@ function launchInput(
|
|
|
891
1037
|
const dir = d.workspaceDir(ws);
|
|
892
1038
|
if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
|
|
893
1039
|
throw refused(`workspace ${ws}'s directory ${dir} does not exist`);
|
|
1040
|
+
// den#4: only Den's hooks count compactions; without them, no rotation.
|
|
1041
|
+
const after = persona.guardrails?.hooks
|
|
1042
|
+
? rotateAfter(cfg.rotate ?? DEFAULT_ROTATE, persona)
|
|
1043
|
+
: 0;
|
|
894
1044
|
return {
|
|
895
1045
|
persona,
|
|
896
1046
|
workspace: { name: ws, dir },
|
|
@@ -902,9 +1052,15 @@ function launchInput(
|
|
|
902
1052
|
stateDir: d.stateDir(),
|
|
903
1053
|
env: d.env,
|
|
904
1054
|
...(bin ? { bin } : {}),
|
|
1055
|
+
rotateAfter: after,
|
|
905
1056
|
};
|
|
906
1057
|
}
|
|
907
1058
|
|
|
1059
|
+
/** The rotation limit a launch counts against: 0 when its runtime can't relaunch. */
|
|
1060
|
+
function launchAfter(rt: Runtime, input: LaunchInput): number {
|
|
1061
|
+
return rt.firstPrompt ? (input.rotateAfter ?? 0) : 0;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
908
1064
|
function ptyOrRefuse(d: AgentsResolved): void {
|
|
909
1065
|
const pty = d.pty();
|
|
910
1066
|
if (!pty.available)
|
|
@@ -942,9 +1098,18 @@ export interface StartRequest {
|
|
|
942
1098
|
id?: string;
|
|
943
1099
|
}
|
|
944
1100
|
|
|
1101
|
+
/** What a relaunch from handoff adds to a start (den#4). */
|
|
1102
|
+
interface StartMore {
|
|
1103
|
+
firstPrompt?: string;
|
|
1104
|
+
rotations?: AgentRecord["rotations"];
|
|
1105
|
+
/** Called once the old session is known ended, before the launch. */
|
|
1106
|
+
beforeLaunch?: () => void;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
945
1109
|
export async function startAgent(
|
|
946
1110
|
d: AgentsResolved,
|
|
947
1111
|
req: StartRequest,
|
|
1112
|
+
more: StartMore = {},
|
|
948
1113
|
): Promise<Started> {
|
|
949
1114
|
const where = "agents start";
|
|
950
1115
|
const ws = checkWorkspaceName(where, req.workspace);
|
|
@@ -970,7 +1135,10 @@ export async function startAgent(
|
|
|
970
1135
|
`agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
|
|
971
1136
|
);
|
|
972
1137
|
ptyOrRefuse(d);
|
|
973
|
-
const input =
|
|
1138
|
+
const input = {
|
|
1139
|
+
...launchInput(d, cfg, persona, ws, id, model, det.path),
|
|
1140
|
+
...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
|
|
1141
|
+
};
|
|
974
1142
|
|
|
975
1143
|
return withClient(d, async (c) => {
|
|
976
1144
|
const sessions = await c.list();
|
|
@@ -980,7 +1148,17 @@ export async function startAgent(
|
|
|
980
1148
|
`Attach: ${d.cli} sessions attach ${old.name}`,
|
|
981
1149
|
]);
|
|
982
1150
|
const out = launchOrThrow(() => rt.launch(input));
|
|
1151
|
+
more.beforeLaunch?.();
|
|
1152
|
+
// A new conversation: a handoff left from the last one is kept aside,
|
|
1153
|
+
// so the next rotation needs its own (den#4).
|
|
1154
|
+
if (fs.existsSync(handoffFile(input.filesDir)))
|
|
1155
|
+
archiveHandoff(
|
|
1156
|
+
input.filesDir,
|
|
1157
|
+
`${new Date().toISOString().replace(/[:.]/g, "-")}-unused`,
|
|
1158
|
+
);
|
|
983
1159
|
writeRuntimeFiles(input.filesDir, out.files);
|
|
1160
|
+
// den#4: a new conversation's compactions count from 0.
|
|
1161
|
+
launchRotation(input.filesDir, out.resumeToken, launchAfter(rt, input));
|
|
984
1162
|
const { rec, info } = await startSession(
|
|
985
1163
|
d,
|
|
986
1164
|
c,
|
|
@@ -993,7 +1171,10 @@ export async function startAgent(
|
|
|
993
1171
|
workspace: ws,
|
|
994
1172
|
cwd: input.worktree,
|
|
995
1173
|
sessionName: sessionName(d.dist, ws, id),
|
|
996
|
-
|
|
1174
|
+
// 0.4.8: launched on this token, so a resume continues it.
|
|
1175
|
+
...(out.resumeToken
|
|
1176
|
+
? { resumeToken: out.resumeToken, launched: true }
|
|
1177
|
+
: {}),
|
|
997
1178
|
},
|
|
998
1179
|
argv: out.argv,
|
|
999
1180
|
env: out.env,
|
|
@@ -1002,7 +1183,7 @@ export async function startAgent(
|
|
|
1002
1183
|
...binDirOf(det),
|
|
1003
1184
|
},
|
|
1004
1185
|
old,
|
|
1005
|
-
{},
|
|
1186
|
+
more.rotations?.length ? { rotations: more.rotations } : {},
|
|
1006
1187
|
);
|
|
1007
1188
|
const early = await exitedAtOnce(d, c, info);
|
|
1008
1189
|
return { rec, info, warnings: out.warnings ?? [], early };
|
|
@@ -1035,22 +1216,49 @@ export interface Stopped {
|
|
|
1035
1216
|
rec: AgentRecord;
|
|
1036
1217
|
/** The session it sent TERM to; undefined when it wasn't running. */
|
|
1037
1218
|
session?: SessionInfo;
|
|
1219
|
+
/** Its session was still running when the wait ran out. */
|
|
1220
|
+
stillStopping?: boolean;
|
|
1038
1221
|
}
|
|
1039
1222
|
|
|
1223
|
+
/**
|
|
1224
|
+
* Stop: TERM to the agent's session, then wait (up to d.stopWaitMs) until
|
|
1225
|
+
* the session has ended, so a resume right after it never finds the agent
|
|
1226
|
+
* still running. Still running at the bound: `stillStopping`. With
|
|
1227
|
+
* `wait: false` (or d.stopWaitMs 0) it returns once TERM is sent.
|
|
1228
|
+
*/
|
|
1040
1229
|
export async function stopAgent(
|
|
1041
1230
|
d: AgentsResolved,
|
|
1042
1231
|
id: string,
|
|
1043
1232
|
ws?: string,
|
|
1233
|
+
opts: { wait?: boolean } = {},
|
|
1044
1234
|
): Promise<Stopped> {
|
|
1045
1235
|
const rec = agentRecord(d, id, ws);
|
|
1046
1236
|
return withClient(d, async (c) => {
|
|
1047
1237
|
const s = sessionOf(await c.list(), rec);
|
|
1048
1238
|
// Its token stops working before the session is asked to end.
|
|
1049
1239
|
revokeAgentTokens(d.stateDir(), rec.id);
|
|
1050
|
-
|
|
1240
|
+
const live = Boolean(s && !s.exited);
|
|
1241
|
+
if (live) await c.kill(s!.ref.id, "TERM");
|
|
1051
1242
|
const next = { ...rec, stoppedAt: new Date().toISOString() };
|
|
1052
1243
|
writeRecord(d.stateDir(), next);
|
|
1053
|
-
|
|
1244
|
+
let stillStopping = false;
|
|
1245
|
+
if (live && opts.wait !== false && d.stopWaitMs > 0) {
|
|
1246
|
+
const deadline = Date.now() + d.stopWaitMs;
|
|
1247
|
+
for (;;) {
|
|
1248
|
+
const now = (await c.list()).find((x) => x.ref.id === s!.ref.id);
|
|
1249
|
+
if (!now || now.exited) break;
|
|
1250
|
+
if (Date.now() >= deadline) {
|
|
1251
|
+
stillStopping = true;
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
1254
|
+
await new Promise((r) => setTimeout(r, EXIT_POLL_MS));
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return {
|
|
1258
|
+
rec: next,
|
|
1259
|
+
...(live ? { session: s! } : {}),
|
|
1260
|
+
...(stillStopping ? { stillStopping } : {}),
|
|
1261
|
+
};
|
|
1054
1262
|
});
|
|
1055
1263
|
}
|
|
1056
1264
|
|
|
@@ -1059,6 +1267,20 @@ export interface Resumed extends Started {
|
|
|
1059
1267
|
fresh: boolean;
|
|
1060
1268
|
}
|
|
1061
1269
|
|
|
1270
|
+
/**
|
|
1271
|
+
* The grants a resume serves (AG-14): the ones the agent had, never more
|
|
1272
|
+
* than agents.<id>.grants gives now (a grant taken out of the config is
|
|
1273
|
+
* gone). A record from before 0.4.8 has none noted: the config's.
|
|
1274
|
+
*/
|
|
1275
|
+
export function resumeGrants(
|
|
1276
|
+
rec: AgentRecord,
|
|
1277
|
+
cfg: ResolvedAgentsConfig,
|
|
1278
|
+
): string[] {
|
|
1279
|
+
const now = cfg.agents[rec.id]?.grants ?? [];
|
|
1280
|
+
if (!rec.grants) return [...now];
|
|
1281
|
+
return rec.grants.filter((g) => now.includes(g));
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1062
1284
|
export async function resumeAgent(
|
|
1063
1285
|
d: AgentsResolved,
|
|
1064
1286
|
id: string,
|
|
@@ -1067,11 +1289,28 @@ export async function resumeAgent(
|
|
|
1067
1289
|
const rec = agentRecord(d, id, ws);
|
|
1068
1290
|
syncCatalog(d);
|
|
1069
1291
|
const rt = runtimeFor(rec.runtime);
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1292
|
+
// AG-14.2: never a resume that would start a new conversation instead.
|
|
1293
|
+
const why = resumeRefusal(rec);
|
|
1294
|
+
if (why)
|
|
1295
|
+
throw refused(why, [
|
|
1296
|
+
`Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
|
|
1073
1297
|
]);
|
|
1298
|
+
// den#4: a rotated conversation ends there; its role relaunches from
|
|
1299
|
+
// the handoff, in a fresh one.
|
|
1300
|
+
const rot = rotationInfo(d.stateDir(), rec);
|
|
1301
|
+
if (rot?.ready)
|
|
1302
|
+
throw new AgentError(
|
|
1303
|
+
`agent ${rec.id} rotated after ${reasonText(rot.due!, rot.compactions)}: its conversation is not resumed`,
|
|
1304
|
+
{
|
|
1305
|
+
code: "rotated",
|
|
1306
|
+
hints: [
|
|
1307
|
+
`Relaunch from handoff: ${d.cli} agents relaunch ${rec.id}`,
|
|
1308
|
+
`Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
|
|
1309
|
+
],
|
|
1310
|
+
},
|
|
1311
|
+
);
|
|
1074
1312
|
const cfg = agentsConfig(d);
|
|
1313
|
+
const grants = resumeGrants(rec, cfg);
|
|
1075
1314
|
const persona = personas(d, cfg).get(rec.persona);
|
|
1076
1315
|
if (!persona)
|
|
1077
1316
|
throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
|
|
@@ -1091,6 +1330,7 @@ export async function resumeAgent(
|
|
|
1091
1330
|
rec.id,
|
|
1092
1331
|
rec.model,
|
|
1093
1332
|
det.path,
|
|
1333
|
+
grants,
|
|
1094
1334
|
);
|
|
1095
1335
|
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
1096
1336
|
const at = {
|
|
@@ -1098,29 +1338,416 @@ export async function resumeAgent(
|
|
|
1098
1338
|
worktree: rec.cwd,
|
|
1099
1339
|
...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
|
|
1100
1340
|
};
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
// lists
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1341
|
+
// 0.4.8: the runtime's own resume first, whenever this token was
|
|
1342
|
+
// launched (the record says so), its conversation is on disk, the last
|
|
1343
|
+
// session was prompted, or the host no longer lists it. Only a record
|
|
1344
|
+
// from before 0.4.8 whose session nobody prompted starts afresh under
|
|
1345
|
+
// the same id.
|
|
1346
|
+
const token = rec.resumeToken;
|
|
1347
|
+
// A runtime that can tell (den-claude: its transcript) decides before
|
|
1348
|
+
// launching: a conversation on disk, or a last session someone typed
|
|
1349
|
+
// into, resumes; neither starts fresh at once. Without that, the record
|
|
1350
|
+
// decides: launched on this token, or a session the host lost.
|
|
1351
|
+
const known = rt.conversationExists
|
|
1352
|
+
? Boolean(token && rt.conversationExists(token, d.env)) ||
|
|
1353
|
+
Boolean(old?.prompted)
|
|
1354
|
+
: Boolean(rec.launched) || !old || Boolean(old.prompted);
|
|
1355
|
+
const launchAs = (asFresh: boolean) => {
|
|
1356
|
+
const out = launchOrThrow(() =>
|
|
1357
|
+
asFresh ? rt.launch(at) : rt.resume!(at),
|
|
1358
|
+
);
|
|
1359
|
+
if (!out) throw refused(`agent ${rec.id} has nothing to resume`);
|
|
1360
|
+
writeRuntimeFiles(input.filesDir, out.files);
|
|
1361
|
+
// den#4: the same conversation keeps its count.
|
|
1362
|
+
launchRotation(input.filesDir, token, launchAfter(rt, input));
|
|
1363
|
+
return out;
|
|
1364
|
+
};
|
|
1108
1365
|
const { session: _s, startedAt: _t, resumedAt: _r, ...base } = rec;
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1366
|
+
const run = async (asFresh: boolean, prior: SessionInfo | undefined) => {
|
|
1367
|
+
const out = launchAs(asFresh);
|
|
1368
|
+
const { rec: next, info } = await startSession(
|
|
1369
|
+
d,
|
|
1370
|
+
c,
|
|
1371
|
+
{
|
|
1372
|
+
rec: { ...base, cwd: rec.cwd, ...(token ? { launched: true } : {}) },
|
|
1373
|
+
argv: out.argv,
|
|
1374
|
+
env: out.env,
|
|
1375
|
+
passthrough: rt.envPassthrough,
|
|
1376
|
+
grants,
|
|
1377
|
+
...binDirOf(det),
|
|
1378
|
+
},
|
|
1379
|
+
prior,
|
|
1380
|
+
asFresh ? {} : { resumedAt: new Date().toISOString() },
|
|
1381
|
+
);
|
|
1382
|
+
const early = await exitedAtOnce(d, c, info);
|
|
1383
|
+
return { out, next, info, early };
|
|
1384
|
+
};
|
|
1385
|
+
let fresh = !known && Boolean(token);
|
|
1386
|
+
let r = await run(fresh, old);
|
|
1387
|
+
// It exited at once saying the other way was the right one: try that,
|
|
1388
|
+
// once. A resume with nothing to continue starts fresh under the same
|
|
1389
|
+
// id; a fresh start whose id is taken resumes it.
|
|
1390
|
+
const said = r.early?.said ?? "";
|
|
1391
|
+
const other = fresh
|
|
1392
|
+
? rt.launchFailures?.idInUse?.test(said)
|
|
1393
|
+
: rt.launchFailures?.noConversation?.test(said);
|
|
1394
|
+
if (r.early && other && token) {
|
|
1395
|
+
const first = r;
|
|
1396
|
+
fresh = !fresh;
|
|
1397
|
+
// The first try's token goes with its session.
|
|
1398
|
+
revokeAgentTokens(d.stateDir(), rec.id);
|
|
1399
|
+
r = await run(
|
|
1400
|
+
fresh,
|
|
1401
|
+
(await c.list()).find((x) => x.ref.id === first.info.ref.id),
|
|
1402
|
+
);
|
|
1403
|
+
if (r.early)
|
|
1404
|
+
throw new AgentError(
|
|
1405
|
+
`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"})`,
|
|
1406
|
+
{
|
|
1407
|
+
status: 502,
|
|
1408
|
+
code: "resume_failed",
|
|
1409
|
+
hints: [
|
|
1410
|
+
`Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id} (a new conversation)`,
|
|
1411
|
+
],
|
|
1412
|
+
},
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
return {
|
|
1416
|
+
rec: r.next,
|
|
1417
|
+
info: r.info,
|
|
1418
|
+
warnings: r.out.warnings ?? [],
|
|
1419
|
+
early: r.early,
|
|
1420
|
+
fresh,
|
|
1421
|
+
};
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ── AG-14: start fresh, remove, restart ─────────────────────────────────────
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* A new conversation for agent `id` (AG-14.1 Start fresh): the same
|
|
1429
|
+
* persona, runtime, model and workspace, under the same id. Its last
|
|
1430
|
+
* conversation stays in the runtime's own history.
|
|
1431
|
+
*/
|
|
1432
|
+
export async function startFresh(
|
|
1433
|
+
d: AgentsResolved,
|
|
1434
|
+
id: string,
|
|
1435
|
+
ws?: string,
|
|
1436
|
+
): Promise<Started> {
|
|
1437
|
+
const rec = agentRecord(d, id, ws);
|
|
1438
|
+
return startAgent(d, {
|
|
1439
|
+
workspace: rec.workspace,
|
|
1440
|
+
persona: rec.persona,
|
|
1441
|
+
runtime: rec.runtime,
|
|
1442
|
+
...(rec.model ? { model: rec.model } : {}),
|
|
1443
|
+
id: rec.id,
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// ── den#4: relaunch from handoff ────────────────────────────────────────────
|
|
1448
|
+
|
|
1449
|
+
export interface Relaunched extends Started {
|
|
1450
|
+
/** Why the last session rotated: "3 compactions", "PR merged". */
|
|
1451
|
+
why: string;
|
|
1452
|
+
/** Where its handoff was kept. */
|
|
1453
|
+
handoff: string;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Relaunch from handoff (den#4): once agent `id`'s session rotated (a
|
|
1458
|
+
* trigger fired and handoff.md passes the check), a fresh conversation
|
|
1459
|
+
* with the same persona, runtime, model, workspace, folder and grants,
|
|
1460
|
+
* whose first prompt is the handoff. A running session is stopped first.
|
|
1461
|
+
* The handoff moves to handoffs/ so the next rotation needs a new one.
|
|
1462
|
+
* Missing or failing: refused, saying so, with Start fresh and Resume.
|
|
1463
|
+
*/
|
|
1464
|
+
export async function relaunchAgent(
|
|
1465
|
+
d: AgentsResolved,
|
|
1466
|
+
id: string,
|
|
1467
|
+
ws?: string,
|
|
1468
|
+
): Promise<Relaunched> {
|
|
1469
|
+
const rec = agentRecord(d, id, ws);
|
|
1470
|
+
syncCatalog(d);
|
|
1471
|
+
const rt = runtimeFor(rec.runtime);
|
|
1472
|
+
const files = agentFilesDir(d.stateDir(), rec.id);
|
|
1473
|
+
const rot = rotationInfo(d.stateDir(), rec);
|
|
1474
|
+
const fresh = `Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`;
|
|
1475
|
+
const resume = `Resume: ${d.cli} agents resume ${rec.id}`;
|
|
1476
|
+
if (!rt.firstPrompt)
|
|
1477
|
+
throw refused(`${rec.runtime} can't start a conversation from a handoff`, [
|
|
1478
|
+
fresh,
|
|
1479
|
+
]);
|
|
1480
|
+
if (!rot?.due)
|
|
1481
|
+
throw refused(`agent ${rec.id} has not rotated: resume it instead`, [
|
|
1482
|
+
resume,
|
|
1483
|
+
]);
|
|
1484
|
+
const h = readHandoff(files);
|
|
1485
|
+
if (h.status !== "ok")
|
|
1486
|
+
throw new AgentError(
|
|
1487
|
+
h.status === "missing"
|
|
1488
|
+
? `agent ${rec.id} has no handoff.md yet, so it can't relaunch from one`
|
|
1489
|
+
: `agent ${rec.id}'s handoff.md is not ready: ${h.problems.join(" ")}`,
|
|
1490
|
+
{ code: "no_handoff", hints: [fresh, resume] },
|
|
1122
1491
|
);
|
|
1123
|
-
|
|
1124
|
-
|
|
1492
|
+
// The rotated session ends here, if it is still open.
|
|
1493
|
+
const s = sessionOf(await hostSessions(d), rec);
|
|
1494
|
+
if (s && !s.exited) {
|
|
1495
|
+
const stopped = await stopAgent(d, rec.id, ws);
|
|
1496
|
+
if (stopped.stillStopping)
|
|
1497
|
+
throw refused(`agent ${rec.id} is still stopping; try again`);
|
|
1498
|
+
}
|
|
1499
|
+
const now = new Date().toISOString();
|
|
1500
|
+
const why = reasonText(rot.due, rot.compactions);
|
|
1501
|
+
const entry = {
|
|
1502
|
+
at: now,
|
|
1503
|
+
reason: rot.due,
|
|
1504
|
+
compactions: rot.compactions,
|
|
1505
|
+
...(rec.resumeToken ? { conversation: rec.resumeToken } : {}),
|
|
1506
|
+
};
|
|
1507
|
+
let kept = "";
|
|
1508
|
+
const before = readRotation(files);
|
|
1509
|
+
// The launch failed: the handoff and the rotation are as they were, for
|
|
1510
|
+
// the conversation the record now names, so Relaunch is offered again.
|
|
1511
|
+
const undo = (conversation?: string) => {
|
|
1512
|
+
if (kept && fs.existsSync(kept) && !fs.existsSync(handoffFile(files)))
|
|
1513
|
+
fs.renameSync(kept, handoffFile(files));
|
|
1514
|
+
if (before)
|
|
1515
|
+
writeRotation(files, {
|
|
1516
|
+
...before,
|
|
1517
|
+
due: rot.due,
|
|
1518
|
+
...(conversation ? { conversation } : {}),
|
|
1519
|
+
});
|
|
1520
|
+
};
|
|
1521
|
+
const started = await startAgent(
|
|
1522
|
+
d,
|
|
1523
|
+
{
|
|
1524
|
+
workspace: rec.workspace,
|
|
1525
|
+
persona: rec.persona,
|
|
1526
|
+
runtime: rec.runtime,
|
|
1527
|
+
...(rec.model ? { model: rec.model } : {}),
|
|
1528
|
+
id: rec.id,
|
|
1529
|
+
},
|
|
1530
|
+
{
|
|
1531
|
+
firstPrompt: handoffPrompt(h.text!, rot.due, rot.compactions),
|
|
1532
|
+
rotations: [...(rec.rotations ?? []), entry],
|
|
1533
|
+
beforeLaunch: () => {
|
|
1534
|
+
kept = archiveHandoff(
|
|
1535
|
+
files,
|
|
1536
|
+
`${now.replace(/[:.]/g, "-")}-${rec.resumeToken ?? "conversation"}`,
|
|
1537
|
+
);
|
|
1538
|
+
},
|
|
1539
|
+
},
|
|
1540
|
+
).catch((e: unknown) => {
|
|
1541
|
+
undo();
|
|
1542
|
+
throw e;
|
|
1125
1543
|
});
|
|
1544
|
+
if (started.early) undo(started.rec.resumeToken);
|
|
1545
|
+
return { ...started, why, handoff: kept };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/** The host's sessions, never starting a host (none running: none). */
|
|
1549
|
+
async function hostSessions(d: AgentsResolved): Promise<SessionInfo[]> {
|
|
1550
|
+
const c = await d.connectExisting();
|
|
1551
|
+
try {
|
|
1552
|
+
return c ? await c.list() : [];
|
|
1553
|
+
} finally {
|
|
1554
|
+
c?.close();
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/** An agent removed (AG-14.1b): what Undo brings back. */
|
|
1559
|
+
export interface Removed {
|
|
1560
|
+
agent: string;
|
|
1561
|
+
workspace: string;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Forget agents `ids` (AG-14.1b): each record moves aside for Undo, its
|
|
1566
|
+
* actor tokens are revoked and the host forgets its ended session. A
|
|
1567
|
+
* running agent is refused: stop it first. Never asks a host to start.
|
|
1568
|
+
*/
|
|
1569
|
+
async function removeRecords(
|
|
1570
|
+
d: AgentsResolved,
|
|
1571
|
+
recs: AgentRecord[],
|
|
1572
|
+
): Promise<Removed[]> {
|
|
1573
|
+
const c = await d.connectExisting();
|
|
1574
|
+
try {
|
|
1575
|
+
const sessions = c ? await c.list() : [];
|
|
1576
|
+
for (const rec of recs) {
|
|
1577
|
+
const s = sessionOf(sessions, rec);
|
|
1578
|
+
if (!isStoppedState(rowState(rec, s)))
|
|
1579
|
+
throw refused(`agent ${rec.id} is running: stop it first`, [
|
|
1580
|
+
`${d.cli} agents stop ${rec.id}`,
|
|
1581
|
+
]);
|
|
1582
|
+
}
|
|
1583
|
+
const out: Removed[] = [];
|
|
1584
|
+
for (const rec of recs) {
|
|
1585
|
+
const s = sessionOf(sessions, rec);
|
|
1586
|
+
revokeAgentTokens(d.stateDir(), rec.id);
|
|
1587
|
+
if (c && s?.exited) await c.forget(s.ref.id).catch(() => undefined);
|
|
1588
|
+
if (removeRecord(d.stateDir(), rec.id))
|
|
1589
|
+
out.push({ agent: rec.id, workspace: rec.workspace });
|
|
1590
|
+
}
|
|
1591
|
+
purgeRemoved(d.stateDir());
|
|
1592
|
+
return out;
|
|
1593
|
+
} finally {
|
|
1594
|
+
c?.close();
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
export async function removeAgent(
|
|
1599
|
+
d: AgentsResolved,
|
|
1600
|
+
id: string,
|
|
1601
|
+
ws?: string,
|
|
1602
|
+
): Promise<Removed[]> {
|
|
1603
|
+
return removeRecords(d, [agentRecord(d, id, ws)]);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/**
|
|
1607
|
+
* Remove every stopped, interrupted or exited agent of `workspaces`
|
|
1608
|
+
* (AG-14.1b Clear stopped); running ones stay.
|
|
1609
|
+
*/
|
|
1610
|
+
export async function removeStopped(
|
|
1611
|
+
d: AgentsResolved,
|
|
1612
|
+
workspaces: readonly string[],
|
|
1613
|
+
): Promise<Removed[]> {
|
|
1614
|
+
const sessions = await hostSessions(d);
|
|
1615
|
+
const recs = listRecords(d.stateDir()).filter(
|
|
1616
|
+
(r) =>
|
|
1617
|
+
workspaces.includes(r.workspace) &&
|
|
1618
|
+
isStoppedState(rowState(r, sessionOf(sessions, r))),
|
|
1619
|
+
);
|
|
1620
|
+
return removeRecords(d, recs);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/** Undo: bring removed agents back; the ones that came back. */
|
|
1624
|
+
export function restoreAgents(
|
|
1625
|
+
d: AgentsResolved,
|
|
1626
|
+
ids: readonly string[],
|
|
1627
|
+
): string[] {
|
|
1628
|
+
return ids.filter((id) => restoreRecord(d.stateDir(), id));
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* Forget stopped and exited agents older than `days` (AG-14.1b,
|
|
1633
|
+
* agents.remove_stopped_after in user.yml; 0: never). Interrupted ones
|
|
1634
|
+
* are never forgotten. Returns the ids forgotten.
|
|
1635
|
+
*/
|
|
1636
|
+
export async function pruneStopped(
|
|
1637
|
+
d: AgentsResolved,
|
|
1638
|
+
days: number,
|
|
1639
|
+
now = Date.now(),
|
|
1640
|
+
): Promise<string[]> {
|
|
1641
|
+
if (!(days > 0)) return [];
|
|
1642
|
+
const sessions = await hostSessions(d);
|
|
1643
|
+
const cutoff = now - days * 86_400_000;
|
|
1644
|
+
const old = listRecords(d.stateDir()).filter((r) => {
|
|
1645
|
+
const st = rowState(r, sessionOf(sessions, r));
|
|
1646
|
+
if (st === "interrupted" || !isStoppedState(st)) return false;
|
|
1647
|
+
const at = Date.parse(r.stoppedAt ?? r.endedAt ?? r.startedAt);
|
|
1648
|
+
return Number.isFinite(at) && at < cutoff;
|
|
1649
|
+
});
|
|
1650
|
+
for (const r of old) {
|
|
1651
|
+
revokeAgentTokens(d.stateDir(), r.id);
|
|
1652
|
+
removeRecord(d.stateDir(), r.id);
|
|
1653
|
+
}
|
|
1654
|
+
purgeRemoved(d.stateDir(), 0, now);
|
|
1655
|
+
return old.map((r) => r.id);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/** What a restart found (AG-14.3). */
|
|
1659
|
+
export interface RestartFound {
|
|
1660
|
+
/** Agents newly marked interrupted. */
|
|
1661
|
+
interrupted: string[];
|
|
1662
|
+
/** Of those, the ones resumed on their own (standing, resume_on_restart). */
|
|
1663
|
+
resumed: string[];
|
|
1664
|
+
/** Ones that should have resumed but couldn't, with why. */
|
|
1665
|
+
failed: { agent: string; why: string }[];
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* When the web server starts (AG-14.3): every agent whose session is gone
|
|
1670
|
+
* though nobody stopped it and it never exited is marked interrupted. A
|
|
1671
|
+
* session host that survived a web-server-only restart still lists its
|
|
1672
|
+
* sessions, so nothing is marked. Then the standing personas, and those
|
|
1673
|
+
* with `resume_on_restart: true`, resume (when their runtime can); the
|
|
1674
|
+
* rest wait for someone to click Resume.
|
|
1675
|
+
*/
|
|
1676
|
+
export async function markInterrupted(
|
|
1677
|
+
d: AgentsResolved,
|
|
1678
|
+
opts: { autoResume?: boolean } = {},
|
|
1679
|
+
): Promise<RestartFound> {
|
|
1680
|
+
const sessions = await hostSessions(d);
|
|
1681
|
+
const found: RestartFound = { interrupted: [], resumed: [], failed: [] };
|
|
1682
|
+
const now = new Date().toISOString();
|
|
1683
|
+
const marked: AgentRecord[] = [];
|
|
1684
|
+
for (const rec of listRecords(d.stateDir())) {
|
|
1685
|
+
if (rec.interruptedAt) continue;
|
|
1686
|
+
if (rowState(rec, sessionOf(sessions, rec)) !== "interrupted") continue;
|
|
1687
|
+
const next = { ...rec, interruptedAt: now };
|
|
1688
|
+
writeRecord(d.stateDir(), next);
|
|
1689
|
+
marked.push(next);
|
|
1690
|
+
found.interrupted.push(rec.id);
|
|
1691
|
+
}
|
|
1692
|
+
if (opts.autoResume === false || !marked.length) return found;
|
|
1693
|
+
let all: Map<string, Persona>;
|
|
1694
|
+
try {
|
|
1695
|
+
all = personas(d, agentsConfig(d));
|
|
1696
|
+
} catch {
|
|
1697
|
+
return found;
|
|
1698
|
+
}
|
|
1699
|
+
for (const rec of marked) {
|
|
1700
|
+
const p = all.get(rec.persona);
|
|
1701
|
+
if (!p || !(p.standing || p.resumeOnRestart)) continue;
|
|
1702
|
+
if (resumeRefusal(rec)) continue;
|
|
1703
|
+
try {
|
|
1704
|
+
// den#4: a rotated one relaunches from its handoff instead.
|
|
1705
|
+
const r = rotationInfo(d.stateDir(), rec)?.ready
|
|
1706
|
+
? await relaunchAgent(d, rec.id)
|
|
1707
|
+
: await resumeAgent(d, rec.id);
|
|
1708
|
+
if (r.early) found.failed.push({ agent: rec.id, why: "exited at once" });
|
|
1709
|
+
else found.resumed.push(rec.id);
|
|
1710
|
+
} catch (e) {
|
|
1711
|
+
found.failed.push({ agent: rec.id, why: (e as Error).message });
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return found;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Resume every interrupted agent of `workspaces` that can resume
|
|
1719
|
+
* (AG-14.3 "Resume all", `agents resume --all-interrupted`).
|
|
1720
|
+
*/
|
|
1721
|
+
export async function resumeInterrupted(
|
|
1722
|
+
d: AgentsResolved,
|
|
1723
|
+
workspaces?: readonly string[],
|
|
1724
|
+
): Promise<{
|
|
1725
|
+
resumed: Resumed[];
|
|
1726
|
+
failed: { agent: string; why: string }[];
|
|
1727
|
+
}> {
|
|
1728
|
+
const sessions = await hostSessions(d);
|
|
1729
|
+
const recs = listRecords(d.stateDir()).filter(
|
|
1730
|
+
(r) =>
|
|
1731
|
+
(!workspaces || workspaces.includes(r.workspace)) &&
|
|
1732
|
+
rowState(r, sessionOf(sessions, r)) === "interrupted",
|
|
1733
|
+
);
|
|
1734
|
+
const resumed: Resumed[] = [];
|
|
1735
|
+
const failed: { agent: string; why: string }[] = [];
|
|
1736
|
+
for (const rec of recs) {
|
|
1737
|
+
const rot = rotationInfo(d.stateDir(), rec);
|
|
1738
|
+
const why =
|
|
1739
|
+
resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null);
|
|
1740
|
+
if (why) {
|
|
1741
|
+
failed.push({ agent: rec.id, why });
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
try {
|
|
1745
|
+
const r = await resumeAgent(d, rec.id);
|
|
1746
|
+
if (r.early) failed.push({ agent: rec.id, why: "exited at once" });
|
|
1747
|
+
else resumed.push(r);
|
|
1748
|
+
} catch (e) {
|
|
1749
|
+
failed.push({ agent: rec.id, why: (e as Error).message });
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return { resumed, failed };
|
|
1126
1753
|
}
|