@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
@@ -32,6 +32,16 @@ export interface RuntimeConversation {
32
32
  lastAt: string;
33
33
  /** The folder it ran in. */
34
34
  cwd: string;
35
+ /**
36
+ * AG-17.1c: the model its last answer came from (the last assistant
37
+ * record's `message.model`), so Bring in keeps it. Absent when unknown.
38
+ */
39
+ model?: string;
40
+ /**
41
+ * AG-17.1c: the MCP servers its tools came from (`mcp__<server>__<tool>`
42
+ * tool calls in the ends read), sorted. Absent when none.
43
+ */
44
+ mcp?: string[];
35
45
  }
36
46
 
37
47
  /** Bytes read at each end of a transcript. */
@@ -114,22 +124,63 @@ function promptText(rec: Record<string, unknown>): string | null {
114
124
  return cleanName(text);
115
125
  }
116
126
 
117
- /** A transcript's cwd and title, from its two ends. */
127
+ /** A model name as a transcript records it: short, printable, not synthetic. */
128
+ const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
129
+ /** An MCP tool's name: `mcp__<server>__<tool>`. */
130
+ const MCP_TOOL_RE = /^mcp__([A-Za-z0-9_.-]+?)__[A-Za-z0-9_.-]+$/;
131
+
132
+ /** An assistant record's tool calls' names. */
133
+ function toolNames(rec: Record<string, unknown>): string[] {
134
+ const content = (rec.message as { content?: unknown } | undefined)?.content;
135
+ if (!Array.isArray(content)) return [];
136
+ return content.flatMap((c) => {
137
+ const b = c as { type?: unknown; name?: unknown };
138
+ return b?.type === "tool_use" && typeof b.name === "string" ? [b.name] : [];
139
+ });
140
+ }
141
+
142
+ /** A transcript's summary from its two ends (AG-17.1, AG-17.1c). */
143
+ export interface TranscriptSummary {
144
+ cwd: string | null;
145
+ title: string | null;
146
+ /** The last assistant record's model, or null. */
147
+ model: string | null;
148
+ /** The MCP servers its tool calls name, sorted. */
149
+ mcp: string[];
150
+ }
151
+
152
+ /** A transcript's cwd, title, last model and MCP servers, from its two ends. */
118
153
  export function transcriptSummary(
119
154
  file: string,
120
155
  bytes = ENDS_BYTES,
121
- ): { cwd: string | null; title: string | null } {
156
+ ): TranscriptSummary {
122
157
  const { head, tail } = readEnds(file, bytes);
123
158
  let cwd: string | null = null;
124
159
  let prompt: string | null = null;
125
160
  let title: string | null = null;
161
+ let model: string | null = null;
162
+ const mcp = new Set<string>();
126
163
  const titleIn = (line: string) => {
127
164
  if (!line.includes("custom-title")) return;
128
165
  const r = parse(line);
129
166
  if (r?.type === "custom-title") title = cleanName(r.customTitle) ?? title;
130
167
  };
168
+ // AG-17.1c: the last answer's model, and the MCP servers its tools came from.
169
+ const assistantIn = (line: string) => {
170
+ if (!line.includes('"assistant"')) return;
171
+ const r = parse(line);
172
+ if (r?.type !== "assistant") return;
173
+ const m = (r.message as { model?: unknown } | undefined)?.model;
174
+ if (typeof m === "string" && MODEL_RE.test(m) && m !== "<synthetic>")
175
+ model = m;
176
+ for (const n of toolNames(r)) {
177
+ const hit = MCP_TOOL_RE.exec(n);
178
+ if (hit) mcp.add(hit[1]);
179
+ }
180
+ };
131
181
  for (const line of head) {
132
182
  titleIn(line);
183
+ assistantIn(line);
133
184
  if (cwd && prompt) continue;
134
185
  if (!line.includes('"cwd"') && !line.includes('"user"')) continue;
135
186
  const r = parse(line);
@@ -138,8 +189,73 @@ export function transcriptSummary(
138
189
  cwd = r.cwd;
139
190
  prompt ??= promptText(r);
140
191
  }
141
- for (const line of tail) titleIn(line);
142
- return { cwd, title: title ?? prompt };
192
+ for (const line of tail) {
193
+ titleIn(line);
194
+ assistantIn(line);
195
+ }
196
+ return { cwd, title: title ?? prompt, model, mcp: [...mcp].sort() };
197
+ }
198
+
199
+ /** Written this recently, a turn whose last record is an answer is still going. */
200
+ export const TURN_QUIET_MS = 5_000;
201
+
202
+ /**
203
+ * AG-18: whether a conversation's turn is still going, from its tail: the
204
+ * last user or assistant record. An answer that ended (`end_turn`, a stop
205
+ * sequence, the token limit) is idle; a tool call, or a prompt or tool
206
+ * result with no answer yet, is busy; an answer with no stop reason is busy
207
+ * until the file has been quiet TURN_QUIET_MS. An interrupted turn is idle.
208
+ * No transcript: idle (nothing was asked yet).
209
+ */
210
+ export function transcriptBusy(
211
+ file: string | null,
212
+ now = Date.now(),
213
+ bytes = ENDS_BYTES,
214
+ ): boolean {
215
+ if (!file) return false;
216
+ let mtime: number;
217
+ try {
218
+ mtime = fs.statSync(file).mtimeMs;
219
+ } catch {
220
+ return false;
221
+ }
222
+ const { head, tail } = readEnds(file, bytes);
223
+ const lines = tail.length ? [...head, ...tail] : head;
224
+ for (let i = lines.length - 1; i >= 0; i--) {
225
+ const line = lines[i];
226
+ if (!line.includes('"user"') && !line.includes('"assistant"')) continue;
227
+ const r = parse(line);
228
+ if (!r || r.isSidechain === true || r.isMeta === true) continue;
229
+ if (r.type === "assistant") {
230
+ const msg = r.message as { stop_reason?: unknown } | undefined;
231
+ const stop = msg?.stop_reason;
232
+ if (stop === "tool_use" || toolNames(r).length) return true;
233
+ if (
234
+ stop === "end_turn" ||
235
+ stop === "stop_sequence" ||
236
+ stop === "max_tokens"
237
+ )
238
+ return false;
239
+ return now - mtime < TURN_QUIET_MS;
240
+ }
241
+ if (r.type === "user") {
242
+ const content = (r.message as { content?: unknown } | undefined)?.content;
243
+ const text =
244
+ typeof content === "string"
245
+ ? content
246
+ : Array.isArray(content)
247
+ ? ((
248
+ content.find(
249
+ (c) => (c as { type?: unknown })?.type === "text",
250
+ ) as { text?: unknown } | undefined
251
+ )?.text ?? "")
252
+ : "";
253
+ if (typeof text === "string" && text.startsWith("[Request interrupted"))
254
+ return false;
255
+ return true;
256
+ }
257
+ }
258
+ return false;
143
259
  }
144
260
 
145
261
  /** A folder's absolute forms: as given and its real path. */
@@ -159,13 +275,25 @@ function forms(dir: string): string[] {
159
275
  * newest first, at most MAX_CONVERSATIONS. Never throws: a history it
160
276
  * can't read is no conversations.
161
277
  */
278
+ /**
279
+ * Where a workspace's conversations ran (AG-17.1): in or under one of
280
+ * `under`, or exactly in one of `exact` (a shared repos root: its other
281
+ * subfolders are other workspaces' or no repo's).
282
+ */
283
+ export interface ConversationScope {
284
+ under: string[];
285
+ exact?: string[];
286
+ }
287
+
162
288
  export function claudeConversations(
163
- folder: string,
289
+ scope: string | ConversationScope,
164
290
  env: NodeJS.ProcessEnv = process.env,
165
291
  ): RuntimeConversation[] {
292
+ const sc = typeof scope === "string" ? { under: [scope] } : scope;
166
293
  const root = claudeProjectsDir(env);
167
- const dirs = forms(folder);
168
- const slugs = dirs.map(claudeSlug);
294
+ const dirs = sc.under.flatMap(forms);
295
+ const exact = (sc.exact ?? []).flatMap(forms);
296
+ const slugs = [...dirs, ...exact].map(claudeSlug);
169
297
  let projects: string[];
170
298
  try {
171
299
  projects = fs.readdirSync(root);
@@ -208,18 +336,37 @@ export function claudeConversations(
208
336
  continue;
209
337
  }
210
338
  const cwd = s.cwd;
211
- if (!cwd || !dirs.some((d) => within(cwd, d))) continue;
339
+ // Filtered before the cap, so others' conversations never crowd out
340
+ // this workspace's.
341
+ if (
342
+ !cwd ||
343
+ !(
344
+ dirs.some((d) => within(cwd, d)) ||
345
+ exact.some((d) => path.resolve(cwd) === d)
346
+ )
347
+ )
348
+ continue;
212
349
  seen.add(f.id);
213
350
  out.push({
214
351
  id: f.id,
215
352
  title: s.title,
216
353
  lastAt: new Date(f.mtimeMs).toISOString(),
217
354
  cwd,
355
+ ...(s.model ? { model: s.model } : {}),
356
+ ...(s.mcp.length ? { mcp: s.mcp } : {}),
218
357
  });
219
358
  }
220
359
  return out;
221
360
  }
222
361
 
362
+ /** Whether a conversation that ran in `cwd` is in `scope` (AG-17.1). */
363
+ export function inScope(cwd: string, scope: ConversationScope): boolean {
364
+ return (
365
+ scope.under.flatMap(forms).some((d) => within(cwd, d)) ||
366
+ (scope.exact ?? []).flatMap(forms).some((d) => path.resolve(cwd) === d)
367
+ );
368
+ }
369
+
223
370
  /** Whether a conversation last written at `lastAt` is still open elsewhere. */
224
371
  export function openElsewhere(lastAt: string, now = Date.now()): boolean {
225
372
  const t = Date.parse(lastAt);
@@ -70,7 +70,11 @@ import { catalogFind } from "./catalog-runtime.js";
70
70
  import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
71
71
  import { claudeProjectsDir } from "../usage/paths.js";
72
72
  import { cleanName, transcriptTitle } from "./transcript-follow.js";
73
- import { claudeConversations } from "./conversations.js";
73
+ import {
74
+ claudeConversations,
75
+ transcriptBusy,
76
+ transcriptSummary,
77
+ } from "./conversations.js";
74
78
 
75
79
  /**
76
80
  * `/rename "x"` keeps the quotes in customTitle: one matching pair around
@@ -490,13 +494,31 @@ export const denClaude: Runtime = {
490
494
  return transcriptFile(token, env) !== null;
491
495
  },
492
496
  // AG-17.1: its transcripts for the folder (conversations.ts).
493
- listSessions(cwd, env = process.env) {
497
+ listSessions(scope, env = process.env) {
494
498
  try {
495
- return claudeConversations(cwd, env);
499
+ return claudeConversations(scope, env);
496
500
  } catch {
497
501
  return [];
498
502
  }
499
503
  },
504
+ // AG-17.1: the folder a conversation ran in (only the ends of its file),
505
+ // so a foreign one is refused by name.
506
+ conversationCwd(token, env = process.env) {
507
+ try {
508
+ const file = transcriptFile(token, env);
509
+ return file ? transcriptSummary(file).cwd : null;
510
+ } catch {
511
+ return null;
512
+ }
513
+ },
514
+ // AG-18: mid-turn, from its transcript's tail (never the whole file).
515
+ turnBusy(token, env = process.env) {
516
+ try {
517
+ return transcriptBusy(transcriptFile(token, env));
518
+ } catch {
519
+ return false;
520
+ }
521
+ },
500
522
  // AG-4b, unofficial (catalog sessionName): `/rename` appends a
501
523
  // {"type":"custom-title","customTitle":…} record to the transcript; the
502
524
  // latest one, read on from the last ask (never the whole file), less one
@@ -103,6 +103,25 @@ export interface AgentRecord {
103
103
  * the list tags it. Start fresh and Relaunch keep the choices.
104
104
  */
105
105
  overrides?: Override[];
106
+ /**
107
+ * AG-18: each persona change, oldest first: from, to, when, and who (a
108
+ * person's sign-in, or "cli").
109
+ */
110
+ personaChanges?: PersonaChange[];
111
+ /**
112
+ * AG-18: a persona change waiting for this agent's turn to end. The wait
113
+ * lives in the server's memory, so a restart drops it; the upkeep at
114
+ * start clears this and audits the cancel (cancelStalePersonaChanges).
115
+ */
116
+ pendingPersona?: { to: string; since: string; by?: string };
117
+ }
118
+
119
+ /** One persona change of an agent (AG-18). */
120
+ export interface PersonaChange {
121
+ from: string;
122
+ to: string;
123
+ at: string;
124
+ by?: string;
106
125
  }
107
126
 
108
127
  /** A setting a session can override for one launch (AG-10.9). */
@@ -166,6 +185,24 @@ export interface AgentUsage {
166
185
  /** An agent id: the same rules as a workspace or session name. */
167
186
  export const AGENT_ID_RE = WORKSPACE_NAME_RE;
168
187
 
188
+ /**
189
+ * AG-10.10b: the agent id a Name gives, so the name works in the CLI too:
190
+ * lowercase, spaces to "-", only id characters, at most 64. Null when
191
+ * nothing of it is left ("!!!"). The page's nameSlug is the same rule.
192
+ */
193
+ export function nameSlug(name: string): string | null {
194
+ const s = name
195
+ .trim()
196
+ .toLowerCase()
197
+ .replace(/\s+/g, "-")
198
+ .replace(/[^a-z0-9._-]/g, "")
199
+ .replace(/-{2,}/g, "-")
200
+ .replace(/^[^a-z0-9]+/, "")
201
+ .slice(0, 64)
202
+ .replace(/[-._]+$/, "");
203
+ return s && AGENT_ID_RE.test(s) ? s : null;
204
+ }
205
+
169
206
  export function agentsDir(stateDir: string): string {
170
207
  return path.join(stateDir, "agents");
171
208
  }
@@ -201,7 +201,17 @@ export interface Runtime {
201
201
  * transcript). Each id is one `resume` continues. None: this runtime
202
202
  * can't bring one in yet. Must never throw.
203
203
  */
204
- listSessions?(cwd: string, env?: NodeJS.ProcessEnv): RuntimeConversation[];
204
+ listSessions?(
205
+ scope: string | { under: string[]; exact?: string[] },
206
+ env?: NodeJS.ProcessEnv,
207
+ ): RuntimeConversation[];
208
+ /** AG-17.1: the folder conversation `token` ran in, or null. Never throws. */
209
+ conversationCwd?(token: string, env?: NodeJS.ProcessEnv): string | null;
210
+ /**
211
+ * AG-18: whether conversation `token` is mid-turn (den-claude: its
212
+ * transcript's tail). None: the runtime can't tell. Never throws.
213
+ */
214
+ turnBusy?(token: string, env?: NodeJS.ProcessEnv): boolean;
205
215
  /**
206
216
  * What the runtime prints when it exits at once (0.4.8): `noConversation`
207
217
  * when a resume finds nothing to continue, `idInUse` when a new
@@ -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;
@@ -83,6 +83,13 @@ export function githubClientId(configured: string | undefined): string {
83
83
  return (configured ?? MO_DEN_GITHUB_CLIENT_ID).trim();
84
84
  }
85
85
 
86
+ /**
87
+ * The docs section the signed-out page's Learn more opens (SEC-2a-UX):
88
+ * docs/distributions.md, "Signing in with GitHub", in the public repo.
89
+ */
90
+ export const GITHUB_SIGNIN_DOCS =
91
+ "https://github.com/Monster-Orange/den/blob/main/docs/distributions.md#signing-in-with-github";
92
+
86
93
  /** What a GitHub button says while no client id is configured. */
87
94
  export function noClientIdMessage(cli: string): string {
88
95
  return (