@monster-orange/den 0.4.14 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +226 -19
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +155 -8
  7. package/src/shared/agents/den-claude.ts +25 -3
  8. package/src/shared/agents/records.ts +37 -0
  9. package/src/shared/agents/runtime.ts +11 -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/github-app.ts +7 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +372 -23
  17. package/src/ui/cli/agents-command.ts +71 -4
  18. package/src/ui/cli/doctor-command.ts +29 -3
  19. package/src/ui/cli/doctor-onboard.ts +26 -0
  20. package/src/ui/cli/flag-parser.ts +9 -1
  21. package/src/ui/cli/index.ts +29 -3
  22. package/src/ui/cli/onboard-command.ts +180 -0
  23. package/src/ui/cli/onboard-reads.ts +613 -0
  24. package/src/ui/cli/repo-command.ts +156 -10
  25. package/src/ui/cli/web-command.ts +92 -1
  26. package/src/ui/mcp/api.ts +14 -2
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +368 -1
  29. package/src/ui/server/agent-routes.ts +91 -7
  30. package/src/ui/server/approval-routes.ts +390 -0
  31. package/src/ui/server/approvals.ts +416 -0
  32. package/src/ui/server/github-routes.ts +13 -5
  33. package/src/ui/server/held-actions.ts +203 -0
  34. package/src/ui/server/http-helpers.ts +35 -0
  35. package/src/ui/server/index.ts +308 -21
  36. package/src/ui/server/lifecycle-exec.ts +70 -0
  37. package/src/ui/server/onboard-routes.ts +132 -0
  38. package/src/ui/server/pair-routes.ts +7 -4
  39. package/src/ui/server/public/agents-page.js +378 -30
  40. package/src/ui/server/public/agents.html +1 -0
  41. package/src/ui/server/public/error-page.js +97 -18
  42. package/src/ui/server/public/error.html +18 -0
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +65 -5
  45. package/src/ui/server/public/i18n/ja.js +67 -5
  46. package/src/ui/server/public/index.html +1 -1
  47. package/src/ui/server/public/local-dev.html +5 -4
  48. package/src/ui/server/public/persona-ui.css +32 -0
  49. package/src/ui/server/public/release.html +0 -1
  50. package/src/ui/server/public/settings-registry.js +3 -1
  51. package/src/ui/server/public/settings.html +9 -1
  52. package/src/ui/server/public/shell.js +348 -5
  53. package/src/ui/server/request-guard.ts +2 -0
  54. package/src/ui/server/route-classes.ts +34 -1
  55. package/src/ui/server/session-routes.ts +14 -3
  56. package/src/ui/server/workspace-routes.ts +21 -10
  57. package/themes/den/den-theme.json +6 -6
@@ -20,6 +20,13 @@
20
20
  * existed: true, and an existing side-car is never overwritten.
21
21
  * - The workspace: `-w <ws>` (created and activated when missing), else the
22
22
  * focused one, else a new `default` workspace, activated and focused.
23
+ * - `--dry-run` (ON-3, the MCP tool manifest_draft's twin): a local path
24
+ * only. Detection and the validator run as above, and the answer carries
25
+ * the side-car as YAML (`draft`) and `validation`; nothing is written, not
26
+ * the side-car, the workspace entry or the catalog.
27
+ *
28
+ * `<cli> repo detect <folder> [--json]` (ON-3, repos_detect's twin): the git
29
+ * repos in a folder and suggested workspaces (onboard-reads.ts).
23
30
  */
24
31
  import { execFileSync } from "child_process";
25
32
  import * as fs from "fs";
@@ -69,6 +76,8 @@ export interface RepoAddArgs {
69
76
  health?: string;
70
77
  branch?: string;
71
78
  json: boolean;
79
+ /** `--dry-run`: detect and validate, write nothing (a local path only). */
80
+ dryRun?: boolean;
72
81
  }
73
82
 
74
83
  export interface RepoAddResult {
@@ -93,11 +102,17 @@ export interface RepoAddResult {
93
102
  fix?: string;
94
103
  ask?: string;
95
104
  choices?: string[];
105
+ /** `--dry-run`: nothing was written. */
106
+ dryRun?: boolean;
107
+ /** `--dry-run`: the side-car Den would write, as YAML. */
108
+ draft?: string;
109
+ /** `--dry-run`: the validator's answer for the draft. */
110
+ validation?: { valid: boolean; errors: { path: string; message: string }[] };
96
111
  }
97
112
 
98
113
  const USAGE = () =>
99
114
  `${product().cli} repo add <path|git-url> [--sidecar] [-w <workspace>] [--name <repo>] ` +
100
- `[--run "<cmd>"] [--down "<cmd>"] [--health <url>] [--branch <branch>] [--json]`;
115
+ `[--run "<cmd>"] [--down "<cmd>"] [--health <url>] [--branch <branch>] [--dry-run] [--json]`;
101
116
 
102
117
  export const REPO_HELP = () =>
103
118
  [
@@ -111,7 +126,14 @@ export const REPO_HELP = () =>
111
126
  ' cannot decide, nothing is written and --json answers with "ask" and',
112
127
  ' "choices": re-run with --run "<cmd>". --health <url> adds an HTTP health',
113
128
  " check. -w picks the workspace (default: the focused one, else a new",
114
- " `default`). Safe to re-run: the same repo answers existed: true.\n",
129
+ " `default`). Safe to re-run: the same repo answers existed: true.",
130
+ " --dry-run (a local folder only): detect and validate the side-car and",
131
+ ' print it ("draft"), writing nothing: no side-car, no workspace entry.\n',
132
+ `\n ${product().cli} repo detect <folder> [--json]\n`,
133
+ " Find the git repos in a folder inside your home (one or two levels deep),",
134
+ " say how each one runs, and suggest workspaces: the repos in one parent",
135
+ " folder go in one workspace named after it. Reads only: it never writes,",
136
+ " never runs a repo's tools and never follows a symlink.\n",
115
137
  ].join("\n");
116
138
 
117
139
  const VALUE_FLAGS = new Set([
@@ -136,6 +158,7 @@ export function parseRepoAddArgs(argv: string[]): RepoAddArgs {
136
158
  const flag = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
137
159
  if (a === "--sidecar") out.sidecar = true;
138
160
  else if (a === "--json") out.json = true;
161
+ else if (a === "--dry-run" || a === "-n") out.dryRun = true;
139
162
  else if (VALUE_FLAGS.has(flag)) {
140
163
  const v = flag !== a ? a.slice(eq + 1) : argv[++i];
141
164
  if (v === undefined || v === "") throw new Error(`${flag} needs a value`);
@@ -236,6 +259,15 @@ export function repoAdd(
236
259
  fix: `${bin} repo add ${a.spec} --health http://localhost:<port>/ --json`,
237
260
  };
238
261
 
262
+ if (a.dryRun && url)
263
+ return {
264
+ ok: false,
265
+ dryRun: true,
266
+ error:
267
+ "--dry-run takes a local folder: a git URL would have to be cloned first",
268
+ fix: `${bin} repo add <path> --dry-run --json`,
269
+ };
270
+
239
271
  // ── Where the repo is ────────────────────────────────────────────────────
240
272
  let repoPath: string;
241
273
  let mode: "dev-managed" | "managed";
@@ -299,6 +331,10 @@ export function repoAdd(
299
331
  ? (originRepoName(repoPath) ?? name)
300
332
  : (originRepoName(repoPath) ?? path.basename(repoPath));
301
333
  let manifestInfo: RepoAddResult["manifest"];
334
+ /** --dry-run: the side-car that would be written. */
335
+ let drafted:
336
+ | { manifest: Record<string, unknown>; text: string; valid: boolean }
337
+ | undefined;
302
338
  if (own) {
303
339
  manifestInfo = { source: "repo", file: own };
304
340
  } else {
@@ -324,29 +360,43 @@ export function repoAdd(
324
360
  };
325
361
  const manifest = sidecarManifest(mName, d.commands, a.health);
326
362
  const v = validate(manifest);
363
+ const dry = a.dryRun
364
+ ? {
365
+ dryRun: true,
366
+ draft: formatManifestYaml(manifest),
367
+ validation: { valid: v.valid, errors: v.errors },
368
+ }
369
+ : {};
327
370
  if (!v.valid)
328
371
  return {
329
372
  ok: false,
330
373
  error: `the detected manifest is not valid: ${v.errors.map((e) => `${e.path} ${e.message}`).join("; ")}`,
331
374
  fix: `${bin} repo add ${a.spec} --run "<command>" --json`,
375
+ ...dry,
332
376
  };
333
377
  const file = sidecarFile(key);
334
- fs.mkdirSync(path.dirname(file), { recursive: true });
335
- // "wx": never overwrite a side-car someone may have edited.
336
- fs.writeFileSync(file, formatManifestYaml(manifest), {
337
- encoding: "utf-8",
338
- flag: "wx",
339
- });
378
+ if (a.dryRun) {
379
+ drafted = { manifest, text: dry.draft as string, valid: v.valid };
380
+ } else {
381
+ fs.mkdirSync(path.dirname(file), { recursive: true });
382
+ // "wx": never overwrite a side-car someone may have edited.
383
+ fs.writeFileSync(file, formatManifestYaml(manifest), {
384
+ encoding: "utf-8",
385
+ flag: "wx",
386
+ });
387
+ }
340
388
  manifestInfo = {
341
389
  source: "sidecar",
342
390
  file,
343
- written: true,
391
+ written: !a.dryRun,
344
392
  detected: d.commands.source,
345
393
  };
346
394
  }
347
395
  }
348
396
 
349
- const manifest = readManifest(repoPath);
397
+ const manifest = drafted
398
+ ? (drafted.manifest as unknown as ReturnType<typeof readManifest>)
399
+ : readManifest(repoPath);
350
400
  if (!manifest)
351
401
  return {
352
402
  ok: false,
@@ -366,6 +416,34 @@ export function repoAdd(
366
416
  fix: `${bin} repo add ${a.spec} --name <repo> --json`,
367
417
  };
368
418
 
419
+ if (a.dryRun) {
420
+ // Nothing is written: not the side-car, not the workspace entry.
421
+ const flags = [
422
+ a.name !== undefined ? ` --name ${a.name}` : "",
423
+ a.run !== undefined ? ` --run "${a.run}"` : "",
424
+ a.down !== undefined ? ` --down "${a.down}"` : "",
425
+ a.health !== undefined ? ` --health ${a.health}` : "",
426
+ a.workspace !== undefined ? ` -w ${a.workspace}` : "",
427
+ ].join("");
428
+ return {
429
+ ok: true,
430
+ dryRun: true,
431
+ repo: name,
432
+ workspace: a.workspace ?? getFocusedWorkspaceName() ?? "default",
433
+ mode,
434
+ path: repoPath,
435
+ manifest: manifestInfo,
436
+ services: Object.keys(manifest.services ?? {}),
437
+ ...(drafted
438
+ ? {
439
+ draft: drafted.text,
440
+ validation: { valid: drafted.valid, errors: [] },
441
+ }
442
+ : {}),
443
+ next: [`${bin} repo add ${a.spec}${flags} --json`],
444
+ };
445
+ }
446
+
369
447
  // ── The workspace entry ──────────────────────────────────────────────────
370
448
  const ws = targetWorkspace(a.workspace);
371
449
  const file = readWorkspacesFile();
@@ -423,6 +501,18 @@ export function repoAdd(
423
501
  }
424
502
 
425
503
  function printHuman(r: RepoAddResult): void {
504
+ if (r.dryRun && r.ok) {
505
+ console.log(
506
+ `✓ Dry run: ${r.repo} would be added to workspace ${r.workspace} (${r.mode}, ${r.path}); nothing was written`,
507
+ );
508
+ if (r.manifest)
509
+ console.log(
510
+ ` manifest: ${r.manifest.source} ${r.manifest.file}${r.draft ? ` (would be written, from ${r.manifest.detected})` : ""}`,
511
+ );
512
+ if (r.draft) console.log(`\n${r.draft.replace(/^/gm, " ")}`);
513
+ console.log(` To add it: ${r.next?.[0] ?? ""}`);
514
+ return;
515
+ }
426
516
  if (!r.ok) {
427
517
  console.error(`✗ repo add: ${r.error}`);
428
518
  if (r.ask) console.error(` ${r.ask}`);
@@ -442,6 +532,61 @@ function printHuman(r: RepoAddResult): void {
442
532
  console.log(` Next: ${r.next?.[0] ?? ""}`);
443
533
  }
444
534
 
535
+ /** `repo detect <folder> [--json]` (ON-3, repos_detect's twin). */
536
+ async function cmdRepoDetect(argv: string[]): Promise<number> {
537
+ const { processPathRules, reposDetect } = await import("./onboard-reads.js");
538
+ const { registeredRepos } = await import("./onboard-command.js");
539
+ const { getStateDir } = await import("../../shared/paths.js");
540
+ const json = argv.includes("--json");
541
+ const positional = argv.filter((a) => a !== "--json");
542
+ const bad = positional.find((a) => a.startsWith("-"));
543
+ if (bad || positional.length !== 1) {
544
+ const r = {
545
+ ok: false,
546
+ error: bad ? `unknown flag ${bad}` : "repo detect needs one <folder>",
547
+ fix: `${runnable()} repo detect <folder> --json`,
548
+ };
549
+ if (json) printJson(r);
550
+ else console.error(`✗ repo detect: ${r.error}`);
551
+ return 2;
552
+ }
553
+ // A relative folder is the person's own, from where they typed it.
554
+ const given = positional[0];
555
+ const folder =
556
+ given.startsWith("~") || path.isAbsolute(given)
557
+ ? given
558
+ : path.resolve(getInvokeCwd(), given);
559
+ const r = reposDetect(folder, processPathRules(getStateDir()), {
560
+ registered: registeredRepos,
561
+ bin: runnable(),
562
+ });
563
+ if (json) printJson(r);
564
+ else if (!r.ok) console.error(`✗ repo detect: ${r.error}`);
565
+ else {
566
+ console.log(
567
+ `✓ ${r.repos.length} repo${r.repos.length === 1 ? "" : "s"} in ${r.path}${r.truncated ? " (stopped early: more are there)" : ""}`,
568
+ );
569
+ for (const x of r.repos) {
570
+ const how = x.runs
571
+ ? x.runs.up
572
+ : x.manifest !== "none"
573
+ ? `its ${x.manifest === "repo" ? "own" : "side-car"} manifest`
574
+ : (x.ask ?? "");
575
+ const inWs = x.registered?.length
576
+ ? ` (in ${x.registered.map((g) => g.workspace).join(", ")})`
577
+ : "";
578
+ console.log(` ${x.name.padEnd(20)} ${how}${inWs}`);
579
+ }
580
+ if (r.workspaces.length) {
581
+ console.log("\n Suggested workspaces:");
582
+ for (const w of r.workspaces)
583
+ console.log(` ${w.name.padEnd(20)} ${w.repos.join(", ")}`);
584
+ }
585
+ if (r.next[0]) console.log(`\n Next: ${r.next[0]}`);
586
+ }
587
+ return r.ok ? 0 : 1;
588
+ }
589
+
445
590
  /** `repo <sub> …`; returns the exit code. */
446
591
  export async function cmdRepo(
447
592
  argv: string[],
@@ -453,6 +598,7 @@ export async function cmdRepo(
453
598
  return sub ? 0 : 1;
454
599
  }
455
600
  const json = rest.includes("--json");
601
+ if (sub === "detect") return cmdRepoDetect(rest);
456
602
  if (sub !== "add") {
457
603
  const r = {
458
604
  ok: false,
@@ -64,6 +64,12 @@ import {
64
64
  } from "./server-identity.js";
65
65
 
66
66
  import { product } from "../../shared/product.js";
67
+ import {
68
+ CLAIM_CODE_TTL_MS,
69
+ claimLink,
70
+ mintClaimCode,
71
+ personGrantRefusal,
72
+ } from "../../shared/claim-code.js";
67
73
  import { redactSecrets } from "../../shared/redact.js";
68
74
  interface WebCommandOptions {
69
75
  /** `open` is `up` that always opens a fresh sign-in tab (SEC-1b). */
@@ -1142,6 +1148,7 @@ export const WEB_SUBCOMMANDS_WITH_HELP = [
1142
1148
  "logout",
1143
1149
  "owner",
1144
1150
  "approve",
1151
+ "claim-link",
1145
1152
  ] as const;
1146
1153
 
1147
1154
  export function displayWebHelp(): void {
@@ -1190,6 +1197,9 @@ export function displayWebHelp(): void {
1190
1197
  console.log(
1191
1198
  " approve <code> Let in a computer whose signed-out page shows this code",
1192
1199
  );
1200
+ console.log(
1201
+ " claim-link Print a one-time setup link (30 min) that signs this machine's browser in",
1202
+ );
1193
1203
  console.log(" (start/stop remain accepted as legacy aliases for up/down)");
1194
1204
  console.log();
1195
1205
  console.log(chalk.bold("Options:"));
@@ -1276,6 +1286,10 @@ export async function cmdWeb(args: string[]): Promise<void> {
1276
1286
  process.exitCode = await cmdWebApprove(args.slice(1));
1277
1287
  return;
1278
1288
  }
1289
+ if (args[0] === "claim-link") {
1290
+ process.exitCode = cmdWebClaimLink(args.slice(1));
1291
+ return;
1292
+ }
1279
1293
  if (args.includes("--help") || args.includes("-h")) {
1280
1294
  displayWebHelp();
1281
1295
  return;
@@ -1400,7 +1414,8 @@ export async function cmdWeb(args: string[]): Promise<void> {
1400
1414
  // SEC-1b: what opens is a sign-in link (localhost, one-time, 60 s). It
1401
1415
  // is printed only when the browser couldn't be opened: the one line the
1402
1416
  // person then needs.
1403
- let link: string | null = null;
1417
+ // Inside an agent's session: the plain page, never a sign-in link.
1418
+ let link: string | null = personGrantRefusal(process.env) ? url : null;
1404
1419
  const signIn = () => (link ??= freshLoginLink(options.port));
1405
1420
  const browser = openOrFocusDenWebUrl(
1406
1421
  signIn,
@@ -1450,9 +1465,65 @@ export function freshLoginLink(
1450
1465
  origin: string = localhostOrigin(port),
1451
1466
  stateDir: string = getStateDir(),
1452
1467
  ): string {
1468
+ const refused = personGrantRefusal(process.env);
1469
+ if (refused) throw new Error(refused);
1453
1470
  return loginLink(origin, mintLoginCode(ensureWebSecret(stateDir)), next);
1454
1471
  }
1455
1472
 
1473
+ /**
1474
+ * `<cli> web claim-link [--json] [--port <port>]` (0.4.15): a one-time
1475
+ * setup link, `http://localhost:<port>/?claim=<code>` (30 minutes), on
1476
+ * stdout alone. Opening it signs that browser in, so it can approve an
1477
+ * agent's changes from the first page. Refused inside an agent's session:
1478
+ * the person opens it, never an agent. Never logged.
1479
+ */
1480
+ export function cmdWebClaimLink(args: string[], io: CliIo = stdIo): number {
1481
+ const usage = `Usage: ${product().cli} web claim-link [--json] [--port <port>]\n`;
1482
+ if (isHelp(args)) {
1483
+ io.out(
1484
+ usage +
1485
+ " Prints a one-time setup link (it lasts 30 minutes and works once). Open it\n" +
1486
+ " yourself, in the browser on this machine: it signs that browser in, so you\n" +
1487
+ " can approve what an agent asks to change. Don't let any agent open it.\n",
1488
+ );
1489
+ return 0;
1490
+ }
1491
+ const refused = personGrantRefusal(process.env);
1492
+ if (refused) {
1493
+ io.err(`${product().cli}: ${refused}\n`);
1494
+ return 1;
1495
+ }
1496
+ let port: number;
1497
+ let rest: string[];
1498
+ try {
1499
+ ({ port, rest } = takePort(args));
1500
+ } catch (e) {
1501
+ io.err(`${(e as Error).message}\n`);
1502
+ return 2;
1503
+ }
1504
+ const asJson = rest.includes("--json");
1505
+ if (rest.some((a) => a !== "--json")) {
1506
+ io.err(usage);
1507
+ return 2;
1508
+ }
1509
+ const now = Date.now();
1510
+ const url = claimLink(
1511
+ localhostOrigin(port),
1512
+ mintClaimCode(ensureWebSecret(io.stateDir ?? getStateDir()), now),
1513
+ );
1514
+ if (asJson)
1515
+ io.out(
1516
+ `${JSON.stringify({ url, expiresAt: new Date(now + CLAIM_CODE_TTL_MS).toISOString() })}\n`,
1517
+ );
1518
+ else {
1519
+ io.err(
1520
+ `${product().cli}: open this link yourself within 30 minutes; it signs in one browser, once. Don't let any agent open it.\n`,
1521
+ );
1522
+ io.out(`${url}\n`);
1523
+ }
1524
+ return 0;
1525
+ }
1526
+
1456
1527
  interface CliIo {
1457
1528
  out: (s: string) => void;
1458
1529
  err: (s: string) => void;
@@ -1570,6 +1641,11 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1570
1641
  io.out(LOGIN_HELP());
1571
1642
  return 0;
1572
1643
  }
1644
+ const refused = personGrantRefusal(process.env);
1645
+ if (refused) {
1646
+ io.err(`${product().cli}: ${refused}\n`);
1647
+ return 1;
1648
+ }
1573
1649
  const a = parseLoginArgs(args, io);
1574
1650
  if (typeof a === "number") return a;
1575
1651
  const link = freshLoginLink(
@@ -1610,6 +1686,11 @@ export async function cmdWebLoginWait(
1610
1686
  io.out(LOGIN_HELP());
1611
1687
  return 0;
1612
1688
  }
1689
+ const refused = personGrantRefusal(process.env);
1690
+ if (refused) {
1691
+ io.err(`${product().cli}: ${refused}\n`);
1692
+ return 1;
1693
+ }
1613
1694
  const a = parseLoginArgs(args, io);
1614
1695
  if (typeof a === "number") return a;
1615
1696
  if (a.noWait)
@@ -2029,6 +2110,11 @@ export async function cmdWebApprove(
2029
2110
  io.err(usage);
2030
2111
  return 2;
2031
2112
  }
2113
+ const refused = list ? null : personGrantRefusal(process.env);
2114
+ if (refused) {
2115
+ io.err(`${product().cli}: ${refused}\n`);
2116
+ return 1;
2117
+ }
2032
2118
  const stateDir = io.stateDir ?? getStateDir();
2033
2119
  const r = list
2034
2120
  ? await serverCall(stateDir, port, "GET", "/api/web/waiting")
@@ -2133,6 +2219,11 @@ export async function cmdWebOwner(
2133
2219
  }
2134
2220
  const stateDir = io.stateDir ?? getStateDir();
2135
2221
  const cli = product().cli;
2222
+ const refused = sub === "show" ? null : personGrantRefusal(process.env);
2223
+ if (refused) {
2224
+ io.err(`${cli}: ${refused}\n`);
2225
+ return 1;
2226
+ }
2136
2227
  if (sub === "show") {
2137
2228
  const o = readWebOwner(stateDir);
2138
2229
  if (asJson) io.out(`${JSON.stringify({ owner: o }, null, 2)}\n`);
package/src/ui/mcp/api.ts CHANGED
@@ -129,6 +129,10 @@ const READ_ROUTES = new Set([
129
129
  "/api/stacks",
130
130
  "/api/edge",
131
131
  "/api/server-info",
132
+ // ON-3: onboarding reads (nothing written).
133
+ "/api/onboard/status",
134
+ "/api/onboard/repos",
135
+ "/api/onboard/manifest",
132
136
  ]);
133
137
 
134
138
  /** Action routes and their method. Anything else is never reached. */
@@ -138,6 +142,8 @@ const ACTION_ROUTES: Record<string, string> = {
138
142
  "/api/service/exec": "GET",
139
143
  "/api/run": "GET",
140
144
  "/api/workspace/sync": "POST",
145
+ // ON-3's writes: asks the person to allow one (held; nothing runs here).
146
+ "/api/approvals": "POST",
141
147
  };
142
148
 
143
149
  /**
@@ -145,6 +151,8 @@ const ACTION_ROUTES: Record<string, string> = {
145
151
  * and Bring in's list and import (MCP agent_import, AG-17.1).
146
152
  */
147
153
  const ACTION_ROUTE_PATTERNS: { re: RegExp; method: string }[] = [
154
+ // One held request's state (its own, the server checks). Never /decide.
155
+ { re: /^\/api\/approvals\/[A-Za-z0-9_-]{16}$/, method: "GET" },
148
156
  {
149
157
  re: /^\/api\/workspaces\/[A-Za-z0-9._-]{1,64}\/agents\/[A-Za-z0-9._-]{1,64}\/resume$/,
150
158
  method: "POST",
@@ -222,6 +230,8 @@ export interface DenApi {
222
230
  query?: Query,
223
231
  method?: "GET" | "POST",
224
232
  body?: unknown,
233
+ /** Extra request headers (an approval's ticket). */
234
+ headers?: Record<string, string>,
225
235
  ): Promise<unknown>;
226
236
  /** An SSE route, read to a bound; the connection is closed after. */
227
237
  stream(path: string, query: Query, opts: StreamOpts): Promise<StreamResult>;
@@ -302,6 +312,7 @@ export function createDenApi(opts: {
302
312
  query: Query,
303
313
  signal?: AbortSignal,
304
314
  body?: unknown,
315
+ extra: Record<string, string> = {},
305
316
  ): Promise<Response> {
306
317
  assertAllowedRequest(method, path, query);
307
318
  await ensureOurs();
@@ -309,6 +320,7 @@ export function createDenApi(opts: {
309
320
  const res = await fetch(buildUrl(baseUrl, path, query), {
310
321
  method,
311
322
  headers: {
323
+ ...extra,
312
324
  ...opts.headers(),
313
325
  ...(opts.auth ?? cliAuthHeaders)(),
314
326
  ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
@@ -338,8 +350,8 @@ export function createDenApi(opts: {
338
350
  return {
339
351
  baseUrl,
340
352
 
341
- async get(path, query = {}, method = "GET", body) {
342
- const res = await request(method, path, query, undefined, body);
353
+ async get(path, query = {}, method = "GET", body, headers) {
354
+ const res = await request(method, path, query, undefined, body, headers);
343
355
  const text = await res.text();
344
356
  try {
345
357
  return JSON.parse(text) as unknown;
@@ -22,7 +22,12 @@ import { product } from "../../shared/product.js";
22
22
  import { DEFAULT_PORT } from "../../shared/constants.js";
23
23
  import { createDenApi, type DenApi } from "./api.js";
24
24
  import { ownServer } from "../cli/server-identity.js";
25
- import { callTool, toolsFor, type ToolContext } from "./tools.js";
25
+ import {
26
+ approvalsState,
27
+ callTool,
28
+ toolsFor,
29
+ type ToolContext,
30
+ } from "./tools.js";
26
31
  import { plural } from "../../shared/plural.js";
27
32
  import {
28
33
  checkGrants,
@@ -60,7 +65,8 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
60
65
  `through the running ${p.title} web server (\`${opts.cli} web up\`). ` +
61
66
  (opts.cfg.actions.length
62
67
  ? `Actions enabled: ${opts.cfg.actions.join(", ")}.`
63
- : "Read-only: the org has enabled no actions (mcp.actions).") +
68
+ : "The org has enabled no actions (mcp.actions).") +
69
+ ` The ${approvalsState(opts.cfg)}.` +
64
70
  (opts.scope
65
71
  ? ` Scoped to workspace "${opts.scope.workspace}" as ${opts.scope.actor}: ` +
66
72
  "a call naming another workspace is refused, and lists show this one only, " +
@@ -258,6 +264,10 @@ export async function runOneShot(
258
264
  if (shot.kind === "list") {
259
265
  const names = tools.map((t) => t.name);
260
266
  out(names.length ? `${names.join("\n")}\n` : "");
267
+ // Stdout stays one name a line; the state goes to stderr.
268
+ (opts.err ?? ((t: string) => process.stderr.write(t)))(
269
+ `${approvalsState(opts.cfg)}\n`,
270
+ );
261
271
  return 0;
262
272
  }
263
273
  const ctx: ToolContext = {