@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.
- package/CHANGELOG.md +176 -0
- package/README.md +7 -7
- package/docs/distributions.md +185 -7
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +256 -0
- package/src/shared/agents/den-claude.ts +19 -0
- package/src/shared/agents/records.ts +23 -0
- package/src/shared/agents/runtime.ts +14 -0
- package/src/shared/claim-code.ts +163 -0
- package/src/shared/cli-command.ts +31 -0
- package/src/shared/den-config.ts +14 -0
- package/src/shared/environment-manager/service-runner.ts +31 -12
- package/src/shared/environment-manager/workspaces.ts +31 -0
- package/src/shared/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +320 -18
- package/src/ui/cli/agents-command.ts +89 -2
- 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 +26 -3
- package/src/ui/mcp/server.ts +12 -2
- package/src/ui/mcp/tools.ts +427 -1
- package/src/ui/server/agent-resume-grant.ts +35 -0
- package/src/ui/server/agent-routes.ts +149 -1
- package/src/ui/server/approval-routes.ts +390 -0
- package/src/ui/server/approvals.ts +416 -0
- package/src/ui/server/github-routes.ts +12 -4
- package/src/ui/server/held-actions.ts +203 -0
- package/src/ui/server/http-helpers.ts +35 -0
- package/src/ui/server/index.ts +286 -16
- 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 +288 -8
- package/src/ui/server/public/agents.html +18 -0
- package/src/ui/server/public/error-page.js +27 -3
- package/src/ui/server/public/header.js +1 -1
- package/src/ui/server/public/i18n/es.js +52 -0
- package/src/ui/server/public/i18n/ja.js +55 -0
- 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 +45 -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 +336 -0
- package/src/ui/server/request-guard.ts +19 -4
- package/src/ui/server/route-classes.ts +41 -1
- package/src/ui/server/session-routes.ts +11 -3
- package/src/ui/server/workspace-routes.ts +21 -10
- 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,
|
|
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,
|
|
@@ -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
|
|
1016
|
-
*
|
|
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
|
|
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
|
|
1021
|
-
|
|
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
|
-
|
|
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
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
:
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
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. */
|