@monster-orange/den 0.4.14 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +144 -8
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +33 -4
  7. package/src/shared/agents/den-claude.ts +13 -3
  8. package/src/shared/agents/records.ts +18 -0
  9. package/src/shared/agents/runtime.ts +6 -1
  10. package/src/shared/claim-code.ts +163 -0
  11. package/src/shared/cli-command.ts +31 -0
  12. package/src/shared/den-config.ts +12 -0
  13. package/src/shared/environment-manager/workspaces.ts +20 -0
  14. package/src/shared/web-session.ts +64 -5
  15. package/src/ui/cli/agent-ops.ts +75 -22
  16. package/src/ui/cli/agents-command.ts +4 -1
  17. package/src/ui/cli/doctor-command.ts +29 -3
  18. package/src/ui/cli/doctor-onboard.ts +26 -0
  19. package/src/ui/cli/flag-parser.ts +9 -1
  20. package/src/ui/cli/index.ts +29 -3
  21. package/src/ui/cli/onboard-command.ts +180 -0
  22. package/src/ui/cli/onboard-reads.ts +613 -0
  23. package/src/ui/cli/repo-command.ts +156 -10
  24. package/src/ui/cli/web-command.ts +92 -1
  25. package/src/ui/mcp/api.ts +14 -2
  26. package/src/ui/mcp/server.ts +12 -2
  27. package/src/ui/mcp/tools.ts +368 -1
  28. package/src/ui/server/agent-routes.ts +2 -5
  29. package/src/ui/server/approval-routes.ts +390 -0
  30. package/src/ui/server/approvals.ts +416 -0
  31. package/src/ui/server/github-routes.ts +12 -4
  32. package/src/ui/server/held-actions.ts +203 -0
  33. package/src/ui/server/http-helpers.ts +35 -0
  34. package/src/ui/server/index.ts +286 -16
  35. package/src/ui/server/lifecycle-exec.ts +70 -0
  36. package/src/ui/server/onboard-routes.ts +132 -0
  37. package/src/ui/server/pair-routes.ts +7 -4
  38. package/src/ui/server/public/agents-page.js +91 -4
  39. package/src/ui/server/public/error-page.js +27 -3
  40. package/src/ui/server/public/header.js +1 -1
  41. package/src/ui/server/public/i18n/es.js +29 -0
  42. package/src/ui/server/public/i18n/ja.js +31 -0
  43. package/src/ui/server/public/index.html +1 -1
  44. package/src/ui/server/public/local-dev.html +5 -4
  45. package/src/ui/server/public/persona-ui.css +21 -0
  46. package/src/ui/server/public/release.html +0 -1
  47. package/src/ui/server/public/settings-registry.js +3 -1
  48. package/src/ui/server/public/settings.html +9 -1
  49. package/src/ui/server/public/shell.js +313 -0
  50. package/src/ui/server/request-guard.ts +2 -0
  51. package/src/ui/server/route-classes.ts +28 -1
  52. package/src/ui/server/session-routes.ts +11 -3
  53. package/src/ui/server/workspace-routes.ts +21 -10
  54. package/themes/den/den-theme.json +6 -6
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The claim code (0.4.15, Ward 2026-11-07): how the browser the person
3
+ * opens at the end of the install signs in without a sign-in step, so that
4
+ * approving an agent's changes (which needs a signed-in browser session,
5
+ * even on localhost) works from the first page.
6
+ *
7
+ * - `c1.<nonce>.<expiry>.<mac>`, the mac HMAC-SHA256(web.secret, context +
8
+ * the rest), its context its own: a login code (web-session.ts) is never
9
+ * a claim code, and the other way round.
10
+ * - Valid for 30 minutes: an install hands it over, and the person may take
11
+ * a while to open it. An expiry further out than that is refused.
12
+ * - Single use. Used nonces are kept in `<state dir>/claim-nonces.json`
13
+ * (0600) until they expire, so a server restart can't revive one. The
14
+ * file holds nonces only, never a code. A file that won't parse refuses
15
+ * every claim (fail closed); the sign-in flows still work.
16
+ * - Minted by the CLI (`<cli> web claim-link`), which reads web.secret as
17
+ * the same OS user. The CLI refuses inside an agent's session
18
+ * (claimRefusalForAgent): an agent must never hold one.
19
+ *
20
+ * Limit: an agent running as the same OS user could, in principle, read
21
+ * web.secret or the browser's cookie store from disk. That is the strength
22
+ * of Den's other person-only routes too; only an OS boundary is stronger.
23
+ */
24
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
25
+ import * as fs from "fs";
26
+ import * as path from "path";
27
+ import { ACTOR_TOKEN_ENV } from "./actor-token.js";
28
+ import { AGENT_ACTOR_ENV } from "./sessions/env.js";
29
+
30
+ export const CLAIM_CODE_TTL_MS = 30 * 60_000;
31
+ const SKEW_MS = 5_000;
32
+ const CONTEXT = "den-claim-v1:";
33
+ export const CLAIM_NONCES_FILE = "claim-nonces.json";
34
+
35
+ function mac(secret: Buffer, body: string): Buffer {
36
+ return createHmac("sha256", secret)
37
+ .update(CONTEXT + body)
38
+ .digest();
39
+ }
40
+
41
+ /** A fresh claim code, valid for 30 minutes from `now`. */
42
+ export function mintClaimCode(
43
+ secret: Buffer,
44
+ now: number = Date.now(),
45
+ ): string {
46
+ const nonce = randomBytes(16).toString("base64url");
47
+ const body = `c1.${nonce}.${(now + CLAIM_CODE_TTL_MS).toString(36)}`;
48
+ return `${body}.${mac(secret, body).toString("base64url")}`;
49
+ }
50
+
51
+ export type ClaimCodeCheck =
52
+ | { ok: true; nonce: string; expiresAt: number }
53
+ | { ok: false; reason: "malformed" | "bad_mac" | "expired" };
54
+
55
+ /**
56
+ * Whether `code` is a claim code this secret minted, still in its 30
57
+ * minutes. Constant-time mac. Single use is ClaimNonces'.
58
+ */
59
+ export function checkClaimCode(
60
+ code: string,
61
+ secret: Buffer,
62
+ now: number = Date.now(),
63
+ ): ClaimCodeCheck {
64
+ const m =
65
+ /^(c1\.([A-Za-z0-9_-]{22})\.([0-9a-z]{1,11}))\.([A-Za-z0-9_-]{43})$/.exec(
66
+ code,
67
+ );
68
+ if (!m) return { ok: false, reason: "malformed" };
69
+ const given = Buffer.from(m[4], "base64url");
70
+ const want = mac(secret, m[1]);
71
+ if (given.length !== want.length || !timingSafeEqual(given, want))
72
+ return { ok: false, reason: "bad_mac" };
73
+ const expiresAt = parseInt(m[3], 36);
74
+ if (
75
+ !Number.isFinite(expiresAt) ||
76
+ now >= expiresAt ||
77
+ expiresAt - now > CLAIM_CODE_TTL_MS + SKEW_MS
78
+ )
79
+ return { ok: false, reason: "expired" };
80
+ return { ok: true, nonce: m[2], expiresAt };
81
+ }
82
+
83
+ /**
84
+ * `<origin>/auth?claim=<code>`: the sign-in route, so it is classed as one
85
+ * (a mutating GET: another site can't open it; an agent's token can't).
86
+ */
87
+ export function claimLink(origin: string, code: string): string {
88
+ return `${origin.replace(/\/+$/, "")}/auth?claim=${encodeURIComponent(code)}`;
89
+ }
90
+
91
+ /**
92
+ * Why the CLI won't let a browser in from here (a claim link, a sign-in
93
+ * link, pairing a device, the GitHub owner), or null when it may: inside
94
+ * an agent's session (DEN_AGENT or DEN_ACTOR_TOKEN set) it never does.
95
+ */
96
+ export function personGrantRefusal(env: NodeJS.ProcessEnv): string | null {
97
+ if (env[AGENT_ACTOR_ENV] !== undefined || env[ACTOR_TOKEN_ENV] !== undefined)
98
+ return "An agent can't do this: it lets a browser in as you. Run it in your own terminal.";
99
+ return null;
100
+ }
101
+
102
+ /** Why the CLI won't mint a claim code here, or null when it may. */
103
+ export function claimRefusalForAgent(env: NodeJS.ProcessEnv): string | null {
104
+ return personGrantRefusal(env)
105
+ ? "An agent can't make a claim link: it signs a browser in as the person. Run it in your own terminal."
106
+ : null;
107
+ }
108
+
109
+ /** The used claim nonces, kept until they expire (a file, 0600). */
110
+ export class ClaimNonces {
111
+ constructor(private readonly stateDir: string) {}
112
+
113
+ private file(): string {
114
+ return path.join(this.stateDir, CLAIM_NONCES_FILE);
115
+ }
116
+
117
+ /** The used nonces; null when the file is there but won't parse (fail closed). */
118
+ private read(): Record<string, number> | null {
119
+ let raw: string;
120
+ try {
121
+ raw = fs.readFileSync(this.file(), "utf-8");
122
+ } catch (e) {
123
+ return (e as NodeJS.ErrnoException).code === "ENOENT" ? {} : null;
124
+ }
125
+ let o: unknown;
126
+ try {
127
+ o = JSON.parse(raw);
128
+ } catch {
129
+ return null;
130
+ }
131
+ if (!o || typeof o !== "object" || Array.isArray(o)) return null;
132
+ const out: Record<string, number> = {};
133
+ for (const [k, v] of Object.entries(o as Record<string, unknown>))
134
+ if (/^[A-Za-z0-9_-]{22}$/.test(k) && typeof v === "number") out[k] = v;
135
+ return out;
136
+ }
137
+
138
+ private write(used: Record<string, number>): void {
139
+ fs.mkdirSync(this.stateDir, { recursive: true });
140
+ const tmp = `${this.file()}.${process.pid}.${randomBytes(4).toString("hex")}`;
141
+ fs.writeFileSync(tmp, JSON.stringify(used) + "\n", { mode: 0o600 });
142
+ fs.chmodSync(tmp, 0o600);
143
+ fs.renameSync(tmp, this.file());
144
+ }
145
+
146
+ /**
147
+ * Use `nonce` (its code expires at `expiresAt`): true the first time,
148
+ * false once used. Expired entries are pruned on the way.
149
+ */
150
+ consume(nonce: string, expiresAt: number, now: number = Date.now()): boolean {
151
+ const used = this.read();
152
+ // Unreadable: refuse every claim rather than forget which were used.
153
+ if (!used) return false;
154
+ for (const [k, exp] of Object.entries(used)) if (exp <= now) delete used[k];
155
+ if (nonce in used) {
156
+ this.write(used);
157
+ return false;
158
+ }
159
+ used[nonce] = expiresAt;
160
+ this.write(used);
161
+ return true;
162
+ }
163
+ }
@@ -143,6 +143,37 @@ export function sshTarget(
143
143
  return /^[a-z0-9._:-]+$/.test(h) && !h.startsWith("-") ? h : fallback;
144
144
  }
145
145
 
146
+ /** An account name ssh can take before `@`: letters, digits, `.`, `_`, `-`. */
147
+ const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
148
+
149
+ /**
150
+ * `web.ssh_user` as stored (SEC-2a-UX): the account the signed-out page's
151
+ * ssh link and command log in as. undefined when unset (the account Den
152
+ * runs as); "" leaves the account off. Throws naming the key otherwise.
153
+ */
154
+ export function parseSshUser(v: unknown): string | undefined {
155
+ if (v === undefined || v === null) return undefined;
156
+ if (typeof v !== "string" || (v.trim() !== "" && !SSH_USER_RE.test(v.trim())))
157
+ throw new Error(
158
+ `web.ssh_user must be an account name (letters, digits, ".", "_", "-"), or "" to leave it off; got ${JSON.stringify(v)}`,
159
+ );
160
+ return v.trim();
161
+ }
162
+
163
+ /**
164
+ * The account for the ssh link: `setting` when set ("" for none), else
165
+ * `osUser` when ssh can take it, else none.
166
+ */
167
+ export function sshUser(setting: string | undefined, osUser: string): string {
168
+ if (setting !== undefined) return setting;
169
+ return SSH_USER_RE.test(osUser) ? osUser : "";
170
+ }
171
+
172
+ /** `user@target`, or the bare target when there's no user. */
173
+ export function sshLogin(target: string, user: string): string {
174
+ return user ? `${user}@${target}` : target;
175
+ }
176
+
146
177
  /** `Mac-Studio.localdomain` → `Mac-Studio`; an IP literal stays whole. */
147
178
  export function shortHostname(hostname: string): string {
148
179
  const h = hostname.trim();
@@ -831,6 +831,11 @@ export interface McpConfig {
831
831
  run_tests?: { envs?: string[] };
832
832
  /** `remote: true` lets the log tools read non-local sources (e.g. CloudWatch). */
833
833
  logs?: { remote?: boolean };
834
+ /**
835
+ * The held writes (workspace_create, repo_add, service_try): on by
836
+ * default, each run only after the person's Allow. false hides them.
837
+ */
838
+ approvals?: boolean;
834
839
  }
835
840
 
836
841
  /** `mcp:` with its defaults applied. */
@@ -838,6 +843,8 @@ export interface ResolvedMcpConfig {
838
843
  actions: McpAction[];
839
844
  runTestsEnvs: Environment[];
840
845
  logsRemote: boolean;
846
+ /** Whether the held writes are registered (default true). */
847
+ approvals: boolean;
841
848
  }
842
849
 
843
850
  /**
@@ -865,10 +872,15 @@ export function readMcpConfig(
865
872
  return e as Environment;
866
873
  },
867
874
  );
875
+ if (raw.approvals !== undefined && typeof raw.approvals !== "boolean")
876
+ throw new Error(
877
+ `mcp.approvals: true or false, not ${JSON.stringify(raw.approvals)}`,
878
+ );
868
879
  return {
869
880
  actions: [...new Set(actions)],
870
881
  runTestsEnvs: [...new Set(runTestsEnvs)],
871
882
  logsRemote: raw.logs?.remote === true,
883
+ approvals: raw.approvals !== false,
872
884
  };
873
885
  }
874
886
 
@@ -355,6 +355,26 @@ export function repoFolders(cfg: WorkspaceConfig | undefined): string[] {
355
355
  .filter((p): p is string => typeof p === "string" && path.isAbsolute(p));
356
356
  }
357
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
+
358
378
  export function getWorkspaceByName(name: string): {
359
379
  name: string;
360
380
  config: WorkspaceConfig;
@@ -21,7 +21,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
21
21
  import * as fs from "fs";
22
22
  import * as path from "path";
23
23
  import * as yaml from "yaml";
24
- import { parseMachineName } from "./cli-command.js";
24
+ import { parseMachineName, parseSshUser } from "./cli-command.js";
25
25
  import { getStateDir } from "./paths.js";
26
26
 
27
27
  export const LOGIN_CODE_TTL_MS = 60_000;
@@ -195,6 +195,12 @@ export interface WebAuthConfig {
195
195
  * page gives this machine ("runs on <name>"). Unset: the short hostname.
196
196
  */
197
197
  machineName?: string;
198
+ /**
199
+ * `web.ssh_user` (SEC-2a-UX; user.yml, else the distribution's): the
200
+ * account the signed-out page's ssh link logs in as. Unset: the account
201
+ * Den runs as. "": no account on the page.
202
+ */
203
+ sshUser?: string;
198
204
  }
199
205
 
200
206
  /** `web.edge_unattested`'s values. */
@@ -290,8 +296,13 @@ export function readWebAuthConfig(
290
296
  const clientId =
291
297
  clientIdOf(user, "config/local/user.yml") ?? clientIdOf(team, configFile);
292
298
  const machineName = parseMachineName(user.machine_name);
299
+ const sshUser =
300
+ user.ssh_user !== undefined
301
+ ? parseSshUser(user.ssh_user)
302
+ : parseSshUser(team.ssh_user);
293
303
  return {
294
304
  ...(machineName ? { machineName } : {}),
305
+ ...(sshUser !== undefined ? { sshUser } : {}),
295
306
  allowPlainHttpRemote: plain === true,
296
307
  ignored,
297
308
  edgeUnattested: unattested ?? "signin",
@@ -319,6 +330,43 @@ function clientIdOf(
319
330
  return v.trim();
320
331
  }
321
332
 
333
+ // ── The approval key (0.4.15) ─────────────────────────────────────────────
334
+
335
+ /**
336
+ * Why a cookie alone can't approve an agent's change: browsers send a
337
+ * localhost cookie to every localhost port (cookies ignore ports), so a
338
+ * dev server an agent runs on another port receives Den's session cookie,
339
+ * and GET /api/session gives any cookie holder the session's CSRF token.
340
+ * The approval key is a second secret only the signed-in page holds: it
341
+ * comes in the sign-in's own answer (a 303's URL fragment, which never
342
+ * reaches a server, or the sign-in poll's JSON), the page keeps it in its
343
+ * localStorage (per origin, port included), and sends it as
344
+ * X-Den-Approve-Key.
345
+ */
346
+ export const APPROVE_KEY_HEADER = "x-den-approve-key";
347
+ /** The fragment parameter a sign-in's 303 carries it in. */
348
+ export const APPROVE_KEY_FRAGMENT = "ak";
349
+
350
+ export function approveKeyHash(key: string): string {
351
+ return createHash("sha256").update(`den-approve-v1:${key}`).digest("hex");
352
+ }
353
+
354
+ /** Whether `given` is the key whose hash the session keeps (constant time). */
355
+ export function approveKeyMatches(
356
+ storedHash: string | undefined,
357
+ given: string | undefined | null,
358
+ ): boolean {
359
+ if (!storedHash || !given || !/^[A-Za-z0-9_-]{43}$/.test(given)) return false;
360
+ const a = Buffer.from(approveKeyHash(given), "hex");
361
+ const b = Buffer.from(storedHash, "hex");
362
+ return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
363
+ }
364
+
365
+ /** `next` with the approval key in its fragment (a sign-in's 303). */
366
+ export function withApproveKey(next: string, key: string): string {
367
+ return `${next.split("#")[0]}#${APPROVE_KEY_FRAGMENT}=${key}`;
368
+ }
369
+
322
370
  // ── The session store ──────────────────────────────────────────────────────
323
371
 
324
372
  export interface SessionRecord {
@@ -329,14 +377,21 @@ export interface SessionRecord {
329
377
  csrf: string;
330
378
  /** The Host it was issued on. */
331
379
  host: string;
332
- /** SEC-2a: how it signed in: a GitHub sign-in, or a one-time link (pairing). Unset: a link (before 0.4.5). */
333
- via?: "github" | "link" | "code";
380
+ /** SEC-2a: how it signed in: a GitHub sign-in, a one-time link, a code (pairing), or the install's setup link (claim, 0.4.15). Unset: a link (before 0.4.5). */
381
+ via?: "github" | "link" | "code" | "claim";
334
382
  /** SEC-2a: the client's address when Den could attest it. */
335
383
  client?: string;
336
384
  /** SEC-2a: a GitHub sign-in's login (the chip's "@login"). */
337
385
  login?: string;
338
386
  /** SEC-2a: not yet greeted ("This device is signed in…", once). */
339
387
  welcome?: true;
388
+ /**
389
+ * 0.4.15: sha256 of this session's approval key (approvalKeyMatches).
390
+ * The key itself is handed over once, at the end of the sign-in, and
391
+ * never by GET /api/session. Unset: a session from before 0.4.15, which
392
+ * must sign in again to approve.
393
+ */
394
+ approveKeyHash?: string;
340
395
  }
341
396
 
342
397
  interface StoreFile {
@@ -464,14 +519,17 @@ export class WebSessionStore {
464
519
  create(meta: {
465
520
  userAgent?: string;
466
521
  host?: string;
467
- via?: "github" | "link" | "code";
522
+ via?: "github" | "link" | "code" | "claim";
468
523
  client?: string;
469
524
  login?: string;
470
525
  }): {
471
526
  id: string;
472
527
  session: SessionView;
528
+ /** The approval key, for the sign-in's answer only (never stored). */
529
+ approveKey: string;
473
530
  } {
474
531
  const id = randomBytes(32).toString("base64url");
532
+ const approveKey = randomBytes(32).toString("base64url");
475
533
  const key = this.keyOf(id);
476
534
  if (!key) throw new Error("web.secret is missing: no session can be made");
477
535
  const now = this.now();
@@ -487,12 +545,13 @@ export class WebSessionStore {
487
545
  ...(meta.client ? { client: String(meta.client).slice(0, 64) } : {}),
488
546
  ...(meta.login ? { login: String(meta.login).slice(0, 64) } : {}),
489
547
  welcome: true,
548
+ approveKeyHash: approveKeyHash(approveKey),
490
549
  };
491
550
  const data = structuredClone(this.load());
492
551
  this.prune(data, now);
493
552
  data.sessions[key] = rec;
494
553
  this.save(data);
495
- return { id, session: { key, ...rec } };
554
+ return { id, session: { key, ...rec }, approveKey };
496
555
  }
497
556
 
498
557
  /**
@@ -116,6 +116,7 @@ import {
116
116
  restoreRecord,
117
117
  writeRecord,
118
118
  checkDisplayName,
119
+ nameSlug,
119
120
  writeRuntimeFiles,
120
121
  type AgentRecord,
121
122
  type Override,
@@ -138,6 +139,7 @@ import type {
138
139
  RuntimePermissions,
139
140
  } from "../../shared/agents/runtime.js";
140
141
  import {
142
+ inScope,
141
143
  OPEN_ELSEWHERE_MESSAGE,
142
144
  openElsewhere,
143
145
  type RuntimeConversation,
@@ -195,7 +197,7 @@ export interface AgentsDeps extends SessionsDeps {
195
197
  * AG-17.1: the folders a workspace's conversations may have run in: its
196
198
  * own folder, and each repo's path, a repo added by path outside it too.
197
199
  */
198
- workspaceFolders?: (ws: string) => string[];
200
+ workspaceFolders?: (ws: string) => { under: string[]; exact?: string[] };
199
201
  /** The MCP server binary and its env; Den adds the scoped args. */
200
202
  mcpServer?: () => { command: string[] | string; env: Record<string, string> };
201
203
  /**
@@ -232,7 +234,7 @@ export interface AgentsResolved extends Resolved {
232
234
  agentsConfig: () => ResolvedAgentsConfig;
233
235
  distRoot: () => string;
234
236
  workspaceDir: (ws: string) => string;
235
- workspaceFolders: (ws: string) => string[];
237
+ workspaceFolders: (ws: string) => { under: string[]; exact?: string[] };
236
238
  mcpServer: () => { command: string[] | string; env: Record<string, string> };
237
239
  exitWatchMs: number;
238
240
  stopWaitMs: number;
@@ -292,7 +294,8 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
292
294
  new AgentError(message, { status, hints });
293
295
 
294
296
  let workspaceDirFn: ((ws: string) => string) | null = null;
295
- let repoPathsFn: ((ws: string) => string[]) | null = null;
297
+ let foldersFn: ((ws: string) => { under: string[]; exact?: string[] }) | null =
298
+ null;
296
299
  /** The workspaces config, for start and resume (loaded on first use). */
297
300
  export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
298
301
  await loadWorkspaces(d);
@@ -305,8 +308,11 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
305
308
  return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
306
309
  };
307
310
  // A by-path repo's own folder (repo add <path>), wherever it is.
308
- repoPathsFn = (name) =>
309
- ws.repoFolders(ws.readWorkspacesFile().workspaces[name]);
311
+ foldersFn = (name) => {
312
+ const cfg = ws.readWorkspacesFile().workspaces[name];
313
+ if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
314
+ return ws.conversationFolders(name, cfg, getReposRoot());
315
+ };
310
316
  }
311
317
 
312
318
  /** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
@@ -340,13 +346,10 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
340
346
  workspaceFolders:
341
347
  d.workspaceFolders ??
342
348
  ((ws) => {
343
- 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) ?? [])];
349
+ // A caller that gives its own folder (a test) gives just it.
350
+ if (d.workspaceDir) return { under: [d.workspaceDir(ws)] };
351
+ if (!foldersFn) throw new Error("workspaces not loaded");
352
+ return foldersFn(ws);
350
353
  }),
351
354
  mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
352
355
  exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
@@ -1048,6 +1051,48 @@ function sessionOf(
1048
1051
  * else the first of `<base>-2`, `<base>-3`… no agent has. A stopped agent's
1049
1052
  * id is never reused by a new start: only its Resume or Start fresh.
1050
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.
1060
+ */
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
+
1051
1096
  export function defaultId(
1052
1097
  stateDir: string,
1053
1098
  persona: string,
@@ -1487,8 +1532,6 @@ export async function startAgent(
1487
1532
  throw usage(
1488
1533
  `${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
1489
1534
  );
1490
- const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
1491
- checkId(where, id);
1492
1535
  let given: string | null = null;
1493
1536
  if (req.name !== undefined)
1494
1537
  try {
@@ -1496,6 +1539,11 @@ export async function startAgent(
1496
1539
  } catch (e) {
1497
1540
  throw usage(`${where}: ${(e as Error).message}`);
1498
1541
  }
1542
+ const id =
1543
+ req.id ??
1544
+ (given ? await namedId(d, given, ws) : null) ??
1545
+ defaultId(d.stateDir(), persona.id, ws);
1546
+ checkId(where, id);
1499
1547
  syncCatalog(d);
1500
1548
  const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
1501
1549
  const det = await availableOrRefuse(d, rt, ws);
@@ -1678,13 +1726,8 @@ export function listConversations(
1678
1726
  throw usage(`${where}: workspace "${ws}" does not exist`, 404);
1679
1727
  syncCatalog(d);
1680
1728
  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
- );
1729
+ // Its folder and every repo's; a shared root only exactly (AG-17.1).
1730
+ const scope = d.workspaceFolders(ws);
1688
1731
  const taken = agentByToken(d);
1689
1732
  const now = Date.now();
1690
1733
  const conversations: ListedConversation[] = [];
@@ -1693,7 +1736,7 @@ export function listConversations(
1693
1736
  if (runtime !== undefined && id !== runtime) continue;
1694
1737
  const rt = getRuntime(id);
1695
1738
  if (!rt?.listSessions) continue;
1696
- for (const c of scan.flatMap((f) => rt.listSessions!(f, d.env))) {
1739
+ for (const c of rt.listSessions(scope, d.env)) {
1697
1740
  if (seen.has(`${id} ${c.id}`)) continue;
1698
1741
  seen.add(`${id} ${c.id}`);
1699
1742
  const agent = taken.get(`${id} ${c.id}`);
@@ -1755,6 +1798,16 @@ export async function importAgent(
1755
1798
  422,
1756
1799
  );
1757
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
+ );
1758
1811
  if (!c)
1759
1812
  throw refused(
1760
1813
  `${where}: no ${rt.label} conversation "${req.session}" in workspace ${req.workspace}'s folder`,
@@ -123,7 +123,7 @@ export const AGENTS_HELP = {
123
123
  "",
124
124
  "{cli} agents list -w <ws> [--stopped] [--json]",
125
125
  " --stopped: only the stopped, interrupted and exited ones",
126
- "{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--id <id>]",
126
+ "{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--name <name>] [--id <id>]",
127
127
  " runs in the workspace's dir as <dist>-<ws>:<id>; its MCP is pinned to <ws>",
128
128
  " (plus agents.<id>.grants); capped by agents.max_concurrent",
129
129
  "{cli} agents stop <id> [-w <ws>] [--no-wait]",
@@ -273,12 +273,14 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
273
273
  "--persona": true,
274
274
  "--runtime": true,
275
275
  "--model": true,
276
+ "--name": true,
276
277
  "--id": true,
277
278
  });
278
279
  noExtra(where, p);
279
280
  const ws = workspaceFlag(where, p);
280
281
  if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
281
282
  const persona = str(p, "--persona");
283
+ const name = str(p, "--name");
282
284
  if (!persona) throw new UsageError(`${where} needs --persona <p>`);
283
285
  const s = await startAgent(d, {
284
286
  workspace: ws,
@@ -286,6 +288,7 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
286
288
  runtime: str(p, "--runtime"),
287
289
  model: str(p, "--model"),
288
290
  id: str(p, "--id"),
291
+ ...(name !== undefined ? { name } : {}),
289
292
  });
290
293
  return report(
291
294
  d,
@@ -22,7 +22,12 @@
22
22
  * nothing is sent.
23
23
  */
24
24
  import chalk from "chalk";
25
- import { readDenConfig, type DenConfig } from "../../shared/den-config.js";
25
+ import {
26
+ readDenConfig,
27
+ readMcpConfig,
28
+ type DenConfig,
29
+ } from "../../shared/den-config.js";
30
+ import { approvalsState } from "../mcp/tools.js";
26
31
  import { getStateDir } from "../../shared/paths.js";
27
32
  import { product } from "../../shared/product.js";
28
33
  import { listRecords } from "../../shared/agents/records.js";
@@ -216,6 +221,25 @@ export function agentsConfigured(
216
221
  }
217
222
  }
218
223
 
224
+ /**
225
+ * The held writes' state (mcp.approvals), as doctor prints it. A bad value
226
+ * is a problem: the MCP server refuses to start on it.
227
+ */
228
+ export function approvalsLines(read: () => boolean): {
229
+ lines: string[];
230
+ problem: boolean;
231
+ } {
232
+ try {
233
+ const state = approvalsState({ approvals: read() });
234
+ return { lines: ["", ` ℹ MCP ${state}`], problem: false };
235
+ } catch (err) {
236
+ return {
237
+ lines: ["", chalk.red(` ✗ MCP ${(err as Error).message}`)],
238
+ problem: true,
239
+ };
240
+ }
241
+ }
242
+
219
243
  /** Informational lines after the rows: never a failure. */
220
244
  export function doctorNotes(cli: string, configured: boolean): string[] {
221
245
  return configured
@@ -312,6 +336,8 @@ export async function cmdDoctor(args: string[]): Promise<number> {
312
336
  }
313
337
  const rows = doctorRows();
314
338
  for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
339
+ const held = approvalsLines(() => readMcpConfig().approvals);
340
+ for (const l of held.lines) console.log(l);
315
341
  for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
316
342
  const cli = product().cli;
317
343
  if (!probe) {
@@ -320,7 +346,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
320
346
  agentsConfigured(readConfigOrNull(), getStateDir()),
321
347
  ))
322
348
  console.log(l);
323
- return rows.some((r) => r.problem) ? 1 : 0;
349
+ return rows.some((r) => r.problem) || held.problem ? 1 : 0;
324
350
  }
325
351
  const { runEnvProbe, formatProbe } = await import("./env-probe.js");
326
352
  const { resolveDeps, plainSessionEnv } =
@@ -339,5 +365,5 @@ export async function cmdDoctor(args: string[]): Promise<number> {
339
365
  console.log("");
340
366
  for (const l of formatProbe(results)) console.log(l);
341
367
  const found = results.some((r) => r.findings.length);
342
- return rows.some((r) => r.problem) || found ? 1 : 0;
368
+ return rows.some((r) => r.problem) || held.problem || found ? 1 : 0;
343
369
  }