@yaag/cli 0.3.0 → 0.5.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.
@@ -28,6 +28,7 @@ export type { RunContext } from "./run-context.ts";
28
28
  export type { DiscoveredSkill, SkillProbeFactory } from "./skill-probe.ts";
29
29
  export type { AgentInfo, AgentState, AskingAgentInfo, EndedRunSummary, ExitedAgentInfo, IdleAgentInfo, NodeInfo, RunningRunSummary, RunOutcome, RunState, RunSummary, } from "./summary.ts";
30
30
  export { applyEvent, initialSummary } from "./summary.ts";
31
+ export { readSystemPromptSidecar } from "./system-prompt-recorder.ts";
31
32
  export type { AgentStats, AgentTransport, AskMarker, AskMarkerContext, AskPlayback, Frame, TokenBreakdown, TransportFactory, TransportStartup, TransportStartupObserver, WorktreeResolution, } from "./transport.ts";
32
33
  export type { AskOptions, Handle, ResolvedSpawnOptions, SpawnOptions, SpawnOverrides, StructuredAskOptions, ThinkingLevel, } from "./types.ts";
33
34
  export { AGENT_NODE_TABLE_MAX, ASK_OUTPUT_FLUSH_INTERVAL_MS, ASK_OUTPUT_MAX_BYTES, ASK_OUTPUT_TRUNCATION_MARKER, NODE_GIST_MAX_CHARS, PROMPT_GIST_MAX_CHARS, TOOL_ARGS_GIST_MAX_CHARS, } from "./wire-constants.ts";
@@ -10,6 +10,25 @@ export interface AgentProgress {
10
10
  }
11
11
  /** The command line for one Agent. */
12
12
  export declare function piCommand(options: OpenOptions, toolProbeExtensionPath?: string): string[];
13
+ /** The spawn options that decide pi's `--tools` allowlist. */
14
+ export interface ToolSelection {
15
+ readonly tools?: readonly string[] | undefined;
16
+ readonly inherit?: boolean | undefined;
17
+ }
18
+ /**
19
+ * The names for pi's `--tools` flag, or null when yaag emits no allowlist and
20
+ * pi composes the toolset itself.
21
+ *
22
+ * This is the single source of the allowlist rule: `piCommand` writes the flag
23
+ * from it, and `toolContract` in tool-probe.ts states its promise from it.
24
+ *
25
+ * `--no-tools` strips extension tools as well, and refuses a tool registered
26
+ * afterwards, so "no tools" becomes an allowlist of exactly yaag's own tool
27
+ * (e2e/report-result-spike.test.ts). The tool stays inactive until a
28
+ * schema-bearing Ask activates it, so the Agent's effective capability is
29
+ * unchanged.
30
+ */
31
+ export declare function toolAllowlist(options: ToolSelection): readonly string[] | null;
13
32
  /**
14
33
  * `provider/id` from a `get_state` response — what the Agent actually resolved
15
34
  * to, not the pattern that was requested. Null when the payload has no model.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * yaag's private system-prompt recorder, loaded into every Agent at spawn.
3
+ *
4
+ * pi assembles the system prompt per turn and never persists it in the session
5
+ * file, so a Peek has nothing to show (observation #1204). This extension
6
+ * writes the assembled prompt to a sidecar file beside the session file, and pi
7
+ * loads it last, so the prompt it records holds every other extension's
8
+ * appends. It records rather than reports, so it is no probe: it sends no frame
9
+ * and the Orchestrator never waits for it (ADR-0035).
10
+ *
11
+ * It is strictly best-effort and silent: an Agent without a session file, or a
12
+ * write that fails, records nothing. It never changes the prompt, and it emits
13
+ * no frame, so no Ask can settle differently because of it.
14
+ *
15
+ * Types are declared locally rather than imported from pi: this file is passed
16
+ * to `pi -e` as a path and must load without yaag's dependency graph. pi loads
17
+ * it through jiti, in Node, so it uses `node:fs/promises` and no Bun-only API.
18
+ */
19
+ /** The pi event that carries the assembled prompt of one turn. */
20
+ interface BeforeAgentStart {
21
+ readonly systemPrompt?: unknown;
22
+ }
23
+ /** The pi context slice this extension reads. */
24
+ interface RecorderContext {
25
+ readonly sessionManager: {
26
+ getSessionFile(): string | undefined;
27
+ };
28
+ }
29
+ /** The pi surface this extension registers on. */
30
+ interface RecorderAPI {
31
+ on(event: "before_agent_start", handler: (event: BeforeAgentStart, context: RecorderContext) => Promise<undefined>): void;
32
+ }
33
+ /** Extension of the sidecar that holds one Agent's latest system prompt. */
34
+ export declare const SYSTEM_PROMPT_SIDECAR_SUFFIX = ".system-prompt.md";
35
+ /**
36
+ * The sidecar path for one pi session file.
37
+ *
38
+ * The session file's `.jsonl` extension is dropped, so `<id>.jsonl` and its
39
+ * sidecar `<id>.system-prompt.md` sit next to each other in the session dir.
40
+ */
41
+ export declare function systemPromptSidecarPath(sessionFile: string): string;
42
+ export default function (pi: RecorderAPI): void;
43
+ export {};
@@ -0,0 +1,17 @@
1
+ import { systemPromptSidecarPath } from "./system-prompt-recorder-extension.ts";
2
+ export { systemPromptSidecarPath };
3
+ /** Filesystem sibling path of the recorder; decodes percent-escapes (e.g. spaces). */
4
+ export declare function systemPromptRecorderExtensionPath(moduleUrl: string): string;
5
+ /** Loadable source path for the private extension that records the system prompt. */
6
+ export declare const SYSTEM_PROMPT_RECORDER_EXTENSION_PATH: string;
7
+ /**
8
+ * Reads the system-prompt sidecar of one Agent, or null when there is none.
9
+ *
10
+ * Null covers every case a Peek cannot show a real prompt for: an Agent that
11
+ * reported no session file, a session whose first turn has not started, and a
12
+ * session written before yaag recorded a sidecar. A Peek then falls back to the
13
+ * session file. An Agent of a replayed Run has the recording's session path, so
14
+ * a Peek on the recording machine reads the original Agent's sidecar, exactly
15
+ * as it reads the original Agent's transcript.
16
+ */
17
+ export declare function readSystemPromptSidecar(sessionFile: string | null | undefined): Promise<string | null>;
@@ -1,3 +1,4 @@
1
+ import { type ToolSelection } from "./pi-state.ts";
1
2
  import type { Frame } from "./transport.ts";
2
3
  /** The private status key used only by yaag's internal pi tool probe extension. */
3
4
  export declare const TOOL_PROBE_STATUS_KEY = "yaag.tool-probe.v1";
@@ -14,17 +15,48 @@ export interface ToolProbe {
14
15
  /** Rejects startup when the spawned pi process ends before reporting. */
15
16
  fail(error: Error): void;
16
17
  }
18
+ /** What the spawn flags promised about the Agent's effective toolset. */
19
+ export interface ToolContract {
20
+ /** Names that must be active at startup. Never holds `report_result`. */
21
+ readonly required: readonly string[];
22
+ /** The only names that may be active; null when the effective set is unpredictable. */
23
+ readonly allowed: readonly string[] | null;
24
+ /** Names that must not be active. */
25
+ readonly denied: readonly string[];
26
+ }
27
+ /** The spawn options that decide what the probe can verify. */
28
+ export interface ToolContractOptions extends ToolSelection {
29
+ readonly disallowedTools?: readonly string[] | undefined;
30
+ }
17
31
  /**
18
- * Returns promised tools that survive pi's final denylist composition.
32
+ * Returns what the spawn flags promised, or null when they promised nothing.
33
+ *
34
+ * Two promises are verifiable. A spawn that pi gets a `--tools` allowlist for
35
+ * promises that the allowlist holds every active name. A spawn that only denies
36
+ * tools promises the absence of every denied name, and nothing about the rest.
37
+ * An inheriting spawn without `tools` and without `disallowedTools` promises
38
+ * nothing, so it needs no probe.
19
39
  *
20
- * Null means no tool allowlist promised anything and therefore needs no probe.
40
+ * `report_result` is allowed but never required: report-result-extension.ts
41
+ * registers the tool when a schema-bearing Ask activates it, long after startup
42
+ * (ADR-0032), so at startup the probe reports it as inactive.
21
43
  */
22
- export declare function requestedToolNames(tools: readonly string[] | undefined, disallowedTools?: readonly string[] | undefined): readonly string[] | null;
23
- /** Creates a startup collector only when a non-empty allowlist promised tool selection. */
24
- export declare function createToolProbe(tools: readonly string[] | undefined, _disallowedTools?: readonly string[] | undefined, timeoutMs?: number): ToolProbe | null;
44
+ export declare function toolContract(options: ToolContractOptions): ToolContract | null;
45
+ /**
46
+ * Creates a startup collector for the probe's report.
47
+ *
48
+ * The caller creates one only when `toolContract` returned a contract to verify
49
+ * it against, and loads the probe extension in the same decision.
50
+ */
51
+ export declare function createToolProbe(timeoutMs?: number): ToolProbe;
25
52
  /** True exactly for a status frame emitted by the private probe extension. */
26
53
  export declare function isToolProbeFrame(frame: Frame): boolean;
27
54
  /** Parses the private status payload, rejecting every malformed boundary. */
28
55
  export declare function parseToolProbeFrame(frame: Frame): readonly string[];
29
- /** Throws a diagnostic naming every missing requested tool. */
30
- export declare function verifyEffectiveTools(promised: readonly string[], effective: readonly string[]): void;
56
+ /**
57
+ * Throws a diagnostic when the effective toolset breaks the promise.
58
+ *
59
+ * A missing required tool, an extra tool outside the allowlist, and a denied
60
+ * tool that is still active all fail the spawn the same way.
61
+ */
62
+ export declare function verifyEffectiveTools(contract: ToolContract, effective: readonly string[]): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.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.3.0",
25
- "@yaag/tui": "0.3.0",
24
+ "@yaag/runtime": "0.5.0",
25
+ "@yaag/tui": "0.5.0",
26
26
  "typebox": "1.3.7"
27
27
  }
28
28
  }
package/src/argv.ts CHANGED
@@ -7,6 +7,7 @@ import { resolve } from "node:path";
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
9
  yaag run --eval <source> [--quiet] [--args <json>] [--record <file>] [--replay <file>] [--resume <file>] [--events-fd <n>]
10
+ yaag run --eval-fd <n> [--quiet] [--args <json>] [--record <file>] [--replay <file>] [--resume <file>] [--events-fd <n>]
10
11
  yaag describe <program.ts>
11
12
  yaag setup-workspace [dir]
12
13
 
@@ -15,8 +16,12 @@ program file or --eval, but do not give both. A program that --eval runs can
15
16
  import "@yaag/runtime" and "typebox" only. A program that imports other modules
16
17
  must be a file.
17
18
 
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
19
+ --eval-fd reads the program source from descriptor <n>, and needs the writer to
20
+ close that descriptor. It keeps the source out of the process argument list,
21
+ where every local user can read it. The yaag extension always uses it.
22
+
23
+ A --resume or --replay Run also needs the program: give the program file,
24
+ --eval <source>, or --eval-fd <n>. A Cassette holds the history of a Run, and never the program
20
25
  to run.
21
26
 
22
27
  Warning: describe imports the module and executes its top level. Keep program module top level side-effect free.`;
@@ -24,7 +29,8 @@ Warning: describe imports the module and executes its top level. Keep program mo
24
29
  /** The program one `run` invocation names: a file, or source text (ADR-0033). */
25
30
  export type ProgramSource =
26
31
  | { readonly kind: "file"; readonly file: string }
27
- | { readonly kind: "inline"; readonly source: string };
32
+ | { readonly kind: "inline"; readonly source: string }
33
+ | { readonly kind: "inline-fd"; readonly fd: number };
28
34
 
29
35
  export type ParsedArgv =
30
36
  | {
@@ -60,6 +66,7 @@ export function parseArgv(argv: readonly string[]): ParsedArgv {
60
66
  function parseRun(tokens: readonly string[]): ParsedArgv {
61
67
  let file: string | undefined;
62
68
  let evalSource: string | undefined;
69
+ let evalFd: number | undefined;
63
70
  let args: unknown = {};
64
71
  let eventsFd: number | undefined;
65
72
  let record: string | undefined;
@@ -74,6 +81,7 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
74
81
  } else if (
75
82
  token === "--args" ||
76
83
  token === "--eval" ||
84
+ token === "--eval-fd" ||
77
85
  token === "--events-fd" ||
78
86
  token === "--record" ||
79
87
  token === "--replay" ||
@@ -88,6 +96,10 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
88
96
  args = parsed.value;
89
97
  } else if (token === "--eval") {
90
98
  evalSource = value;
99
+ } else if (token === "--eval-fd") {
100
+ const parsed = parseEvalDescriptor(value);
101
+ if (!parsed.ok) return parsed;
102
+ evalFd = parsed.value;
91
103
  } else if (token === "--events-fd") {
92
104
  const parsed = parseDescriptor(value);
93
105
  if (!parsed.ok) return parsed;
@@ -108,9 +120,9 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
108
120
  }
109
121
  }
110
122
 
111
- const clash = conflict({ file, evalSource, record, replay, resume });
123
+ const clash = conflict({ file, evalSource, evalFd, eventsFd, record, replay, resume });
112
124
  if (clash !== null) return failure(`${clash}\n${USAGE}`);
113
- const program = programSource(file, evalSource);
125
+ const program = programSource(file, evalSource, evalFd);
114
126
  if (program === undefined) return missingProgram(resume, replay);
115
127
  return {
116
128
  ok: true,
@@ -128,6 +140,8 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
128
140
  interface RunFlags {
129
141
  readonly file?: string;
130
142
  readonly evalSource?: string;
143
+ readonly evalFd?: number;
144
+ readonly eventsFd?: number;
131
145
  readonly record?: string;
132
146
  readonly replay?: string;
133
147
  readonly resume?: string;
@@ -135,10 +149,19 @@ interface RunFlags {
135
149
 
136
150
  /** Names the first pair of options that cannot appear in one invocation. */
137
151
  function conflict(flags: RunFlags): string | null {
138
- const { file, evalSource, record, replay, resume } = flags;
152
+ const { file, evalSource, evalFd, eventsFd, record, replay, resume } = flags;
139
153
  if (file !== undefined && evalSource !== undefined) {
140
154
  return "--eval and a program file cannot be used together";
141
155
  }
156
+ if (evalSource !== undefined && evalFd !== undefined) {
157
+ return "--eval and --eval-fd cannot be used together";
158
+ }
159
+ if (file !== undefined && evalFd !== undefined) {
160
+ return "--eval-fd and a program file cannot be used together";
161
+ }
162
+ if (evalFd !== undefined && eventsFd !== undefined && evalFd === eventsFd) {
163
+ return "--eval-fd and --events-fd must use different descriptors";
164
+ }
142
165
  if (record !== undefined && replay !== undefined) {
143
166
  return "--record and --replay cannot be used together";
144
167
  }
@@ -159,7 +182,7 @@ function missingProgram(
159
182
  if (resume === undefined && replay === undefined) return failure(USAGE);
160
183
  const flag = resume !== undefined ? "--resume" : "--replay";
161
184
  return failure(
162
- `${flag} needs the program too: give <program.ts> or --eval <source>. ` +
185
+ `${flag} needs the program too: give <program.ts>, --eval <source>, or --eval-fd <n>. ` +
163
186
  `A cassette holds the history of a Run, and never the program to run.\n${USAGE}`,
164
187
  );
165
188
  }
@@ -167,8 +190,10 @@ function missingProgram(
167
190
  function programSource(
168
191
  file: string | undefined,
169
192
  source: string | undefined,
193
+ fd: number | undefined,
170
194
  ): ProgramSource | undefined {
171
195
  if (source !== undefined) return { kind: "inline", source };
196
+ if (fd !== undefined) return { kind: "inline-fd", fd };
172
197
  return file === undefined ? undefined : { kind: "file", file };
173
198
  }
174
199
 
@@ -202,6 +227,14 @@ function parseDescriptor(value: string): ParsedValue<number> {
202
227
  : failure(`--events-fd must be a descriptor number\n${USAGE}`);
203
228
  }
204
229
 
230
+ /** 0, 1 and 2 are stdin, stdout and stderr, so an Inline Program needs 3 or higher. */
231
+ function parseEvalDescriptor(value: string): ParsedValue<number> {
232
+ const fd = Number(value);
233
+ return Number.isInteger(fd) && fd >= 3
234
+ ? { ok: true, value: fd }
235
+ : failure(`--eval-fd must be a descriptor number of 3 or higher\n${USAGE}`);
236
+ }
237
+
205
238
  function failure(error: string): { readonly ok: false; readonly error: string } {
206
239
  return { ok: false, error };
207
240
  }
@@ -6,11 +6,9 @@
6
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
- import { readFile, writeFile } from "node:fs/promises";
10
- import { tmpdir } from "node:os";
11
- import { join } from "node:path";
9
+ import { readFile } from "node:fs/promises";
12
10
  import type { Terminal } from "@earendil-works/pi-tui";
13
- import type { AgentInfo } from "@yaag/runtime";
11
+ import { type AgentInfo, readSystemPromptSidecar } from "@yaag/runtime";
14
12
  import { cliKeybindings, type RunTreeViewHost, type TreeState } from "@yaag/tui";
15
13
 
16
14
  /** The terminal services one open CLI view holds. */
@@ -21,8 +19,6 @@ export interface CliTreeHostOptions {
21
19
  /** Shows a transient message; `tree-app.ts` routes it to the TUI. */
22
20
  notify(message: string, level: "info" | "warning" | "error"): void;
23
21
  requestRender(): void;
24
- /** Where `openEditor` puts the body; defaults to the system temp directory. */
25
- readonly scratchDir?: string;
26
22
  }
27
23
 
28
24
  /** The read-only capabilities the CLI Run view has. */
@@ -35,23 +31,14 @@ export function createCliTreeHost(options: CliTreeHostOptions): ReadOnlyCliHost
35
31
  keybindings: cliKeybindings(),
36
32
  agentInfo: (agent) => state.summary.agents[agent],
37
33
  readSession: (agent) => readSession(state.summary.agents[agent]),
34
+ readSystemPromptSidecar: (agent) =>
35
+ readSystemPromptSidecar(state.summary.agents[agent]?.sessionFile),
38
36
  sessionPath: (agent) => state.summary.agents[agent]?.sessionFile ?? null,
39
37
  // OSC 52 is the only copy path that works over SSH without a child
40
38
  // process, and it is what pi-tui itself uses for its own copy gesture.
41
39
  copyPath: async (text) => {
42
40
  terminal.write(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
43
41
  },
44
- // Spawning $EDITOR inside a live alt-screen would fight the TUI for the
45
- // terminal, so the body lands in a file and the path is reported instead.
46
- openEditor: async (title, body) => {
47
- const path = join(options.scratchDir ?? tmpdir(), scratchName(title));
48
- try {
49
- await writeFile(path, body, "utf8");
50
- options.notify(`Wrote ${title} to ${path}`, "info");
51
- } catch (error) {
52
- options.notify(`Could not write ${title}: ${message(error)}`, "error");
53
- }
54
- },
55
42
  notify: (text, level) => options.notify(text, level),
56
43
  requestRender: () => options.requestRender(),
57
44
  rows: () => Math.max(1, terminal.rows - 1),
@@ -70,12 +57,3 @@ async function readSession(info: AgentInfo | undefined): Promise<string | null>
70
57
  return null;
71
58
  }
72
59
  }
73
-
74
- function scratchName(title: string): string {
75
- const slug = title.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
76
- return `yaag-${slug === "" ? "note" : slug}.txt`;
77
- }
78
-
79
- function message(error: unknown): string {
80
- return error instanceof Error ? error.message : String(error);
81
- }
package/src/eval-fd.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Reads the source of an Inline Program from a file descriptor (ADR-0033).
3
+ *
4
+ * An argv element is readable by every local user through `ps` and
5
+ * `/proc/<pid>/cmdline`, so the source of an Inline Program travels on a
6
+ * descriptor instead. Descriptor 3 stays exclusive to Run Lifecycle Events
7
+ * (ADR-0016), so the extension writes the source to descriptor 4.
8
+ */
9
+
10
+ /** The descriptor the extension writes an Inline Program to; fd 3 is events. */
11
+ export const DEFAULT_EVAL_FD = 4;
12
+
13
+ /**
14
+ * Reads the whole Inline Program source from `fd`, until end of file.
15
+ *
16
+ * The read stops when the writer closes the descriptor, so a caller that
17
+ * keeps the descriptor open parks the Run before the program loads.
18
+ *
19
+ * Throws an `Error` that names the descriptor when the read fails, and when
20
+ * the descriptor gives no source.
21
+ */
22
+ export async function readEvalSource(fd: number): Promise<string> {
23
+ let source: string;
24
+ try {
25
+ source = await Bun.file(fd).text();
26
+ } catch (error) {
27
+ throw new Error(`--eval-fd ${fd} cannot be read: ${String(error)}`);
28
+ }
29
+ if (source === "") {
30
+ throw new Error(
31
+ `--eval-fd ${fd} gave no source; write the Orchestration Program to the descriptor and close it`,
32
+ );
33
+ }
34
+ return source;
35
+ }
@@ -35,7 +35,7 @@ export function assertClosedImports(source: string): void {
35
35
  throw new Error(
36
36
  `inline program cannot import a computed specifier ` +
37
37
  `(import call on line ${computed.line}): ` +
38
- `--eval allows ${allowedText()} only, each one as a string literal`,
38
+ `an Inline Program allows ${allowedText()} only, each one as a string literal`,
39
39
  );
40
40
  }
41
41
  const transpiler = new Bun.Transpiler({ loader: "ts" });
@@ -43,7 +43,7 @@ export function assertClosedImports(source: string): void {
43
43
  if (ALLOWED_INLINE_IMPORTS.includes(scanned.path)) continue;
44
44
  throw new Error(
45
45
  `inline program cannot import ${JSON.stringify(scanned.path)}: ` +
46
- `--eval allows ${allowedText()} only`,
46
+ `an Inline Program allows ${allowedText()} only`,
47
47
  );
48
48
  }
49
49
  }
@@ -61,7 +61,7 @@ export function assertClosedImports(source: string): void {
61
61
  * by exactly one.
62
62
  */
63
63
  export function inlineRequirePrelude(): string {
64
- const suffix = JSON.stringify(`: --eval allows ${allowedText()} only`);
64
+ const suffix = JSON.stringify(`: an Inline Program allows ${allowedText()} only`);
65
65
  return [
66
66
  "const require = ((real, allowed) => (id) => {",
67
67
  " if (allowed.includes(id)) return real(id);",
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Composes the two ways `yaag run` gets its Orchestration Program: a file, or
3
- * the source text of an Inline Program.
2
+ * Composes the ways `yaag run` gets its Orchestration Program: a file, or the
3
+ * source of an Inline Program. That source arrives as text, or on a
4
+ * descriptor (ADR-0033); the loader below does not know which.
4
5
  *
5
6
  * The composition sits above both loaders, so neither loader knows the other
6
7
  * and the imports stay a DAG.
@@ -8,6 +9,7 @@
8
9
  import { resolve } from "node:path";
9
10
  import type { OrchestrationProgram } from "@yaag/runtime";
10
11
  import type { ProgramSource } from "./argv.ts";
12
+ import { readEvalSource } from "./eval-fd.ts";
11
13
  import { loadInlineProgram } from "./inline-program.ts";
12
14
  import { loadProgram } from "./program-loader.ts";
13
15
 
@@ -25,7 +27,9 @@ export interface LoadedProgram {
25
27
  * A file path is resolved against the working directory, and its
26
28
  * `programFile` is that absolute path. An Inline Program reports no
27
29
  * `programFile`, because its temporary module is unlinked after the import; it
28
- * reports its source text as its identity instead.
30
+ * reports its source text as its identity instead. A `--eval-fd` program is
31
+ * read to end of file first, so a writer that never closes the descriptor
32
+ * parks the Run (see `eval-fd.ts`).
29
33
  * Each failure of `loadProgram` or `loadInlineProgram` — a missing file, a
30
34
  * specifier outside the closed contract, a module that is not a program —
31
35
  * passes through unchanged.
@@ -42,5 +46,13 @@ export async function loadRunProgram(source: ProgramSource): Promise<LoadedProgr
42
46
  programFile: undefined,
43
47
  programSource: source.source,
44
48
  };
49
+ case "inline-fd": {
50
+ const text = await readEvalSource(source.fd);
51
+ return {
52
+ program: await loadInlineProgram(text),
53
+ programFile: undefined,
54
+ programSource: text,
55
+ };
56
+ }
45
57
  }
46
58
  }