@monster-orange/den 0.4.11 → 0.4.13

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 (50) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +212 -12
  4. package/package.json +2 -1
  5. package/src/shared/agents/catalog.ts +77 -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 +151 -17
  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 +57 -0
  19. package/src/shared/agents/roles.ts +288 -0
  20. package/src/shared/agents/runtime.ts +49 -0
  21. package/src/shared/agents/session-name.ts +110 -0
  22. package/src/shared/agents/transcript-follow.ts +180 -0
  23. package/src/shared/den-config.ts +27 -1
  24. package/src/ui/cli/agent-ops.ts +578 -11
  25. package/src/ui/cli/agents-command.ts +12 -2
  26. package/src/ui/cli/env-probe.ts +4 -1
  27. package/src/ui/mcp/tools.ts +1 -1
  28. package/src/ui/server/agent-paths.ts +52 -0
  29. package/src/ui/server/agent-routes.ts +329 -19
  30. package/src/ui/server/agents-layout-pref.ts +43 -0
  31. package/src/ui/server/editor-open-pref.ts +41 -0
  32. package/src/ui/server/index.ts +22 -0
  33. package/src/ui/server/public/agent-start.js +3 -2
  34. package/src/ui/server/public/agents-page.js +1037 -67
  35. package/src/ui/server/public/agents-section.js +1 -1
  36. package/src/ui/server/public/agents.html +236 -20
  37. package/src/ui/server/public/header.js +2 -0
  38. package/src/ui/server/public/i18n/es.js +139 -0
  39. package/src/ui/server/public/i18n/ja.js +139 -0
  40. package/src/ui/server/public/persona-ui.css +539 -0
  41. package/src/ui/server/public/persona-ui.js +796 -0
  42. package/src/ui/server/public/settings-page.js +4 -9
  43. package/src/ui/server/public/settings-registry.js +66 -0
  44. package/src/ui/server/public/settings.html +1 -0
  45. package/src/ui/server/public/shell.js +137 -0
  46. package/src/ui/server/public/term-input.js +129 -0
  47. package/src/ui/server/public/topbar.js +22 -4
  48. package/src/ui/server/route-classes.ts +29 -0
  49. package/src/ui/server/session-routes.ts +34 -1
  50. package/src/ui/server/ui-assets.ts +8 -0
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Roles and persona categories (AG-10.5, AG-11): two small registries.
3
+ *
4
+ * A role is a template and a dispatch key. Den owns planner, orchestrator,
5
+ * sde, reviewer, tester and designer. Each has default instructions, a
6
+ * permission mode and a command preset (plain command prefixes the agent
7
+ * may run without asking). Picking a role in New persona fills the form in
8
+ * (never over text already there), and the role adds nothing hidden to an
9
+ * agent's prompt: what it receives is the persona's instructions.
10
+ *
11
+ * A distribution adds roles in its config (`agents.roles`), and may extend
12
+ * a Den role's preset there, never redefine it:
13
+ *
14
+ * agents:
15
+ * roles:
16
+ * copywriter: { description: …, instructions: …, mode: ask, preset: [], category: marketing }
17
+ * reviewer: { preset: [semgrep] } # adds to Den's preset only
18
+ *
19
+ * Categories group personas in the Start list and Settings › Personas.
20
+ * Den's are Engineering, Design, Product, Marketing and Operations; a
21
+ * distribution adds more (`agents.categories: [{ id, label }]`).
22
+ */
23
+ import { checkCommands, PERMISSION_INTENTS } from "./permissions.js";
24
+ import type { PermissionIntent } from "./permissions.js";
25
+
26
+ /** Where a role or category came from (hub: later, once Den Hub exists). */
27
+ export type RegistrySource = "den" | "distribution" | "project" | "hub";
28
+
29
+ export interface RoleTemplate {
30
+ id: string;
31
+ source: RegistrySource;
32
+ /** One line: what a persona of this role does. */
33
+ description: string;
34
+ /** The starting instructions New persona fills in. */
35
+ instructions: string;
36
+ /** The starting permission mode. */
37
+ mode: PermissionIntent;
38
+ /** Plain command prefixes allowed without asking (AG-13.3). */
39
+ preset: string[];
40
+ /** The category a new persona of this role starts in. */
41
+ category: string;
42
+ /** Set when a distribution extended a Den role's preset. */
43
+ extendedBy?: RegistrySource;
44
+ /** The commands that extension added (shown apart from Den's). */
45
+ extension?: string[];
46
+ }
47
+
48
+ export interface Category {
49
+ id: string;
50
+ label: string;
51
+ source: RegistrySource;
52
+ }
53
+
54
+ /** A role or category id: lowercase letters, digits and "-", 1-32 long. */
55
+ export const REGISTRY_ID_RE = /^[a-z][a-z0-9-]{0,31}$/;
56
+
57
+ /** Den's categories, in the order lists show them. */
58
+ export const DEN_CATEGORIES: readonly Category[] = [
59
+ { id: "engineering", label: "Engineering", source: "den" },
60
+ { id: "design", label: "Design", source: "den" },
61
+ { id: "product", label: "Product", source: "den" },
62
+ { id: "marketing", label: "Marketing", source: "den" },
63
+ { id: "operations", label: "Operations", source: "den" },
64
+ ];
65
+
66
+ /**
67
+ * Den's roles. The presets are read-only commands only: a preset is
68
+ * allowed without asking, so Den's defaults never let an agent change
69
+ * anything unasked.
70
+ */
71
+ export const DEN_ROLES: readonly RoleTemplate[] = [
72
+ {
73
+ id: "planner",
74
+ source: "den",
75
+ description:
76
+ "Turns goals into small, ready stories with acceptance criteria.",
77
+ instructions:
78
+ "Read the workspace's specs and open work items. Split goals into stories small enough for one pull request, each with testable acceptance criteria. Hand ready stories to the orchestrator. Don't write code.",
79
+ mode: "ask",
80
+ preset: ["gh issue list", "gh issue view"],
81
+ category: "engineering",
82
+ },
83
+ {
84
+ id: "orchestrator",
85
+ source: "den",
86
+ description:
87
+ "Hands ready stories to other agents and follows them to done.",
88
+ instructions:
89
+ "Take the next ready story in priority order and start the persona that fits it, one agent per story. Respect the workspace's concurrency cap. Ask a person before anything irreversible; never approve or merge.",
90
+ mode: "ask",
91
+ preset: ["gh issue list", "gh pr view"],
92
+ category: "engineering",
93
+ },
94
+ {
95
+ id: "sde",
96
+ source: "den",
97
+ description: "Implements one story, test first, and opens a pull request.",
98
+ instructions:
99
+ "Take one story. Write a failing test for each acceptance criterion first, then make it pass. Keep the change small, run the repo's checks, and open a pull request that says what you verified.",
100
+ mode: "ask",
101
+ preset: ["git status", "git diff", "git log"],
102
+ category: "engineering",
103
+ },
104
+ {
105
+ id: "reviewer",
106
+ source: "den",
107
+ description:
108
+ "Reviews a pull request adversarially. Never approves or merges.",
109
+ instructions:
110
+ "Check the change against its story's acceptance criteria, then look for what wasn't tested: edge cases, failure paths, security. Verify each finding in the code and report them ranked by impact. Never approve, merge or push.",
111
+ mode: "ask",
112
+ preset: ["git diff", "git log", "gh pr diff", "gh pr view"],
113
+ category: "engineering",
114
+ },
115
+ {
116
+ id: "tester",
117
+ source: "den",
118
+ description: "Runs the test suites and reports failures with repro steps.",
119
+ instructions:
120
+ "Run the workspace's unit, integration and end-to-end suites. For each failure, write the exact repro and the smallest failing command. Don't fix code.",
121
+ mode: "ask",
122
+ preset: ["git status"],
123
+ category: "engineering",
124
+ },
125
+ {
126
+ id: "designer",
127
+ source: "den",
128
+ description: "Designs flows and screens with the design system.",
129
+ instructions:
130
+ "Start from the user's goal and the story's acceptance criteria. Compose from the design system's components, and describe every state (empty, loading, error, success) and narrow screens. Hand off a short spec the developers can build and test.",
131
+ mode: "ask",
132
+ preset: [],
133
+ category: "design",
134
+ },
135
+ ];
136
+
137
+ const isMapping = (v: unknown): v is Record<string, unknown> =>
138
+ v !== null && typeof v === "object" && !Array.isArray(v);
139
+ const shown = (v: unknown) =>
140
+ (JSON.stringify(v) ?? "undefined").replace(/[^\x20-\x7e]/g, "?").slice(0, 60);
141
+
142
+ function line(where: string, v: unknown, max: number): string {
143
+ if (
144
+ typeof v !== "string" ||
145
+ !v.trim() ||
146
+ [...v].length > max ||
147
+ /[\p{Cc}\p{Cf}]/u.test(v)
148
+ )
149
+ throw new Error(
150
+ `${where}: must be one line of 1-${max} characters, no control characters`,
151
+ );
152
+ return v.trim();
153
+ }
154
+
155
+ /**
156
+ * `agents.categories` checked: a list of { id, label }. A Den category's
157
+ * id is refused (it can't be renamed). Anything else throws, naming it.
158
+ */
159
+ export function checkCategoriesConfig(raw: unknown): Category[] {
160
+ if (raw === undefined || raw === null) return [];
161
+ if (!Array.isArray(raw))
162
+ throw new Error("agents.categories: must be a list of { id, label }");
163
+ const seen = new Set(DEN_CATEGORIES.map((c) => c.id));
164
+ return raw.map((x, i) => {
165
+ const where = `agents.categories[${i}]`;
166
+ if (!isMapping(x))
167
+ throw new Error(`${where}: must be a mapping { id, label }`);
168
+ for (const k of Object.keys(x))
169
+ if (k !== "id" && k !== "label")
170
+ throw new Error(`${where}.${k}: unknown key (takes id, label)`);
171
+ if (typeof x.id !== "string" || !REGISTRY_ID_RE.test(x.id))
172
+ throw new Error(
173
+ `${where}.id: ${shown(x.id)} must be lowercase letters, digits and "-" (1-32)`,
174
+ );
175
+ if (seen.has(x.id))
176
+ throw new Error(`${where}.id: "${x.id}" is already a category`);
177
+ seen.add(x.id);
178
+ return {
179
+ id: x.id,
180
+ label: line(`${where}.label`, x.label, 40),
181
+ source: "distribution" as const,
182
+ };
183
+ });
184
+ }
185
+
186
+ /** What `agents.roles` holds, checked (merged with Den's by roleRegistry). */
187
+ export interface RolesConfig {
188
+ /** New roles. */
189
+ added: RoleTemplate[];
190
+ /** Den roles' preset extensions, by role id. */
191
+ extended: Record<string, string[]>;
192
+ }
193
+
194
+ /**
195
+ * `agents.roles` checked. A Den role takes `preset` only (an extension);
196
+ * a new role takes description, instructions, mode, preset and category.
197
+ * `categories` are the ids a new role's category may name.
198
+ */
199
+ export function checkRolesConfig(
200
+ raw: unknown,
201
+ categories: readonly string[] = DEN_CATEGORIES.map((c) => c.id),
202
+ ): RolesConfig {
203
+ const out: RolesConfig = { added: [], extended: {} };
204
+ if (raw === undefined || raw === null) return out;
205
+ if (!isMapping(raw))
206
+ throw new Error(
207
+ "agents.roles: must be a mapping of role id to its template",
208
+ );
209
+ for (const [id, v] of Object.entries(raw)) {
210
+ const where = `agents.roles.${id}`;
211
+ if (!REGISTRY_ID_RE.test(id))
212
+ throw new Error(
213
+ `${where}: a role id must be lowercase letters, digits and "-" (1-32)`,
214
+ );
215
+ if (!isMapping(v)) throw new Error(`${where}: must be a mapping`);
216
+ const den = DEN_ROLES.find((r) => r.id === id);
217
+ if (den) {
218
+ for (const k of Object.keys(v))
219
+ if (k !== "preset")
220
+ throw new Error(
221
+ `${where}.${k}: ${id} is Den's role: a distribution may only extend its preset`,
222
+ );
223
+ out.extended[id] = checkCommands(v.preset, `${where}.preset`);
224
+ continue;
225
+ }
226
+ const keys = ["description", "instructions", "mode", "preset", "category"];
227
+ for (const k of Object.keys(v))
228
+ if (!keys.includes(k))
229
+ throw new Error(
230
+ `${where}.${k}: unknown key (takes ${keys.join(", ")})`,
231
+ );
232
+ const mode = v.mode ?? "ask";
233
+ if (!(PERMISSION_INTENTS as readonly unknown[]).includes(mode))
234
+ throw new Error(
235
+ `${where}.mode: ${shown(mode)} must be one of ${PERMISSION_INTENTS.join(", ")}`,
236
+ );
237
+ const category = v.category ?? "engineering";
238
+ if (typeof category !== "string" || !categories.includes(category))
239
+ throw new Error(
240
+ `${where}.category: ${shown(category)} is not a category (${categories.join(", ")})`,
241
+ );
242
+ const instructions = v.instructions ?? "";
243
+ if (typeof instructions !== "string" || instructions.length > 4000)
244
+ throw new Error(
245
+ `${where}.instructions: must be text of up to 4000 characters`,
246
+ );
247
+ out.added.push({
248
+ id,
249
+ source: "distribution",
250
+ description: line(`${where}.description`, v.description, 200),
251
+ instructions: instructions.trim(),
252
+ mode: mode as PermissionIntent,
253
+ preset: checkCommands(v.preset, `${where}.preset`),
254
+ category,
255
+ });
256
+ }
257
+ return out;
258
+ }
259
+
260
+ /** Den's roles with a distribution's extensions, then its new roles. */
261
+ export function roleRegistry(
262
+ cfg: RolesConfig = { added: [], extended: {} },
263
+ ): RoleTemplate[] {
264
+ const den = DEN_ROLES.map((r) => {
265
+ const ext = cfg.extended[r.id];
266
+ if (!ext?.length) return { ...r, preset: [...r.preset] };
267
+ return {
268
+ ...r,
269
+ preset: [...new Set([...r.preset, ...ext])],
270
+ extendedBy: "distribution" as const,
271
+ extension: [...ext],
272
+ };
273
+ });
274
+ return [...den, ...cfg.added];
275
+ }
276
+
277
+ /** Den's categories, then a distribution's. */
278
+ export function categoryRegistry(extra: readonly Category[] = []): Category[] {
279
+ return [...DEN_CATEGORIES, ...extra];
280
+ }
281
+
282
+ /** A role's command preset ([] for none, or a role that isn't registered). */
283
+ export function rolePreset(
284
+ roles: readonly RoleTemplate[],
285
+ role: string | undefined,
286
+ ): string[] {
287
+ return role ? (roles.find((r) => r.id === role)?.preset ?? []) : [];
288
+ }
@@ -17,8 +17,10 @@ import {
17
17
  type StrategyEntry,
18
18
  } from "../strategies/registry.js";
19
19
  import type { CatalogEntry } from "./catalog.js";
20
+ import type { AgentRecord } from "./records.js";
20
21
  import type { Persona } from "./personas.js";
21
22
  import type { BinSource, RuntimeFind } from "./runtime-search.js";
23
+ import type { PermissionIntent } from "./permissions.js";
22
24
 
23
25
  export const RUNTIME_KIND = "runtime";
24
26
 
@@ -94,6 +96,16 @@ export interface LaunchInput {
94
96
  * session rotates (0: never). Den's hooks count them. Default 3.
95
97
  */
96
98
  rotateAfter?: number;
99
+ /**
100
+ * The permission intent to launch with (AG-13): the agent's, else its
101
+ * persona's `permission_mode`. Only a runtime with `permissions` takes it.
102
+ */
103
+ permission?: PermissionIntent;
104
+ /**
105
+ * Plain command prefixes allowed without asking (AG-13.3, 13.4): the
106
+ * role's preset and the extra commands, checked (permissions.ts).
107
+ */
108
+ allow?: string[];
97
109
  /**
98
110
  * The new conversation's first prompt (a relaunch from handoff.md).
99
111
  * Only a runtime with `firstPrompt: true` is given one.
@@ -115,6 +127,29 @@ export interface LaunchOutput {
115
127
 
116
128
  export type ResumeInput = LaunchInput;
117
129
 
130
+ /**
131
+ * A model a runtime offers (AG-12.3): its id as the Model list shows it,
132
+ * and the versions it can be pinned to, newest first. "Latest" tracks the
133
+ * newest release; a pinned version is one of `versions`.
134
+ */
135
+ export interface RuntimeModel {
136
+ id: string;
137
+ label: string;
138
+ versions: string[];
139
+ latest: string;
140
+ }
141
+
142
+ /**
143
+ * How a runtime expresses Den's permission intents (AG-13.2), as its own
144
+ * launch config: a read-only preview of each mode ("What this allows on
145
+ * <runtime>"), and how one plain command prefix becomes its rules
146
+ * (`{c}` the prefix). The runtime's launch writes exactly these.
147
+ */
148
+ export interface RuntimePermissions {
149
+ modes: Record<PermissionIntent, string>;
150
+ commandRules: string[];
151
+ }
152
+
118
153
  export interface Runtime {
119
154
  /** "den-claude", "generic". */
120
155
  id: string;
@@ -141,6 +176,13 @@ export interface Runtime {
141
176
  * firstPrompt): Relaunch from handoff needs it (den#4).
142
177
  */
143
178
  firstPrompt?: boolean;
179
+ /**
180
+ * The models it offers (AG-12.3), from the catalog shipped with each Den
181
+ * release; none: it takes no model choice from Den.
182
+ */
183
+ models?(): RuntimeModel[];
184
+ /** How it expresses permission intents (AG-13); none: it can't yet. */
185
+ permissions?: RuntimePermissions;
144
186
  /** Throws LaunchError when this persona or this machine can't run it. */
145
187
  launch(input: LaunchInput): LaunchOutput;
146
188
  /** A new run that continues the last one; null when there is nothing to resume. */
@@ -157,6 +199,13 @@ export interface Runtime {
157
199
  * conversation's id is taken. Den then tries the other way, once.
158
200
  */
159
201
  launchFailures?: { noConversation?: RegExp; idInUse?: RegExp };
202
+ /**
203
+ * The name the runtime gives the agent's conversation (AG-4b: Claude
204
+ * Code's `/rename`), or null when it has none or can't say. Optional:
205
+ * a runtime without it has Den's rename only. Must be quick and never
206
+ * throw; its catalog entry's `sessionName` says how stable its source is.
207
+ */
208
+ sessionName?(rec: AgentRecord, env?: NodeJS.ProcessEnv): string | null;
160
209
  /** Where permission prompts surface (Q-AG-2). */
161
210
  approvals?: "terminal" | "hook";
162
211
  /**
@@ -0,0 +1,110 @@
1
+ /**
2
+ * An agent's session name (AG-4b): what its pane header adds after
3
+ * `{workspace} · {runtime}`, and what its tab, the Agents list and ⌘K show.
4
+ *
5
+ * Two sources, and the most recent rename wins:
6
+ *
7
+ * - Den's own: a person double-clicked its tab (the record's displayName,
8
+ * made at displayNameAt). Every runtime has it.
9
+ * - The runtime's, when its adapter has `sessionName` (Runtime, AG-4b):
10
+ * Claude Code's `/rename`. Its time is when Den first saw it, kept in
11
+ * the agent's files dir (session-name.json, with the conversation's
12
+ * token: Start fresh forgets it). An adapter that fails or throws is no
13
+ * name; it never breaks the list.
14
+ *
15
+ * Den never writes a name back into the runtime.
16
+ */
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import { agentFilesDir, writePrivate, type AgentRecord } from "./records.js";
20
+ import { getRuntime } from "./runtimes.js";
21
+ import { cleanName } from "./transcript-follow.js";
22
+
23
+ export {
24
+ FOLLOW_MAX_BYTES,
25
+ followState,
26
+ oscTitles,
27
+ resetFollowers,
28
+ transcriptTitle,
29
+ } from "./transcript-follow.js";
30
+
31
+ interface Seen {
32
+ token: string | null;
33
+ name: string;
34
+ /** When Den first saw it (ms). */
35
+ at: number;
36
+ }
37
+
38
+ const seenFile = (stateDir: string, id: string) =>
39
+ path.join(agentFilesDir(stateDir, id), "session-name.json");
40
+
41
+ function readSeen(file: string): Seen | null {
42
+ try {
43
+ const v = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<Seen>;
44
+ if (typeof v.name !== "string" || typeof v.at !== "number") return null;
45
+ return {
46
+ token: typeof v.token === "string" ? v.token : null,
47
+ name: v.name,
48
+ at: v.at,
49
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** The runtime's name for the agent's conversation, with when it was first seen. */
56
+ function runtimeName(
57
+ rec: AgentRecord,
58
+ stateDir: string,
59
+ env: NodeJS.ProcessEnv,
60
+ now: () => number,
61
+ ): Seen | null {
62
+ const rt = getRuntime(rec.runtime);
63
+ if (!rt?.sessionName) return null;
64
+ const token = rec.resumeToken ?? null;
65
+ const file = seenFile(stateDir, rec.id);
66
+ let seen = readSeen(file);
67
+ if (seen && seen.token !== token) seen = null;
68
+ let name: string | null = null;
69
+ try {
70
+ name = cleanName(rt.sessionName(rec, env));
71
+ } catch {
72
+ name = null;
73
+ }
74
+ // null: nothing new (not caught up, not found); the last name stays.
75
+ if (name && name !== seen?.name) {
76
+ seen = { token, name, at: now() };
77
+ try {
78
+ writePrivate(file, JSON.stringify(seen) + "\n");
79
+ } catch {
80
+ /* shown, not kept: it is seen again next time */
81
+ }
82
+ }
83
+ return seen;
84
+ }
85
+
86
+ /**
87
+ * The agent's session name: the latest of Den's rename and the runtime's,
88
+ * or undefined when neither has one. Never throws.
89
+ */
90
+ export function agentSessionName(
91
+ rec: AgentRecord,
92
+ stateDir: string,
93
+ env: NodeJS.ProcessEnv = process.env,
94
+ now: () => number = Date.now,
95
+ ): string | undefined {
96
+ let rt: Seen | null = null;
97
+ try {
98
+ rt = runtimeName(rec, stateDir, env, now);
99
+ } catch {
100
+ rt = null;
101
+ }
102
+ const den = rec.displayName
103
+ ? {
104
+ name: rec.displayName,
105
+ at: Date.parse(rec.displayNameAt ?? rec.startedAt) || 0,
106
+ }
107
+ : null;
108
+ if (den && rt) return rt.at > den.at ? rt.name : den.name;
109
+ return (den ?? rt)?.name;
110
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Reading a session's name from what a runtime writes (AG-4b), without
3
+ * ever reading a whole file at once:
4
+ *
5
+ * - transcriptTitle(file): the latest `{"type":"custom-title",
6
+ * "customTitle":…}` record in a JSON-lines transcript (Claude Code's
7
+ * `/rename`). Each ask reads on from where the last one stopped, at
8
+ * most FOLLOW_MAX_BYTES, so a long transcript is caught up over a few
9
+ * asks; a file that got shorter is read again from the start. Only
10
+ * lines that mention "custom-title" are parsed. Any failure is null.
11
+ * - oscTitles(chunk, carry): the terminal titles (OSC 0 and OSC 2) in a
12
+ * PTY's output, for runtimes the catalog marks `titleFromOsc`.
13
+ *
14
+ * A name is cleaned for the page: control, format and line-separator
15
+ * characters go, spaces are squeezed, and it is cut at MAX_NAME.
16
+ */
17
+ import * as fs from "fs";
18
+
19
+ /** The most one ask reads of a transcript. */
20
+ export const FOLLOW_MAX_BYTES = 1024 * 1024;
21
+ const CHUNK = 64 * 1024;
22
+ /** A line longer than this is no title record: skipped to its end. */
23
+ const MAX_LINE = 64 * 1024;
24
+ /** The longest name kept. */
25
+ export const MAX_NAME = 80;
26
+ /** The most files followed at once (the oldest is forgotten first). */
27
+ const MAX_FOLLOWED = 256;
28
+
29
+ interface Follow {
30
+ offset: number;
31
+ title: string | null;
32
+ /** The start of a line whose newline hasn't come yet. */
33
+ rest: Buffer;
34
+ /** Inside a line too long to be a title: drop bytes to its newline. */
35
+ skipping: boolean;
36
+ }
37
+
38
+ const followers = new Map<string, Follow>();
39
+
40
+ /** A name as the page may show it, or null when nothing is left. */
41
+ export function cleanName(v: unknown): string | null {
42
+ if (typeof v !== "string") return null;
43
+ const s = v
44
+ .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ")
45
+ .replace(/\s+/g, " ")
46
+ .trim();
47
+ if (!s) return null;
48
+ return [...s].slice(0, MAX_NAME).join("").trim() || null;
49
+ }
50
+
51
+ function titleOf(line: Buffer): string | null | undefined {
52
+ if (!line.includes("custom-title")) return undefined;
53
+ try {
54
+ const rec = JSON.parse(line.toString("utf-8")) as {
55
+ type?: unknown;
56
+ customTitle?: unknown;
57
+ };
58
+ if (rec?.type !== "custom-title") return undefined;
59
+ return cleanName(rec.customTitle) ?? undefined;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /** The latest custom-title in `file` (read on from the last ask), or null. */
66
+ export function transcriptTitle(file: string): string | null {
67
+ let fd: number | undefined;
68
+ try {
69
+ const st = fs.statSync(file);
70
+ if (!st.isFile()) {
71
+ followers.delete(file);
72
+ return null;
73
+ }
74
+ let f = followers.get(file);
75
+ if (!f || st.size < f.offset) {
76
+ f = { offset: 0, title: null, rest: Buffer.alloc(0), skipping: false };
77
+ followers.delete(file);
78
+ if (followers.size >= MAX_FOLLOWED)
79
+ followers.delete(followers.keys().next().value as string);
80
+ followers.set(file, f);
81
+ }
82
+ if (st.size === f.offset) return f.title;
83
+ fd = fs.openSync(file, "r");
84
+ const end = Math.min(st.size, f.offset + FOLLOW_MAX_BYTES);
85
+ const buf = Buffer.alloc(CHUNK);
86
+ while (f.offset < end) {
87
+ const n = fs.readSync(
88
+ fd,
89
+ buf,
90
+ 0,
91
+ Math.min(CHUNK, end - f.offset),
92
+ f.offset,
93
+ );
94
+ if (n <= 0) break;
95
+ f.offset += n;
96
+ let data = buf.subarray(0, n);
97
+ for (;;) {
98
+ const nl = data.indexOf(0x0a);
99
+ if (nl < 0) {
100
+ if (!f.skipping) {
101
+ f.rest = Buffer.concat([f.rest, data]);
102
+ if (f.rest.length > MAX_LINE) {
103
+ f.rest = Buffer.alloc(0);
104
+ f.skipping = true;
105
+ }
106
+ }
107
+ break;
108
+ }
109
+ const piece = data.subarray(0, nl);
110
+ data = data.subarray(nl + 1);
111
+ if (f.skipping) {
112
+ f.skipping = false;
113
+ continue;
114
+ }
115
+ const line = f.rest.length ? Buffer.concat([f.rest, piece]) : piece;
116
+ f.rest = Buffer.alloc(0);
117
+ const t = titleOf(line);
118
+ if (t) f.title = t;
119
+ }
120
+ }
121
+ return f.title;
122
+ } catch {
123
+ return null;
124
+ } finally {
125
+ if (fd !== undefined)
126
+ try {
127
+ fs.closeSync(fd);
128
+ } catch {
129
+ /* closed */
130
+ }
131
+ }
132
+ }
133
+
134
+ /** Where a followed file is up to (tests). */
135
+ export function followState(file: string): { offset: number } | undefined {
136
+ const f = followers.get(file);
137
+ return f ? { offset: f.offset } : undefined;
138
+ }
139
+
140
+ /** Forget every followed file (tests). */
141
+ export function resetFollowers(): void {
142
+ followers.clear();
143
+ }
144
+
145
+ /** The most of an unended OSC kept between chunks. */
146
+ const MAX_CARRY = 1024;
147
+ const OSC_START = "\u001b]";
148
+
149
+ /**
150
+ * The terminal titles (OSC 0 and OSC 2, ended by BEL or ST) in `chunk`,
151
+ * with `carry` the unended OSC the last chunk left; returns what this one
152
+ * leaves. Every other OSC (a link, the cwd) is not a title.
153
+ */
154
+ export function oscTitles(
155
+ chunk: string,
156
+ carry: string,
157
+ ): { titles: string[]; carry: string } {
158
+ const s = carry + chunk;
159
+ const titles: string[] = [];
160
+ let i = 0;
161
+ for (;;) {
162
+ const at = s.indexOf(OSC_START, i);
163
+ if (at < 0) {
164
+ // A lone ESC at the very end may start an OSC in the next chunk.
165
+ return { titles, carry: s.endsWith("\u001b") ? "\u001b" : "" };
166
+ }
167
+ const bel = s.indexOf("\u0007", at + 2);
168
+ const st = s.indexOf("\u001b\\", at + 2);
169
+ const ends = [bel, st].filter((x) => x >= 0);
170
+ if (!ends.length) return { titles, carry: s.slice(at, at + MAX_CARRY) };
171
+ const stop = Math.min(...ends);
172
+ const body = s.slice(at + 2, stop);
173
+ const m = /^([02]);([\s\S]*)$/.exec(body);
174
+ if (m) {
175
+ const t = cleanName(m[2]);
176
+ if (t) titles.push(t);
177
+ }
178
+ i = stop + (stop === bel ? 1 : 2);
179
+ }
180
+ }