@yaag/extension 0.2.1 → 0.4.0

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.
package/README.md CHANGED
@@ -35,11 +35,25 @@ For a throwaway session instead: `pi -e ./packages/extension`.
35
35
 
36
36
  | Parameter | Type | Meaning |
37
37
  |---|---|---|
38
- | `file` | `string` | Path to the Orchestration Program file |
38
+ | `file` | `string?` | Path to the Orchestration Program file |
39
+ | `script` | `string?` | Orchestration Program source text (Inline Program) |
39
40
  | `args` | `string?` | The program's arguments, as a JSON object string |
40
41
  | `background` | `boolean?` | Start the Run in the background and return its Run id |
41
42
  | `record` | `string?` | Write this Run's Cassette artifact to this path |
42
- | `resume` | `string?` | Replay a matching Cassette prefix, then continue live |
43
+ | `resume` | `string?` | Replay a matching Cassette prefix, then continue live; alone it resumes a stored Inline Program |
44
+
45
+ Give `file` or `script`, and not both; a violation is a parameter error raised
46
+ before any process starts. A `script` program can import `@yaag/runtime` and
47
+ `typebox` only (ADR-0033). The source travels to the CLI on a descriptor, and
48
+ never through the process argument list (`ps`, `/proc/<pid>/cmdline`), which
49
+ every local user can read. The durable Run record keeps the source, so the
50
+ record directory is `0700` and each record file is `0600`. To resume an inline
51
+ Run, give `resume` and give no `file` and no `script`: yaag reads the source
52
+ back from the Run record whose Checkpoint path matches, and gives it to the CLI
53
+ again, so the program identity check keeps its meaning (ADR-0033). Give `script`
54
+ again only to change the program. A Cassette that you
55
+ ask for with `record` also embeds the source, and it is written owner-only,
56
+ with mode `0600` (ADR-0021).
43
57
 
44
58
  Blocking by default: the call returns when the Run ends, and its content is the
45
59
  Run's return value. With `background: true` it returns at once with a short Run
@@ -194,7 +208,8 @@ bun apps/yaag/src/cli.ts run .yaag/review.ts
194
208
  and `yaag_run` are the same execution path. The extension spawns exactly this
195
209
  CLI as a child — `bun cli.ts run <file> --events-fd 3 --args <json>` — and reads
196
210
  structured Lifecycle Events from descriptor 3 while the CLI's own stderr format
197
- stays unchanged. **What you debug in a terminal is what the session runs.**
211
+ stays unchanged. An Inline Program adds one inbound channel: the extension
212
+ writes the source to descriptor 4, closes it, and names it as `--eval-fd 4`. **What you debug in a terminal is what the session runs.**
198
213
 
199
214
  ## How stopping works
200
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/extension",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,9 +24,9 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@earendil-works/pi-tui": "^0.84.0",
27
- "@yaag/cli": "0.2.1",
28
- "@yaag/runtime": "0.2.1",
29
- "@yaag/tui": "0.2.1",
27
+ "@yaag/cli": "0.4.0",
28
+ "@yaag/runtime": "0.4.0",
29
+ "@yaag/tui": "0.4.0",
30
30
  "nanoid": "^6.0.1"
31
31
  },
32
32
  "peerDependencies": {
package/src/cli-child.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { spawn } from "node:child_process";
1
+ import { type StdioOptions, spawn } from "node:child_process";
2
2
  import type { Readable, Writable } from "node:stream";
3
3
  import { type ReapTarget, reap } from "@yaag/runtime";
4
+ import { writeEvalSource } from "./eval-pipe.ts";
4
5
  import { StderrTail } from "./stderr-buffer.ts";
5
6
 
6
7
  /** The process-level output of one Bun CLI child. */
@@ -16,6 +17,12 @@ export interface CliChildOptions {
16
17
  readonly argv: readonly string[];
17
18
  /** Run reserves fd 3 for Lifecycle Events; ordinary CLI calls do not. */
18
19
  readonly events?: boolean;
20
+ /**
21
+ * The Inline Program source: written to fd 4 and closed there, because the
22
+ * CLI reads it with `--eval-fd 4` (ADR-0033). Fd 3 stays the Lifecycle Event
23
+ * stream, so this option implies `events`.
24
+ */
25
+ readonly evalSource?: string;
19
26
  }
20
27
 
21
28
  /** A detached Bun CLI child and the common reap ladder used to stop it. */
@@ -32,12 +39,19 @@ export interface CliChild {
32
39
  *
33
40
  * The returned `stop` closes stdin, then escalates through SIGTERM and group
34
41
  * SIGKILL via `reap()`. It never interprets CLI argv, stdout, or fd 3.
42
+ *
43
+ * An `evalSource` goes to fd 4 right after the spawn, and that descriptor is
44
+ * closed at once, because the CLI reads it until end of file (ADR-0033).
35
45
  */
36
46
  export function startCliChild(options: CliChildOptions): CliChild {
37
47
  const child = spawn(options.bun, [options.cli, ...options.argv], {
38
- stdio: options.events === true ? ["pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
48
+ stdio: childStdio(options),
39
49
  detached: true,
40
50
  });
51
+ if (options.evalSource !== undefined) {
52
+ const pipe = child.stdio[4];
53
+ if (isWritable(pipe)) writeEvalSource(pipe, options.evalSource);
54
+ }
41
55
  const stderr = new StderrTail();
42
56
  const exited = observeExit(child);
43
57
  releasePipesAfterExit(child);
@@ -79,6 +93,22 @@ function isReadable(stream: unknown): stream is Readable {
79
93
  return typeof stream === "object" && stream !== null && "on" in stream;
80
94
  }
81
95
 
96
+ function isWritable(stream: unknown): stream is Writable {
97
+ return typeof stream === "object" && stream !== null && "end" in stream;
98
+ }
99
+
100
+ /**
101
+ * The stdio slots one child needs: the three standard ones, fd 3 for
102
+ * Lifecycle Events, and fd 4 for the Inline Program source. Fd 4 needs an fd 3
103
+ * slot, so an `evalSource` always gets the events slot too.
104
+ */
105
+ function childStdio(options: CliChildOptions): StdioOptions {
106
+ const stdio: StdioOptions = ["pipe", "pipe", "pipe"];
107
+ if (options.events === true || options.evalSource !== undefined) stdio.push("pipe");
108
+ if (options.evalSource !== undefined) stdio.push("pipe");
109
+ return stdio;
110
+ }
111
+
82
112
  async function text(stream: Readable | null): Promise<string> {
83
113
  if (stream === null) return "";
84
114
  let out = "";
@@ -114,8 +144,8 @@ const PIPE_EOF_GRACE_MS = 2_000;
114
144
  function releasePipesAfterExit(child: ReturnType<typeof spawn>): void {
115
145
  child.once("exit", () => {
116
146
  const timer = setTimeout(() => {
117
- for (const stream of [child.stdout, child.stderr, child.stdio[3]]) {
118
- if (isReadable(stream) && !stream.destroyed) stream.destroy();
147
+ for (const stream of [child.stdout, child.stderr, child.stdio[3], child.stdio[4]]) {
148
+ if ((isReadable(stream) || isWritable(stream)) && !stream.destroyed) stream.destroy();
119
149
  }
120
150
  }, PIPE_EOF_GRACE_MS);
121
151
  // A housekeeping timer must never keep the host process alive.
@@ -0,0 +1,25 @@
1
+ import type { Writable } from "node:stream";
2
+
3
+ /**
4
+ * The descriptor an Inline Program's source travels on, never argv (ADR-0033).
5
+ *
6
+ * An argv element is readable by every local user through `ps` and
7
+ * `/proc/<pid>/cmdline`. Descriptor 3 stays exclusive to Run Lifecycle Events
8
+ * (ADR-0016), so the source goes on descriptor 4.
9
+ */
10
+ export const EVAL_FD = 4;
11
+
12
+ /**
13
+ * Writes the Inline Program source to the descriptor and closes it.
14
+ *
15
+ * One write and a close, because the CLI reads the descriptor until end of
16
+ * file. Never throws: the CLI can exit before it reads — a bad `--resume`
17
+ * Cassette is refused before the program loads — and that broken pipe is the
18
+ * child's failure, reported through its exit code and error output.
19
+ */
20
+ export function writeEvalSource(stream: Writable, source: string): void {
21
+ stream.on("error", () => {
22
+ // EPIPE or ERR_STREAM_DESTROYED: the child is gone, and its outcome says why.
23
+ });
24
+ stream.end(source);
25
+ }
@@ -1,6 +1,14 @@
1
1
  import type { Readable } from "node:stream";
2
2
  import type { LifecycleEvent } from "@yaag/runtime";
3
3
 
4
+ /**
5
+ * The descriptor Run Lifecycle Events travel out on (ADR-0006, ADR-0016).
6
+ *
7
+ * It stays exclusive to that stream, which is why an Inline Program's source
8
+ * goes on descriptor 4 instead (`EVAL_FD`, ADR-0033).
9
+ */
10
+ export const EVENTS_FD = 3;
11
+
4
12
  /**
5
13
  * Reads Lifecycle Events from the CLI's dedicated descriptor: one JSON object
6
14
  * per line, wrapped in the `{ v: 1, ... }` envelope (ADR-0016).
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * A fully typed `ExtensionUIContext` for the tui-mode test context.
3
3
  *
4
- * `custom`, `notify`, `editor`, `setWidget`, `setStatus`, and a scripted
5
- * `select` work;
4
+ * `custom`, `notify`, `setWidget`, `setStatus`, and a scripted `select` work;
6
5
  * every other capability throws, so a dependency a view is not supposed to
7
6
  * have fails loudly. The
8
7
  * class implements the interface, so pi's UI surface is checked at compile
@@ -32,12 +31,6 @@ export interface Notice {
32
31
  readonly level: "info" | "warning" | "error";
33
32
  }
34
33
 
35
- /** An editor the view opened, with the body it pre-filled. */
36
- export interface OpenedEditor {
37
- readonly title: string;
38
- readonly body: string;
39
- }
40
-
41
34
  /**
42
35
  * The fake terminal size a test gives the view. Both fields are optional and
43
36
  * default to a 20x100 terminal; no value here can make construction fail.
@@ -52,7 +45,6 @@ export interface FakeExtensionUiOptions {
52
45
  /** Runs the factory `ctx.ui.custom()` receives against fake pi services. */
53
46
  export class FakeExtensionUi implements ExtensionUIContext {
54
47
  readonly notices: Notice[] = [];
55
- readonly edits: OpenedEditor[] = [];
56
48
  /** The latest frame per widget key; `undefined` means the key was cleared. */
57
49
  readonly widgets = new Map<string, string[] | undefined>();
58
50
  /** The latest text per footer key; `undefined` means the key was cleared. */
@@ -102,9 +94,11 @@ export class FakeExtensionUi implements ExtensionUIContext {
102
94
  this.notices.push({ message, level: type ?? "info" });
103
95
  }
104
96
 
105
- async editor(title: string, prefill?: string): Promise<string | undefined> {
106
- this.edits.push({ title, body: prefill ?? "" });
107
- return prefill;
97
+ // A pi dialog clears the editor container, which removes an open non-overlay
98
+ // `ui.custom` view and stops pi's input loop (ADR-0034). `select` survives
99
+ // because the Run picker runs before the view opens.
100
+ async editor(): Promise<string | undefined> {
101
+ return unavailable("ui.editor");
108
102
  }
109
103
 
110
104
  async select(): Promise<string | undefined> {
@@ -0,0 +1,25 @@
1
+ import { initialSummary } from "@yaag/runtime";
2
+ import type { RunHandle, StartRunOptions } from "./spawn-run.ts";
3
+
4
+ /** A start seam that never spawns: it captures its options and ends the Run at once. */
5
+ export function fakeStart(stdout = "{}"): {
6
+ readonly start: (options: StartRunOptions) => RunHandle;
7
+ readonly seen: StartRunOptions[];
8
+ } {
9
+ const seen: StartRunOptions[] = [];
10
+ return {
11
+ seen,
12
+ start: (options) => {
13
+ seen.push(options);
14
+ return {
15
+ stop: () => {},
16
+ outcome: Promise.resolve({
17
+ code: 0,
18
+ stdout,
19
+ stderr: "",
20
+ summary: initialSummary(),
21
+ }),
22
+ };
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,77 @@
1
+ import { resolve } from "node:path";
2
+ import type { ProgramParams } from "./run-program-param.ts";
3
+ import type { RunRecord } from "./run-record.ts";
4
+
5
+ /**
6
+ * Which stored Inline Program source a Checkpoint path resumes.
7
+ *
8
+ * A Cassette is never an execution source (ADR-0033): the caller always gives
9
+ * the program. For a resume of an Inline Program the caller is the extension
10
+ * itself, which reads the inline launch source back from its own owner-only Run record
11
+ * and writes it to descriptor 4. The Cassette stays history only, and the
12
+ * advisory `programSource` comparison of the CLI keeps its meaning, because the
13
+ * source it sees is byte-identical to the recorded one.
14
+ */
15
+
16
+ /** The stored Runs a resume lookup reads; a structural type, so a test needs no store. */
17
+ export interface InlineSourceLookup {
18
+ /** The Inline Program source whose Run published `artifact`, or null. */
19
+ inlineSourceFor(artifact: string): Promise<string | null>;
20
+ }
21
+
22
+ /**
23
+ * The stored Inline Program source whose Run published `artifact`, or null.
24
+ *
25
+ * Pure. A record matches when its launch is inline and its published Checkpoint
26
+ * or its `record` path resolves to `artifact`. Records arrive oldest first, so
27
+ * the last match — the newest Run — wins.
28
+ */
29
+ export function pickInlineResumeSource(
30
+ records: readonly RunRecord[],
31
+ artifact: string,
32
+ ): string | null {
33
+ let found: string | null = null;
34
+ for (const record of records) {
35
+ const { launch } = record;
36
+ if (launch.kind !== "inline") continue;
37
+ const { script, record: recorded } = launch;
38
+ const published = record.summary.artifact;
39
+ const matches =
40
+ (published !== null && published !== undefined && resolve(published) === artifact) ||
41
+ (recorded !== undefined && resolve(recorded) === artifact);
42
+ if (matches) found = script;
43
+ }
44
+ return found;
45
+ }
46
+
47
+ /** What the tool says when no record holds the source of the given Checkpoint. */
48
+ export function noStoredSourceMessage(artifact: string): string {
49
+ return [
50
+ "yaag_run: give file or script;",
51
+ `no Run record for the checkpoint ${artifact} holds an Inline Program source,`,
52
+ "so give script again to resume it.",
53
+ "The path must be the same path the Run recorded.",
54
+ ].join(" ");
55
+ }
56
+
57
+ /**
58
+ * The program parameters to run, with a stored Inline Program source supplied.
59
+ *
60
+ * A call that gives `file` or `script` passes through untouched, so an explicit
61
+ * program always wins and an invalid shape still fails in `programTarget`. A
62
+ * call that gives `resume` alone reads the source from the Run records.
63
+ *
64
+ * Throws an `Error` whose message begins with `yaag_run:` when no record holds
65
+ * the source of that Checkpoint.
66
+ */
67
+ export async function resolveProgramParams(
68
+ params: ProgramParams,
69
+ lookup: InlineSourceLookup,
70
+ ): Promise<ProgramParams> {
71
+ if (params.file !== undefined || params.script !== undefined) return params;
72
+ if (params.resume === undefined) return params;
73
+ const artifact = resolve(params.resume);
74
+ const source = await lookup.inlineSourceFor(artifact);
75
+ if (source === null) throw new Error(noStoredSourceMessage(artifact));
76
+ return { ...params, script: source };
77
+ }
@@ -0,0 +1,39 @@
1
+ import { EVAL_FD } from "./eval-pipe.ts";
2
+ import { EVENTS_FD } from "./event-reader.ts";
3
+
4
+ /** What a Run runs: a program file, or Inline Program source text (ADR-0033). */
5
+ export type ProgramTarget =
6
+ | { readonly kind: "file"; readonly file: string }
7
+ | { readonly kind: "inline"; readonly source: string };
8
+
9
+ /** The parts of a Run the CLI argv carries. */
10
+ export interface RunArgvOptions {
11
+ readonly program: ProgramTarget;
12
+ /** Opaque JSON, forwarded untouched: the program validates it (ADR-0010). */
13
+ readonly args?: string;
14
+ /** Cassette path to write, forwarded as `--record` (ADR-0013). */
15
+ readonly record?: string;
16
+ /** Cassette path to resume from, forwarded as `--resume` (ADR-0014). */
17
+ readonly resume?: string;
18
+ }
19
+
20
+ /**
21
+ * Builds the CLI argv for one Run.
22
+ *
23
+ * An Inline Program's source never travels in argv, because argv is readable
24
+ * by every local user through `ps` and `/proc/<pid>/cmdline`. The argv names
25
+ * the descriptor only, as `--eval-fd 4` (ADR-0033); the caller writes the
26
+ * source to that descriptor with `writeEvalSource`. The CLI owns the closed
27
+ * import contract, and the size guard is upstream in `run-program-param.ts`.
28
+ */
29
+ export function runArgv(options: RunArgvOptions): readonly string[] {
30
+ const { program } = options;
31
+ return [
32
+ ...(program.kind === "file" ? ["run", program.file] : ["run", "--eval-fd", String(EVAL_FD)]),
33
+ "--events-fd",
34
+ String(EVENTS_FD),
35
+ ...(options.args === undefined ? [] : ["--args", options.args]),
36
+ ...(options.record === undefined ? [] : ["--record", options.record]),
37
+ ...(options.resume === undefined ? [] : ["--resume", options.resume]),
38
+ ];
39
+ }
@@ -0,0 +1,58 @@
1
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
2
+ import { fakeTheme } from "./fake-theme.ts";
3
+ import { resolveBun } from "./resolve-bun.ts";
4
+ import { resolveCliEntry } from "./resolve-cli.ts";
5
+ import { RunRegistry } from "./run-registry.ts";
6
+ import { createRunTool } from "./run-tool.ts";
7
+
8
+ const cli = resolveCliEntry();
9
+ const bun = await resolveBun();
10
+ if (bun === null) throw new Error("these tests need bun on PATH");
11
+
12
+ /** The `yaag_run` parameters a test gives the tool. */
13
+ export interface RunParams {
14
+ readonly file?: string;
15
+ readonly script?: string;
16
+ readonly args?: string;
17
+ readonly background?: boolean;
18
+ readonly record?: string;
19
+ readonly resume?: string;
20
+ }
21
+
22
+ function renderCallComponent(params: RunParams, expanded: boolean) {
23
+ const tool = createRunTool({
24
+ bun,
25
+ cli,
26
+ registry: new RunRegistry(),
27
+ sendMessage: () => undefined,
28
+ });
29
+ const component = tool.renderCall?.(params, fakeTheme(), {
30
+ args: params,
31
+ toolCallId: "call-1",
32
+ invalidate: () => {},
33
+ lastComponent: undefined,
34
+ state: undefined,
35
+ cwd: process.cwd(),
36
+ executionStarted: false,
37
+ argsComplete: true,
38
+ isPartial: false,
39
+ expanded,
40
+ showImages: false,
41
+ isError: false,
42
+ });
43
+ return component;
44
+ }
45
+
46
+ /** The call line, stripped of styling, as the collapsed transcript shows it. */
47
+ export function renderCallLabel(params: RunParams): string {
48
+ return stripTerminalSequences(renderCallComponent(params, false)?.render(80)[0] ?? "");
49
+ }
50
+
51
+ /** Every rendered line of the call, stripped of styling. */
52
+ export function renderCallLines(
53
+ params: RunParams,
54
+ options: { readonly expanded?: boolean } = {},
55
+ ): readonly string[] {
56
+ const component = renderCallComponent(params, options.expanded ?? false);
57
+ return (component?.render(80) ?? []).map(stripTerminalSequences);
58
+ }
@@ -0,0 +1,178 @@
1
+ import { access } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { clampToWidth, sanitizeTerminalLine, sanitizeTerminalText } from "@yaag/tui";
4
+ import type { ProgramTarget } from "./run-argv.ts";
5
+
6
+ /**
7
+ * The `yaag_run` parameters that name the program; exactly one is given.
8
+ *
9
+ * `resume` names a Checkpoint. It is read at render time only: a call that
10
+ * gives `resume` alone takes its source from the Run record (`resume-source.ts`).
11
+ */
12
+ export interface ProgramParams {
13
+ readonly file?: string;
14
+ readonly script?: string;
15
+ readonly resume?: string;
16
+ }
17
+
18
+ /** The expanded view keeps at most this many source lines. */
19
+ const EXPANSION_LINES = 200;
20
+ /** The expanded view keeps at most this many source bytes. */
21
+ const EXPANSION_BYTES = 8192;
22
+
23
+ /**
24
+ * The largest Inline Program the tool accepts, in UTF-8 bytes.
25
+ *
26
+ * The source travels on descriptor 4, and never in argv (ADR-0033), so the
27
+ * MAX_ARG_STRLEN limit of Linux no longer applies. The cap now bounds the
28
+ * volume the transcript and the durable Run record hold — `run-record.ts`
29
+ * keeps the whole `script` — and keeps the write to the descriptor bounded.
30
+ * It refuses early, with a message that says what to do.
31
+ */
32
+ export const MAX_SCRIPT_BYTES = 65_536;
33
+
34
+ /**
35
+ * The widest a rendered call label may be, in terminal display columns.
36
+ *
37
+ * A path is caller-controlled and unbounded; the call line must stay one
38
+ * readable line on a narrow terminal. Columns, not UTF-16 units: 60 CJK
39
+ * characters occupy 120 columns.
40
+ */
41
+ export const LABEL_WIDTH = 60;
42
+
43
+ /**
44
+ * Validates the shape of the program parameters and names the target.
45
+ *
46
+ * Pure: it touches no filesystem, so a Host Session with no Bun still gets the
47
+ * Bun status report rather than a path error (ADR-0033). A `file` target is
48
+ * resolved to an absolute path but never probed — that is
49
+ * {@link confirmProgramTarget}.
50
+ *
51
+ * Throws an `Error` whose message begins with `yaag_run:` when the parameters
52
+ * are not one program: both `file` and `script`, neither of them, a `script`
53
+ * that is blank, or a `script` above {@link MAX_SCRIPT_BYTES} bytes.
54
+ */
55
+ export function programTarget(params: ProgramParams): ProgramTarget {
56
+ const { file, script } = params;
57
+ if (file !== undefined && script !== undefined) {
58
+ throw new Error("yaag_run: give file or script, and not both");
59
+ }
60
+ if (script !== undefined) {
61
+ if (script.trim() === "") throw new Error("yaag_run: script is empty");
62
+ const bytes = byteCount(script);
63
+ if (bytes > MAX_SCRIPT_BYTES) {
64
+ throw new Error(
65
+ `yaag_run: script is too large (${bytes} bytes); write it to a file and use file`,
66
+ );
67
+ }
68
+ return { kind: "inline", source: script };
69
+ }
70
+ if (file === undefined) throw new Error("yaag_run: give file or script");
71
+ return { kind: "file", file: resolve(file) };
72
+ }
73
+
74
+ /**
75
+ * Confirms a file target exists; an Inline Program passes through untouched.
76
+ *
77
+ * Rejects with `yaag_run: no such Orchestration Program: <path>` when the file
78
+ * is not accessible. An inline target never touches the filesystem.
79
+ */
80
+ export async function confirmProgramTarget(target: ProgramTarget): Promise<ProgramTarget> {
81
+ if (target.kind === "inline") return target;
82
+ try {
83
+ await access(target.file);
84
+ } catch {
85
+ throw new Error(`yaag_run: no such Orchestration Program: ${target.file}`);
86
+ }
87
+ return target;
88
+ }
89
+
90
+ /**
91
+ * A one-line label for the call render. Total by design: the render runs before
92
+ * `execute` validates, so an invalid parameter pair must still produce a label.
93
+ *
94
+ * The label never quotes the source. `yaag_run` has no approval gate, so any
95
+ * text taken from an Inline Program would be a trust-bearing string the model
96
+ * chose — a comment or an unrelated `name:` could name a program that is not
97
+ * the one that runs. The Inline Program branch states only what the extension
98
+ * measured: its kind and its size in bytes. The source itself is shown, whole,
99
+ * in the expanded view ({@link programExpansion}).
100
+ *
101
+ * The two caller-controlled branches — a file path and a Checkpoint path — are
102
+ * sanitized to one line and clamped to {@link LABEL_WIDTH} columns, so a
103
+ * hostile path can neither emit an escape byte nor overflow the call line. The
104
+ * clamp covers the whole `Inline Program from …` text, prefix included.
105
+ */
106
+ export function programLabel(params: ProgramParams): string {
107
+ const { file, script } = params;
108
+ if (file !== undefined && script !== undefined) return "(invalid call)";
109
+ if (script !== undefined) {
110
+ if (script.trim() === "") return "(empty script)";
111
+ const bytes = byteCount(script);
112
+ return `Inline Program, ${bytes} ${bytes === 1 ? "byte" : "bytes"}`;
113
+ }
114
+ if (file !== undefined) return safeLabel(file);
115
+ // A resume with no file and no script is a valid call: the source comes from
116
+ // the Run record, and the label names the Checkpoint the source belongs to.
117
+ if (params.resume !== undefined) return safeLabel(`Inline Program from ${params.resume}`);
118
+ return "(invalid call)";
119
+ }
120
+
121
+ function safeLabel(value: string): string {
122
+ return clampToWidth(sanitizeTerminalLine(value), LABEL_WIDTH);
123
+ }
124
+
125
+ /**
126
+ * The Inline Program source as the expanded call view shows it: sanitized,
127
+ * verbatim, and capped so a huge script cannot flood the transcript.
128
+ *
129
+ * Returns an empty string when the call names a file or is invalid, and for a
130
+ * resume of a stored Inline Program: the source is not known at render time.
131
+ * This cap
132
+ * bounds transcript volume only; the transport cap is {@link MAX_SCRIPT_BYTES}.
133
+ */
134
+ export function programExpansion(params: ProgramParams): string {
135
+ const { file, script } = params;
136
+ if (file !== undefined || script === undefined || script.trim() === "") return "";
137
+ const total = byteCount(script);
138
+ const source = sanitizeTerminalText(script).replace(/\n+$/, "");
139
+ const lines: string[] = [];
140
+ let kept = 0;
141
+ let cut = false;
142
+ for (const line of source.split("\n")) {
143
+ if (lines.length >= EXPANSION_LINES) {
144
+ cut = true;
145
+ break;
146
+ }
147
+ if (kept + byteCount(line) > EXPANSION_BYTES) {
148
+ // A minified program can be one line longer than the whole cap. Keep the
149
+ // prefix that fits, so the view still shows where the source starts.
150
+ const head = bytePrefix(line, EXPANSION_BYTES - kept);
151
+ if (head !== "") lines.push(` ${head}`);
152
+ cut = true;
153
+ break;
154
+ }
155
+ kept += byteCount(line) + 1;
156
+ lines.push(` ${line}`);
157
+ }
158
+ if (cut) lines.push(` … (truncated, ${total} bytes total)`);
159
+ return lines.join("\n");
160
+ }
161
+
162
+ function byteCount(value: string): number {
163
+ return new TextEncoder().encode(value).length;
164
+ }
165
+
166
+ /** The longest prefix of `value` that fits `limit` UTF-8 bytes, whole characters only. */
167
+ function bytePrefix(value: string, limit: number): string {
168
+ if (limit <= 0) return "";
169
+ let kept = "";
170
+ let used = 0;
171
+ for (const character of value) {
172
+ const size = byteCount(character);
173
+ if (used + size > limit) break;
174
+ used += size;
175
+ kept += character;
176
+ }
177
+ return kept;
178
+ }