@monster-orange/den 0.4.14 → 0.4.16
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 +194 -0
- package/README.md +7 -7
- package/docs/distributions.md +226 -19
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +155 -8
- package/src/shared/agents/den-claude.ts +25 -3
- package/src/shared/agents/records.ts +37 -0
- package/src/shared/agents/runtime.ts +11 -1
- package/src/shared/claim-code.ts +163 -0
- package/src/shared/cli-command.ts +31 -0
- package/src/shared/den-config.ts +12 -0
- package/src/shared/environment-manager/workspaces.ts +20 -0
- package/src/shared/github-app.ts +7 -0
- package/src/shared/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +372 -23
- package/src/ui/cli/agents-command.ts +71 -4
- package/src/ui/cli/doctor-command.ts +29 -3
- package/src/ui/cli/doctor-onboard.ts +26 -0
- package/src/ui/cli/flag-parser.ts +9 -1
- package/src/ui/cli/index.ts +29 -3
- package/src/ui/cli/onboard-command.ts +180 -0
- package/src/ui/cli/onboard-reads.ts +613 -0
- package/src/ui/cli/repo-command.ts +156 -10
- package/src/ui/cli/web-command.ts +92 -1
- package/src/ui/mcp/api.ts +14 -2
- package/src/ui/mcp/server.ts +12 -2
- package/src/ui/mcp/tools.ts +368 -1
- package/src/ui/server/agent-routes.ts +91 -7
- package/src/ui/server/approval-routes.ts +390 -0
- package/src/ui/server/approvals.ts +416 -0
- package/src/ui/server/github-routes.ts +13 -5
- package/src/ui/server/held-actions.ts +203 -0
- package/src/ui/server/http-helpers.ts +35 -0
- package/src/ui/server/index.ts +308 -21
- package/src/ui/server/lifecycle-exec.ts +70 -0
- package/src/ui/server/onboard-routes.ts +132 -0
- package/src/ui/server/pair-routes.ts +7 -4
- package/src/ui/server/public/agents-page.js +378 -30
- package/src/ui/server/public/agents.html +1 -0
- package/src/ui/server/public/error-page.js +97 -18
- package/src/ui/server/public/error.html +18 -0
- package/src/ui/server/public/header.js +1 -1
- package/src/ui/server/public/i18n/es.js +65 -5
- package/src/ui/server/public/i18n/ja.js +67 -5
- package/src/ui/server/public/index.html +1 -1
- package/src/ui/server/public/local-dev.html +5 -4
- package/src/ui/server/public/persona-ui.css +32 -0
- package/src/ui/server/public/release.html +0 -1
- package/src/ui/server/public/settings-registry.js +3 -1
- package/src/ui/server/public/settings.html +9 -1
- package/src/ui/server/public/shell.js +348 -5
- package/src/ui/server/request-guard.ts +2 -0
- package/src/ui/server/route-classes.ts +34 -1
- package/src/ui/server/session-routes.ts +14 -3
- package/src/ui/server/workspace-routes.ts +21 -10
- package/themes/den/den-theme.json +6 -6
|
@@ -21,7 +21,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
|
21
21
|
import * as fs from "fs";
|
|
22
22
|
import * as path from "path";
|
|
23
23
|
import * as yaml from "yaml";
|
|
24
|
-
import { parseMachineName } from "./cli-command.js";
|
|
24
|
+
import { parseMachineName, parseSshUser } from "./cli-command.js";
|
|
25
25
|
import { getStateDir } from "./paths.js";
|
|
26
26
|
|
|
27
27
|
export const LOGIN_CODE_TTL_MS = 60_000;
|
|
@@ -195,6 +195,12 @@ export interface WebAuthConfig {
|
|
|
195
195
|
* page gives this machine ("runs on <name>"). Unset: the short hostname.
|
|
196
196
|
*/
|
|
197
197
|
machineName?: string;
|
|
198
|
+
/**
|
|
199
|
+
* `web.ssh_user` (SEC-2a-UX; user.yml, else the distribution's): the
|
|
200
|
+
* account the signed-out page's ssh link logs in as. Unset: the account
|
|
201
|
+
* Den runs as. "": no account on the page.
|
|
202
|
+
*/
|
|
203
|
+
sshUser?: string;
|
|
198
204
|
}
|
|
199
205
|
|
|
200
206
|
/** `web.edge_unattested`'s values. */
|
|
@@ -290,8 +296,13 @@ export function readWebAuthConfig(
|
|
|
290
296
|
const clientId =
|
|
291
297
|
clientIdOf(user, "config/local/user.yml") ?? clientIdOf(team, configFile);
|
|
292
298
|
const machineName = parseMachineName(user.machine_name);
|
|
299
|
+
const sshUser =
|
|
300
|
+
user.ssh_user !== undefined
|
|
301
|
+
? parseSshUser(user.ssh_user)
|
|
302
|
+
: parseSshUser(team.ssh_user);
|
|
293
303
|
return {
|
|
294
304
|
...(machineName ? { machineName } : {}),
|
|
305
|
+
...(sshUser !== undefined ? { sshUser } : {}),
|
|
295
306
|
allowPlainHttpRemote: plain === true,
|
|
296
307
|
ignored,
|
|
297
308
|
edgeUnattested: unattested ?? "signin",
|
|
@@ -319,6 +330,43 @@ function clientIdOf(
|
|
|
319
330
|
return v.trim();
|
|
320
331
|
}
|
|
321
332
|
|
|
333
|
+
// ── The approval key (0.4.15) ─────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Why a cookie alone can't approve an agent's change: browsers send a
|
|
337
|
+
* localhost cookie to every localhost port (cookies ignore ports), so a
|
|
338
|
+
* dev server an agent runs on another port receives Den's session cookie,
|
|
339
|
+
* and GET /api/session gives any cookie holder the session's CSRF token.
|
|
340
|
+
* The approval key is a second secret only the signed-in page holds: it
|
|
341
|
+
* comes in the sign-in's own answer (a 303's URL fragment, which never
|
|
342
|
+
* reaches a server, or the sign-in poll's JSON), the page keeps it in its
|
|
343
|
+
* localStorage (per origin, port included), and sends it as
|
|
344
|
+
* X-Den-Approve-Key.
|
|
345
|
+
*/
|
|
346
|
+
export const APPROVE_KEY_HEADER = "x-den-approve-key";
|
|
347
|
+
/** The fragment parameter a sign-in's 303 carries it in. */
|
|
348
|
+
export const APPROVE_KEY_FRAGMENT = "ak";
|
|
349
|
+
|
|
350
|
+
export function approveKeyHash(key: string): string {
|
|
351
|
+
return createHash("sha256").update(`den-approve-v1:${key}`).digest("hex");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Whether `given` is the key whose hash the session keeps (constant time). */
|
|
355
|
+
export function approveKeyMatches(
|
|
356
|
+
storedHash: string | undefined,
|
|
357
|
+
given: string | undefined | null,
|
|
358
|
+
): boolean {
|
|
359
|
+
if (!storedHash || !given || !/^[A-Za-z0-9_-]{43}$/.test(given)) return false;
|
|
360
|
+
const a = Buffer.from(approveKeyHash(given), "hex");
|
|
361
|
+
const b = Buffer.from(storedHash, "hex");
|
|
362
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** `next` with the approval key in its fragment (a sign-in's 303). */
|
|
366
|
+
export function withApproveKey(next: string, key: string): string {
|
|
367
|
+
return `${next.split("#")[0]}#${APPROVE_KEY_FRAGMENT}=${key}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
322
370
|
// ── The session store ──────────────────────────────────────────────────────
|
|
323
371
|
|
|
324
372
|
export interface SessionRecord {
|
|
@@ -329,14 +377,21 @@ export interface SessionRecord {
|
|
|
329
377
|
csrf: string;
|
|
330
378
|
/** The Host it was issued on. */
|
|
331
379
|
host: string;
|
|
332
|
-
/** SEC-2a: how it signed in: a GitHub sign-in,
|
|
333
|
-
via?: "github" | "link" | "code";
|
|
380
|
+
/** SEC-2a: how it signed in: a GitHub sign-in, a one-time link, a code (pairing), or the install's setup link (claim, 0.4.15). Unset: a link (before 0.4.5). */
|
|
381
|
+
via?: "github" | "link" | "code" | "claim";
|
|
334
382
|
/** SEC-2a: the client's address when Den could attest it. */
|
|
335
383
|
client?: string;
|
|
336
384
|
/** SEC-2a: a GitHub sign-in's login (the chip's "@login"). */
|
|
337
385
|
login?: string;
|
|
338
386
|
/** SEC-2a: not yet greeted ("This device is signed in…", once). */
|
|
339
387
|
welcome?: true;
|
|
388
|
+
/**
|
|
389
|
+
* 0.4.15: sha256 of this session's approval key (approvalKeyMatches).
|
|
390
|
+
* The key itself is handed over once, at the end of the sign-in, and
|
|
391
|
+
* never by GET /api/session. Unset: a session from before 0.4.15, which
|
|
392
|
+
* must sign in again to approve.
|
|
393
|
+
*/
|
|
394
|
+
approveKeyHash?: string;
|
|
340
395
|
}
|
|
341
396
|
|
|
342
397
|
interface StoreFile {
|
|
@@ -464,14 +519,17 @@ export class WebSessionStore {
|
|
|
464
519
|
create(meta: {
|
|
465
520
|
userAgent?: string;
|
|
466
521
|
host?: string;
|
|
467
|
-
via?: "github" | "link" | "code";
|
|
522
|
+
via?: "github" | "link" | "code" | "claim";
|
|
468
523
|
client?: string;
|
|
469
524
|
login?: string;
|
|
470
525
|
}): {
|
|
471
526
|
id: string;
|
|
472
527
|
session: SessionView;
|
|
528
|
+
/** The approval key, for the sign-in's answer only (never stored). */
|
|
529
|
+
approveKey: string;
|
|
473
530
|
} {
|
|
474
531
|
const id = randomBytes(32).toString("base64url");
|
|
532
|
+
const approveKey = randomBytes(32).toString("base64url");
|
|
475
533
|
const key = this.keyOf(id);
|
|
476
534
|
if (!key) throw new Error("web.secret is missing: no session can be made");
|
|
477
535
|
const now = this.now();
|
|
@@ -487,12 +545,13 @@ export class WebSessionStore {
|
|
|
487
545
|
...(meta.client ? { client: String(meta.client).slice(0, 64) } : {}),
|
|
488
546
|
...(meta.login ? { login: String(meta.login).slice(0, 64) } : {}),
|
|
489
547
|
welcome: true,
|
|
548
|
+
approveKeyHash: approveKeyHash(approveKey),
|
|
490
549
|
};
|
|
491
550
|
const data = structuredClone(this.load());
|
|
492
551
|
this.prune(data, now);
|
|
493
552
|
data.sessions[key] = rec;
|
|
494
553
|
this.save(data);
|
|
495
|
-
return { id, session: { key, ...rec } };
|
|
554
|
+
return { id, session: { key, ...rec }, approveKey };
|
|
496
555
|
}
|
|
497
556
|
|
|
498
557
|
/**
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -116,6 +116,7 @@ import {
|
|
|
116
116
|
restoreRecord,
|
|
117
117
|
writeRecord,
|
|
118
118
|
checkDisplayName,
|
|
119
|
+
nameSlug,
|
|
119
120
|
writeRuntimeFiles,
|
|
120
121
|
type AgentRecord,
|
|
121
122
|
type Override,
|
|
@@ -138,6 +139,7 @@ import type {
|
|
|
138
139
|
RuntimePermissions,
|
|
139
140
|
} from "../../shared/agents/runtime.js";
|
|
140
141
|
import {
|
|
142
|
+
inScope,
|
|
141
143
|
OPEN_ELSEWHERE_MESSAGE,
|
|
142
144
|
openElsewhere,
|
|
143
145
|
type RuntimeConversation,
|
|
@@ -195,7 +197,7 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
195
197
|
* AG-17.1: the folders a workspace's conversations may have run in: its
|
|
196
198
|
* own folder, and each repo's path, a repo added by path outside it too.
|
|
197
199
|
*/
|
|
198
|
-
workspaceFolders?: (ws: string) => string[];
|
|
200
|
+
workspaceFolders?: (ws: string) => { under: string[]; exact?: string[] };
|
|
199
201
|
/** The MCP server binary and its env; Den adds the scoped args. */
|
|
200
202
|
mcpServer?: () => { command: string[] | string; env: Record<string, string> };
|
|
201
203
|
/**
|
|
@@ -232,7 +234,7 @@ export interface AgentsResolved extends Resolved {
|
|
|
232
234
|
agentsConfig: () => ResolvedAgentsConfig;
|
|
233
235
|
distRoot: () => string;
|
|
234
236
|
workspaceDir: (ws: string) => string;
|
|
235
|
-
workspaceFolders: (ws: string) => string[];
|
|
237
|
+
workspaceFolders: (ws: string) => { under: string[]; exact?: string[] };
|
|
236
238
|
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
237
239
|
exitWatchMs: number;
|
|
238
240
|
stopWaitMs: number;
|
|
@@ -292,7 +294,8 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
|
|
|
292
294
|
new AgentError(message, { status, hints });
|
|
293
295
|
|
|
294
296
|
let workspaceDirFn: ((ws: string) => string) | null = null;
|
|
295
|
-
let
|
|
297
|
+
let foldersFn: ((ws: string) => { under: string[]; exact?: string[] }) | null =
|
|
298
|
+
null;
|
|
296
299
|
/** The workspaces config, for start and resume (loaded on first use). */
|
|
297
300
|
export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
|
|
298
301
|
await loadWorkspaces(d);
|
|
@@ -305,8 +308,11 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
|
|
|
305
308
|
return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
|
|
306
309
|
};
|
|
307
310
|
// A by-path repo's own folder (repo add <path>), wherever it is.
|
|
308
|
-
|
|
309
|
-
ws.
|
|
311
|
+
foldersFn = (name) => {
|
|
312
|
+
const cfg = ws.readWorkspacesFile().workspaces[name];
|
|
313
|
+
if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
|
|
314
|
+
return ws.conversationFolders(name, cfg, getReposRoot());
|
|
315
|
+
};
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
/** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
|
|
@@ -340,13 +346,10 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
|
340
346
|
workspaceFolders:
|
|
341
347
|
d.workspaceFolders ??
|
|
342
348
|
((ws) => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return workspaceDirFn(ws);
|
|
348
|
-
})();
|
|
349
|
-
return [dir, ...(repoPathsFn?.(ws) ?? [])];
|
|
349
|
+
// A caller that gives its own folder (a test) gives just it.
|
|
350
|
+
if (d.workspaceDir) return { under: [d.workspaceDir(ws)] };
|
|
351
|
+
if (!foldersFn) throw new Error("workspaces not loaded");
|
|
352
|
+
return foldersFn(ws);
|
|
350
353
|
}),
|
|
351
354
|
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
352
355
|
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
@@ -452,6 +455,11 @@ export interface AgentRow {
|
|
|
452
455
|
* runtime's resume token, and it isn't running.
|
|
453
456
|
*/
|
|
454
457
|
resumable: boolean;
|
|
458
|
+
/**
|
|
459
|
+
* AG-18: Change persona… applies: its runtime can resume and its record
|
|
460
|
+
* has a conversation to continue (running or not), not rotated.
|
|
461
|
+
*/
|
|
462
|
+
personaChange?: boolean;
|
|
455
463
|
/**
|
|
456
464
|
* Why Resume doesn't apply to a stopped agent (AG-14.2), e.g. "codex
|
|
457
465
|
* can't resume a conversation": only Start fresh is offered.
|
|
@@ -550,6 +558,9 @@ export function agentRows(
|
|
|
550
558
|
state: st,
|
|
551
559
|
startedAt: s?.startedAt ?? rec.startedAt,
|
|
552
560
|
resumable,
|
|
561
|
+
...(!rot?.ready && !personaChangeRefusal(rec)
|
|
562
|
+
? { personaChange: true }
|
|
563
|
+
: {}),
|
|
553
564
|
...(why ? { noResume: why } : {}),
|
|
554
565
|
...(rec.stoppedAt ? { stoppedAt: rec.stoppedAt } : {}),
|
|
555
566
|
...(rec.endedAt ? { endedAt: rec.endedAt } : {}),
|
|
@@ -1048,6 +1059,48 @@ function sessionOf(
|
|
|
1048
1059
|
* else the first of `<base>-2`, `<base>-3`… no agent has. A stopped agent's
|
|
1049
1060
|
* id is never reused by a new start: only its Resume or Start fresh.
|
|
1050
1061
|
*/
|
|
1062
|
+
/**
|
|
1063
|
+
* AG-10.10b: the id a Name gives: its slug while no agent has it; a
|
|
1064
|
+
* running agent in the workspace with that id or that title is refused
|
|
1065
|
+
* (open it instead); a stopped one, or another workspace's, makes it the
|
|
1066
|
+
* next free `<slug>-2`… (Resume continues the stopped one). Null when the
|
|
1067
|
+
* Name has no id characters: the persona's default then.
|
|
1068
|
+
*/
|
|
1069
|
+
async function namedId(
|
|
1070
|
+
d: AgentsResolved,
|
|
1071
|
+
name: string,
|
|
1072
|
+
ws: string,
|
|
1073
|
+
): Promise<string | null> {
|
|
1074
|
+
const slug = nameSlug(name);
|
|
1075
|
+
const sessions = await withClient(d, (c) => c.list());
|
|
1076
|
+
const live = (rec: AgentRecord) => {
|
|
1077
|
+
const s = sessionOf(sessions, rec);
|
|
1078
|
+
return s && !s.exited ? s : undefined;
|
|
1079
|
+
};
|
|
1080
|
+
// No two live agents in one workspace share a title, or this id.
|
|
1081
|
+
for (const rec of listRecords(d.stateDir())) {
|
|
1082
|
+
if (rec.workspace !== ws) continue;
|
|
1083
|
+
const same =
|
|
1084
|
+
rec.id === slug ||
|
|
1085
|
+
(rec.displayName ?? "").toLowerCase() === name.toLowerCase();
|
|
1086
|
+
const s = same ? live(rec) : undefined;
|
|
1087
|
+
if (s)
|
|
1088
|
+
throw new AgentError(`agent ${rec.id} is running (${s.name})`, {
|
|
1089
|
+
status: 409,
|
|
1090
|
+
hints: [`Attach: ${d.cli} sessions attach ${s.name}`],
|
|
1091
|
+
running: { id: rec.id, sessionId: s.ref.id },
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
if (!slug) return null;
|
|
1095
|
+
if (!readRecord(d.stateDir(), slug)) return slug;
|
|
1096
|
+
const stem = slug.slice(0, 58);
|
|
1097
|
+
for (let n = 2; n < 1000; n++) {
|
|
1098
|
+
const id = `${stem}-${n}`;
|
|
1099
|
+
if (!readRecord(d.stateDir(), id)) return id;
|
|
1100
|
+
}
|
|
1101
|
+
throw refused(`no free agent id for ${slug}: give one with --id`);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1051
1104
|
export function defaultId(
|
|
1052
1105
|
stateDir: string,
|
|
1053
1106
|
persona: string,
|
|
@@ -1487,8 +1540,6 @@ export async function startAgent(
|
|
|
1487
1540
|
throw usage(
|
|
1488
1541
|
`${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
|
|
1489
1542
|
);
|
|
1490
|
-
const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
|
|
1491
|
-
checkId(where, id);
|
|
1492
1543
|
let given: string | null = null;
|
|
1493
1544
|
if (req.name !== undefined)
|
|
1494
1545
|
try {
|
|
@@ -1496,6 +1547,11 @@ export async function startAgent(
|
|
|
1496
1547
|
} catch (e) {
|
|
1497
1548
|
throw usage(`${where}: ${(e as Error).message}`);
|
|
1498
1549
|
}
|
|
1550
|
+
const id =
|
|
1551
|
+
req.id ??
|
|
1552
|
+
(given ? await namedId(d, given, ws) : null) ??
|
|
1553
|
+
defaultId(d.stateDir(), persona.id, ws);
|
|
1554
|
+
checkId(where, id);
|
|
1499
1555
|
syncCatalog(d);
|
|
1500
1556
|
const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
|
|
1501
1557
|
const det = await availableOrRefuse(d, rt, ws);
|
|
@@ -1627,6 +1683,17 @@ export interface ListedConversation extends RuntimeConversation {
|
|
|
1627
1683
|
open: boolean;
|
|
1628
1684
|
/** The Den agent already continuing it, if one is. */
|
|
1629
1685
|
agent?: string;
|
|
1686
|
+
/**
|
|
1687
|
+
* AG-17.1c: the persona Bring in preselects: the one of the latest Den
|
|
1688
|
+
* agent that worked in its folder (or one inside it), else the
|
|
1689
|
+
* workspace's latest agent's. The person confirms; never submitted.
|
|
1690
|
+
*/
|
|
1691
|
+
suggest?: string;
|
|
1692
|
+
/**
|
|
1693
|
+
* AG-17.1c: the MCP servers its tools came from that Den won't load (an
|
|
1694
|
+
* agent runs with Den's tools only).
|
|
1695
|
+
*/
|
|
1696
|
+
lost?: string[];
|
|
1630
1697
|
}
|
|
1631
1698
|
|
|
1632
1699
|
/** Each runtime, and whether it can bring a conversation in. */
|
|
@@ -1678,14 +1745,10 @@ export function listConversations(
|
|
|
1678
1745
|
throw usage(`${where}: workspace "${ws}" does not exist`, 404);
|
|
1679
1746
|
syncCatalog(d);
|
|
1680
1747
|
if (runtime !== undefined) runtimeFor(runtime);
|
|
1681
|
-
// Its folder and every repo's
|
|
1682
|
-
const
|
|
1683
|
-
...new Set(d.workspaceFolders(ws).map((f) => path.resolve(f))),
|
|
1684
|
-
];
|
|
1685
|
-
const scan = folders.filter(
|
|
1686
|
-
(f) => !folders.some((o) => o !== f && f.startsWith(o + path.sep)),
|
|
1687
|
-
);
|
|
1748
|
+
// Its folder and every repo's; a shared root only exactly (AG-17.1).
|
|
1749
|
+
const scope = d.workspaceFolders(ws);
|
|
1688
1750
|
const taken = agentByToken(d);
|
|
1751
|
+
const suggestFor = personaSuggester(d, ws);
|
|
1689
1752
|
const now = Date.now();
|
|
1690
1753
|
const conversations: ListedConversation[] = [];
|
|
1691
1754
|
const seen = new Set<string>();
|
|
@@ -1693,15 +1756,19 @@ export function listConversations(
|
|
|
1693
1756
|
if (runtime !== undefined && id !== runtime) continue;
|
|
1694
1757
|
const rt = getRuntime(id);
|
|
1695
1758
|
if (!rt?.listSessions) continue;
|
|
1696
|
-
for (const c of
|
|
1759
|
+
for (const c of rt.listSessions(scope, d.env)) {
|
|
1697
1760
|
if (seen.has(`${id} ${c.id}`)) continue;
|
|
1698
1761
|
seen.add(`${id} ${c.id}`);
|
|
1699
1762
|
const agent = taken.get(`${id} ${c.id}`);
|
|
1763
|
+
const lost = (c.mcp ?? []).filter((m) => m !== d.cli);
|
|
1764
|
+
const suggest = suggestFor(c.cwd);
|
|
1700
1765
|
conversations.push({
|
|
1701
1766
|
...c,
|
|
1702
1767
|
runtime: id,
|
|
1703
1768
|
open: openElsewhere(c.lastAt, now),
|
|
1704
1769
|
...(agent ? { agent } : {}),
|
|
1770
|
+
...(suggest ? { suggest } : {}),
|
|
1771
|
+
...(lost.length ? { lost } : {}),
|
|
1705
1772
|
});
|
|
1706
1773
|
}
|
|
1707
1774
|
}
|
|
@@ -1709,6 +1776,36 @@ export function listConversations(
|
|
|
1709
1776
|
return { runtimes: bringInRuntimes(), conversations };
|
|
1710
1777
|
}
|
|
1711
1778
|
|
|
1779
|
+
/**
|
|
1780
|
+
* AG-17.1c: the persona to preselect for a conversation that ran in a
|
|
1781
|
+
* folder: the latest agent's of this workspace that worked there (or in a
|
|
1782
|
+
* folder inside it), else the workspace's latest agent's; only a persona
|
|
1783
|
+
* that still exists. Undefined: none to suggest (the page's first).
|
|
1784
|
+
*/
|
|
1785
|
+
function personaSuggester(
|
|
1786
|
+
d: AgentsResolved,
|
|
1787
|
+
ws: string,
|
|
1788
|
+
): (cwd: string) => string | undefined {
|
|
1789
|
+
let known: Set<string>;
|
|
1790
|
+
try {
|
|
1791
|
+
known = new Set(personas(d, agentsConfig(d)).keys());
|
|
1792
|
+
} catch {
|
|
1793
|
+
return () => undefined;
|
|
1794
|
+
}
|
|
1795
|
+
const recs = listRecords(d.stateDir())
|
|
1796
|
+
.filter((r) => r.workspace === ws && known.has(r.persona))
|
|
1797
|
+
.sort((a, b) =>
|
|
1798
|
+
(b.resumedAt ?? b.startedAt).localeCompare(a.resumedAt ?? a.startedAt),
|
|
1799
|
+
);
|
|
1800
|
+
const inside = (p: string, dir: string) => {
|
|
1801
|
+
const rel = path.relative(dir, p);
|
|
1802
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
1803
|
+
};
|
|
1804
|
+
return (cwd) =>
|
|
1805
|
+
(recs.find((r) => inside(r.cwd, cwd) || inside(cwd, r.cwd)) ?? recs[0])
|
|
1806
|
+
?.persona;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1712
1809
|
export interface ImportRequest {
|
|
1713
1810
|
workspace: string;
|
|
1714
1811
|
/** The runtime's conversation id. */
|
|
@@ -1716,6 +1813,13 @@ export interface ImportRequest {
|
|
|
1716
1813
|
persona: string;
|
|
1717
1814
|
runtime?: string;
|
|
1718
1815
|
id?: string;
|
|
1816
|
+
/**
|
|
1817
|
+
* AG-17.1c: a model for it (Advanced). Absent: the conversation's own
|
|
1818
|
+
* (its last answer's), else the persona's.
|
|
1819
|
+
*/
|
|
1820
|
+
model?: string;
|
|
1821
|
+
/** With model: "latest" or a pinned version (AG-12.3). */
|
|
1822
|
+
version?: string;
|
|
1719
1823
|
}
|
|
1720
1824
|
|
|
1721
1825
|
/** The persona's id, else the first `<persona>-<n>` no agent has. */
|
|
@@ -1755,6 +1859,16 @@ export async function importAgent(
|
|
|
1755
1859
|
422,
|
|
1756
1860
|
);
|
|
1757
1861
|
const c = conversations.find((x) => x.id === req.session);
|
|
1862
|
+
// Another workspace's (or no repo's): say whose, never bring it in.
|
|
1863
|
+
const elsewhere = c
|
|
1864
|
+
? null
|
|
1865
|
+
: (getRuntime(runtime)?.conversationCwd?.(req.session, d.env) ?? null);
|
|
1866
|
+
if (!c && elsewhere && !inScope(elsewhere, d.workspaceFolders(req.workspace)))
|
|
1867
|
+
throw refused(
|
|
1868
|
+
`This conversation belongs to ${elsewhere}, not to workspace ${req.workspace}`,
|
|
1869
|
+
[`List this workspace's: ${d.cli} agents import -w ${req.workspace}`],
|
|
1870
|
+
409,
|
|
1871
|
+
);
|
|
1758
1872
|
if (!c)
|
|
1759
1873
|
throw refused(
|
|
1760
1874
|
`${where}: no ${rt.label} conversation "${req.session}" in workspace ${req.workspace}'s folder`,
|
|
@@ -1778,9 +1892,23 @@ export async function importAgent(
|
|
|
1778
1892
|
const id = req.id ?? freeId(d, req.persona);
|
|
1779
1893
|
if (readRecord(d.stateDir(), id))
|
|
1780
1894
|
throw refused(`${where}: agent ${id} exists; give this one another --id`);
|
|
1895
|
+
// AG-17.1c: the conversation keeps its own model unless one is picked.
|
|
1896
|
+
const model = req.model ?? c.model;
|
|
1781
1897
|
return startAgent(
|
|
1782
1898
|
d,
|
|
1783
|
-
{
|
|
1899
|
+
{
|
|
1900
|
+
workspace: req.workspace,
|
|
1901
|
+
persona: req.persona,
|
|
1902
|
+
runtime,
|
|
1903
|
+
id,
|
|
1904
|
+
...(model ? { model } : {}),
|
|
1905
|
+
...(req.model !== undefined && req.version !== undefined
|
|
1906
|
+
? { version: req.version }
|
|
1907
|
+
: {}),
|
|
1908
|
+
// A model picked on the page is checked against the catalog; the
|
|
1909
|
+
// conversation's own is taken as it ran.
|
|
1910
|
+
...(req.model !== undefined ? { strict: true } : {}),
|
|
1911
|
+
},
|
|
1784
1912
|
{ bringIn: { token: c.id, cwd: c.cwd } },
|
|
1785
1913
|
);
|
|
1786
1914
|
}
|
|
@@ -2060,6 +2188,227 @@ export async function resumeAgent(
|
|
|
2060
2188
|
});
|
|
2061
2189
|
}
|
|
2062
2190
|
|
|
2191
|
+
// ── AG-18: change a running agent's persona ─────────────────────────────────
|
|
2192
|
+
|
|
2193
|
+
/** The refusal code while its turn is going and no `when` was given. */
|
|
2194
|
+
export const PERSONA_BUSY = "busy";
|
|
2195
|
+
|
|
2196
|
+
export interface PersonaRequest {
|
|
2197
|
+
persona: string;
|
|
2198
|
+
/** Advanced: another model. Absent: the one it runs (the conversation's). */
|
|
2199
|
+
model?: string;
|
|
2200
|
+
/** With model: "latest" or a pinned version. */
|
|
2201
|
+
version?: string;
|
|
2202
|
+
/**
|
|
2203
|
+
* Mid-turn: "after-turn" waits until its turn ends, "now" stops it. None:
|
|
2204
|
+
* refused (PERSONA_BUSY) while it works, so a turn is never cut silently.
|
|
2205
|
+
*/
|
|
2206
|
+
when?: "now" | "after-turn";
|
|
2207
|
+
/** Who asked (a person's sign-in, "cli"), for its record. */
|
|
2208
|
+
by?: string;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
export interface PersonaChanged extends Resumed {
|
|
2212
|
+
from: string;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/** How often, and how long, "after this turn" checks its turn (AG-18). */
|
|
2216
|
+
export const TURN_POLL_MS = 1_000;
|
|
2217
|
+
export const TURN_WAIT_MS = 30 * 60_000;
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* Whether agent `rec` is mid-turn: true or false from its runtime (den-
|
|
2221
|
+
* claude: its transcript), null when it can't tell. Not running: false.
|
|
2222
|
+
*/
|
|
2223
|
+
async function agentBusy(
|
|
2224
|
+
d: AgentsResolved,
|
|
2225
|
+
rec: AgentRecord,
|
|
2226
|
+
): Promise<boolean | null> {
|
|
2227
|
+
const live = await withClient(d, async (c) => {
|
|
2228
|
+
const s = sessionOf(await c.list(), rec);
|
|
2229
|
+
return Boolean(s && !s.exited);
|
|
2230
|
+
});
|
|
2231
|
+
if (!live) return false;
|
|
2232
|
+
const rt = getRuntime(rec.runtime);
|
|
2233
|
+
if (!rt?.turnBusy || !rec.resumeToken) return null;
|
|
2234
|
+
return rt.turnBusy(rec.resumeToken, d.env);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/** Why agent `rec` can't change persona (it can't resume), or null. */
|
|
2238
|
+
export function personaChangeRefusal(rec: AgentRecord): string | null {
|
|
2239
|
+
const rt = getRuntime(rec.runtime);
|
|
2240
|
+
if (!rt?.resume)
|
|
2241
|
+
return `${rt?.label ?? rec.runtime} can't continue a conversation, so it can't change persona`;
|
|
2242
|
+
return resumeRefusal(rec);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/**
|
|
2246
|
+
* AG-18, change persona: Den stops the agent's session and resumes the
|
|
2247
|
+
* same conversation with the new persona's launch settings (its role
|
|
2248
|
+
* prompt, permission mode, allowed commands). The history stays; so do the
|
|
2249
|
+
* id, name, workspace, folder and grants, and the model it runs unless
|
|
2250
|
+
* one is given. Mid-turn it never stops a turn unasked: refused with
|
|
2251
|
+
* PERSONA_BUSY, or `when` says to wait for the turn's end or stop now.
|
|
2252
|
+
* Only a runtime that can resume can. The person's only: callers refuse an
|
|
2253
|
+
* agent's token before this.
|
|
2254
|
+
*/
|
|
2255
|
+
export async function changePersona(
|
|
2256
|
+
d: AgentsResolved,
|
|
2257
|
+
id: string,
|
|
2258
|
+
ws: string | undefined,
|
|
2259
|
+
req: PersonaRequest,
|
|
2260
|
+
opts: { pollMs?: number; maxMs?: number } = {},
|
|
2261
|
+
): Promise<PersonaChanged> {
|
|
2262
|
+
const where = "agents persona";
|
|
2263
|
+
const rec = agentRecord(d, id, ws);
|
|
2264
|
+
const cfg = agentsConfig(d);
|
|
2265
|
+
const all = personas(d, cfg);
|
|
2266
|
+
if (!all.get(req.persona))
|
|
2267
|
+
throw usage(
|
|
2268
|
+
`${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
|
|
2269
|
+
);
|
|
2270
|
+
if (req.persona === rec.persona && req.model === undefined)
|
|
2271
|
+
throw usage(`${where}: agent ${rec.id} is already ${rec.persona}`);
|
|
2272
|
+
const why = personaChangeRefusal(rec);
|
|
2273
|
+
if (why) throw refused(why, [], 422);
|
|
2274
|
+
let busy = await agentBusy(d, rec);
|
|
2275
|
+
if (busy !== false && req.when === undefined)
|
|
2276
|
+
throw new AgentError(
|
|
2277
|
+
busy
|
|
2278
|
+
? `agent ${rec.id} is working. Change after this turn, or stop it now?`
|
|
2279
|
+
: `agent ${rec.id} is running and Den can't tell whether it is mid-turn. Change after this turn, or stop it now?`,
|
|
2280
|
+
{
|
|
2281
|
+
status: 409,
|
|
2282
|
+
code: PERSONA_BUSY,
|
|
2283
|
+
hints: [
|
|
2284
|
+
`After this turn: ${d.cli} agents persona ${rec.id} ${req.persona} --after-turn`,
|
|
2285
|
+
`Stop it now: ${d.cli} agents persona ${rec.id} ${req.persona} --now`,
|
|
2286
|
+
],
|
|
2287
|
+
},
|
|
2288
|
+
);
|
|
2289
|
+
if (busy === null && req.when === "after-turn")
|
|
2290
|
+
throw refused(
|
|
2291
|
+
`${where}: Den can't tell when agent ${rec.id}'s turn ends; stop it now instead (--now)`,
|
|
2292
|
+
);
|
|
2293
|
+
if (req.when !== "after-turn") return switchPersona(d, rec, req, where);
|
|
2294
|
+
// Waiting: marked on the record, so a restart that drops the wait can
|
|
2295
|
+
// say so (cancelStalePersonaChanges); cleared however this ends.
|
|
2296
|
+
writeRecord(d.stateDir(), {
|
|
2297
|
+
...rec,
|
|
2298
|
+
pendingPersona: {
|
|
2299
|
+
to: req.persona,
|
|
2300
|
+
since: new Date().toISOString(),
|
|
2301
|
+
...(req.by ? { by: req.by } : {}),
|
|
2302
|
+
},
|
|
2303
|
+
});
|
|
2304
|
+
try {
|
|
2305
|
+
const deadline = Date.now() + (opts.maxMs ?? TURN_WAIT_MS);
|
|
2306
|
+
while (busy) {
|
|
2307
|
+
if (Date.now() >= deadline)
|
|
2308
|
+
throw refused(
|
|
2309
|
+
`${where}: agent ${rec.id} was still working after ${Math.round((opts.maxMs ?? TURN_WAIT_MS) / 60_000)} min; nothing changed`,
|
|
2310
|
+
);
|
|
2311
|
+
await new Promise((r) => setTimeout(r, opts.pollMs ?? TURN_POLL_MS));
|
|
2312
|
+
busy = await agentBusy(d, agentRecord(d, id, ws));
|
|
2313
|
+
}
|
|
2314
|
+
return await switchPersona(d, rec, req, where);
|
|
2315
|
+
} catch (e) {
|
|
2316
|
+
clearPendingPersona(d, rec.id);
|
|
2317
|
+
throw e;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/** Drop agent `id`'s pending persona change mark, if it has one. */
|
|
2322
|
+
function clearPendingPersona(d: AgentsResolved, id: string): void {
|
|
2323
|
+
const cur = readRecord(d.stateDir(), id);
|
|
2324
|
+
if (!cur?.pendingPersona) return;
|
|
2325
|
+
const { pendingPersona: _pp, ...rest } = cur;
|
|
2326
|
+
writeRecord(d.stateDir(), rest);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/**
|
|
2330
|
+
* At start (AG-18): every persona change still marked pending was dropped
|
|
2331
|
+
* when Den stopped, since the wait lived in memory. Clears each mark and
|
|
2332
|
+
* returns them, for the audit trail.
|
|
2333
|
+
*/
|
|
2334
|
+
export function cancelStalePersonaChanges(d: AgentsResolved): {
|
|
2335
|
+
id: string;
|
|
2336
|
+
workspace: string;
|
|
2337
|
+
from: string;
|
|
2338
|
+
to: string;
|
|
2339
|
+
by?: string;
|
|
2340
|
+
}[] {
|
|
2341
|
+
const out: ReturnType<typeof cancelStalePersonaChanges> = [];
|
|
2342
|
+
for (const rec of listRecords(d.stateDir())) {
|
|
2343
|
+
const p = rec.pendingPersona;
|
|
2344
|
+
if (!p) continue;
|
|
2345
|
+
clearPendingPersona(d, rec.id);
|
|
2346
|
+
out.push({
|
|
2347
|
+
id: rec.id,
|
|
2348
|
+
workspace: rec.workspace,
|
|
2349
|
+
from: rec.persona,
|
|
2350
|
+
to: p.to,
|
|
2351
|
+
...(p.by ? { by: p.by } : {}),
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
return out;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/** The switch itself: stop, the new persona's settings, resume. */
|
|
2358
|
+
async function switchPersona(
|
|
2359
|
+
d: AgentsResolved,
|
|
2360
|
+
rec: AgentRecord,
|
|
2361
|
+
req: PersonaRequest,
|
|
2362
|
+
where: string,
|
|
2363
|
+
): Promise<PersonaChanged> {
|
|
2364
|
+
const stopped = await stopAgent(d, rec.id, rec.workspace);
|
|
2365
|
+
if (stopped.stillStopping)
|
|
2366
|
+
throw refused(
|
|
2367
|
+
`${where}: agent ${rec.id} is still stopping; try again in a moment`,
|
|
2368
|
+
);
|
|
2369
|
+
// The new persona's permissions apply: the old one's choices go. The
|
|
2370
|
+
// model stays (the conversation's) unless one is given.
|
|
2371
|
+
const {
|
|
2372
|
+
permission: _p,
|
|
2373
|
+
allow: _a,
|
|
2374
|
+
overrides: _o,
|
|
2375
|
+
pendingPersona: _pp,
|
|
2376
|
+
...base
|
|
2377
|
+
} = stopped.rec;
|
|
2378
|
+
const keptOverrides = (rec.overrides ?? []).filter(
|
|
2379
|
+
(o) => o === "runtime" || o === "model" || o === "version",
|
|
2380
|
+
);
|
|
2381
|
+
const next: AgentRecord = {
|
|
2382
|
+
...base,
|
|
2383
|
+
persona: req.persona,
|
|
2384
|
+
...(req.model !== undefined
|
|
2385
|
+
? { model: req.model, version: req.version ?? "latest" }
|
|
2386
|
+
: {}),
|
|
2387
|
+
...(keptOverrides.length || req.model !== undefined
|
|
2388
|
+
? {
|
|
2389
|
+
overrides: [
|
|
2390
|
+
...new Set([
|
|
2391
|
+
...keptOverrides,
|
|
2392
|
+
...(req.model !== undefined ? (["model"] as const) : []),
|
|
2393
|
+
]),
|
|
2394
|
+
],
|
|
2395
|
+
}
|
|
2396
|
+
: {}),
|
|
2397
|
+
personaChanges: [
|
|
2398
|
+
...(rec.personaChanges ?? []),
|
|
2399
|
+
{
|
|
2400
|
+
from: rec.persona,
|
|
2401
|
+
to: req.persona,
|
|
2402
|
+
at: new Date().toISOString(),
|
|
2403
|
+
...(req.by ? { by: req.by } : {}),
|
|
2404
|
+
},
|
|
2405
|
+
],
|
|
2406
|
+
};
|
|
2407
|
+
writeRecord(d.stateDir(), next);
|
|
2408
|
+
const r = await resumeAgent(d, rec.id, rec.workspace);
|
|
2409
|
+
return { ...r, from: rec.persona };
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2063
2412
|
// ── AG-14: start fresh, remove, restart ─────────────────────────────────────
|
|
2064
2413
|
|
|
2065
2414
|
/**
|