@monster-orange/den 0.4.12 → 0.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +189 -11
  4. package/package.json +1 -1
  5. package/src/shared/agents/conversations.ts +227 -0
  6. package/src/shared/agents/default-pack/be-dev.md +1 -0
  7. package/src/shared/agents/default-pack/devops.md +1 -0
  8. package/src/shared/agents/default-pack/fe-dev.md +1 -0
  9. package/src/shared/agents/default-pack/orchestrator.md +3 -1
  10. package/src/shared/agents/default-pack/planner.md +1 -0
  11. package/src/shared/agents/default-pack/reviewer.md +1 -0
  12. package/src/shared/agents/default-pack/tester.md +16 -0
  13. package/src/shared/agents/default-pack/ux-designer.md +1 -0
  14. package/src/shared/agents/den-claude.ts +128 -13
  15. package/src/shared/agents/permissions.ts +108 -0
  16. package/src/shared/agents/persona-store.ts +286 -0
  17. package/src/shared/agents/personas.ts +116 -12
  18. package/src/shared/agents/records.ts +33 -0
  19. package/src/shared/agents/roles.ts +288 -0
  20. package/src/shared/agents/runtime.ts +50 -0
  21. package/src/shared/den-config.ts +22 -1
  22. package/src/shared/environment-manager/service-runner.ts +31 -12
  23. package/src/shared/environment-manager/workspaces.ts +11 -0
  24. package/src/ui/cli/agent-ops.ts +796 -29
  25. package/src/ui/cli/agents-command.ts +97 -3
  26. package/src/ui/cli/env-probe.ts +4 -1
  27. package/src/ui/mcp/api.ts +12 -1
  28. package/src/ui/mcp/tools.ts +60 -1
  29. package/src/ui/server/agent-resume-grant.ts +35 -0
  30. package/src/ui/server/agent-routes.ts +412 -22
  31. package/src/ui/server/public/agent-start.js +3 -2
  32. package/src/ui/server/public/agents-page.js +693 -49
  33. package/src/ui/server/public/agents.html +76 -15
  34. package/src/ui/server/public/i18n/es.js +143 -0
  35. package/src/ui/server/public/i18n/ja.js +144 -0
  36. package/src/ui/server/public/persona-ui.css +563 -0
  37. package/src/ui/server/public/persona-ui.js +796 -0
  38. package/src/ui/server/public/settings-page.js +4 -9
  39. package/src/ui/server/public/settings-registry.js +22 -0
  40. package/src/ui/server/public/settings.html +1 -0
  41. package/src/ui/server/public/shell.js +140 -0
  42. package/src/ui/server/public/topbar.js +2 -2
  43. package/src/ui/server/request-guard.ts +17 -4
  44. package/src/ui/server/route-classes.ts +31 -0
  45. package/src/ui/server/session-routes.ts +21 -5
  46. package/src/ui/server/ui-assets.ts +3 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * AG-17.1, bring in a conversation: the runtime's own recent conversations
3
+ * for a folder, read from its history without ever reading a whole
4
+ * transcript.
5
+ *
6
+ * Claude Code keeps one JSON-lines file per conversation,
7
+ * `<projects>/<slug>/<id>.jsonl`, where <slug> is the cwd with every
8
+ * character that isn't a letter or digit made "-" (`/w/a.b` → `-w-a-b`).
9
+ * The slug alone can't tell `/w/north` from `/w/north-2`, so a
10
+ * conversation is listed only when the `cwd` its own records carry is the
11
+ * folder or inside it.
12
+ *
13
+ * Each file is read at its two ends only (ENDS_BYTES each): the head for
14
+ * its cwd and its first prompt, the tail for its latest `/rename`
15
+ * (custom-title). Title: the latest custom-title, else the first prompt a
16
+ * person typed, else null. lastAt is the file's mtime: a conversation
17
+ * written in the last OPEN_ELSEWHERE_MS is taken to be open in another
18
+ * terminal (one writer only).
19
+ */
20
+ import * as fs from "fs";
21
+ import * as path from "path";
22
+ import { claudeProjectsDir } from "../usage/paths.js";
23
+ import { cleanName } from "./transcript-follow.js";
24
+
25
+ /** One of a runtime's conversations, as Bring in lists it. */
26
+ export interface RuntimeConversation {
27
+ /** The runtime's own id for it (Claude Code: the transcript's name). */
28
+ id: string;
29
+ /** Its name for people, or null when it has none yet. */
30
+ title: string | null;
31
+ /** When it was last written (ISO). */
32
+ lastAt: string;
33
+ /** The folder it ran in. */
34
+ cwd: string;
35
+ }
36
+
37
+ /** Bytes read at each end of a transcript. */
38
+ export const ENDS_BYTES = 64 * 1024;
39
+ /** Written this recently: still open in another terminal (AG-17.1). */
40
+ export const OPEN_ELSEWHERE_MS = 60_000;
41
+ /** The most conversations listed for one folder (the newest). */
42
+ export const MAX_CONVERSATIONS = 50;
43
+
44
+ export const OPEN_ELSEWHERE_MESSAGE =
45
+ "Still open in another terminal. Close it there first";
46
+
47
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
48
+
49
+ /** Claude Code's project folder name for `dir`. */
50
+ export function claudeSlug(dir: string): string {
51
+ return dir.replace(/[^A-Za-z0-9]/g, "-");
52
+ }
53
+
54
+ /** Whether `p` is `dir` or inside it. */
55
+ function within(p: string, dir: string): boolean {
56
+ const rel = path.relative(dir, p);
57
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
58
+ }
59
+
60
+ /**
61
+ * The complete lines at the two ends of `file`: at most `bytes` from each.
62
+ * A line cut at either edge is left out; a short file is all head.
63
+ */
64
+ export function readEnds(
65
+ file: string,
66
+ bytes = ENDS_BYTES,
67
+ ): { head: string[]; tail: string[] } {
68
+ const fd = fs.openSync(file, "r");
69
+ try {
70
+ const size = fs.fstatSync(fd).size;
71
+ const read = (at: number, n: number) => {
72
+ const buf = Buffer.alloc(n);
73
+ const got = fs.readSync(fd, buf, 0, n, at);
74
+ return buf.subarray(0, got).toString("utf-8");
75
+ };
76
+ const headText = read(0, Math.min(size, bytes));
77
+ const head = headText.split("\n");
78
+ // The whole file: every line. Else the last piece was cut.
79
+ if (size <= bytes) return { head: head.filter(Boolean), tail: [] };
80
+ head.pop();
81
+ const start = Math.max(bytes, size - bytes);
82
+ const tail = read(start, size - start).split("\n");
83
+ // The first piece was cut (or is the head's).
84
+ tail.shift();
85
+ return { head: head.filter(Boolean), tail: tail.filter(Boolean) };
86
+ } finally {
87
+ fs.closeSync(fd);
88
+ }
89
+ }
90
+
91
+ function parse(line: string): Record<string, unknown> | null {
92
+ try {
93
+ const v = JSON.parse(line) as unknown;
94
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** The text a person typed in a user record, or null (tool results, meta). */
101
+ function promptText(rec: Record<string, unknown>): string | null {
102
+ if (rec.type !== "user" || rec.isMeta === true) return null;
103
+ const msg = rec.message as { content?: unknown } | undefined;
104
+ let text: unknown = msg?.content;
105
+ if (Array.isArray(text)) {
106
+ const first = text.find(
107
+ (c) => (c as { type?: unknown })?.type === "text",
108
+ ) as { text?: unknown } | undefined;
109
+ text = first?.text;
110
+ }
111
+ if (typeof text !== "string") return null;
112
+ // Slash commands and their output come wrapped in tags: not a prompt.
113
+ if (text.trimStart().startsWith("<")) return null;
114
+ return cleanName(text);
115
+ }
116
+
117
+ /** A transcript's cwd and title, from its two ends. */
118
+ export function transcriptSummary(
119
+ file: string,
120
+ bytes = ENDS_BYTES,
121
+ ): { cwd: string | null; title: string | null } {
122
+ const { head, tail } = readEnds(file, bytes);
123
+ let cwd: string | null = null;
124
+ let prompt: string | null = null;
125
+ let title: string | null = null;
126
+ const titleIn = (line: string) => {
127
+ if (!line.includes("custom-title")) return;
128
+ const r = parse(line);
129
+ if (r?.type === "custom-title") title = cleanName(r.customTitle) ?? title;
130
+ };
131
+ for (const line of head) {
132
+ titleIn(line);
133
+ if (cwd && prompt) continue;
134
+ if (!line.includes('"cwd"') && !line.includes('"user"')) continue;
135
+ const r = parse(line);
136
+ if (!r) continue;
137
+ if (!cwd && typeof r.cwd === "string" && path.isAbsolute(r.cwd))
138
+ cwd = r.cwd;
139
+ prompt ??= promptText(r);
140
+ }
141
+ for (const line of tail) titleIn(line);
142
+ return { cwd, title: title ?? prompt };
143
+ }
144
+
145
+ /** A folder's absolute forms: as given and its real path. */
146
+ function forms(dir: string): string[] {
147
+ const out = [path.resolve(dir)];
148
+ try {
149
+ const real = fs.realpathSync(dir);
150
+ if (!out.includes(real)) out.push(real);
151
+ } catch {
152
+ /* gone: as given */
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * Claude Code's conversations that ran in `folder` or a folder inside it,
159
+ * newest first, at most MAX_CONVERSATIONS. Never throws: a history it
160
+ * can't read is no conversations.
161
+ */
162
+ export function claudeConversations(
163
+ folder: string,
164
+ env: NodeJS.ProcessEnv = process.env,
165
+ ): RuntimeConversation[] {
166
+ const root = claudeProjectsDir(env);
167
+ const dirs = forms(folder);
168
+ const slugs = dirs.map(claudeSlug);
169
+ let projects: string[];
170
+ try {
171
+ projects = fs.readdirSync(root);
172
+ } catch {
173
+ return [];
174
+ }
175
+ const files: { id: string; file: string; mtimeMs: number }[] = [];
176
+ for (const p of projects) {
177
+ if (!slugs.some((s) => p === s || p.startsWith(`${s}-`))) continue;
178
+ let names: string[];
179
+ try {
180
+ names = fs.readdirSync(path.join(root, p));
181
+ } catch {
182
+ continue;
183
+ }
184
+ for (const n of names) {
185
+ if (!n.endsWith(".jsonl")) continue;
186
+ const id = n.slice(0, -".jsonl".length);
187
+ if (!ID_RE.test(id)) continue;
188
+ const file = path.join(root, p, n);
189
+ try {
190
+ const st = fs.statSync(file);
191
+ if (st.isFile() && st.size > 0)
192
+ files.push({ id, file, mtimeMs: st.mtimeMs });
193
+ } catch {
194
+ /* gone */
195
+ }
196
+ }
197
+ }
198
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
199
+ const out: RuntimeConversation[] = [];
200
+ const seen = new Set<string>();
201
+ for (const f of files) {
202
+ if (out.length >= MAX_CONVERSATIONS) break;
203
+ if (seen.has(f.id)) continue;
204
+ let s: ReturnType<typeof transcriptSummary>;
205
+ try {
206
+ s = transcriptSummary(f.file);
207
+ } catch {
208
+ continue;
209
+ }
210
+ const cwd = s.cwd;
211
+ if (!cwd || !dirs.some((d) => within(cwd, d))) continue;
212
+ seen.add(f.id);
213
+ out.push({
214
+ id: f.id,
215
+ title: s.title,
216
+ lastAt: new Date(f.mtimeMs).toISOString(),
217
+ cwd,
218
+ });
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /** Whether a conversation last written at `lastAt` is still open elsewhere. */
224
+ export function openElsewhere(lastAt: string, now = Date.now()): boolean {
225
+ const t = Date.parse(lastAt);
226
+ return Number.isFinite(t) && now - t < OPEN_ELSEWHERE_MS;
227
+ }
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: be-dev
3
+ role: sde
3
4
  description: Implements back-end stories, test first, keeping contracts stable.
4
5
  context:
5
6
  [
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: devops
3
+ role: sde
3
4
  description: Builds and maintains pipelines, environments and service configuration.
4
5
  context: [the story, the workspace's service manifests, CI configuration]
5
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: fe-dev
3
+ role: sde
3
4
  description: Implements front-end stories, test first, to the design system.
4
5
  context:
5
6
  [
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: orchestrator
3
+ role: orchestrator
3
4
  description: Takes the next ready story, picks the persona for it and starts that agent.
4
5
  standing: true
5
6
  context: [ready work items, running agents in the workspace]
@@ -9,7 +10,8 @@ You are the orchestrator. Keep work flowing without overloading the machine
9
10
  or the people reviewing it.
10
11
 
11
12
  - Take the next ready story, in priority order. Pick the persona that fits it:
12
- fe-dev, be-dev or devops for implementation, ux-designer for design work.
13
+ fe-dev, be-dev or devops for implementation, ux-designer for design work,
14
+ tester to run the suites.
13
15
  - Start one agent per story, in its own worktree, and link it to the story.
14
16
  - Respect the workspace's concurrency cap; wait rather than queueing more.
15
17
  - When a pull request is opened, ask the reviewer for a review. Never approve
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: planner
3
+ role: planner
3
4
  description: Writes stories and their acceptance criteria for the workspace.
4
5
  standing: true
5
6
  context: [the workspace's specs and docs, open work items, the design system]
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: reviewer
3
+ role: reviewer
3
4
  description: Reviews a pull request adversarially. Never approves or merges.
4
5
  context: [the pull request and its story, the repo's conventions and tests]
5
6
  ---
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: tester
3
+ role: tester
4
+ description: Runs the test suites and reports failures with repro steps.
5
+ context: [the story and its acceptance criteria, the repo's test commands]
6
+ ---
7
+
8
+ You are the tester. Find out what is broken before anyone ships it.
9
+
10
+ - Run the workspace's unit, integration and end-to-end suites, as the repo
11
+ documents them.
12
+ - For each failure, write the exact repro: the smallest failing command, what
13
+ it printed, and what you expected.
14
+ - Separate real failures from flaky ones; run a suspected flake again before
15
+ reporting it.
16
+ - Don't fix code. Report what you found to the person or agent who asked.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: ux-designer
3
+ role: designer
3
4
  description: Designs flows and screens with the design system, on request.
4
5
  context: [the story, the design system, existing screens of the product]
5
6
  ---
@@ -61,12 +61,27 @@ import {
61
61
  type LaunchOutput,
62
62
  type Runtime,
63
63
  type RuntimeDetect,
64
+ type RuntimeModel,
65
+ type RuntimePermissions,
64
66
  } from "./runtime.js";
67
+ import { PERMISSION_INTENTS, type PermissionIntent } from "./permissions.js";
65
68
  import { builtinEntry } from "./catalog.js";
66
69
  import { catalogFind } from "./catalog-runtime.js";
67
70
  import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
68
71
  import { claudeProjectsDir } from "../usage/paths.js";
69
- import { transcriptTitle } from "./transcript-follow.js";
72
+ import { cleanName, transcriptTitle } from "./transcript-follow.js";
73
+ import { claudeConversations } from "./conversations.js";
74
+
75
+ /**
76
+ * `/rename "x"` keeps the quotes in customTitle: one matching pair around
77
+ * the whole name goes, for display only (0.4.13). Nothing inside: null.
78
+ */
79
+ export function unquoteTitle(name: string | null): string | null {
80
+ if (!name || name.length < 2) return name;
81
+ const q = name[0];
82
+ if ((q !== '"' && q !== "'") || name[name.length - 1] !== q) return name;
83
+ return cleanName(name.slice(1, -1));
84
+ }
70
85
 
71
86
  export const DEN_CLAUDE_ID = "den-claude";
72
87
  const BIN = "claude";
@@ -90,12 +105,91 @@ export const PERMISSION_MODES = [
90
105
  ] as const;
91
106
  export const DEFAULT_PERMISSION_MODE = "default";
92
107
 
93
- /** The persona's permission mode, checked; a bad one names the key. */
108
+ /**
109
+ * Den's permission intents (AG-13) as Claude Code's modes:
110
+ * https://code.claude.com/docs/en/iam (permission modes): `default` asks
111
+ * on first use of each tool, `acceptEdits` accepts file edits for the
112
+ * session, `bypassPermissions` skips every prompt.
113
+ */
114
+ export const INTENT_MODE: Record<PermissionIntent, string> = {
115
+ ask: "default",
116
+ edits: "acceptEdits",
117
+ all: "bypassPermissions",
118
+ };
119
+
120
+ /**
121
+ * One plain command prefix as Claude Code allow rules (the same docs,
122
+ * Bash rules): the command alone, and the command with arguments (`*`
123
+ * matches any run of characters). Deny rules (Den's state dir) still win:
124
+ * Claude Code checks deny before allow.
125
+ */
126
+ export const COMMAND_RULES = ["Bash({c})", "Bash({c} *)"];
127
+
128
+ /** How den-claude expresses permission intents (AG-13.2). */
129
+ export const DEN_CLAUDE_PERMISSIONS: RuntimePermissions = {
130
+ modes: {
131
+ ask: "--permission-mode default: asks before edits and commands",
132
+ edits:
133
+ "--permission-mode acceptEdits: file edits run without asking; commands ask",
134
+ all: "--permission-mode bypassPermissions: nothing asks",
135
+ },
136
+ commandRules: COMMAND_RULES,
137
+ };
138
+
139
+ /** A command prefix's allow rules (permissions.ts checked it). */
140
+ export function allowRules(commands: readonly string[]): string[] {
141
+ return commands.flatMap((c) =>
142
+ COMMAND_RULES.map((r) => r.split("{c}").join(c)),
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Claude Code's models (AG-12.3): the aliases `claude --model` takes,
148
+ * each "latest" (the alias tracks its newest model) or pinned to one full
149
+ * model id. Claude Code has no command that lists models, so the list
150
+ * ships with each Den release (https://code.claude.com/docs/en/model-config:
151
+ * aliases opus, sonnet, haiku, or a full model name). Newest first.
152
+ */
153
+ export const DEN_CLAUDE_MODELS: readonly RuntimeModel[] = [
154
+ {
155
+ id: "opus",
156
+ label: "Opus",
157
+ versions: [
158
+ "claude-opus-5-5",
159
+ "claude-opus-5",
160
+ "claude-opus-4-8",
161
+ "claude-opus-4-7",
162
+ "claude-opus-4-6",
163
+ ],
164
+ latest: "claude-opus-5-5",
165
+ },
166
+ {
167
+ id: "sonnet",
168
+ label: "Sonnet",
169
+ versions: ["claude-sonnet-5-5", "claude-sonnet-5", "claude-sonnet-4-6"],
170
+ latest: "claude-sonnet-5-5",
171
+ },
172
+ {
173
+ id: "haiku",
174
+ label: "Haiku",
175
+ versions: ["claude-haiku-5-5", "claude-haiku-4-5"],
176
+ latest: "claude-haiku-5-5",
177
+ },
178
+ ];
179
+
180
+ /**
181
+ * The mode to launch with: the agent's intent (Start › Advanced), else the
182
+ * persona's `permission_mode`: one of Den's intents, or one of Claude
183
+ * Code's own modes (as before AG-13). A bad one names the key.
184
+ */
94
185
  function permissionMode(input: LaunchInput): string {
186
+ if (input.permission) return INTENT_MODE[input.permission];
95
187
  const m = input.persona.permissionMode ?? DEFAULT_PERMISSION_MODE;
188
+ if ((PERMISSION_INTENTS as readonly string[]).includes(m))
189
+ return INTENT_MODE[m as PermissionIntent];
96
190
  if (!(PERMISSION_MODES as readonly string[]).includes(m))
97
191
  throw new LaunchError(
98
- `${input.persona.source}: permission_mode "${m.replace(/[^\x20-\x7e]/g, "?").slice(0, 40)}" must be one of ${PERMISSION_MODES.join(", ")}`,
192
+ `${input.persona.source}: permission_mode "${m.replace(/[^\x20-\x7e]/g, "?").slice(0, 40)}" must be one of ${[...PERMISSION_INTENTS, ...PERMISSION_MODES].join(", ")}`,
99
193
  );
100
194
  return m;
101
195
  }
@@ -244,7 +338,9 @@ function common(input: LaunchInput): {
244
338
  }),
245
339
  )
246
340
  : null;
247
- const settings = input.stateDir || hooks ? settingsPath(input) : null;
341
+ const allow = allowRules(input.allow ?? []);
342
+ const settings =
343
+ input.stateDir || hooks || allow.length ? settingsPath(input) : null;
248
344
  return {
249
345
  argv: [
250
346
  "--mcp-config",
@@ -268,13 +364,18 @@ function common(input: LaunchInput): {
268
364
  ? {
269
365
  [settings]: `${JSON.stringify(
270
366
  {
271
- ...(input.stateDir
367
+ ...(input.stateDir || allow.length
272
368
  ? {
273
369
  permissions: {
274
- deny: stateDirDenyRules(
275
- input.stateDir,
276
- input.env?.HOME,
277
- ),
370
+ ...(allow.length ? { allow } : {}),
371
+ ...(input.stateDir
372
+ ? {
373
+ deny: stateDirDenyRules(
374
+ input.stateDir,
375
+ input.env?.HOME,
376
+ ),
377
+ }
378
+ : {}),
278
379
  },
279
380
  }
280
381
  : {}),
@@ -289,7 +390,9 @@ function common(input: LaunchInput): {
289
390
  warnings:
290
391
  mode === "bypassPermissions"
291
392
  ? [
292
- `⚠ persona ${input.persona.id} sets permission_mode: bypassPermissions: Claude Code will not ask before running any tool`,
393
+ input.permission === "all"
394
+ ? `⚠ agent ${input.agentId} starts with auto-approve all (bypassPermissions): Claude Code will not ask before running any tool`
395
+ : `⚠ persona ${input.persona.id} sets permission_mode: bypassPermissions: Claude Code will not ask before running any tool`,
293
396
  ]
294
397
  : [],
295
398
  };
@@ -347,6 +450,9 @@ export const denClaude: Runtime = {
347
450
  denTools: true,
348
451
  firstPrompt: true,
349
452
  find: DEN_CLAUDE_FIND,
453
+ models: () =>
454
+ DEN_CLAUDE_MODELS.map((m) => ({ ...m, versions: [...m.versions] })),
455
+ permissions: DEN_CLAUDE_PERMISSIONS,
350
456
  detect,
351
457
  launch(input): LaunchOutput {
352
458
  const bin = binFor(input);
@@ -383,14 +489,23 @@ export const denClaude: Runtime = {
383
489
  conversationExists(token, env = process.env) {
384
490
  return transcriptFile(token, env) !== null;
385
491
  },
492
+ // AG-17.1: its transcripts for the folder (conversations.ts).
493
+ listSessions(cwd, env = process.env) {
494
+ try {
495
+ return claudeConversations(cwd, env);
496
+ } catch {
497
+ return [];
498
+ }
499
+ },
386
500
  // AG-4b, unofficial (catalog sessionName): `/rename` appends a
387
501
  // {"type":"custom-title","customTitle":…} record to the transcript; the
388
- // latest one, read on from the last ask (never the whole file). Null on
389
- // any failure: it must never break a pane.
502
+ // latest one, read on from the last ask (never the whole file), less one
503
+ // pair of surrounding quotes. Null on any failure: it must never break a
504
+ // pane.
390
505
  sessionName(rec, env = process.env) {
391
506
  try {
392
507
  const file = transcriptFile(rec.resumeToken, env);
393
- return file ? transcriptTitle(file) : null;
508
+ return file ? unquoteTitle(transcriptTitle(file)) : null;
394
509
  } catch {
395
510
  return null;
396
511
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Permissions as intent (AG-13): what a person chooses, before any
3
+ * runtime's syntax.
4
+ *
5
+ * ask Ask for everything (the default)
6
+ * edits Auto-approve edits in the worktree
7
+ * all Auto-approve all (a session started so is always tagged)
8
+ *
9
+ * And extra allowed commands: plain command prefixes ("npm test"), one per
10
+ * entry, no patterns. Each runtime translates both into its own launch
11
+ * config (den-claude.ts); a runtime that can't express one says so, and it
12
+ * is never silently dropped. What a persona pack shares is this intent
13
+ * only, never vendor syntax.
14
+ */
15
+
16
+ export const PERMISSION_INTENTS = ["ask", "edits", "all"] as const;
17
+ export type PermissionIntent = (typeof PERMISSION_INTENTS)[number];
18
+ export const DEFAULT_INTENT: PermissionIntent = "ask";
19
+
20
+ /**
21
+ * A persona's `permission_mode` as an intent. Den's three intents are
22
+ * themselves; Claude Code's own modes from before AG-13 read as the intent
23
+ * they meant (default, acceptEdits, bypassPermissions). Anything else
24
+ * (Claude Code's plan, a typo) is not an intent: undefined, and the
25
+ * runtime decides (den-claude refuses a mode it doesn't know).
26
+ */
27
+ export function intentOf(
28
+ mode: string | undefined,
29
+ ): PermissionIntent | undefined {
30
+ if (mode === undefined) return DEFAULT_INTENT;
31
+ if ((PERMISSION_INTENTS as readonly string[]).includes(mode))
32
+ return mode as PermissionIntent;
33
+ if (mode === "default") return "ask";
34
+ if (mode === "acceptEdits") return "edits";
35
+ if (mode === "bypassPermissions") return "all";
36
+ return undefined;
37
+ }
38
+
39
+ /** The most extra commands one persona or launch takes. */
40
+ export const MAX_COMMANDS = 32;
41
+ /** The longest command prefix. */
42
+ export const MAX_COMMAND_LEN = 120;
43
+ /**
44
+ * What a plain prefix is made of: letters, digits, spaces and . _ / : = @
45
+ * % + , -. No shell syntax (quotes, ;, &, |, $, backticks, redirects) and
46
+ * no pattern characters (* ? [ ] ^ ( ) { }): Den translates a prefix, it
47
+ * never passes one through a shell.
48
+ */
49
+ const PREFIX_RE = /^[A-Za-z0-9][A-Za-z0-9 ._/:=@%+,-]*$/;
50
+ const PATTERN_RE = /[*?[\]^$(){}|]/;
51
+
52
+ export type CommandProblem = "pattern" | "invalid" | "long";
53
+
54
+ /** Why `c` isn't a plain prefix, or null when it is. */
55
+ export function commandProblem(c: string): CommandProblem | null {
56
+ if ([...c].length > MAX_COMMAND_LEN) return "long";
57
+ if (PATTERN_RE.test(c)) return "pattern";
58
+ if (!PREFIX_RE.test(c) || /\s{2,}/.test(c) || c !== c.trim())
59
+ return "invalid";
60
+ return null;
61
+ }
62
+
63
+ const shown = (v: unknown) =>
64
+ (JSON.stringify(v) ?? "undefined").replace(/[^\x20-\x7e]/g, "?").slice(0, 60);
65
+
66
+ /**
67
+ * A list of extra allowed commands, checked: an array of plain prefixes
68
+ * (each trimmed, duplicates dropped), at most MAX_COMMANDS. undefined or
69
+ * null is none. Anything else throws, naming the entry and why.
70
+ */
71
+ export function checkCommands(v: unknown, where: string): string[] {
72
+ if (v === undefined || v === null) return [];
73
+ if (!Array.isArray(v))
74
+ throw new Error(`${where}: must be a list of command prefixes`);
75
+ if (v.length > MAX_COMMANDS)
76
+ throw new Error(`${where}: at most ${MAX_COMMANDS} commands`);
77
+ const out: string[] = [];
78
+ v.forEach((x, i) => {
79
+ if (typeof x !== "string")
80
+ throw new Error(`${where}[${i}]: must be a string`);
81
+ const c = x.trim();
82
+ const why = c ? commandProblem(c) : "invalid";
83
+ if (why === "pattern")
84
+ throw new Error(
85
+ `${where}[${i}]: ${shown(c)} is a pattern: use a plain prefix ("npm test")`,
86
+ );
87
+ if (why === "long")
88
+ throw new Error(`${where}[${i}]: at most ${MAX_COMMAND_LEN} characters`);
89
+ if (why)
90
+ throw new Error(
91
+ `${where}[${i}]: ${shown(c)} must be a plain prefix: letters, digits, single spaces and . _ / : = @ % + , -`,
92
+ );
93
+ if (!out.includes(c)) out.push(c);
94
+ });
95
+ return out;
96
+ }
97
+
98
+ /** An intent, checked; a usage message naming `where` otherwise. */
99
+ export function checkIntent(v: unknown, where: string): PermissionIntent {
100
+ if (
101
+ typeof v === "string" &&
102
+ (PERMISSION_INTENTS as readonly string[]).includes(v)
103
+ )
104
+ return v as PermissionIntent;
105
+ throw new Error(
106
+ `${where}: ${shown(v)} must be one of ${PERMISSION_INTENTS.join(", ")}`,
107
+ );
108
+ }