@hajimism/dek 0.4.0 → 0.5.1

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 (77) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +8 -7
  5. package/src/cli/address.ts +59 -15
  6. package/src/cli/commands.ts +47 -14
  7. package/src/cli/contract.ts +49 -3
  8. package/src/cli/files.ts +1 -0
  9. package/src/cli/format.ts +5 -4
  10. package/src/cli/goto.ts +41 -10
  11. package/src/cli/keep-alive.ts +12 -6
  12. package/src/cli/lint.ts +32 -13
  13. package/src/cli/ls.ts +5 -1
  14. package/src/cli/main.ts +48 -13
  15. package/src/cli/new.ts +14 -1
  16. package/src/cli/ref.ts +1 -0
  17. package/src/cli/result.ts +42 -4
  18. package/src/cli/scope.ts +40 -1
  19. package/src/cli/shot.ts +77 -6
  20. package/src/cli/show.ts +15 -2
  21. package/src/cli/text.ts +61 -9
  22. package/src/cli/theme.ts +50 -16
  23. package/src/cli/tty.ts +16 -2
  24. package/src/cli/voice.ts +11 -6
  25. package/src/core/assets.ts +18 -10
  26. package/src/core/attribute-value.ts +188 -0
  27. package/src/core/build.ts +3 -3
  28. package/src/core/css-scan.ts +173 -39
  29. package/src/core/css-transform.ts +57 -11
  30. package/src/core/css.ts +19 -13
  31. package/src/core/foreign-text.ts +138 -0
  32. package/src/core/github.ts +1 -0
  33. package/src/core/html-entities.ts +318 -0
  34. package/src/core/html-scan.ts +49 -27
  35. package/src/core/html.ts +21 -15
  36. package/src/core/lint/asset-refs.ts +44 -6
  37. package/src/core/lint/context.ts +2 -0
  38. package/src/core/lint/shared-styles.ts +26 -1
  39. package/src/core/lint/slide-html.ts +21 -18
  40. package/src/core/lint/theme-css.ts +64 -3
  41. package/src/core/lint.ts +15 -2
  42. package/src/core/mv.ts +28 -18
  43. package/src/core/order.ts +14 -0
  44. package/src/core/path.ts +8 -2
  45. package/src/core/playwright-module.d.ts +12 -1
  46. package/src/core/playwright-motion.ts +1 -0
  47. package/src/core/playwright-sheet.ts +1 -0
  48. package/src/core/playwright-visual.ts +27 -10
  49. package/src/core/playwright.ts +77 -19
  50. package/src/core/pptx.ts +1 -0
  51. package/src/core/ref-name.ts +8 -3
  52. package/src/core/ref.ts +145 -47
  53. package/src/core/resolve.ts +3 -2
  54. package/src/core/rumdl.ts +51 -25
  55. package/src/core/safe-fs.ts +19 -2
  56. package/src/core/sandbox.ts +217 -0
  57. package/src/core/shot-cache.ts +1 -0
  58. package/src/core/slide-eval-worker.ts +1 -0
  59. package/src/core/slide-measure.ts +1 -0
  60. package/src/core/slide-script-eval.ts +2 -0
  61. package/src/core/spawn.ts +136 -35
  62. package/src/core/sync.ts +3 -12
  63. package/src/core/tar.ts +315 -0
  64. package/src/core/text-contrast.ts +2 -0
  65. package/src/core/toml-keys.ts +116 -51
  66. package/src/core/url-attributes.ts +27 -9
  67. package/src/core/visual.ts +28 -9
  68. package/src/core/voice.ts +2 -1
  69. package/src/server/auth.ts +21 -0
  70. package/src/server/dev.ts +13 -5
  71. package/src/server/lock.ts +43 -13
  72. package/src/server/serial.ts +8 -2
  73. package/src/video/mux.ts +27 -3
  74. package/src/video/recorder.ts +3 -2
  75. package/src/video/worker.ts +1 -0
  76. package/src/voice/engine.ts +81 -18
  77. package/tsconfig.json +1 -1
@@ -0,0 +1,217 @@
1
+ import { DekError } from "./error.ts";
2
+ import { runJsonWorker } from "./spawn.ts";
3
+
4
+ /**
5
+ * Set to 1, the user accepts that Chromium draws without its sandbox where the sandbox cannot
6
+ * start, as in a container running as root: a ref's slides too, which are refused otherwise, and
7
+ * the project's own with no warning. Read only from the environment dek starts in (ADR 12).
8
+ */
9
+ const ALLOW_UNSANDBOXED = "DEK_ALLOW_UNSANDBOXED";
10
+
11
+ /**
12
+ * What a Playwright worker is told of Chromium's sandbox: draw only inside it, or inside it where
13
+ * it starts and without it where it does not. A worker told anything else requires it.
14
+ */
15
+ export type SandboxMode = "required" | "preferred";
16
+
17
+ /** Chromium drew without its sandbox: why, in Chromium's words, and how to have it back. */
18
+ type Unsandboxed = { reason: string; hint: string };
19
+
20
+ function accepted(): boolean {
21
+ return process.env[ALLOW_UNSANDBOXED] === "1";
22
+ }
23
+
24
+ /**
25
+ * How a worker may draw: a ref's slide scripts are someone else's, so only in the sandbox unless
26
+ * the user accepted otherwise; the project's own wherever Chromium starts (ADR 29).
27
+ */
28
+ export function sandboxMode(ref: boolean): SandboxMode {
29
+ return ref && !accepted() ? "required" : "preferred";
30
+ }
31
+
32
+ /**
33
+ * What Chromium says when its sandbox cannot start: as root, without user namespaces, or with its
34
+ * SUID helper set up wrong; then what Playwright 1.64 puts in place of Chromium's log when it holds
35
+ * one of those, which the call log after it still holds.
36
+ */
37
+ const SANDBOX_SAYS = [
38
+ /No usable sandbox!|Running as root without --no-sandbox|SUID sandbox helper|Failed to move to new namespace|Received invalid process ID for zygote/,
39
+ /Chromium sandboxing failed!/,
40
+ ];
41
+
42
+ /** A line Chromium wrote to stderr, as Playwright's call log marks it, or one it logged as FATAL. */
43
+ const CHROMIUM_SAYS = /\[err\]|FATAL/;
44
+
45
+ /** The most of a line a reason keeps, in whole sentences; it keeps the first however long. */
46
+ const REASON_CHARS = 280;
47
+
48
+ /** A sentence: up to a `.`, `!`, or `?` that ends a word, outside parentheses, as `(0 vs. 1)` is. */
49
+ const SENTENCE = /(?:\([^)]*\)|\((?!\)|$)|[^()!?.]|[.!?](?!\s|$))+(?:[.!?]+|$)/g;
50
+
51
+ /**
52
+ * What Chromium said when it failed: its words for a sandbox that cannot start, else its first
53
+ * line of error, past the call log's `- [pid=…][err]` and its own `[…:FATAL:…]`. Undefined when
54
+ * it said nothing, as when Playwright found no Chromium to start.
55
+ */
56
+ export function chromiumFailure(error: unknown): string | undefined {
57
+ const lines = (error instanceof Error ? error.message : String(error)).split("\n");
58
+ for (const pattern of [...SANDBOX_SAYS, CHROMIUM_SAYS]) {
59
+ const line = lines.find((text) => pattern.test(text));
60
+ if (line !== undefined) {
61
+ return sentences(line.replace(/^[\s-]*(?:\[[^\]]*\]\s*)*/, ""));
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ /**
68
+ * Why a launch failed, in a line: what Chromium said, else the first line of Playwright's error.
69
+ * Never the command line Playwright's call log opens with, or its advice to launch without the
70
+ * sandbox.
71
+ */
72
+ export function launchFailure(error: unknown): string {
73
+ const first = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
74
+ return chromiumFailure(error) ?? sentences(first);
75
+ }
76
+
77
+ function sentences(text: string): string {
78
+ let kept = "";
79
+ for (const [found] of text.trim().matchAll(SENTENCE)) {
80
+ const sentence = found.trim();
81
+ const next = kept ? `${kept} ${sentence}` : sentence;
82
+ if (kept && next.length > REASON_CHARS) {
83
+ break;
84
+ }
85
+ kept = next;
86
+ }
87
+ return kept || text.trim();
88
+ }
89
+
90
+ const GENERAL_HELP =
91
+ "run dekc where Chromium's sandbox can start: as a user other than root, with user namespaces allowed (on Ubuntu 23.10 and later, `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`)";
92
+
93
+ /** What lets the sandbox start, for what Chromium said. */
94
+ function sandboxHelp(reason: string): string {
95
+ if (/Running as root/.test(reason)) {
96
+ return "run dekc as a user other than root, where Chromium's sandbox can start";
97
+ }
98
+ if (/No usable sandbox|namespace|zygote|AppArmor/i.test(reason)) {
99
+ return "let Chromium make user namespaces: on Ubuntu 23.10 and later, `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, or an AppArmor profile for it; in a container, a seccomp profile that allows them";
100
+ }
101
+ return GENERAL_HELP;
102
+ }
103
+
104
+ /** How to have the sandbox back, and while the user has not, how to accept going without it. */
105
+ function unsandboxedHint(reason: string): string {
106
+ const help = sandboxHelp(reason);
107
+ return accepted() ? help : `${help}; or set ${ALLOW_UNSANDBOXED}=1 to accept drawing without it`;
108
+ }
109
+
110
+ /** Why Chromium drew without its sandbox in this run, once it has; see `takeUnsandboxed`. */
111
+ let drewUnsandboxed: string | undefined;
112
+
113
+ /**
114
+ * Says on stderr that Chromium drew without its sandbox, once a run, as lint --visual starts a
115
+ * worker or two per deck and the dev server one per pass; and keeps why, for the result. The
116
+ * project's own pages go unsaid once the user accepted it, a ref's never: it is someone else's code.
117
+ */
118
+ function noteUnsandboxed(reason: string, ref: boolean): void {
119
+ if (drewUnsandboxed === undefined && (ref || !accepted())) {
120
+ process.stderr.write(
121
+ ref
122
+ ? `warning: Chromium drew a ref without its sandbox, as ${ALLOW_UNSANDBOXED}=1 lets it: ${reason}\n help: ${sandboxHelp(reason)}\n`
123
+ : `warning: Chromium drew without its sandbox, which did not start here: ${reason}\n help: ${unsandboxedHint(reason)}\n`,
124
+ );
125
+ }
126
+ drewUnsandboxed = reason;
127
+ }
128
+
129
+ /**
130
+ * `{ unsandboxed }` when Chromium drew without its sandbox since the last call, for the result's
131
+ * `--json`, and nothing otherwise, as `skipped` is absent when every check ran. Each call starts
132
+ * the next run afresh.
133
+ */
134
+ export function takeUnsandboxed(): { unsandboxed?: Unsandboxed } {
135
+ const reason = drewUnsandboxed;
136
+ drewUnsandboxed = undefined;
137
+ return reason === undefined ? {} : { unsandboxed: { reason, hint: unsandboxedHint(reason) } };
138
+ }
139
+
140
+ /**
141
+ * A worker's answer and why it drew without the sandbox; or, having drawn nothing, why the
142
+ * sandbox did not start, and why Chromium did not start without it either when it tried.
143
+ */
144
+ type Drawn<T> =
145
+ | { answer: T; unsandboxed?: string }
146
+ | { refused: string }
147
+ | { failed: string; unsandboxed: string };
148
+
149
+ /** The worker's JSON as `parse` reads it, with what it says of the sandbox. */
150
+ function readDrawn<T>(
151
+ text: string,
152
+ sandbox: SandboxMode,
153
+ parse: (text: string) => T | null,
154
+ ): Drawn<T> | null {
155
+ let said: { unsandboxed?: unknown; failed?: unknown } | null;
156
+ try {
157
+ said = JSON.parse(text);
158
+ } catch {
159
+ return null;
160
+ }
161
+ const { unsandboxed, failed } = said ?? {};
162
+ if (unsandboxed !== undefined && typeof unsandboxed !== "string") {
163
+ return null;
164
+ }
165
+ if (typeof unsandboxed === "string" && typeof failed === "string") {
166
+ return { failed, unsandboxed };
167
+ }
168
+ if (unsandboxed !== undefined && sandbox === "required") {
169
+ return { refused: unsandboxed };
170
+ }
171
+ const answer = parse(text);
172
+ return answer === null ? null : { answer, ...(unsandboxed === undefined ? {} : { unsandboxed }) };
173
+ }
174
+
175
+ /**
176
+ * Runs a Playwright worker as `runJsonWorker` does, telling it how it may draw: `ref` pages only in
177
+ * Chromium's sandbox unless the user accepted otherwise. A draw without the sandbox is said and
178
+ * kept for the result; a ref's pages the sandbox could not hold are refused, undrawn.
179
+ */
180
+ export async function runDrawingWorker<T>(
181
+ cmd: string[],
182
+ request: object,
183
+ parse: (text: string) => T | null,
184
+ options: Parameters<typeof runJsonWorker>[3] & { ref?: boolean },
185
+ ): Promise<T> {
186
+ const { ref = false, ...worker } = options;
187
+ const sandbox = sandboxMode(ref);
188
+ const drawn = await runJsonWorker(
189
+ cmd,
190
+ { ...request, sandbox },
191
+ (text) => readDrawn(text, sandbox, parse),
192
+ worker,
193
+ );
194
+ if ("failed" in drawn) {
195
+ const first =
196
+ drawn.failed === drawn.unsandboxed ? "" : `; with it, Chromium said: ${drawn.unsandboxed}`;
197
+ throw new DekError(`Chromium did not start, with its sandbox or without: ${drawn.failed}`, {
198
+ hint: `${worker.hint}${first}`,
199
+ });
200
+ }
201
+ if ("refused" in drawn) {
202
+ // A failure that names no sandbox may be Chromium missing as well.
203
+ const missing = /sandbox|namespace|zygote|as root/i.test(drawn.refused)
204
+ ? ""
205
+ : `${worker.hint}; or `;
206
+ throw new DekError(
207
+ `Chromium did not start with its sandbox here, and a ref is drawn only inside it: ${drawn.refused}`,
208
+ {
209
+ hint: `${missing}${sandboxHelp(drawn.refused)}; if you accept running this ref's slide scripts without the sandbox, set ${ALLOW_UNSANDBOXED}=1 and run again`,
210
+ },
211
+ );
212
+ }
213
+ if (drawn.unsandboxed !== undefined) {
214
+ noteUnsandboxed(drawn.unsandboxed, ref);
215
+ }
216
+ return drawn.answer;
217
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ // biome-ignore lint/style/noRestrictedImports: rmSync, which prunes old entries by name in the folder outputDir returned, and takes a link, not its target.
2
3
  import { readdirSync, rmSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { escapeRegExp } from "./escape.ts";
@@ -6,6 +6,7 @@
6
6
  * that never ends is cut off; the parent's timeout covers anything that
7
7
  * escapes into the microtask queue.
8
8
  */
9
+ // biome-ignore lint/style/noRestrictedImports: writeSync, to stdout (fd 1), so each answer is out before the worker is killed or exits.
9
10
  import { readFileSync, writeSync } from "node:fs";
10
11
  import vm from "node:vm";
11
12
 
@@ -251,6 +251,7 @@ export function measureSlideInPage(): SlideMeasure {
251
251
  const own = squash(textNodes.map((node) => node.textContent ?? "").join(" "));
252
252
  // innerText separates block children; textContent would run "item 0item 1" together.
253
253
  // SVG and MathML have no innerText, so they fall back to textContent.
254
+ // biome-ignore lint/performance/useDomNodeTextContent: the rendered line breaks are the point here.
254
255
  const whole = el instanceof HTMLElement ? el.innerText : el.textContent;
255
256
  const text = own || squash(whole ?? "");
256
257
  const parentEl = el.parentElement;
@@ -28,6 +28,7 @@ export function slideScriptsProblems(scripts: ScriptInput[]): SlideScriptProblem
28
28
  const { results, pending } = prepareScripts(scripts);
29
29
  const codes = uncached(pending);
30
30
  if (codes.length > 0) {
31
+ // biome-ignore lint/nursery/noJsRestrictedProperties: lint's path is synchronous, the scripts get an empty environment, and a killed worker's lines still count; runPiped does none of these.
31
32
  const stdout = Bun.spawnSync(evaluatorCommand(), {
32
33
  ...EVALUATOR_OPTIONS,
33
34
  stdin: Buffer.from(JSON.stringify(codes)),
@@ -48,6 +49,7 @@ export async function evaluateSlideScripts(
48
49
  const { results, pending } = prepareScripts(scripts);
49
50
  const codes = uncached(pending);
50
51
  if (codes.length > 0) {
52
+ // biome-ignore lint/nursery/noJsRestrictedProperties: as for spawnSync above; SIGTERM ends the worker, as no host object reaches its scripts.
51
53
  const child = Bun.spawn(evaluatorCommand(), {
52
54
  ...EVALUATOR_OPTIONS,
53
55
  stdin: Buffer.from(JSON.stringify(codes)),
package/src/core/spawn.ts CHANGED
@@ -1,18 +1,5 @@
1
1
  import { DekError } from "./error.ts";
2
2
 
3
- export async function awaitPiped(proc: {
4
- stdout: ReadableStream<Uint8Array> | number;
5
- stderr: ReadableStream<Uint8Array> | number;
6
- exited: Promise<number>;
7
- }): Promise<{ stdout: string; stderr: string; exitCode: number }> {
8
- const [stdout, stderr, exitCode] = await Promise.all([
9
- new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
10
- new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
11
- proc.exited,
12
- ]);
13
- return { stdout, stderr, exitCode };
14
- }
15
-
16
3
  /**
17
4
  * How dek runs bun: with no auto install, and without the working directory's `.env` or
18
5
  * `bunfig.toml`, which a repository someone else wrote can commit to set variables or preload code.
@@ -28,6 +15,15 @@ export function workerCommand(bin: string, args: string[] = []): string[] {
28
15
  /** A browser that hangs must not hang dek; a whole visual run or capture fits well inside this. */
29
16
  const DEFAULT_WORKER_TIMEOUT_MS = 10 * 60 * 1000;
30
17
 
18
+ /** How long a command stopped at its timeout has to exit on SIGTERM before it gets SIGKILL. */
19
+ const KILL_GRACE_MS = 2000;
20
+
21
+ /**
22
+ * How long the output is still read once the command has exited. A process it started and left
23
+ * running holds the pipes open as long as it lives, so what came by then is the output.
24
+ */
25
+ const DRAIN_MS = 1000;
26
+
31
27
  type PipedOptions = {
32
28
  /** The error message when the command cannot start or overruns, such as "ffmpeg failed". */
33
29
  label: string;
@@ -37,9 +33,20 @@ type PipedOptions = {
37
33
  stdin?: string;
38
34
  /** Covers the whole run, from spawn to exit. */
39
35
  timeoutMs: number;
36
+ /** Where the command runs; dek's working directory when left out. */
37
+ cwd?: string;
40
38
  };
41
39
 
42
- type Piped = { stdout: string; stderr: string; exitCode: number };
40
+ type Piped = {
41
+ stdout: string;
42
+ stderr: string;
43
+ exitCode: number;
44
+ /** Set when the output was let go before it ended, `DRAIN_MS` after the command exited. */
45
+ cutShort?: true;
46
+ };
47
+
48
+ /** What `runPiped` throws for a command it stopped at its timeout, unlike one that cannot start. */
49
+ export class OverrunError extends DekError {}
43
50
 
44
51
  /**
45
52
  * Runs `cmd` to its exit with its output piped back. A command that cannot start, or runs past
@@ -48,36 +55,125 @@ type Piped = { stdout: string; stderr: string; exitCode: number };
48
55
  export async function runPiped(cmd: string[], options: PipedOptions): Promise<Piped> {
49
56
  let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
50
57
  try {
51
- proc = Bun.spawn(cmd, { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
58
+ proc = Bun.spawn(cmd, {
59
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
60
+ // The environment as it is now: left out, Bun passes the one dek started with, which holds
61
+ // what a `.env` set before dek took it out (see dropEnvFileVariables).
62
+ env: process.env,
63
+ stdin: "pipe",
64
+ stdout: "pipe",
65
+ stderr: "pipe",
66
+ });
52
67
  } catch (error) {
53
68
  throw new DekError(options.label, { hint: options.hint, cause: error });
54
69
  }
55
- let timedOut = false;
56
- const timer = setTimeout(() => {
57
- timedOut = true;
58
- proc.kill();
59
- }, options.timeoutMs);
70
+ // Read before writing: a command that answers while still reading must not block on a full pipe.
71
+ const output = readOutput(proc);
72
+ const stop = stopAfter(proc, options.timeoutMs);
73
+ const overrun = () =>
74
+ new OverrunError(options.label, {
75
+ hint: `it did not finish within ${Math.round(options.timeoutMs / 1000)}s and was stopped`,
76
+ });
60
77
  try {
61
- // Read before writing: a command that answers while still reading must not block on a full pipe.
62
- const piped = awaitPiped(proc);
63
- if (options.stdin !== undefined) {
64
- proc.stdin.write(options.stdin);
78
+ try {
79
+ if (options.stdin !== undefined) {
80
+ // A write the pipe cannot take at once returns a promise, which can reject with the pipe.
81
+ await proc.stdin.write(options.stdin);
82
+ }
83
+ await proc.stdin.end();
84
+ } catch {
85
+ // The pipe broke: the command exited without reading all of its stdin, as `head` does, or
86
+ // was killed at its timeout while a write waited on it. Its exit says which, and how it went.
65
87
  }
66
- await proc.stdin.end();
67
- const result = await piped;
68
- if (timedOut) {
69
- throw new DekError(options.label, {
70
- hint: `it did not finish within ${Math.round(options.timeoutMs / 1000)}s and was stopped`,
71
- });
88
+ const exitCode = await proc.exited;
89
+ // It finished in time, however long what it left behind keeps the pipes open.
90
+ stop.clear();
91
+ const { stdout, stderr, cutShort } = await output.drain();
92
+ if (stop.fired()) {
93
+ throw overrun();
72
94
  }
73
- return result;
95
+ return { stdout, stderr, exitCode, ...(cutShort ? { cutShort } : {}) };
74
96
  } catch (error) {
75
97
  if (error instanceof DekError) {
76
98
  throw error;
77
99
  }
78
100
  throw new DekError(options.label, { hint: options.hint, cause: error });
79
101
  } finally {
80
- clearTimeout(timer);
102
+ stop.clear();
103
+ output.cancel();
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Stops `proc` once `timeoutMs` passes: SIGTERM, then SIGKILL if it is still running
109
+ * `KILL_GRACE_MS` later. `clear` calls off the timeout, never a SIGKILL already on its way.
110
+ *
111
+ * Only the command is signalled, not what it started: a command that forks a child and is killed
112
+ * leaves the child running. `bun add -d rumdl` puts a Node wrapper in front of rumdl that runs the
113
+ * binary with spawnSync, so a rumdl that hangs is stopped and its binary lives on as an orphan;
114
+ * the drain keeps it from holding dek. Killing the whole group would need the command in a session
115
+ * of its own (`detached`), where the terminal's Ctrl-C no longer reaches it.
116
+ */
117
+ function stopAfter(
118
+ proc: Bun.Subprocess,
119
+ timeoutMs: number,
120
+ ): { fired: () => boolean; clear: () => void } {
121
+ let fired = false;
122
+ const timer = setTimeout(() => {
123
+ fired = true;
124
+ proc.kill();
125
+ const kill = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
126
+ void proc.exited.finally(() => clearTimeout(kill));
127
+ }, timeoutMs);
128
+ return { fired: () => fired, clear: () => clearTimeout(timer) };
129
+ }
130
+
131
+ /**
132
+ * The command's stdout and stderr, read as they come. `drain`, once it has exited, waits
133
+ * `DRAIN_MS` at most for both to end, and says when it had to let go first (`cutShort`); `cancel`
134
+ * lets go of both, which an open pipe needs before dek can exit.
135
+ */
136
+ function readOutput(proc: Bun.Subprocess<"pipe", "pipe", "pipe">): {
137
+ drain: () => Promise<{ stdout: string; stderr: string; cutShort: boolean }>;
138
+ cancel: () => void;
139
+ } {
140
+ const readers = [proc.stdout.getReader(), proc.stderr.getReader()];
141
+ const texts = Promise.all(readers.map(readToEnd));
142
+ // Awaited by `drain`; a read that fails before then must not go unhandled.
143
+ texts.catch(() => {});
144
+ const cancel = () => {
145
+ for (const reader of readers) {
146
+ reader.cancel().catch(() => {});
147
+ }
148
+ };
149
+ return {
150
+ drain: async () => {
151
+ let cutShort = false;
152
+ const timer = setTimeout(() => {
153
+ cutShort = true;
154
+ cancel();
155
+ }, DRAIN_MS);
156
+ try {
157
+ const [stdout = "", stderr = ""] = await texts;
158
+ return { stdout, stderr, cutShort };
159
+ } finally {
160
+ clearTimeout(timer);
161
+ }
162
+ },
163
+ cancel,
164
+ };
165
+ }
166
+
167
+ /** The text a reader reads until its stream ends or is cancelled. */
168
+ async function readToEnd(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
169
+ const decoder = new TextDecoder();
170
+ let text = "";
171
+ for (;;) {
172
+ const { done, value } = await reader.read();
173
+ if (done) {
174
+ return text + decoder.decode();
175
+ }
176
+ text += decoder.decode(value, { stream: true });
81
177
  }
82
178
  }
83
179
 
@@ -94,7 +190,8 @@ type JsonWorkerOptions = {
94
190
  * Runs a worker that takes one JSON request on stdin and answers with JSON on
95
191
  * stdout. Every failure is a DekError named `label`: a worker that exits
96
192
  * non-zero contributes its stderr as the hint, one that overruns the timeout
97
- * is killed and says so, and output `parse` rejects is invalid JSON.
193
+ * is killed and says so, and output `parse` rejects is invalid JSON, or cut
194
+ * short when a process the worker left behind held it open.
98
195
  */
99
196
  export async function runJsonWorker<T>(
100
197
  cmd: string[],
@@ -102,7 +199,7 @@ export async function runJsonWorker<T>(
102
199
  parse: (stdout: string) => T | null,
103
200
  options: JsonWorkerOptions,
104
201
  ): Promise<T> {
105
- const { stdout, stderr, exitCode } = await runPiped(cmd, {
202
+ const { stdout, stderr, exitCode, cutShort } = await runPiped(cmd, {
106
203
  label: options.label,
107
204
  hint: options.hint,
108
205
  stdin: JSON.stringify(request),
@@ -113,7 +210,11 @@ export async function runJsonWorker<T>(
113
210
  }
114
211
  const parsed = parse(stdout);
115
212
  if (parsed === null) {
116
- throw new DekError(options.label, { hint: "worker returned invalid JSON" });
213
+ throw new DekError(options.label, {
214
+ hint: cutShort
215
+ ? "the worker exited while a process it started still held its output, so its answer was cut short; run the command again"
216
+ : "worker returned invalid JSON",
217
+ });
117
218
  }
118
219
  return parsed;
119
220
  }
package/src/core/sync.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig } from "./config.ts";
4
4
  import { type DeckPaths, deckPaths } from "./deck-paths.ts";
5
+ import { foreignLine, TITLE_MAX } from "./foreign-text.ts";
5
6
  import { parseRefSource, refDir, refTitle } from "./ref.ts";
6
7
  import { asResolvedDeck, listSlides, type ResolvedDeck, SLIDE_SIDECARS } from "./resolve.ts";
7
8
  import { outputDir, readSourceIfExists, removeInside, writeInside } from "./safe-fs.ts";
@@ -293,23 +294,13 @@ For commands, run \`dekc help --agent\`.
293
294
  `;
294
295
  }
295
296
 
296
- /** The longest ref title AGENTS.md shows: a title names a deck, it does not describe one. */
297
- const REF_TITLE_MAX = 80;
298
-
299
297
  /**
300
298
  * A ref's title as it goes into AGENTS.md. Someone else wrote it, and agents read dek's block as
301
299
  * dek's own guidance, so a title must not start a line of its own there, such as a heading or an
302
- * instruction: line breaks and control characters become one space, and it is cut to
303
- * REF_TITLE_MAX characters.
300
+ * instruction, nor hold what a person cannot see: it is one foreignLine of TITLE_MAX.
304
301
  */
305
302
  function refTitleLine(title: string | undefined): string | undefined {
306
- // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
307
- const line = title?.replace(/[\s\x00-\x1f\x7f-\x9f]+/gu, " ").trim();
308
- if (!line) {
309
- return undefined;
310
- }
311
- const chars = [...line];
312
- return chars.length > REF_TITLE_MAX ? `${chars.slice(0, REF_TITLE_MAX - 1).join("")}…` : line;
303
+ return title === undefined ? undefined : foreignLine(title, TITLE_MAX) || undefined;
313
304
  }
314
305
 
315
306
  /**