@monster-orange/den 0.4.13 → 0.4.15

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +185 -7
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +256 -0
  7. package/src/shared/agents/den-claude.ts +19 -0
  8. package/src/shared/agents/records.ts +23 -0
  9. package/src/shared/agents/runtime.ts +14 -0
  10. package/src/shared/claim-code.ts +163 -0
  11. package/src/shared/cli-command.ts +31 -0
  12. package/src/shared/den-config.ts +14 -0
  13. package/src/shared/environment-manager/service-runner.ts +31 -12
  14. package/src/shared/environment-manager/workspaces.ts +31 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +320 -18
  17. package/src/ui/cli/agents-command.ts +89 -2
  18. package/src/ui/cli/doctor-command.ts +29 -3
  19. package/src/ui/cli/doctor-onboard.ts +26 -0
  20. package/src/ui/cli/flag-parser.ts +9 -1
  21. package/src/ui/cli/index.ts +29 -3
  22. package/src/ui/cli/onboard-command.ts +180 -0
  23. package/src/ui/cli/onboard-reads.ts +613 -0
  24. package/src/ui/cli/repo-command.ts +156 -10
  25. package/src/ui/cli/web-command.ts +92 -1
  26. package/src/ui/mcp/api.ts +26 -3
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +427 -1
  29. package/src/ui/server/agent-resume-grant.ts +35 -0
  30. package/src/ui/server/agent-routes.ts +149 -1
  31. package/src/ui/server/approval-routes.ts +390 -0
  32. package/src/ui/server/approvals.ts +416 -0
  33. package/src/ui/server/github-routes.ts +12 -4
  34. package/src/ui/server/held-actions.ts +203 -0
  35. package/src/ui/server/http-helpers.ts +35 -0
  36. package/src/ui/server/index.ts +286 -16
  37. package/src/ui/server/lifecycle-exec.ts +70 -0
  38. package/src/ui/server/onboard-routes.ts +132 -0
  39. package/src/ui/server/pair-routes.ts +7 -4
  40. package/src/ui/server/public/agents-page.js +288 -8
  41. package/src/ui/server/public/agents.html +18 -0
  42. package/src/ui/server/public/error-page.js +27 -3
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +52 -0
  45. package/src/ui/server/public/i18n/ja.js +55 -0
  46. package/src/ui/server/public/index.html +1 -1
  47. package/src/ui/server/public/local-dev.html +5 -4
  48. package/src/ui/server/public/persona-ui.css +45 -0
  49. package/src/ui/server/public/release.html +0 -1
  50. package/src/ui/server/public/settings-registry.js +3 -1
  51. package/src/ui/server/public/settings.html +9 -1
  52. package/src/ui/server/public/shell.js +336 -0
  53. package/src/ui/server/request-guard.ts +19 -4
  54. package/src/ui/server/route-classes.ts +41 -1
  55. package/src/ui/server/session-routes.ts +11 -3
  56. package/src/ui/server/workspace-routes.ts +21 -10
  57. package/themes/den/den-theme.json +6 -6
@@ -344,6 +344,37 @@ export function resolveWorkspaceDir(
344
344
  return reposRoot;
345
345
  }
346
346
 
347
+ /**
348
+ * The folders of a workspace's by-path repos (`repo add <path>`), wherever
349
+ * they are, outside its own folder too (AG-17.1). Managed clones and a
350
+ * feature workspace's clones live under Den's or the workspace's folder.
351
+ */
352
+ export function repoFolders(cfg: WorkspaceConfig | undefined): string[] {
353
+ return Object.values(cfg?.services ?? {})
354
+ .map((e) => (e as { path?: string }).path)
355
+ .filter((p): p is string => typeof p === "string" && path.isAbsolute(p));
356
+ }
357
+
358
+ /**
359
+ * Where Bring in looks for a workspace's conversations (AG-17.1). Its own
360
+ * folder, and everything in it; each by-path repo's folder, outside it
361
+ * too. When its folder is only the shared repos root (no folder of its
362
+ * own), the root's other subfolders are other workspaces' repos or no
363
+ * repo at all: only its repos' folders count, and the root itself only
364
+ * exactly.
365
+ */
366
+ export function conversationFolders(
367
+ name: string,
368
+ cfg: WorkspaceConfig,
369
+ reposRoot: string,
370
+ ): { under: string[]; exact: string[] } {
371
+ const dir = resolveWorkspaceDir(name, cfg, reposRoot);
372
+ const repos = repoFolders(cfg);
373
+ if (path.resolve(dir) === path.resolve(reposRoot))
374
+ return { under: repos, exact: [dir] };
375
+ return { under: [dir, ...repos], exact: [] };
376
+ }
377
+
347
378
  export function getWorkspaceByName(name: string): {
348
379
  name: string;
349
380
  config: WorkspaceConfig;
@@ -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, or a one-time link (pairing). Unset: a link (before 0.4.5). */
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
  /**
@@ -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,
@@ -129,6 +130,7 @@ import {
129
130
  syncConfiguredRuntimes,
130
131
  type AgentMcp,
131
132
  type LaunchInput,
133
+ type LaunchOutput,
132
134
  type Runtime,
133
135
  } from "../../shared/agents/runtimes.js";
134
136
  import type {
@@ -136,6 +138,12 @@ import type {
136
138
  RuntimeModel,
137
139
  RuntimePermissions,
138
140
  } from "../../shared/agents/runtime.js";
141
+ import {
142
+ inScope,
143
+ OPEN_ELSEWHERE_MESSAGE,
144
+ openElsewhere,
145
+ type RuntimeConversation,
146
+ } from "../../shared/agents/conversations.js";
139
147
  import {
140
148
  archiveHandoff,
141
149
  DEFAULT_ROTATE,
@@ -185,6 +193,11 @@ export interface AgentsDeps extends SessionsDeps {
185
193
  distRoot?: () => string;
186
194
  /** A workspace's checkout directory: where its agents run. */
187
195
  workspaceDir?: (ws: string) => string;
196
+ /**
197
+ * AG-17.1: the folders a workspace's conversations may have run in: its
198
+ * own folder, and each repo's path, a repo added by path outside it too.
199
+ */
200
+ workspaceFolders?: (ws: string) => { under: string[]; exact?: string[] };
188
201
  /** The MCP server binary and its env; Den adds the scoped args. */
189
202
  mcpServer?: () => { command: string[] | string; env: Record<string, string> };
190
203
  /**
@@ -221,6 +234,7 @@ export interface AgentsResolved extends Resolved {
221
234
  agentsConfig: () => ResolvedAgentsConfig;
222
235
  distRoot: () => string;
223
236
  workspaceDir: (ws: string) => string;
237
+ workspaceFolders: (ws: string) => { under: string[]; exact?: string[] };
224
238
  mcpServer: () => { command: string[] | string; env: Record<string, string> };
225
239
  exitWatchMs: number;
226
240
  stopWaitMs: number;
@@ -250,6 +264,8 @@ export class AgentError extends Error {
250
264
  readonly usage: boolean;
251
265
  readonly code?: string;
252
266
  readonly hints: string[];
267
+ /** AG-10.10: the running agent a start collided with, so a page can open it. */
268
+ readonly running?: { id: string; sessionId: string };
253
269
  constructor(
254
270
  message: string,
255
271
  o: {
@@ -257,6 +273,7 @@ export class AgentError extends Error {
257
273
  usage?: boolean;
258
274
  code?: string;
259
275
  hints?: string[];
276
+ running?: { id: string; sessionId: string };
260
277
  } = {},
261
278
  ) {
262
279
  super(message);
@@ -265,6 +282,7 @@ export class AgentError extends Error {
265
282
  this.usage = o.usage ?? false;
266
283
  this.code = o.code;
267
284
  this.hints = o.hints ?? [];
285
+ if (o.running) this.running = o.running;
268
286
  }
269
287
  }
270
288
 
@@ -276,6 +294,8 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
276
294
  new AgentError(message, { status, hints });
277
295
 
278
296
  let workspaceDirFn: ((ws: string) => string) | null = null;
297
+ let foldersFn: ((ws: string) => { under: string[]; exact?: string[] }) | null =
298
+ null;
279
299
  /** The workspaces config, for start and resume (loaded on first use). */
280
300
  export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
281
301
  await loadWorkspaces(d);
@@ -287,6 +307,12 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
287
307
  if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
288
308
  return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
289
309
  };
310
+ // A by-path repo's own folder (repo add <path>), wherever it is.
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
+ };
290
316
  }
291
317
 
292
318
  /** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
@@ -317,6 +343,14 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
317
343
  if (!workspaceDirFn) throw new Error("workspaces not loaded");
318
344
  return workspaceDirFn(ws);
319
345
  }),
346
+ workspaceFolders:
347
+ d.workspaceFolders ??
348
+ ((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);
353
+ }),
320
354
  mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
321
355
  exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
322
356
  stopWaitMs: d.stopWaitMs ?? STOP_WAIT_MS,
@@ -1012,13 +1046,68 @@ function sessionOf(
1012
1046
  }
1013
1047
 
1014
1048
  /**
1015
- * The id an agent gets: --id, else the persona's id while it is free (or
1016
- * this workspace's), else `<persona>-<ws>`.
1049
+ * The id a new start gets without --id (AG-10.10): the persona's id while
1050
+ * no agent has it; `<persona>-<ws>` when another workspace's agent has it;
1051
+ * else the first of `<base>-2`, `<base>-3`… no agent has. A stopped agent's
1052
+ * id is never reused by a new start: only its Resume or Start fresh.
1053
+ */
1054
+ /**
1055
+ * AG-10.10b: the id a Name gives: its slug while no agent has it; a
1056
+ * running agent in the workspace with that id or that title is refused
1057
+ * (open it instead); a stopped one, or another workspace's, makes it the
1058
+ * next free `<slug>-2`… (Resume continues the stopped one). Null when the
1059
+ * Name has no id characters: the persona's default then.
1017
1060
  */
1018
- function defaultId(stateDir: string, persona: string, ws: string): string {
1061
+ async function namedId(
1062
+ d: AgentsResolved,
1063
+ name: string,
1064
+ ws: string,
1065
+ ): Promise<string | null> {
1066
+ const slug = nameSlug(name);
1067
+ const sessions = await withClient(d, (c) => c.list());
1068
+ const live = (rec: AgentRecord) => {
1069
+ const s = sessionOf(sessions, rec);
1070
+ return s && !s.exited ? s : undefined;
1071
+ };
1072
+ // No two live agents in one workspace share a title, or this id.
1073
+ for (const rec of listRecords(d.stateDir())) {
1074
+ if (rec.workspace !== ws) continue;
1075
+ const same =
1076
+ rec.id === slug ||
1077
+ (rec.displayName ?? "").toLowerCase() === name.toLowerCase();
1078
+ const s = same ? live(rec) : undefined;
1079
+ if (s)
1080
+ throw new AgentError(`agent ${rec.id} is running (${s.name})`, {
1081
+ status: 409,
1082
+ hints: [`Attach: ${d.cli} sessions attach ${s.name}`],
1083
+ running: { id: rec.id, sessionId: s.ref.id },
1084
+ });
1085
+ }
1086
+ if (!slug) return null;
1087
+ if (!readRecord(d.stateDir(), slug)) return slug;
1088
+ const stem = slug.slice(0, 58);
1089
+ for (let n = 2; n < 1000; n++) {
1090
+ const id = `${stem}-${n}`;
1091
+ if (!readRecord(d.stateDir(), id)) return id;
1092
+ }
1093
+ throw refused(`no free agent id for ${slug}: give one with --id`);
1094
+ }
1095
+
1096
+ export function defaultId(
1097
+ stateDir: string,
1098
+ persona: string,
1099
+ ws: string,
1100
+ ): string {
1019
1101
  const rec = readRecord(stateDir, persona);
1020
- if (!rec || rec.workspace === ws) return persona;
1021
- return `${persona}-${ws}`.slice(0, 64);
1102
+ if (!rec) return persona;
1103
+ const base = rec.workspace === ws ? persona : `${persona}-${ws}`.slice(0, 64);
1104
+ if (base !== persona && !readRecord(stateDir, base)) return base;
1105
+ const stem = base.slice(0, 58);
1106
+ for (let n = 2; n < 1000; n++) {
1107
+ const id = `${stem}-${n}`;
1108
+ if (!readRecord(stateDir, id)) return id;
1109
+ }
1110
+ throw refused(`no free agent id for ${persona}: give one with --id`);
1022
1111
  }
1023
1112
 
1024
1113
  function checkId(where: string, id: string): void {
@@ -1310,6 +1399,8 @@ export interface StartRequest {
1310
1399
  permission?: string;
1311
1400
  /** Extra allowed commands for this launch, instead of the persona's. */
1312
1401
  allow?: unknown;
1402
+ /** AG-10.10: a display name for this agent (AG-4b's), to tell two apart. */
1403
+ name?: unknown;
1313
1404
  /**
1314
1405
  * The page's start (AG-10.1): a model must be one the runtime offers,
1315
1406
  * never a free string (the CLI's --model for generic stays free).
@@ -1418,6 +1509,11 @@ interface StartMore {
1418
1509
  rotations?: AgentRecord["rotations"];
1419
1510
  /** Called once the old session is known ended, before the launch. */
1420
1511
  beforeLaunch?: () => void;
1512
+ /**
1513
+ * AG-17.1, bring in a conversation: launch the runtime's resume of this
1514
+ * one (its id, in the folder it ran in) instead of a new conversation.
1515
+ */
1516
+ bringIn?: { token: string; cwd: string };
1421
1517
  }
1422
1518
 
1423
1519
  export async function startAgent(
@@ -1436,7 +1532,17 @@ export async function startAgent(
1436
1532
  throw usage(
1437
1533
  `${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
1438
1534
  );
1439
- const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
1535
+ let given: string | null = null;
1536
+ if (req.name !== undefined)
1537
+ try {
1538
+ given = checkDisplayName(req.name);
1539
+ } catch (e) {
1540
+ throw usage(`${where}: ${(e as Error).message}`);
1541
+ }
1542
+ const id =
1543
+ req.id ??
1544
+ (given ? await namedId(d, given, ws) : null) ??
1545
+ defaultId(d.stateDir(), persona.id, ws);
1440
1546
  checkId(where, id);
1441
1547
  syncCatalog(d);
1442
1548
  const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
@@ -1457,6 +1563,10 @@ export async function startAgent(
1457
1563
  ...(rt.permissions ? { allow } : {}),
1458
1564
  }),
1459
1565
  ...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
1566
+ // AG-17.1: the runtime's resume, in the folder the conversation ran in.
1567
+ ...(more.bringIn
1568
+ ? { worktree: more.bringIn.cwd, resumeToken: more.bringIn.token }
1569
+ : {}),
1460
1570
  };
1461
1571
  // AG-13.4: a command a runtime can't express is said, never dropped.
1462
1572
  const unexpressed =
@@ -1470,10 +1580,14 @@ export async function startAgent(
1470
1580
  const sessions = await c.list();
1471
1581
  const old = prev ? sessionOf(sessions, prev) : undefined;
1472
1582
  if (old && !old.exited)
1473
- throw refused(`agent ${id} is running (${old.name})`, [
1474
- `Attach: ${d.cli} sessions attach ${old.name}`,
1475
- ]);
1476
- const out = launchOrThrow(() => rt.launch(input));
1583
+ throw new AgentError(`agent ${id} is running (${old.name})`, {
1584
+ status: 409,
1585
+ hints: [`Attach: ${d.cli} sessions attach ${old.name}`],
1586
+ running: { id, sessionId: old.ref.id },
1587
+ });
1588
+ const out = more.bringIn
1589
+ ? { ...broughtIn(rt, input), resumeToken: more.bringIn.token }
1590
+ : launchOrThrow(() => rt.launch(input));
1477
1591
  more.beforeLaunch?.();
1478
1592
  // A new conversation: a handoff left from the last one is kept aside,
1479
1593
  // so the next rotation needs its own (den#4).
@@ -1504,18 +1618,24 @@ export async function startAgent(
1504
1618
  // AG-4b: a name given to the agent is the agent's, not its
1505
1619
  // conversation's: Start fresh and Relaunch keep it. AG-10.8:
1506
1620
  // else its persona's name ("Rita").
1507
- ...(prev?.displayName
1508
- ? { displayName: prev.displayName }
1509
- : persona.displayName
1510
- ? { displayName: persona.displayName }
1511
- : {}),
1512
- ...(prev?.displayName && prev.displayNameAt
1513
- ? { displayNameAt: prev.displayNameAt }
1514
- : {}),
1621
+ // AG-10.10: a name given at start wins.
1622
+ ...(given
1623
+ ? { displayName: given, displayNameAt: new Date().toISOString() }
1624
+ : prev?.displayName
1625
+ ? {
1626
+ displayName: prev.displayName,
1627
+ ...(prev.displayNameAt
1628
+ ? { displayNameAt: prev.displayNameAt }
1629
+ : {}),
1630
+ }
1631
+ : persona.displayName
1632
+ ? { displayName: persona.displayName }
1633
+ : {}),
1515
1634
  // 0.4.8: launched on this token, so a resume continues it.
1516
1635
  ...(out.resumeToken
1517
1636
  ? { resumeToken: out.resumeToken, launched: true }
1518
1637
  : {}),
1638
+ ...(more.bringIn ? { broughtInAt: new Date().toISOString() } : {}),
1519
1639
  },
1520
1640
  argv: out.argv,
1521
1641
  env: out.env,
@@ -1536,6 +1656,188 @@ export async function startAgent(
1536
1656
  });
1537
1657
  }
1538
1658
 
1659
+ /** A bring-in's launch: the runtime's resume (AG-17.1), never a new one. */
1660
+ function broughtIn(
1661
+ rt: Runtime,
1662
+ input: LaunchInput,
1663
+ ): Omit<LaunchOutput, "resumeToken"> {
1664
+ const out = launchOrThrow(() => rt.resume?.(input) ?? null);
1665
+ if (!out) throw refused(`${rt.id} can't continue a conversation`);
1666
+ return out;
1667
+ }
1668
+
1669
+ // ── AG-17.1: bring in a conversation ────────────────────────────────────────
1670
+
1671
+ /** A runtime's conversation as Bring in lists it. */
1672
+ export interface ListedConversation extends RuntimeConversation {
1673
+ runtime: string;
1674
+ /** Written in the last minute: still open in another terminal. */
1675
+ open: boolean;
1676
+ /** The Den agent already continuing it, if one is. */
1677
+ agent?: string;
1678
+ }
1679
+
1680
+ /** Each runtime, and whether it can bring a conversation in. */
1681
+ export interface BringInRuntime {
1682
+ id: string;
1683
+ label: string;
1684
+ bringIn: boolean;
1685
+ /** Why it can't, for the page. */
1686
+ why?: string;
1687
+ }
1688
+
1689
+ function bringInRuntimes(): BringInRuntime[] {
1690
+ return runtimeIds().map((id) => {
1691
+ const rt = getRuntime(id)!;
1692
+ const label = rt.label ?? id;
1693
+ return rt.listSessions
1694
+ ? { id, label, bringIn: true }
1695
+ : {
1696
+ id,
1697
+ label,
1698
+ bringIn: false,
1699
+ why: `${label} can't bring in a conversation yet`,
1700
+ };
1701
+ });
1702
+ }
1703
+
1704
+ /** The conversation id each agent of this state dir continues. */
1705
+ function agentByToken(d: AgentsResolved): Map<string, string> {
1706
+ const m = new Map<string, string>();
1707
+ for (const r of listRecords(d.stateDir()))
1708
+ if (r.resumeToken) m.set(`${r.runtime} ${r.resumeToken}`, r.id);
1709
+ return m;
1710
+ }
1711
+
1712
+ /**
1713
+ * The conversations Bring in offers for workspace `ws` (AG-17.1): each
1714
+ * runtime's own (only those with listSessions), that ran in the
1715
+ * workspace's folder or inside it, newest first. Reading never starts
1716
+ * anything; a runtime's history is read only at the ends of each file.
1717
+ */
1718
+ export function listConversations(
1719
+ d: AgentsResolved,
1720
+ ws: string,
1721
+ runtime?: string,
1722
+ ): { runtimes: BringInRuntime[]; conversations: ListedConversation[] } {
1723
+ const where = "agents import";
1724
+ checkWorkspaceName(where, ws);
1725
+ if (!d.workspaceExists(ws))
1726
+ throw usage(`${where}: workspace "${ws}" does not exist`, 404);
1727
+ syncCatalog(d);
1728
+ if (runtime !== undefined) runtimeFor(runtime);
1729
+ // Its folder and every repo's; a shared root only exactly (AG-17.1).
1730
+ const scope = d.workspaceFolders(ws);
1731
+ const taken = agentByToken(d);
1732
+ const now = Date.now();
1733
+ const conversations: ListedConversation[] = [];
1734
+ const seen = new Set<string>();
1735
+ for (const id of runtimeIds()) {
1736
+ if (runtime !== undefined && id !== runtime) continue;
1737
+ const rt = getRuntime(id);
1738
+ if (!rt?.listSessions) continue;
1739
+ for (const c of rt.listSessions(scope, d.env)) {
1740
+ if (seen.has(`${id} ${c.id}`)) continue;
1741
+ seen.add(`${id} ${c.id}`);
1742
+ const agent = taken.get(`${id} ${c.id}`);
1743
+ conversations.push({
1744
+ ...c,
1745
+ runtime: id,
1746
+ open: openElsewhere(c.lastAt, now),
1747
+ ...(agent ? { agent } : {}),
1748
+ });
1749
+ }
1750
+ }
1751
+ conversations.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
1752
+ return { runtimes: bringInRuntimes(), conversations };
1753
+ }
1754
+
1755
+ export interface ImportRequest {
1756
+ workspace: string;
1757
+ /** The runtime's conversation id. */
1758
+ session: string;
1759
+ persona: string;
1760
+ runtime?: string;
1761
+ id?: string;
1762
+ }
1763
+
1764
+ /** The persona's id, else the first `<persona>-<n>` no agent has. */
1765
+ function freeId(d: AgentsResolved, persona: string): string {
1766
+ if (!readRecord(d.stateDir(), persona)) return persona;
1767
+ for (let n = 2; n < 1000; n++) {
1768
+ const id = `${persona.slice(0, 58)}-${n}`;
1769
+ if (!readRecord(d.stateDir(), id)) return id;
1770
+ }
1771
+ throw refused(`no free agent id for ${persona}: give one with --id`);
1772
+ }
1773
+
1774
+ /**
1775
+ * Bring a conversation in (AG-17.1): the runtime's resume of it, under Den
1776
+ * (its token, hooks, guards, the folder it ran in, the audit trail), as a
1777
+ * new agent with the persona given. Afterwards it is an agent like any
1778
+ * other: stop, resume, rename. Refused while the conversation is open in
1779
+ * another terminal (written in the last minute), when an agent already
1780
+ * continues it, or when it isn't one of the workspace's.
1781
+ */
1782
+ export async function importAgent(
1783
+ d: AgentsResolved,
1784
+ req: ImportRequest,
1785
+ ): Promise<Started> {
1786
+ const where = "agents import";
1787
+ const runtime = req.runtime ?? DEFAULT_RUNTIME_ID;
1788
+ const { runtimes, conversations } = listConversations(
1789
+ d,
1790
+ req.workspace,
1791
+ runtime,
1792
+ );
1793
+ const rt = runtimes.find((r) => r.id === runtime);
1794
+ if (!rt?.bringIn)
1795
+ throw refused(
1796
+ rt?.why ?? `${runtime} can't bring in a conversation`,
1797
+ [],
1798
+ 422,
1799
+ );
1800
+ const c = conversations.find((x) => x.id === req.session);
1801
+ // Another workspace's (or no repo's): say whose, never bring it in.
1802
+ const elsewhere = c
1803
+ ? null
1804
+ : (getRuntime(runtime)?.conversationCwd?.(req.session, d.env) ?? null);
1805
+ if (!c && elsewhere && !inScope(elsewhere, d.workspaceFolders(req.workspace)))
1806
+ throw refused(
1807
+ `This conversation belongs to ${elsewhere}, not to workspace ${req.workspace}`,
1808
+ [`List this workspace's: ${d.cli} agents import -w ${req.workspace}`],
1809
+ 409,
1810
+ );
1811
+ if (!c)
1812
+ throw refused(
1813
+ `${where}: no ${rt.label} conversation "${req.session}" in workspace ${req.workspace}'s folder`,
1814
+ [`List them: ${d.cli} agents import -w ${req.workspace}`],
1815
+ 404,
1816
+ );
1817
+ if (c.agent)
1818
+ throw new AgentError(
1819
+ `${where}: agent ${c.agent} already continues this conversation`,
1820
+ {
1821
+ code: "already_agent",
1822
+ hints: [
1823
+ `Resume it: ${d.cli} agents resume ${c.agent} -w ${req.workspace}`,
1824
+ ],
1825
+ },
1826
+ );
1827
+ if (c.open)
1828
+ throw new AgentError(OPEN_ELSEWHERE_MESSAGE, { code: "open_elsewhere" });
1829
+ if (!fs.statSync(c.cwd, { throwIfNoEntry: false })?.isDirectory())
1830
+ throw refused(`${where}: the folder it ran in, ${c.cwd}, is gone`);
1831
+ const id = req.id ?? freeId(d, req.persona);
1832
+ if (readRecord(d.stateDir(), id))
1833
+ throw refused(`${where}: agent ${id} exists; give this one another --id`);
1834
+ return startAgent(
1835
+ d,
1836
+ { workspace: req.workspace, persona: req.persona, runtime, id },
1837
+ { bringIn: { token: c.id, cwd: c.cwd } },
1838
+ );
1839
+ }
1840
+
1539
1841
  // ── stop, resume ────────────────────────────────────────────────────────────
1540
1842
 
1541
1843
  /** The code of a refusal naming another workspace's agent. */