@monster-orange/den 0.4.13 → 0.4.14

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.
@@ -129,6 +129,7 @@ import {
129
129
  syncConfiguredRuntimes,
130
130
  type AgentMcp,
131
131
  type LaunchInput,
132
+ type LaunchOutput,
132
133
  type Runtime,
133
134
  } from "../../shared/agents/runtimes.js";
134
135
  import type {
@@ -136,6 +137,11 @@ import type {
136
137
  RuntimeModel,
137
138
  RuntimePermissions,
138
139
  } from "../../shared/agents/runtime.js";
140
+ import {
141
+ OPEN_ELSEWHERE_MESSAGE,
142
+ openElsewhere,
143
+ type RuntimeConversation,
144
+ } from "../../shared/agents/conversations.js";
139
145
  import {
140
146
  archiveHandoff,
141
147
  DEFAULT_ROTATE,
@@ -185,6 +191,11 @@ export interface AgentsDeps extends SessionsDeps {
185
191
  distRoot?: () => string;
186
192
  /** A workspace's checkout directory: where its agents run. */
187
193
  workspaceDir?: (ws: string) => string;
194
+ /**
195
+ * AG-17.1: the folders a workspace's conversations may have run in: its
196
+ * own folder, and each repo's path, a repo added by path outside it too.
197
+ */
198
+ workspaceFolders?: (ws: string) => string[];
188
199
  /** The MCP server binary and its env; Den adds the scoped args. */
189
200
  mcpServer?: () => { command: string[] | string; env: Record<string, string> };
190
201
  /**
@@ -221,6 +232,7 @@ export interface AgentsResolved extends Resolved {
221
232
  agentsConfig: () => ResolvedAgentsConfig;
222
233
  distRoot: () => string;
223
234
  workspaceDir: (ws: string) => string;
235
+ workspaceFolders: (ws: string) => string[];
224
236
  mcpServer: () => { command: string[] | string; env: Record<string, string> };
225
237
  exitWatchMs: number;
226
238
  stopWaitMs: number;
@@ -250,6 +262,8 @@ export class AgentError extends Error {
250
262
  readonly usage: boolean;
251
263
  readonly code?: string;
252
264
  readonly hints: string[];
265
+ /** AG-10.10: the running agent a start collided with, so a page can open it. */
266
+ readonly running?: { id: string; sessionId: string };
253
267
  constructor(
254
268
  message: string,
255
269
  o: {
@@ -257,6 +271,7 @@ export class AgentError extends Error {
257
271
  usage?: boolean;
258
272
  code?: string;
259
273
  hints?: string[];
274
+ running?: { id: string; sessionId: string };
260
275
  } = {},
261
276
  ) {
262
277
  super(message);
@@ -265,6 +280,7 @@ export class AgentError extends Error {
265
280
  this.usage = o.usage ?? false;
266
281
  this.code = o.code;
267
282
  this.hints = o.hints ?? [];
283
+ if (o.running) this.running = o.running;
268
284
  }
269
285
  }
270
286
 
@@ -276,6 +292,7 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
276
292
  new AgentError(message, { status, hints });
277
293
 
278
294
  let workspaceDirFn: ((ws: string) => string) | null = null;
295
+ let repoPathsFn: ((ws: string) => string[]) | null = null;
279
296
  /** The workspaces config, for start and resume (loaded on first use). */
280
297
  export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
281
298
  await loadWorkspaces(d);
@@ -287,6 +304,9 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
287
304
  if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
288
305
  return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
289
306
  };
307
+ // A by-path repo's own folder (repo add <path>), wherever it is.
308
+ repoPathsFn = (name) =>
309
+ ws.repoFolders(ws.readWorkspacesFile().workspaces[name]);
290
310
  }
291
311
 
292
312
  /** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
@@ -317,6 +337,17 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
317
337
  if (!workspaceDirFn) throw new Error("workspaces not loaded");
318
338
  return workspaceDirFn(ws);
319
339
  }),
340
+ workspaceFolders:
341
+ d.workspaceFolders ??
342
+ ((ws) => {
343
+ const dir = d.workspaceDir
344
+ ? d.workspaceDir(ws)
345
+ : (() => {
346
+ if (!workspaceDirFn) throw new Error("workspaces not loaded");
347
+ return workspaceDirFn(ws);
348
+ })();
349
+ return [dir, ...(repoPathsFn?.(ws) ?? [])];
350
+ }),
320
351
  mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
321
352
  exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
322
353
  stopWaitMs: d.stopWaitMs ?? STOP_WAIT_MS,
@@ -1012,13 +1043,26 @@ function sessionOf(
1012
1043
  }
1013
1044
 
1014
1045
  /**
1015
- * The id an agent gets: --id, else the persona's id while it is free (or
1016
- * this workspace's), else `<persona>-<ws>`.
1046
+ * The id a new start gets without --id (AG-10.10): the persona's id while
1047
+ * no agent has it; `<persona>-<ws>` when another workspace's agent has it;
1048
+ * else the first of `<base>-2`, `<base>-3`… no agent has. A stopped agent's
1049
+ * id is never reused by a new start: only its Resume or Start fresh.
1017
1050
  */
1018
- function defaultId(stateDir: string, persona: string, ws: string): string {
1051
+ export function defaultId(
1052
+ stateDir: string,
1053
+ persona: string,
1054
+ ws: string,
1055
+ ): string {
1019
1056
  const rec = readRecord(stateDir, persona);
1020
- if (!rec || rec.workspace === ws) return persona;
1021
- return `${persona}-${ws}`.slice(0, 64);
1057
+ if (!rec) return persona;
1058
+ const base = rec.workspace === ws ? persona : `${persona}-${ws}`.slice(0, 64);
1059
+ if (base !== persona && !readRecord(stateDir, base)) return base;
1060
+ const stem = base.slice(0, 58);
1061
+ for (let n = 2; n < 1000; n++) {
1062
+ const id = `${stem}-${n}`;
1063
+ if (!readRecord(stateDir, id)) return id;
1064
+ }
1065
+ throw refused(`no free agent id for ${persona}: give one with --id`);
1022
1066
  }
1023
1067
 
1024
1068
  function checkId(where: string, id: string): void {
@@ -1310,6 +1354,8 @@ export interface StartRequest {
1310
1354
  permission?: string;
1311
1355
  /** Extra allowed commands for this launch, instead of the persona's. */
1312
1356
  allow?: unknown;
1357
+ /** AG-10.10: a display name for this agent (AG-4b's), to tell two apart. */
1358
+ name?: unknown;
1313
1359
  /**
1314
1360
  * The page's start (AG-10.1): a model must be one the runtime offers,
1315
1361
  * never a free string (the CLI's --model for generic stays free).
@@ -1418,6 +1464,11 @@ interface StartMore {
1418
1464
  rotations?: AgentRecord["rotations"];
1419
1465
  /** Called once the old session is known ended, before the launch. */
1420
1466
  beforeLaunch?: () => void;
1467
+ /**
1468
+ * AG-17.1, bring in a conversation: launch the runtime's resume of this
1469
+ * one (its id, in the folder it ran in) instead of a new conversation.
1470
+ */
1471
+ bringIn?: { token: string; cwd: string };
1421
1472
  }
1422
1473
 
1423
1474
  export async function startAgent(
@@ -1438,6 +1489,13 @@ export async function startAgent(
1438
1489
  );
1439
1490
  const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
1440
1491
  checkId(where, id);
1492
+ let given: string | null = null;
1493
+ if (req.name !== undefined)
1494
+ try {
1495
+ given = checkDisplayName(req.name);
1496
+ } catch (e) {
1497
+ throw usage(`${where}: ${(e as Error).message}`);
1498
+ }
1441
1499
  syncCatalog(d);
1442
1500
  const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
1443
1501
  const det = await availableOrRefuse(d, rt, ws);
@@ -1457,6 +1515,10 @@ export async function startAgent(
1457
1515
  ...(rt.permissions ? { allow } : {}),
1458
1516
  }),
1459
1517
  ...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
1518
+ // AG-17.1: the runtime's resume, in the folder the conversation ran in.
1519
+ ...(more.bringIn
1520
+ ? { worktree: more.bringIn.cwd, resumeToken: more.bringIn.token }
1521
+ : {}),
1460
1522
  };
1461
1523
  // AG-13.4: a command a runtime can't express is said, never dropped.
1462
1524
  const unexpressed =
@@ -1470,10 +1532,14 @@ export async function startAgent(
1470
1532
  const sessions = await c.list();
1471
1533
  const old = prev ? sessionOf(sessions, prev) : undefined;
1472
1534
  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));
1535
+ throw new AgentError(`agent ${id} is running (${old.name})`, {
1536
+ status: 409,
1537
+ hints: [`Attach: ${d.cli} sessions attach ${old.name}`],
1538
+ running: { id, sessionId: old.ref.id },
1539
+ });
1540
+ const out = more.bringIn
1541
+ ? { ...broughtIn(rt, input), resumeToken: more.bringIn.token }
1542
+ : launchOrThrow(() => rt.launch(input));
1477
1543
  more.beforeLaunch?.();
1478
1544
  // A new conversation: a handoff left from the last one is kept aside,
1479
1545
  // so the next rotation needs its own (den#4).
@@ -1504,18 +1570,24 @@ export async function startAgent(
1504
1570
  // AG-4b: a name given to the agent is the agent's, not its
1505
1571
  // conversation's: Start fresh and Relaunch keep it. AG-10.8:
1506
1572
  // 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
- : {}),
1573
+ // AG-10.10: a name given at start wins.
1574
+ ...(given
1575
+ ? { displayName: given, displayNameAt: new Date().toISOString() }
1576
+ : prev?.displayName
1577
+ ? {
1578
+ displayName: prev.displayName,
1579
+ ...(prev.displayNameAt
1580
+ ? { displayNameAt: prev.displayNameAt }
1581
+ : {}),
1582
+ }
1583
+ : persona.displayName
1584
+ ? { displayName: persona.displayName }
1585
+ : {}),
1515
1586
  // 0.4.8: launched on this token, so a resume continues it.
1516
1587
  ...(out.resumeToken
1517
1588
  ? { resumeToken: out.resumeToken, launched: true }
1518
1589
  : {}),
1590
+ ...(more.bringIn ? { broughtInAt: new Date().toISOString() } : {}),
1519
1591
  },
1520
1592
  argv: out.argv,
1521
1593
  env: out.env,
@@ -1536,6 +1608,183 @@ export async function startAgent(
1536
1608
  });
1537
1609
  }
1538
1610
 
1611
+ /** A bring-in's launch: the runtime's resume (AG-17.1), never a new one. */
1612
+ function broughtIn(
1613
+ rt: Runtime,
1614
+ input: LaunchInput,
1615
+ ): Omit<LaunchOutput, "resumeToken"> {
1616
+ const out = launchOrThrow(() => rt.resume?.(input) ?? null);
1617
+ if (!out) throw refused(`${rt.id} can't continue a conversation`);
1618
+ return out;
1619
+ }
1620
+
1621
+ // ── AG-17.1: bring in a conversation ────────────────────────────────────────
1622
+
1623
+ /** A runtime's conversation as Bring in lists it. */
1624
+ export interface ListedConversation extends RuntimeConversation {
1625
+ runtime: string;
1626
+ /** Written in the last minute: still open in another terminal. */
1627
+ open: boolean;
1628
+ /** The Den agent already continuing it, if one is. */
1629
+ agent?: string;
1630
+ }
1631
+
1632
+ /** Each runtime, and whether it can bring a conversation in. */
1633
+ export interface BringInRuntime {
1634
+ id: string;
1635
+ label: string;
1636
+ bringIn: boolean;
1637
+ /** Why it can't, for the page. */
1638
+ why?: string;
1639
+ }
1640
+
1641
+ function bringInRuntimes(): BringInRuntime[] {
1642
+ return runtimeIds().map((id) => {
1643
+ const rt = getRuntime(id)!;
1644
+ const label = rt.label ?? id;
1645
+ return rt.listSessions
1646
+ ? { id, label, bringIn: true }
1647
+ : {
1648
+ id,
1649
+ label,
1650
+ bringIn: false,
1651
+ why: `${label} can't bring in a conversation yet`,
1652
+ };
1653
+ });
1654
+ }
1655
+
1656
+ /** The conversation id each agent of this state dir continues. */
1657
+ function agentByToken(d: AgentsResolved): Map<string, string> {
1658
+ const m = new Map<string, string>();
1659
+ for (const r of listRecords(d.stateDir()))
1660
+ if (r.resumeToken) m.set(`${r.runtime} ${r.resumeToken}`, r.id);
1661
+ return m;
1662
+ }
1663
+
1664
+ /**
1665
+ * The conversations Bring in offers for workspace `ws` (AG-17.1): each
1666
+ * runtime's own (only those with listSessions), that ran in the
1667
+ * workspace's folder or inside it, newest first. Reading never starts
1668
+ * anything; a runtime's history is read only at the ends of each file.
1669
+ */
1670
+ export function listConversations(
1671
+ d: AgentsResolved,
1672
+ ws: string,
1673
+ runtime?: string,
1674
+ ): { runtimes: BringInRuntime[]; conversations: ListedConversation[] } {
1675
+ const where = "agents import";
1676
+ checkWorkspaceName(where, ws);
1677
+ if (!d.workspaceExists(ws))
1678
+ throw usage(`${where}: workspace "${ws}" does not exist`, 404);
1679
+ syncCatalog(d);
1680
+ if (runtime !== undefined) runtimeFor(runtime);
1681
+ // Its folder and every repo's (one under another is scanned once).
1682
+ const folders = [
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
+ );
1688
+ const taken = agentByToken(d);
1689
+ const now = Date.now();
1690
+ const conversations: ListedConversation[] = [];
1691
+ const seen = new Set<string>();
1692
+ for (const id of runtimeIds()) {
1693
+ if (runtime !== undefined && id !== runtime) continue;
1694
+ const rt = getRuntime(id);
1695
+ if (!rt?.listSessions) continue;
1696
+ for (const c of scan.flatMap((f) => rt.listSessions!(f, d.env))) {
1697
+ if (seen.has(`${id} ${c.id}`)) continue;
1698
+ seen.add(`${id} ${c.id}`);
1699
+ const agent = taken.get(`${id} ${c.id}`);
1700
+ conversations.push({
1701
+ ...c,
1702
+ runtime: id,
1703
+ open: openElsewhere(c.lastAt, now),
1704
+ ...(agent ? { agent } : {}),
1705
+ });
1706
+ }
1707
+ }
1708
+ conversations.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
1709
+ return { runtimes: bringInRuntimes(), conversations };
1710
+ }
1711
+
1712
+ export interface ImportRequest {
1713
+ workspace: string;
1714
+ /** The runtime's conversation id. */
1715
+ session: string;
1716
+ persona: string;
1717
+ runtime?: string;
1718
+ id?: string;
1719
+ }
1720
+
1721
+ /** The persona's id, else the first `<persona>-<n>` no agent has. */
1722
+ function freeId(d: AgentsResolved, persona: string): string {
1723
+ if (!readRecord(d.stateDir(), persona)) return persona;
1724
+ for (let n = 2; n < 1000; n++) {
1725
+ const id = `${persona.slice(0, 58)}-${n}`;
1726
+ if (!readRecord(d.stateDir(), id)) return id;
1727
+ }
1728
+ throw refused(`no free agent id for ${persona}: give one with --id`);
1729
+ }
1730
+
1731
+ /**
1732
+ * Bring a conversation in (AG-17.1): the runtime's resume of it, under Den
1733
+ * (its token, hooks, guards, the folder it ran in, the audit trail), as a
1734
+ * new agent with the persona given. Afterwards it is an agent like any
1735
+ * other: stop, resume, rename. Refused while the conversation is open in
1736
+ * another terminal (written in the last minute), when an agent already
1737
+ * continues it, or when it isn't one of the workspace's.
1738
+ */
1739
+ export async function importAgent(
1740
+ d: AgentsResolved,
1741
+ req: ImportRequest,
1742
+ ): Promise<Started> {
1743
+ const where = "agents import";
1744
+ const runtime = req.runtime ?? DEFAULT_RUNTIME_ID;
1745
+ const { runtimes, conversations } = listConversations(
1746
+ d,
1747
+ req.workspace,
1748
+ runtime,
1749
+ );
1750
+ const rt = runtimes.find((r) => r.id === runtime);
1751
+ if (!rt?.bringIn)
1752
+ throw refused(
1753
+ rt?.why ?? `${runtime} can't bring in a conversation`,
1754
+ [],
1755
+ 422,
1756
+ );
1757
+ const c = conversations.find((x) => x.id === req.session);
1758
+ if (!c)
1759
+ throw refused(
1760
+ `${where}: no ${rt.label} conversation "${req.session}" in workspace ${req.workspace}'s folder`,
1761
+ [`List them: ${d.cli} agents import -w ${req.workspace}`],
1762
+ 404,
1763
+ );
1764
+ if (c.agent)
1765
+ throw new AgentError(
1766
+ `${where}: agent ${c.agent} already continues this conversation`,
1767
+ {
1768
+ code: "already_agent",
1769
+ hints: [
1770
+ `Resume it: ${d.cli} agents resume ${c.agent} -w ${req.workspace}`,
1771
+ ],
1772
+ },
1773
+ );
1774
+ if (c.open)
1775
+ throw new AgentError(OPEN_ELSEWHERE_MESSAGE, { code: "open_elsewhere" });
1776
+ if (!fs.statSync(c.cwd, { throwIfNoEntry: false })?.isDirectory())
1777
+ throw refused(`${where}: the folder it ran in, ${c.cwd}, is gone`);
1778
+ const id = req.id ?? freeId(d, req.persona);
1779
+ if (readRecord(d.stateDir(), id))
1780
+ throw refused(`${where}: agent ${id} exists; give this one another --id`);
1781
+ return startAgent(
1782
+ d,
1783
+ { workspace: req.workspace, persona: req.persona, runtime, id },
1784
+ { bringIn: { token: c.id, cwd: c.cwd } },
1785
+ );
1786
+ }
1787
+
1539
1788
  // ── stop, resume ────────────────────────────────────────────────────────────
1540
1789
 
1541
1790
  /** The code of a refusal naming another workspace's agent. */
@@ -28,6 +28,13 @@
28
28
  * same conversation id (there is nothing to continue).
29
29
  * --all-interrupted (AG-14.3): every agent interrupted when Den or
30
30
  * the machine restarted that can resume.
31
+ * import (AG-17.1) bring in a conversation started outside Den: with
32
+ * no --session, the runtime's own conversations in the
33
+ * workspace's folder (newest first); with --session <id> and
34
+ * --persona <p>, the runtime's resume of it (den-claude:
35
+ * --resume <id>) in the folder it ran in, as a new agent under
36
+ * Den's token, hooks and guards. Refused while it is still open
37
+ * in another terminal (written in the last minute).
31
38
  * rm (AG-14.1b) forgets a stopped, interrupted or exited agent (never
32
39
  * a running one); --stopped forgets every one of -w <ws> (or of
33
40
  * every workspace). The runtime's own history stays.
@@ -69,7 +76,9 @@ import {
69
76
  } from "../../shared/agents/records.js";
70
77
  import {
71
78
  AgentError,
79
+ importAgent,
72
80
  listAgents,
81
+ listConversations,
73
82
  listPersonas as personaList,
74
83
  listRuntimes,
75
84
  loadAgentWorkspaces,
@@ -107,7 +116,7 @@ export {
107
116
 
108
117
  export const AGENTS_HELP = {
109
118
  usage:
110
- "agents <list|start|stop|resume|relaunch|rm|personas|doctor|handback-check|handoff-check> ...",
119
+ "agents <list|start|stop|resume|relaunch|import|rm|personas|doctor|handback-check|handoff-check> ...",
111
120
  lines: [
112
121
  "Agents: a persona and a workspace, run by a runtime in a session that",
113
122
  "outlives the web server (see `{cli} sessions`).",
@@ -132,6 +141,12 @@ export const AGENTS_HELP = {
132
141
  " compactions (default 3) or its pull request merged, and its handoff.md",
133
142
  " passes the check, a fresh conversation (same persona, workspace, folder,",
134
143
  " grants) whose first prompt is the handoff",
144
+ "{cli} agents import -w <ws> [--runtime <r>] [--json]",
145
+ "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>]",
146
+ " bring in a conversation started outside Den: the first lists the runtime's",
147
+ " own (default den-claude) in the workspace's folder; the second continues",
148
+ " one as a Den agent (its resume, in the folder it ran in). Refused while it",
149
+ " is still open in another terminal: close it there first",
135
150
  "{cli} agents rm <id> [-w <ws>]",
136
151
  "{cli} agents rm --stopped [-w <ws>]",
137
152
  " forget a stopped, interrupted or exited agent (never a running one);",
@@ -279,6 +294,72 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
279
294
  );
280
295
  }
281
296
 
297
+ /** `agents import` (AG-17.1): list, or bring one in. */
298
+ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
299
+ const where = "agents import";
300
+ const p = parseArgs(where, args, {
301
+ "--workspace": true,
302
+ "--runtime": true,
303
+ "--session": true,
304
+ "--persona": true,
305
+ "--id": true,
306
+ "--json": false,
307
+ });
308
+ noExtra(where, p);
309
+ const ws = workspaceFlag(where, p);
310
+ if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
311
+ const runtime = str(p, "--runtime");
312
+ const session = str(p, "--session");
313
+ if (!session) {
314
+ if (str(p, "--persona") || str(p, "--id"))
315
+ throw new UsageError(`${where}: --persona and --id need --session <id>`);
316
+ const { runtimes, conversations } = listConversations(d, ws, runtime);
317
+ if (p.flags["--json"]) {
318
+ d.out(
319
+ JSON.stringify({ workspace: ws, runtimes, conversations }, null, 2),
320
+ );
321
+ return 0;
322
+ }
323
+ for (const r of runtimes)
324
+ if (!r.bringIn && (!runtime || runtime === r.id)) d.err(`${r.why}`);
325
+ if (!conversations.length) {
326
+ d.out(`no conversations to bring in for workspace ${ws}`);
327
+ return 0;
328
+ }
329
+ d.out(
330
+ columns([
331
+ ["ID", "RUNTIME", "LAST", "TITLE", "FOLDER", "NOTE"],
332
+ ...conversations.map((c) => [
333
+ c.id,
334
+ c.runtime,
335
+ c.lastAt.slice(0, 16).replace("T", " "),
336
+ c.title ?? "(untitled)",
337
+ c.cwd,
338
+ c.agent ? `agent ${c.agent}` : c.open ? "open elsewhere" : "",
339
+ ]),
340
+ ]).join("\n"),
341
+ );
342
+ d.out(
343
+ `bring one in: ${d.cli} agents import -w ${ws} --session <id> --persona <p>`,
344
+ );
345
+ return 0;
346
+ }
347
+ const persona = str(p, "--persona");
348
+ if (!persona) throw new UsageError(`${where} needs --persona <p>`);
349
+ const s = await importAgent(d, {
350
+ workspace: ws,
351
+ session,
352
+ persona,
353
+ runtime,
354
+ id: str(p, "--id"),
355
+ });
356
+ return report(
357
+ d,
358
+ s,
359
+ `brought in ${session} as agent ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) as ${s.rec.sessionName} in ${s.rec.cwd}`,
360
+ );
361
+ }
362
+
282
363
  /** `<id> [-w <ws>]`. */
283
364
  function idAndWorkspace(
284
365
  where: string,
@@ -616,6 +697,9 @@ export async function cmdAgents(
616
697
  case "relaunch":
617
698
  await loadAgentWorkspaces(deps);
618
699
  return await relaunch(rest, d);
700
+ case "import":
701
+ await loadAgentWorkspaces(deps);
702
+ return await bringIn(rest, d);
619
703
  case "rm":
620
704
  await loadAgentWorkspaces(deps);
621
705
  return await rm(rest, d);
package/src/ui/mcp/api.ts CHANGED
@@ -140,12 +140,23 @@ const ACTION_ROUTES: Record<string, string> = {
140
140
  "/api/workspace/sync": "POST",
141
141
  };
142
142
 
143
- /** Action routes with a parameter: an agent's resume (MCP agent_resume). */
143
+ /**
144
+ * Action routes with a parameter: an agent's resume (MCP agent_resume),
145
+ * and Bring in's list and import (MCP agent_import, AG-17.1).
146
+ */
144
147
  const ACTION_ROUTE_PATTERNS: { re: RegExp; method: string }[] = [
145
148
  {
146
149
  re: /^\/api\/workspaces\/[A-Za-z0-9._-]{1,64}\/agents\/[A-Za-z0-9._-]{1,64}\/resume$/,
147
150
  method: "POST",
148
151
  },
152
+ {
153
+ re: /^\/api\/workspaces\/[A-Za-z0-9._-]{1,64}\/conversations$/,
154
+ method: "GET",
155
+ },
156
+ {
157
+ re: /^\/api\/workspaces\/[A-Za-z0-9._-]{1,64}\/conversations\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\/import$/,
158
+ method: "POST",
159
+ },
149
160
  ];
150
161
 
151
162
  /**
@@ -776,6 +776,63 @@ const agentResumeTool: DenTool = {
776
776
  },
777
777
  };
778
778
 
779
+ /** A runtime's conversation id (Claude Code: a uuid). */
780
+ const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
781
+
782
+ /**
783
+ * MCP agent_import (AG-17.1): bring in a conversation started outside Den
784
+ * as an agent of this workspace (or a granted one). No session: the list.
785
+ * Opt-in through mcp.actions; the web server checks the scope again.
786
+ */
787
+ const agentImportTool: DenTool = {
788
+ name: "agent_import",
789
+ title: "Bring in a conversation",
790
+ description:
791
+ "Bring in a conversation started outside Den (in the workspace's folder) as a Den agent: its runtime's own resume, in the folder it ran in, under Den's token, hooks and guards. Without session: lists the conversations there are (id, title, lastAt, cwd, runtime, open, agent). With session and persona: brings that one in and answers with the agent. Refused while it is still open in another terminal (written in the last minute), or when an agent already continues it.",
792
+ action: true,
793
+ shape: {
794
+ session: z
795
+ .string()
796
+ .optional()
797
+ .describe("The runtime's conversation id (omit to list them)"),
798
+ persona: z.string().optional().describe("The persona to run it as"),
799
+ runtime: z.string().optional().describe("The runtime (default den-claude)"),
800
+ workspace: workspaceArg,
801
+ },
802
+ async run(a, { api, scope }) {
803
+ let ws = str(a.workspace) ?? scope?.workspace;
804
+ if (!ws) {
805
+ const focused = (await api.get("/api/workspaces")) as {
806
+ focus?: unknown;
807
+ };
808
+ ws = typeof focused?.focus === "string" ? focused.focus : undefined;
809
+ }
810
+ if (!ws || !NAME_RE.test(ws))
811
+ throw new Refusal("agent_import: name the workspace");
812
+ if (scope && ws !== scope.workspace && !(scope.grants ?? []).includes(ws))
813
+ throw new ScopeRefusal(
814
+ `agent_import: workspace "${ws}" is outside this agent's scope ("${scope.workspace}")`,
815
+ );
816
+ const runtime = str(a.runtime);
817
+ if (runtime && !NAME_RE.test(runtime))
818
+ throw new Refusal("agent_import: runtime must be a runtime id");
819
+ const session = str(a.session);
820
+ const base = `/api/workspaces/${encodeURIComponent(ws)}/conversations`;
821
+ if (!session) return api.get(base, runtime ? { runtime } : {}, "GET");
822
+ if (!SESSION_RE.test(session))
823
+ throw new Refusal("agent_import: session must be a conversation id");
824
+ const persona = str(a.persona);
825
+ if (!persona || !NAME_RE.test(persona))
826
+ throw new Refusal("agent_import: name the persona to run it as");
827
+ return api.get(
828
+ `${base}/${encodeURIComponent(session)}/import`,
829
+ {},
830
+ "POST",
831
+ { persona, ...(runtime ? { runtime } : {}) },
832
+ );
833
+ },
834
+ };
835
+
779
836
  const ACTION_TOOLS: Record<McpAction, DenTool> = {
780
837
  service_up: lifecycleTool("up"),
781
838
  service_down: lifecycleTool("down"),
@@ -865,6 +922,7 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
865
922
  },
866
923
  },
867
924
  agent_resume: agentResumeTool,
925
+ agent_import: agentImportTool,
868
926
  };
869
927
 
870
928
  export const READ_TOOL_NAMES: readonly string[] = READ_TOOLS.map((t) => t.name);
@@ -903,6 +961,7 @@ const WORKSPACE_TOOLS = new Set([
903
961
  "workspace_sync",
904
962
  "run_tests",
905
963
  "agent_resume",
964
+ "agent_import",
906
965
  ]);
907
966
 
908
967
  /**
@@ -51,3 +51,38 @@ export function agentResumeTarget(
51
51
  return null;
52
52
  }
53
53
  }
54
+
55
+ /**
56
+ * MCP `agent_import` (AG-17.1): bring a conversation in as an agent, and
57
+ * list the ones there are. Off unless `mcp.actions` lists it; only in the
58
+ * caller's workspace or one its grants give it. A person's otherwise.
59
+ */
60
+ export const AGENT_IMPORT_ACTION = "agent_import";
61
+
62
+ /** GET /api/workspaces/<ws>/conversations, POST …/<id>/import. */
63
+ export const AGENT_IMPORT_ROUTE =
64
+ /^\/api\/workspaces\/([^/]+)\/conversations(?:\/([^/]+)\/import)?$/;
65
+
66
+ /** Why this agent may not bring in a conversation of `ws`, or null. */
67
+ export function agentImportRefusal(
68
+ p: ResumingAgent,
69
+ ws: string,
70
+ actions: readonly string[],
71
+ ): string | null {
72
+ if (!actions.includes(AGENT_IMPORT_ACTION))
73
+ return `${p.actor} can't bring in conversations: the org config's mcp.actions doesn't list ${AGENT_IMPORT_ACTION}.`;
74
+ if (ws !== p.workspace && !p.grants.includes(ws))
75
+ return `${p.actor} can bring in conversations of workspace ${p.workspace} only${p.grants.length ? ` (or ${p.grants.join(", ")}, its grants)` : ""}, not ${ws}.`;
76
+ return null;
77
+ }
78
+
79
+ /** The import route's workspace, decoded; null when it isn't it. */
80
+ export function agentImportTarget(pathname: string): { ws: string } | null {
81
+ const m = AGENT_IMPORT_ROUTE.exec(pathname);
82
+ if (!m) return null;
83
+ try {
84
+ return { ws: decodeURIComponent(m[1]) };
85
+ } catch {
86
+ return null;
87
+ }
88
+ }