@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.
- package/CHANGELOG.md +49 -0
- package/README.md +7 -7
- package/docs/distributions.md +43 -1
- package/package.json +1 -1
- package/src/shared/agents/conversations.ts +227 -0
- package/src/shared/agents/den-claude.ts +9 -0
- package/src/shared/agents/records.ts +5 -0
- package/src/shared/agents/runtime.ts +9 -0
- package/src/shared/den-config.ts +2 -0
- package/src/shared/environment-manager/service-runner.ts +31 -12
- package/src/shared/environment-manager/workspaces.ts +11 -0
- package/src/ui/cli/agent-ops.ts +266 -17
- package/src/ui/cli/agents-command.ts +85 -1
- package/src/ui/mcp/api.ts +12 -1
- package/src/ui/mcp/tools.ts +59 -0
- package/src/ui/server/agent-resume-grant.ts +35 -0
- package/src/ui/server/agent-routes.ts +152 -1
- package/src/ui/server/public/agents-page.js +201 -8
- package/src/ui/server/public/agents.html +18 -0
- package/src/ui/server/public/i18n/es.js +23 -0
- package/src/ui/server/public/i18n/ja.js +24 -0
- package/src/ui/server/public/persona-ui.css +24 -0
- package/src/ui/server/public/shell.js +23 -0
- package/src/ui/server/request-guard.ts +17 -4
- package/src/ui/server/route-classes.ts +13 -0
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -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
|
|
1016
|
-
*
|
|
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(
|
|
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
|
|
1021
|
-
|
|
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
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
:
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
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
|
-
/**
|
|
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
|
/**
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -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
|
+
}
|