@yaag/cli 0.2.0 → 0.3.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.
@@ -13,6 +13,7 @@ export declare const CassetteSchema: Type.TObject<{
13
13
  run: Type.TOptional<Type.TObject<{
14
14
  outcome: Type.TUnion<Type.TLiteral<"completed" | "failed" | "interrupted" | "paused" | "stopped">[]>;
15
15
  programFile: Type.TOptional<Type.TString>;
16
+ programSource: Type.TOptional<Type.TString>;
16
17
  args: Type.TOptional<Type.TUnknown>;
17
18
  programHash: Type.TOptional<Type.TString>;
18
19
  }>>;
@@ -11,8 +11,10 @@ export interface CassetteRun {
11
11
  readonly outcome: RunOutcome;
12
12
  /** Invocation identity: what a resume is about to re-execute. */
13
13
  readonly programFile?: string;
14
+ /** Inline Program source text; identity for a Run with no program file (ADR-0033). */
15
+ readonly programSource?: string;
14
16
  readonly args?: unknown;
15
- /** Advisory content hash of the program file; SHA-256 hex. */
17
+ /** Advisory content hash of the program file, or of the inline source; SHA-256 hex. */
16
18
  readonly programHash?: string;
17
19
  }
18
20
  /** Versioned JSON artifact containing the frames exchanged during one Run. */
@@ -0,0 +1,6 @@
1
+ /**
2
+ * One advisory content hash, shared by the modules that record a program's
3
+ * identity and the modules that compare it (ADR-0033).
4
+ */
5
+ /** Advisory content hash of a program's bytes or inline source; SHA-256 hex. */
6
+ export declare function sha256Hex(input: string | ArrayBuffer): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The advisory comparison between a loaded Cassette's recorded program identity
3
+ * and the program this Run executes (ADR-0033).
4
+ */
5
+ import type { Cassette } from "./cassette.ts";
6
+ import type { ProgramIdentity } from "./run-checkpoint.ts";
7
+ /**
8
+ * Warns when a Cassette's recorded program identity does not match the program
9
+ * this Run executes. Advisory only: the caller always supplies the program and
10
+ * the Cassette supplies the history, so a mismatch never stops the Run
11
+ * (ADR-0033). Returns null when nothing inline is involved, so file-to-file
12
+ * resume behaviour is unchanged.
13
+ */
14
+ export declare function programIdentityWarning(cassette: Cassette, path: string, program: ProgramIdentity): string | null;
@@ -44,7 +44,13 @@ export declare function publishRunCheckpoint(options: RunCheckpointOptions): Pro
44
44
  * today's rule that its directory must already exist.
45
45
  */
46
46
  export declare function ensureCheckpointDirectory(record: string | undefined, destination: string): Promise<void>;
47
+ /** Which program a Run executes: a file on disk, or inline source text (ADR-0033). */
48
+ export interface ProgramIdentity {
49
+ readonly programFile?: string;
50
+ /** Inline source, exactly as the caller gave it; it excludes any loader prelude. */
51
+ readonly programSource?: string;
52
+ }
47
53
  /** Resolves what a resume re-executes. The program hash is advisory only. */
48
- export declare function resolveRunIdentity(programFile: string | undefined, args: unknown): Promise<RunIdentity>;
54
+ export declare function resolveRunIdentity(program: ProgramIdentity, args: unknown): Promise<RunIdentity>;
49
55
  /** Reports a secondary Cassette publication failure without displacing the Run's primary error. */
50
56
  export declare function writeRecordingDiagnostic(error: unknown): void;
@@ -16,6 +16,11 @@ export interface RunOptions {
16
16
  * extension declarations. This is Run context, never an Agent working directory.
17
17
  */
18
18
  readonly programFile?: string;
19
+ /**
20
+ * Inline Program source text, recorded as the Run's identity when there is no
21
+ * program file (ADR-0033). It never resolves extension paths.
22
+ */
23
+ readonly programSource?: string;
19
24
  /** Resolves pi's enabled skills for live restriction requests. */
20
25
  readonly skillProbe?: SkillProbeFactory;
21
26
  /** Write every public transport-seam frame once the Run settles. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -21,8 +21,8 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@earendil-works/pi-tui": "^0.84.0",
24
- "@yaag/runtime": "0.2.0",
25
- "@yaag/tui": "0.2.0",
24
+ "@yaag/runtime": "0.3.0",
25
+ "@yaag/tui": "0.3.0",
26
26
  "typebox": "1.3.7"
27
27
  }
28
28
  }
package/src/argv.ts CHANGED
@@ -6,16 +6,31 @@ import { resolve } from "node:path";
6
6
 
7
7
  export const USAGE = `usage:
8
8
  yaag run <program.ts> [--quiet] [--args <json>] [--record <file>] [--replay <file>] [--resume <file>] [--events-fd <n>]
9
+ yaag run --eval <source> [--quiet] [--args <json>] [--record <file>] [--replay <file>] [--resume <file>] [--events-fd <n>]
9
10
  yaag describe <program.ts>
10
11
  yaag setup-workspace [dir]
11
12
 
13
+ --eval runs an Orchestration Program that you give as source text. Give a
14
+ program file or --eval, but do not give both. A program that --eval runs can
15
+ import "@yaag/runtime" and "typebox" only. A program that imports other modules
16
+ must be a file.
17
+
18
+ A --resume or --replay Run also needs the program: give the program file, or
19
+ --eval <source>. A Cassette holds the history of a Run, and never the program
20
+ to run.
21
+
12
22
  Warning: describe imports the module and executes its top level. Keep program module top level side-effect free.`;
13
23
 
24
+ /** The program one `run` invocation names: a file, or source text (ADR-0033). */
25
+ export type ProgramSource =
26
+ | { readonly kind: "file"; readonly file: string }
27
+ | { readonly kind: "inline"; readonly source: string };
28
+
14
29
  export type ParsedArgv =
15
30
  | {
16
31
  readonly ok: true;
17
32
  readonly command: "run";
18
- readonly file: string;
33
+ readonly program: ProgramSource;
19
34
  /** Opaque: parsed JSON, never validated here (ADR-0010). */
20
35
  readonly args: unknown;
21
36
  readonly quiet: boolean;
@@ -44,6 +59,7 @@ export function parseArgv(argv: readonly string[]): ParsedArgv {
44
59
 
45
60
  function parseRun(tokens: readonly string[]): ParsedArgv {
46
61
  let file: string | undefined;
62
+ let evalSource: string | undefined;
47
63
  let args: unknown = {};
48
64
  let eventsFd: number | undefined;
49
65
  let record: string | undefined;
@@ -57,6 +73,7 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
57
73
  quiet = true;
58
74
  } else if (
59
75
  token === "--args" ||
76
+ token === "--eval" ||
60
77
  token === "--events-fd" ||
61
78
  token === "--record" ||
62
79
  token === "--replay" ||
@@ -69,6 +86,8 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
69
86
  const parsed = parseJson(value);
70
87
  if (!parsed.ok) return parsed;
71
88
  args = parsed.value;
89
+ } else if (token === "--eval") {
90
+ evalSource = value;
72
91
  } else if (token === "--events-fd") {
73
92
  const parsed = parseDescriptor(value);
74
93
  if (!parsed.ok) return parsed;
@@ -89,20 +108,14 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
89
108
  }
90
109
  }
91
110
 
92
- if (file === undefined) return failure(USAGE);
93
- if (record !== undefined && replay !== undefined) {
94
- return failure(`--record and --replay cannot be used together\n${USAGE}`);
95
- }
96
- if (resume !== undefined && replay !== undefined) {
97
- return failure(`--resume and --replay cannot be used together\n${USAGE}`);
98
- }
99
- if (resume !== undefined && record !== undefined && resolve(resume) === resolve(record)) {
100
- return failure(`--resume and --record must use different paths\n${USAGE}`);
101
- }
111
+ const clash = conflict({ file, evalSource, record, replay, resume });
112
+ if (clash !== null) return failure(`${clash}\n${USAGE}`);
113
+ const program = programSource(file, evalSource);
114
+ if (program === undefined) return missingProgram(resume, replay);
102
115
  return {
103
116
  ok: true,
104
117
  command: "run",
105
- file,
118
+ program,
106
119
  args,
107
120
  quiet,
108
121
  ...(eventsFd === undefined ? {} : { eventsFd }),
@@ -112,6 +125,53 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
112
125
  };
113
126
  }
114
127
 
128
+ interface RunFlags {
129
+ readonly file?: string;
130
+ readonly evalSource?: string;
131
+ readonly record?: string;
132
+ readonly replay?: string;
133
+ readonly resume?: string;
134
+ }
135
+
136
+ /** Names the first pair of options that cannot appear in one invocation. */
137
+ function conflict(flags: RunFlags): string | null {
138
+ const { file, evalSource, record, replay, resume } = flags;
139
+ if (file !== undefined && evalSource !== undefined) {
140
+ return "--eval and a program file cannot be used together";
141
+ }
142
+ if (record !== undefined && replay !== undefined) {
143
+ return "--record and --replay cannot be used together";
144
+ }
145
+ if (resume !== undefined && replay !== undefined) {
146
+ return "--resume and --replay cannot be used together";
147
+ }
148
+ if (resume !== undefined && record !== undefined && resolve(resume) === resolve(record)) {
149
+ return "--resume and --record must use different paths";
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /** A Cassette is history, never an execution source, so the caller gives the program (ADR-0033). */
155
+ function missingProgram(
156
+ resume: string | undefined,
157
+ replay: string | undefined,
158
+ ): { readonly ok: false; readonly error: string } {
159
+ if (resume === undefined && replay === undefined) return failure(USAGE);
160
+ const flag = resume !== undefined ? "--resume" : "--replay";
161
+ return failure(
162
+ `${flag} needs the program too: give <program.ts> or --eval <source>. ` +
163
+ `A cassette holds the history of a Run, and never the program to run.\n${USAGE}`,
164
+ );
165
+ }
166
+
167
+ function programSource(
168
+ file: string | undefined,
169
+ source: string | undefined,
170
+ ): ProgramSource | undefined {
171
+ if (source !== undefined) return { kind: "inline", source };
172
+ return file === undefined ? undefined : { kind: "file", file };
173
+ }
174
+
115
175
  function parseDescribe(tokens: readonly string[]): ParsedArgv {
116
176
  if (tokens.length !== 1 || tokens[0]?.startsWith("-")) return failure(USAGE);
117
177
  return { ok: true, command: "describe", file: tokens[0] };
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The read-only half of `RunTreeViewHost` for the standalone CLI (spec §4).
2
+ * The read-only half of `RunTreeViewHost` for the standalone CLI (ADR-0008).
3
3
  *
4
4
  * The Host Session gets the same shape from `packages/extension`; here the pi
5
5
  * services are replaced by pi-tui primitives, so `apps/yaag` needs no pi. The
6
- * caller adds `stop`, `detach`, and `done`: nothing in this module can prompt
6
+ * caller adds `stop` and `done`: nothing in this module can prompt
7
7
  * an Agent (ADR — a Peek observes, it never sends).
8
8
  */
9
9
  import { readFile, writeFile } from "node:fs/promises";
@@ -26,7 +26,7 @@ export interface CliTreeHostOptions {
26
26
  }
27
27
 
28
28
  /** The read-only capabilities the CLI Run view has. */
29
- export type ReadOnlyCliHost = Omit<RunTreeViewHost, "stop" | "detach" | "done">;
29
+ export type ReadOnlyCliHost = Omit<RunTreeViewHost, "stop" | "done">;
30
30
 
31
31
  /** Builds the CLI's read-only host; it never writes to an Agent or to the Run. */
32
32
  export function createCliTreeHost(options: CliTreeHostOptions): ReadOnlyCliHost {
package/src/cli.ts CHANGED
@@ -3,7 +3,6 @@ import { resolve } from "node:path";
3
3
  import {
4
4
  assertReplayable,
5
5
  executeRun,
6
- isOrchestrationProgram,
7
6
  isYaagError,
8
7
  loadCassette,
9
8
  type OrchestrationProgram,
@@ -15,7 +14,9 @@ import { parseArgv } from "./argv.ts";
15
14
  import { runInteractive } from "./interactive-run.ts";
16
15
  import { writeChannel, writeChannelFd } from "./output-channel.ts";
17
16
  import { createPlainPresenter } from "./presenter.ts";
17
+ import { loadProgram } from "./program-loader.ts";
18
18
  import { executeOptions, formatResult, type RunFlags } from "./run-invocation.ts";
19
+ import { loadRunProgram } from "./run-program.ts";
19
20
  import { registerRuntimeAlias } from "./runtime-alias.ts";
20
21
  import { setupWorkspace } from "./setup-workspace.ts";
21
22
 
@@ -42,11 +43,11 @@ export async function main(argv: readonly string[]): Promise<number> {
42
43
  if (parsed.command === "run" && parsed.resume !== undefined) {
43
44
  await loadCassette(parsed.resume);
44
45
  }
45
- const programFile = resolve(parsed.file);
46
- const program = await loadProgram(programFile);
47
- if (parsed.command === "describe") return describe(program);
46
+ if (parsed.command === "describe") return describe(await loadProgram(resolve(parsed.file)));
47
+ const { program, programFile, programSource } = await loadRunProgram(parsed.program);
48
48
  return await run(program, {
49
49
  programFile,
50
+ programSource,
50
51
  args: parsed.args,
51
52
  eventsFd: parsed.eventsFd,
52
53
  record: parsed.record,
@@ -142,19 +143,6 @@ function eventSink(fd: number | undefined, present: StampedEventSink): StampedEv
142
143
  };
143
144
  }
144
145
 
145
- /** The program is the module's default export, and nothing else will do. */
146
- async function loadProgram(path: string): Promise<OrchestrationProgram> {
147
- const module: unknown = await import(path);
148
- const program =
149
- typeof module === "object" && module !== null && "default" in module
150
- ? module.default
151
- : undefined;
152
- if (!isOrchestrationProgram(program)) {
153
- throw new Error(`${path}: export defineRun({ run }) as the default export`);
154
- }
155
- return program;
156
- }
157
-
158
146
  if (import.meta.main) {
159
147
  const code = await main(process.argv.slice(2));
160
148
  // Exit rather than wait for the loop to drain: a stopped Run may leave the
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Finds a dynamic `import(...)` whose specifier is not one plain string
3
+ * literal.
4
+ *
5
+ * The specifier of such a call only exists while the program runs, so a scan
6
+ * of the literal specifiers cannot see it, and Bun resolves a built-in module
7
+ * and an absolute path without asking a resolver plugin. The call is refused
8
+ * as syntax, before the source becomes a module (ADR-0033). The `require`
9
+ * binding needs no scan here: `inline-program.ts` replaces it when the module
10
+ * loads, so every `require` call is checked when it runs.
11
+ *
12
+ * The scan reads the raw text and does not tokenize. `import` is a reserved
13
+ * word: real code cannot write it with escape sequences, cannot split it, and
14
+ * cannot put an identifier character or a `.` directly before a dynamic
15
+ * import. So each dynamic import in real code is an occurrence of the word
16
+ * `import` in the raw text, and a check of every occurrence misses none — no
17
+ * reading of a comment, string, or regular expression can hide one. The cost
18
+ * is the reverse case: text that only looks like a computed dynamic import,
19
+ * for example in a prompt string, is also refused. That refusal is safe, and
20
+ * the caller fixes it by making the program a file.
21
+ */
22
+
23
+ /** One `import(...)` shape whose specifier is not one plain string literal. */
24
+ export interface ComputedImport {
25
+ /** 1-based line of the `import` word in the scanned source. */
26
+ readonly line: number;
27
+ }
28
+
29
+ /** Returns the first computed dynamic `import(...)` shape, or `undefined`. */
30
+ export function findComputedImport(source: string): ComputedImport | undefined {
31
+ for (let from = 0; ; ) {
32
+ const at = source.indexOf("import", from);
33
+ if (at === -1) return undefined;
34
+ from = at + "import".length;
35
+ if (isWordChar(source[at - 1]) || source[at - 1] === "." || isWordChar(source[from])) continue;
36
+ const open = skipTrivia(source, from);
37
+ if (source[open] !== "(") continue;
38
+ if (!takesOneStringLiteral(source, open + 1)) return { line: lineAt(source, at) };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * True when the argument list that starts at `from` opens with one plain
44
+ * string literal, followed by `)` or by a `,` and more arguments.
45
+ *
46
+ * When the occurrence is real code, the trivia around the literal is real
47
+ * trivia, so this reads exactly what Bun reads. When the occurrence is inert
48
+ * text, a wrong reading can only refuse a harmless program.
49
+ */
50
+ function takesOneStringLiteral(source: string, from: number): boolean {
51
+ const start = skipTrivia(source, from);
52
+ const quote = source[start];
53
+ if (quote !== '"' && quote !== "'") return false;
54
+ const end = endOfQuoted(source, start);
55
+ if (end === undefined) return false;
56
+ const next = source[skipTrivia(source, end)];
57
+ return next === ")" || next === ",";
58
+ }
59
+
60
+ /** True for a character that can continue an identifier. */
61
+ function isWordChar(char: string | undefined): boolean {
62
+ return char !== undefined && /[\w$]/.test(char);
63
+ }
64
+
65
+ /** The index after the whitespace and comments that start at `index`. */
66
+ function skipTrivia(source: string, index: number): number {
67
+ let cursor = index;
68
+ for (;;) {
69
+ while (/\s/.test(source[cursor] ?? "")) cursor += 1;
70
+ if (source[cursor] === "/" && source[cursor + 1] === "/") {
71
+ const line = source.indexOf("\n", cursor);
72
+ cursor = line === -1 ? source.length : line + 1;
73
+ } else if (source[cursor] === "/" && source[cursor + 1] === "*") {
74
+ const end = source.indexOf("*/", cursor + 2);
75
+ cursor = end === -1 ? source.length : end + 2;
76
+ } else {
77
+ return cursor;
78
+ }
79
+ }
80
+ }
81
+
82
+ /** The index after a `'` or `"` string, or `undefined` when the line ends first. */
83
+ function endOfQuoted(source: string, start: number): number | undefined {
84
+ const quote = source[start];
85
+ for (let index = start + 1; index < source.length; index += 1) {
86
+ const char = source[index];
87
+ if (char === "\\") {
88
+ index += 1;
89
+ continue;
90
+ }
91
+ if (char === "\n") return undefined;
92
+ if (char === quote) return index + 1;
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ function lineAt(source: string, index: number): number {
98
+ let line = 1;
99
+ for (let cursor = 0; cursor < index; cursor += 1) if (source[cursor] === "\n") line += 1;
100
+ return line;
101
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The closed import contract of an Inline Program (ADR-0033).
3
+ *
4
+ * An Inline Program can import `@yaag/runtime` and `typebox` only. Placement
5
+ * in `os.tmpdir()` stops relative and project-package resolution, but it stops
6
+ * neither a built-in module such as `node:fs` nor an absolute path, and
7
+ * removal of the module file stops neither one either. Three checks close the
8
+ * contract. Before the source becomes a module: each literal specifier that
9
+ * Bun's parser reports must be allowed, and a dynamic `import(...)` whose
10
+ * specifier is not one plain string literal is refused as syntax. While the
11
+ * module runs: `inlineRequirePrelude` replaces the `require` binding and
12
+ * `import.meta.require`, so every `require` call — direct, aliased, or
13
+ * computed — is checked with its real specifier when it runs.
14
+ */
15
+ import { findComputedImport } from "./computed-imports.ts";
16
+
17
+ /** The only specifiers an Inline Program can import. */
18
+ export const ALLOWED_INLINE_IMPORTS: readonly string[] = ["@yaag/runtime", "typebox"];
19
+
20
+ /**
21
+ * Rejects an Inline Program that imports outside the closed contract.
22
+ *
23
+ * Refuses a dynamic `import(...)` whose specifier is not one plain string
24
+ * literal, then reads each literal specifier — static import, export-from,
25
+ * `require` call, and dynamic `import()` — and refuses one that is not
26
+ * allowed. Throws `Error` on the first refusal, and on source text that does
27
+ * not parse. The refusal happens before the source becomes a module, so no
28
+ * disallowed module is loaded. A `require` call that computes its specifier
29
+ * passes this check; `inlineRequirePrelude` refuses it when it runs, before
30
+ * any module resolves.
31
+ */
32
+ export function assertClosedImports(source: string): void {
33
+ const computed = findComputedImport(source);
34
+ if (computed !== undefined) {
35
+ throw new Error(
36
+ `inline program cannot import a computed specifier ` +
37
+ `(import call on line ${computed.line}): ` +
38
+ `--eval allows ${allowedText()} only, each one as a string literal`,
39
+ );
40
+ }
41
+ const transpiler = new Bun.Transpiler({ loader: "ts" });
42
+ for (const scanned of transpiler.scanImports(source)) {
43
+ if (ALLOWED_INLINE_IMPORTS.includes(scanned.path)) continue;
44
+ throw new Error(
45
+ `inline program cannot import ${JSON.stringify(scanned.path)}: ` +
46
+ `--eval allows ${allowedText()} only`,
47
+ );
48
+ }
49
+ }
50
+
51
+ /**
52
+ * One line of code that `inline-program.ts` puts before an Inline Program.
53
+ *
54
+ * The line shadows the module-scope `require` binding with a wrapper that
55
+ * refuses every specifier outside `ALLOWED_INLINE_IMPORTS`, and puts the same
56
+ * wrapper on `import.meta.require`. Every `require` call — direct, aliased,
57
+ * stored in a variable, written with identifier escape sequences, or reached
58
+ * through `import.meta` — runs the wrapper, so this check does not depend on
59
+ * a reading of the source. An allowed specifier goes to the real `require`
60
+ * unchanged. The line holds no line break, so it moves each source line down
61
+ * by exactly one.
62
+ */
63
+ export function inlineRequirePrelude(): string {
64
+ const suffix = JSON.stringify(`: --eval allows ${allowedText()} only`);
65
+ return [
66
+ "const require = ((real, allowed) => (id) => {",
67
+ " if (allowed.includes(id)) return real(id);",
68
+ ` throw new Error("inline program cannot require " + JSON.stringify(id) + ${suffix});`,
69
+ ` })(import.meta.require, ${JSON.stringify(ALLOWED_INLINE_IMPORTS)});`,
70
+ " import.meta.require = require;",
71
+ ].join("");
72
+ }
73
+
74
+ function allowedText(): string {
75
+ return ALLOWED_INLINE_IMPORTS.map((name) => JSON.stringify(name)).join(" and ");
76
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Loads an Inline Program: source text that a caller gives instead of a file.
3
+ *
4
+ * The source goes to a temporary `.ts` file in `os.tmpdir()`, through the same
5
+ * loader a file program uses, and the file is unlinked whether the import
6
+ * succeeds or throws. A real file gives real module resolution and real stack
7
+ * traces. The import contract is closed (ADR-0033): before the source becomes
8
+ * a module, `inline-imports.ts` rejects each literal specifier other than
9
+ * `@yaag/runtime` and `typebox` and each computed `import(...)`; while the
10
+ * module runs, a one-line prelude before the source checks every `require`
11
+ * call with its real specifier.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { unlink, writeFile } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import type { OrchestrationProgram } from "@yaag/runtime";
18
+ import { assertClosedImports, inlineRequirePrelude } from "./inline-imports.ts";
19
+ import { loadProgram } from "./program-loader.ts";
20
+
21
+ /**
22
+ * Writes the source to a temporary module, imports it, and removes the file.
23
+ *
24
+ * Throws `Error` when the source imports outside the closed contract, and
25
+ * whatever `writeFile` throws when `os.tmpdir()` cannot take the file. Each
26
+ * import and validation failure of `loadProgram` passes through unchanged, so
27
+ * the guidance is the guidance a bad file gives. The temporary file is
28
+ * unlinked on each path, and a failed unlink never replaces the primary error.
29
+ * Removal of the file does not keep a running program from resolving a
30
+ * built-in module or an absolute path, so the contract is enforced by the
31
+ * check on the source and by the `require` prelude, not by the removal. The
32
+ * prelude is one line before the source, so each stack line the module
33
+ * reports is one below the line the caller wrote; a source that declares its
34
+ * own top-level `require` binding fails to load.
35
+ */
36
+ export async function loadInlineProgram(source: string): Promise<OrchestrationProgram> {
37
+ assertClosedImports(source);
38
+ // A unique name per Run keeps Bun's module cache from aliasing two different
39
+ // Inline Programs when `main()` runs more than one time in one process.
40
+ const path = join(tmpdir(), `yaag-inline-${randomUUID()}.ts`);
41
+ await writeFile(path, `${inlineRequirePrelude()}\n${source}`, { mode: 0o600 });
42
+ try {
43
+ return await loadProgram(path);
44
+ } finally {
45
+ // The program's own error is the primary diagnostic, so a failed unlink
46
+ // must not replace it.
47
+ await unlink(path).catch(() => {});
48
+ }
49
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The `yaag run` path that opens the alt-screen Run tree (spec §4).
2
+ * The `yaag run` path that opens the alt-screen Run tree (architecture §9).
3
3
  *
4
4
  * `orchestrateInteractiveRun` is process-free: every collaborator arrives as a
5
- * parameter, so each exit path — settle, detach, fatal render, signal — is
5
+ * parameter, so each exit path — settle, close, fatal render, signal — is
6
6
  * driven in tests without a TTY. `runInteractive` is the thin production edge
7
7
  * that binds it to the real terminal, the real signals, and the real streams.
8
8
  *
@@ -63,9 +63,10 @@ export interface InteractiveRunDeps {
63
63
  * exit code of the Run.
64
64
  *
65
65
  * The terminal is restored before this resolves or rejects, on every path: the
66
- * settle-then-`q` path, a confirmed stop, a detach, and a render throw. A
67
- * detach closes the alt-screen and keeps awaiting the Run on the plain lines,
68
- * so the process never exits with a Run still running. A fatal render error is
66
+ * settle-then-`esc` path, a confirmed stop, an `esc` over a live Run, and a
67
+ * render throw. Closing the view over a live Run closes the alt-screen and
68
+ * keeps awaiting the Run on the plain lines, so the process never exits with a
69
+ * Run still running. A fatal render error is
69
70
  * reported only after the terminal is restored and the Run is aborted and
70
71
  * reaped, so no Run is abandoned pending. A fatal accepted at any point while
71
72
  * the handlers are installed is rethrown, never converted into a normal return.
@@ -118,7 +119,7 @@ export async function orchestrateInteractiveRun(deps: InteractiveRunDeps): Promi
118
119
  }
119
120
  const settled = await running;
120
121
  app.settle(viewResult(settled));
121
- // The settled view stays interactive until the reader presses `q` (spec §1).
122
+ // The settled view stays interactive until the reader presses `esc`.
122
123
  const exit = await Promise.race([app.exit, signal.wait]);
123
124
  let fatal = exit.kind === "fatal" ? exit : signal.taken();
124
125
  const closing = app.close();
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Loads an Orchestration Program module from a file.
3
+ *
4
+ * One loader keeps a file program and an Inline Program on identical guidance
5
+ * when a module is not a program.
6
+ */
7
+ import { isOrchestrationProgram, type OrchestrationProgram } from "@yaag/runtime";
8
+
9
+ /**
10
+ * Imports `path` and returns its default export as an Orchestration Program.
11
+ *
12
+ * The program is the module's default export, and nothing else will do.
13
+ * Throws what Bun throws when the module is missing, does not parse, resolves
14
+ * no import, or throws at its top level. Throws `Error` with the guidance
15
+ * `export defineRun({ run }) as the default export` when the module imports
16
+ * but its default export is not an Orchestration Program.
17
+ */
18
+ export async function loadProgram(path: string): Promise<OrchestrationProgram> {
19
+ const module: unknown = await import(path);
20
+ const program =
21
+ typeof module === "object" && module !== null && "default" in module
22
+ ? module.default
23
+ : undefined;
24
+ if (!isOrchestrationProgram(program)) {
25
+ throw new Error(`${path}: export defineRun({ run }) as the default export`);
26
+ }
27
+ return program;
28
+ }
@@ -9,7 +9,10 @@ import type { RunOptions, StampedEventSink } from "@yaag/runtime";
9
9
 
10
10
  /** Everything `yaag run` parsed for one Run. */
11
11
  export interface RunFlags {
12
- readonly programFile: string;
12
+ /** Absent for an Inline Program, which has no file to record (ADR-0033). */
13
+ readonly programFile: string | undefined;
14
+ /** Present only for an Inline Program; becomes the Run's recorded identity (ADR-0033). */
15
+ readonly programSource: string | undefined;
13
16
  readonly args: unknown;
14
17
  readonly eventsFd: number | undefined;
15
18
  readonly record: string | undefined;
@@ -27,7 +30,8 @@ export function executeOptions(
27
30
  return {
28
31
  events,
29
32
  args: flags.args,
30
- programFile: flags.programFile,
33
+ ...(flags.programFile === undefined ? {} : { programFile: flags.programFile }),
34
+ ...(flags.programSource === undefined ? {} : { programSource: flags.programSource }),
31
35
  ...(flags.record === undefined ? {} : { record: flags.record }),
32
36
  ...(flags.replay === undefined ? {} : { replay: flags.replay }),
33
37
  ...(flags.resume === undefined ? {} : { resume: flags.resume }),
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Composes the two ways `yaag run` gets its Orchestration Program: a file, or
3
+ * the source text of an Inline Program.
4
+ *
5
+ * The composition sits above both loaders, so neither loader knows the other
6
+ * and the imports stay a DAG.
7
+ */
8
+ import { resolve } from "node:path";
9
+ import type { OrchestrationProgram } from "@yaag/runtime";
10
+ import type { ProgramSource } from "./argv.ts";
11
+ import { loadInlineProgram } from "./inline-program.ts";
12
+ import { loadProgram } from "./program-loader.ts";
13
+
14
+ /** What one Run loaded. An Inline Program has no program file (ADR-0033). */
15
+ export interface LoadedProgram {
16
+ readonly program: OrchestrationProgram;
17
+ readonly programFile: string | undefined;
18
+ /** Present only for an Inline Program: its source is the Run's identity (ADR-0033). */
19
+ readonly programSource: string | undefined;
20
+ }
21
+
22
+ /**
23
+ * Loads the program a `run` invocation names, from a file or from source text.
24
+ *
25
+ * A file path is resolved against the working directory, and its
26
+ * `programFile` is that absolute path. An Inline Program reports no
27
+ * `programFile`, because its temporary module is unlinked after the import; it
28
+ * reports its source text as its identity instead.
29
+ * Each failure of `loadProgram` or `loadInlineProgram` — a missing file, a
30
+ * specifier outside the closed contract, a module that is not a program —
31
+ * passes through unchanged.
32
+ */
33
+ export async function loadRunProgram(source: ProgramSource): Promise<LoadedProgram> {
34
+ switch (source.kind) {
35
+ case "file": {
36
+ const programFile = resolve(source.file);
37
+ return { program: await loadProgram(programFile), programFile, programSource: undefined };
38
+ }
39
+ case "inline":
40
+ return {
41
+ program: await loadInlineProgram(source.source),
42
+ programFile: undefined,
43
+ programSource: source.source,
44
+ };
45
+ }
46
+ }
package/src/tree-app.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The standalone CLI's interactive Run view: the same `@yaag/tui` tree the Host
3
- * Session opens, hosted in an alt-screen pi-tui app (spec §4).
3
+ * Session opens, hosted in an alt-screen pi-tui app (architecture §9).
4
4
  *
5
5
  * Every collaborator arrives as a parameter, so the app is driven in tests over
6
6
  * a recording terminal; `alt-screen.ts` owns the real `ProcessTerminal`.
@@ -28,7 +28,6 @@ export interface TreeAppDeps {
28
28
  /** How the app ended. A fatal exit still restored the terminal. */
29
29
  export type TreeAppExit =
30
30
  | { readonly kind: "dismissed" }
31
- | { readonly kind: "detached" }
32
31
  | { readonly kind: "fatal"; readonly error: unknown };
33
32
 
34
33
  /** What `finish` writes after the alt-screen closes (architecture §9). */
@@ -43,7 +42,7 @@ export interface FinalOutput {
43
42
  export interface TreeApp {
44
43
  /** The projection the view reads; the caller renders the final frame from it. */
45
44
  readonly state: TreeState;
46
- /** Resolves when the reader dismisses or detaches, or a render throws. */
45
+ /** Resolves when the reader closes the view, or a render throws. */
47
46
  readonly exit: Promise<TreeAppExit>;
48
47
  /** Folds one Lifecycle Event into the projection and redraws. */
49
48
  present(event: LifecycleEvent): void;
@@ -54,7 +53,7 @@ export interface TreeApp {
54
53
  /**
55
54
  * Drains input and leaves the alt-screen, writing no channel. Once.
56
55
  *
57
- * A detach closes the alt-screen while the Run is still live, so this step is
56
+ * `esc` closes the alt-screen while the Run is still live, so this step is
58
57
  * separate from the final channel emission.
59
58
  */
60
59
  close(): Promise<void>;
@@ -98,13 +97,11 @@ export function startTreeApp(deps: TreeAppDeps): TreeApp {
98
97
  host: {
99
98
  ...host,
100
99
  stop: deps.stop,
101
- // A CLI has no background surface, so a detach closes the alt-screen and
102
- // the caller keeps awaiting the Run on the plain line presenter.
103
- detach: () => resolveExit({ kind: "detached" }),
104
- done: (kind) => resolveExit({ kind: kind === "detached" ? "detached" : "dismissed" }),
100
+ // A CLI has no background surface, so closing the view closes the
101
+ // alt-screen and the caller keeps awaiting the Run on the plain lines.
102
+ done: () => resolveExit({ kind: "dismissed" }),
105
103
  },
106
104
  ...(deps.label === undefined ? {} : { label: deps.label }),
107
- surface: "foreground",
108
105
  ...(deps.now === undefined ? {} : { now: deps.now }),
109
106
  });
110
107