@executablemd/runtime 0.8.1 → 0.9.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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Observing which executable build a command actually runs
3
+ * (specs/native-agent-session-launch-spec.md §Executable binding).
4
+ *
5
+ * A provider session whose identity XMD chose itself only means something
6
+ * while the build that established it can be reproduced. Two builds of one
7
+ * provider accept the same session identity and disagree silently about what
8
+ * it names — that is how issue #519's first gate produced a healthy-looking
9
+ * session with no history in it. So before a session crosses an ownership
10
+ * boundary, the exact file about to run is observed, and enough is retained to
11
+ * recognize it later.
12
+ *
13
+ * What is observed is a canonical path, the SHA-256 of that file's bytes, and
14
+ * whatever the file says when asked its version. Only the last two ever become
15
+ * durable: a path stops being true when a build moves, and names host layout
16
+ * besides.
17
+ *
18
+ * This is a plain capability the trusted host builds and hands directly to the
19
+ * provider that needs it. It is deliberately not a contextual Api. Executable
20
+ * validation decides which retained history is accepted, and a decision
21
+ * document middleware could replace is not one — a replaceable resolver could
22
+ * point the observation at a different binary than the one that runs.
23
+ *
24
+ * Provider-specific meaning is not this module's business: which command to
25
+ * run, what a version string looks like, and what to do about a mismatch
26
+ * belong to the adapter that knows the provider.
27
+ */
28
+ /**
29
+ * An observation failure that names its reason.
30
+ *
31
+ * The reason is the actionable part and the message is diagnostic. Neither is
32
+ * retained: a caller turns this into its own refusal, and the paths involved
33
+ * stay on this side of that boundary.
34
+ */
35
+ export class ExecutableObservationError extends Error {
36
+ name = "ExecutableObservationError";
37
+ refusal;
38
+ constructor(message, options) {
39
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
40
+ this.refusal = options.refusal;
41
+ }
42
+ }
package/esm/files.js CHANGED
@@ -13,8 +13,10 @@
13
13
  * replacement — admission, resolution, target classification, parent creation,
14
14
  * and commit — rather than a sequence a caller assembles, because assembling it
15
15
  * from outside is what would let a path admitted by one provider be used by
16
- * another. `API.Fs` remains the low-level host surface a host adapter is built
17
- * on; it is not this boundary.
16
+ * another. `deleteFile` is the same kind of whole act, and every operation here
17
+ * is mandatory: a provider with nothing to offer for one does not omit it and
18
+ * leave a document reaching the host instead. `API.Fs` remains the low-level
19
+ * host surface a host adapter is built on; it is not this boundary.
18
20
  *
19
21
  * `checkFilePath` is the one exception, and it is deliberately weak: pure path
20
22
  * arithmetic, no filesystem access, and nothing usable comes back — no path, no
@@ -47,6 +49,7 @@
47
49
  * and `instanceof` answers false across them, which would turn a provider
48
50
  * failure into an unrecognized throw exactly when it matters most.
49
51
  */
52
+ import "./_dnt.polyfills.js";
50
53
  import { createApi } from "@effectionx/context-api";
51
54
  /** The stable discriminant on ordinary filesystem failure data. */
52
55
  export const FILES_ERROR = "executablemd.runtime.files-error/v1";
@@ -79,6 +82,7 @@ const REASONS = [
79
82
  const OPERATIONS = [
80
83
  "check-file-path",
81
84
  "read",
85
+ "delete",
82
86
  "glob",
83
87
  "temporary-directory",
84
88
  ];
@@ -548,6 +552,10 @@ export const Files = createApi("executablemd.runtime.files", {
548
552
  throw new FilesProviderUnavailableError();
549
553
  },
550
554
  // deno-lint-ignore require-yield
555
+ *deleteFile(_input) {
556
+ throw new FilesProviderUnavailableError();
557
+ },
558
+ // deno-lint-ignore require-yield
551
559
  *globFiles(_input) {
552
560
  throw new FilesProviderUnavailableError();
553
561
  },
package/esm/host-files.js CHANGED
@@ -35,6 +35,25 @@
35
35
  * has nothing to resolve, and `rename` replaces the link rather than following
36
36
  * it wherever it points.
37
37
  *
38
+ * ## Deletions
39
+ *
40
+ * A deletion is the mirror image of a write in the one place that matters: the
41
+ * final path segment is deliberately *not* resolved. A write follows an
42
+ * internal link to the file it names, because replacing the link would be the
43
+ * surprising outcome; a deletion removes the link itself, because following it
44
+ * would remove something the document never named — possibly outside the
45
+ * working directory entirely. So resolution stops at the parent prefix, which
46
+ * still catches a directory link leading out, and the authored last segment is
47
+ * put back onto it unresolved.
48
+ *
49
+ * What is then removed is decided by an explicit `lstat` rather than by the
50
+ * platform. A directory is refused whether or not it is empty, and every
51
+ * runtime this ships to reports a nonrecursive removal of one differently.
52
+ * Absence is success on both sides of that classification: a path that already
53
+ * names nothing was already what the document asked for. The removal is the
54
+ * single commit point, and nothing is acquired around it, so cancellation
55
+ * before it changes nothing and there is no cleanup to fail.
56
+ *
38
57
  * ## What crosses the boundary
39
58
  *
40
59
  * Nothing from a caught platform error. An errno code *selects* a
@@ -47,7 +66,7 @@ import { ensure, Err, Ok, resource, scoped, until } from "effection";
47
66
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
48
67
  import { randomUUID } from "node:crypto";
49
68
  import { mkdtempSync } from "node:fs";
50
- import { realpath } from "node:fs/promises";
69
+ import { realpath, rmdir } from "node:fs/promises";
51
70
  import { tmpdir } from "node:os";
52
71
  import { FsApi, rm } from "@effectionx/fs";
53
72
  import { API } from "./apis.js";
@@ -184,6 +203,41 @@ function* destination(input) {
184
203
  return { reason: reasonOf(error) };
185
204
  }
186
205
  }
206
+ /**
207
+ * The path a removal would act on, with the final segment left alone.
208
+ *
209
+ * Deletion is the one operation whose target must *not* be resolved. A final
210
+ * symbolic link is the entry the document named, and following it would remove
211
+ * something the document never mentioned — possibly outside the working
212
+ * directory entirely. So only the parent prefix is resolved, and the authored
213
+ * last segment is put back onto it unresolved. Resolving the parent is what
214
+ * still catches a directory link leading out; leaving the last segment is what
215
+ * keeps a link a link.
216
+ *
217
+ * The working directory names itself — `.`, or anything that normalizes onto it
218
+ * — and that case cannot go through the parent at all: the parent of `cwd` is
219
+ * outside `cwd`, so the same comparison would report the working directory as
220
+ * an escape. It is classified directly instead, and target classification then
221
+ * refuses it for what it is.
222
+ */
223
+ function* removalDestination(input) {
224
+ try {
225
+ const base = (yield* API.Fs.operations.realpath(input.cwd)) ?? input.cwd;
226
+ const named = resolve(input.cwd, input.path);
227
+ if (named === resolve(input.cwd)) {
228
+ return { path: base };
229
+ }
230
+ const parent = yield* resolveExisting(dirname(named));
231
+ const path = join(parent, basename(named));
232
+ if (!within(base, path)) {
233
+ return { reason: "resolved-escape" };
234
+ }
235
+ return { path };
236
+ }
237
+ catch (error) {
238
+ return { reason: reasonOf(error) };
239
+ }
240
+ }
187
241
  function nonWriteFailure(operation, phase, reason) {
188
242
  return Err(filesFailure({ operation, phase, reason }));
189
243
  }
@@ -349,6 +403,75 @@ export function hostFilesHandler(options = {}) {
349
403
  }
350
404
  return Ok(fileWriteSuccess("host-committed"));
351
405
  }
406
+ /**
407
+ * Remove one regular file or one final symbolic link.
408
+ *
409
+ * Classification comes first and it is `lstat`, so what is judged is the
410
+ * entry the document named rather than whatever it leads to. A directory is
411
+ * refused whether or not it is empty: `<File.Delete>` names one file, and the
412
+ * platforms disagree about what removing an empty directory nonrecursively
413
+ * even reports — one answers `EISDIR`, another `EFAULT`. Deciding it here is
414
+ * what makes that disagreement invisible.
415
+ *
416
+ * Absence is success, in both places it can appear. A path that already names
417
+ * nothing was already what the document asked for, and a path that stops
418
+ * existing between the classification and the removal is the same answer
419
+ * arrived at by a different route — the removal is this operation's single
420
+ * commit point, and it has no earlier state to restore.
421
+ */
422
+ function* deleteFile(input) {
423
+ const lexical = inadmissible(input);
424
+ if (lexical !== undefined) {
425
+ return nonWriteFailure("delete", "lexical", lexical);
426
+ }
427
+ const target = yield* removalDestination(input);
428
+ if ("reason" in target) {
429
+ return nonWriteFailure("delete", "resolution", target.reason);
430
+ }
431
+ notify(observe, { operation: "delete", phase: "target" });
432
+ let wasSymbolicLink = false;
433
+ try {
434
+ const info = yield* API.Fs.operations.lstat(target.path);
435
+ if (!info.exists) {
436
+ return Ok(undefined);
437
+ }
438
+ if (info.isDirectory) {
439
+ return nonWriteFailure("delete", "target", "directory");
440
+ }
441
+ if (!info.isFile && !info.isSymbolicLink) {
442
+ return nonWriteFailure("delete", "target", "special-file");
443
+ }
444
+ wasSymbolicLink = info.isSymbolicLink;
445
+ }
446
+ catch (error) {
447
+ return nonWriteFailure("delete", "target", reasonOf(error));
448
+ }
449
+ notify(observe, { operation: "delete", phase: "access" });
450
+ try {
451
+ yield* API.Fs.operations.remove(target.path, { recursive: false });
452
+ }
453
+ catch (error) {
454
+ const reason = reasonOf(error);
455
+ if (reason === "missing") {
456
+ return Ok(undefined);
457
+ }
458
+ // Windows removes a symbolic link to a directory with RemoveDirectory,
459
+ // and not every runtime's `rm` falls back to it. The entry was classified
460
+ // a link above, so removing it as a directory still removes only the
461
+ // link. A fallback that fails leaves the original refusal in force.
462
+ if (wasSymbolicLink) {
463
+ try {
464
+ yield* until(rmdir(target.path));
465
+ return Ok(undefined);
466
+ }
467
+ catch {
468
+ return nonWriteFailure("delete", "access", reason);
469
+ }
470
+ }
471
+ return nonWriteFailure("delete", "access", reason);
472
+ }
473
+ return Ok(undefined);
474
+ }
352
475
  /**
353
476
  * The regular files under `cwd` that `include` selects and `exclude` does not.
354
477
  *
@@ -409,7 +532,7 @@ export function hostFilesHandler(options = {}) {
409
532
  let created;
410
533
  try {
411
534
  // oxlint-disable-next-line local/no-sync-filesystem
412
- created = mkdtempSync(join(tmpdir(), "xmd-tempdir-"));
535
+ created = mkdtempSync(join(options.temporaryRoot ?? tmpdir(), "xmd-tempdir-"));
413
536
  }
414
537
  catch (error) {
415
538
  yield* provide(nonWriteFailure("temporary-directory", "acquire", reasonOf(error)));
@@ -427,7 +550,7 @@ export function hostFilesHandler(options = {}) {
427
550
  yield* provide(Ok(canonical));
428
551
  });
429
552
  }
430
- return { checkFilePath, readTextFile, writeTextFile, globFiles, temporaryDirectory };
553
+ return { checkFilePath, readTextFile, writeTextFile, deleteFile, globFiles, temporaryDirectory };
431
554
  }
432
555
  /**
433
556
  * Remove a temporary directory as its scope ends.
@@ -486,6 +609,9 @@ export function useHostFiles(options = {}) {
486
609
  *writeTextFile([input]) {
487
610
  return yield* handler.writeTextFile(input);
488
611
  },
612
+ *deleteFile([input]) {
613
+ return yield* handler.deleteFile(input);
614
+ },
489
615
  *globFiles([input]) {
490
616
  return yield* handler.globFiles(input);
491
617
  },
@@ -0,0 +1,322 @@
1
+ /**
2
+ * The native launcher — how a host hands one child process the terminal.
3
+ *
4
+ * This is not `exec`. An ordinary command is a captured child: its stdout and
5
+ * stderr are piped so a document can display, capture and journal them, and
6
+ * its exit status is a value the document reads. A native coding-agent UI is
7
+ * the opposite of that. It draws on the terminal, reads the person's
8
+ * keystrokes, and owns the conversation it has with them. None of that may
9
+ * become an XMD process result or a journaled transcript, and a piped child
10
+ * cannot be interactive at all.
11
+ *
12
+ * So a launch asks for three things in order, and each is refusable on its
13
+ * own:
14
+ *
15
+ * 1. `reserve()` takes the one foreground-terminal lease for the run. A host
16
+ * with no terminal refuses here, which is before any session ownership has
17
+ * moved. Two launches cannot hold it at once even when they name different
18
+ * sessions, so native UIs are sequential by construction.
19
+ * 2. `flush()` gives the reader everything the document has produced so far,
20
+ * so the native UI does not open on top of half-written output.
21
+ * 3. `launch()` spawns the child with the terminal inherited, waits for it,
22
+ * and reports its terminal status and nothing else.
23
+ *
24
+ * There is no host default. `xmd run` installs the foreground launcher;
25
+ * a test or embedding host installs a controlled one that needs no terminal.
26
+ * Until one is installed every operation refuses, which is what keeps
27
+ * document help and inspection free of any of this.
28
+ */
29
+ import { createApi } from "@effectionx/context-api";
30
+ import { ensure, race, resource, scoped, until, withResolvers } from "effection";
31
+ import { spawn as spawnChild } from "node:child_process";
32
+ import process from "node:process";
33
+ export const NATIVE_LAUNCHER_UNAVAILABLE = "no native launcher is installed — this host does not hand a native agent UI " +
34
+ "the terminal. `xmd run` installs one; a test or embedding host installs its own.";
35
+ export class NativeLauncherUnavailableError extends Error {
36
+ name = "NativeLauncherUnavailableError";
37
+ constructor(message = NATIVE_LAUNCHER_UNAVAILABLE) {
38
+ super(message);
39
+ }
40
+ }
41
+ export const NativeLauncher = createApi("runtime.nativeLauncher", {
42
+ // deno-lint-ignore require-yield
43
+ *reserve() {
44
+ throw new NativeLauncherUnavailableError();
45
+ },
46
+ // deno-lint-ignore require-yield
47
+ *flush() {
48
+ throw new NativeLauncherUnavailableError();
49
+ },
50
+ // deno-lint-ignore require-yield
51
+ *launch(_request) {
52
+ throw new NativeLauncherUnavailableError();
53
+ },
54
+ });
55
+ /** Hold the foreground-terminal lease for the calling scope. */
56
+ export function reserveTerminal() {
57
+ return NativeLauncher.operations.reserve();
58
+ }
59
+ /** Give the reader everything the document has produced so far. */
60
+ export function flushOutput() {
61
+ return NativeLauncher.operations.flush();
62
+ }
63
+ /** Run one native UI as a foreground child and report how it ended. */
64
+ export function nativeLaunch(request) {
65
+ return NativeLauncher.operations.launch(request);
66
+ }
67
+ export const NO_TERMINAL = "<Session.Launch> needs a terminal: a native agent UI reads keystrokes and " +
68
+ "draws on the screen, and this invocation has none. Run xmd from a terminal, " +
69
+ "or use a host that installs its own launcher.";
70
+ /**
71
+ * How long an interrupted child is given to leave on its own before the
72
+ * launcher escalates. Cancellation is not permitted to strand a process
73
+ * holding the terminal, so the escalation is bounded rather than patient.
74
+ */
75
+ const INTERRUPT_GRACE_MS = 2_000;
76
+ /** How often a reap re-checks whether the child is still reachable. */
77
+ const REAP_POLL_MS = 25;
78
+ /** How long an unanswerable kill is given before the child is called gone. */
79
+ const KILL_SETTLE_MS = 500;
80
+ /**
81
+ * Install the launcher that hands a native UI this process's own terminal.
82
+ *
83
+ * XMD stays the parent. It does not replace itself with the child, because a
84
+ * process that has execed away cannot cancel the document, reap the child,
85
+ * own its exit status, or continue after the UI closes.
86
+ */
87
+ export function* installForegroundLauncher(options = {}) {
88
+ const isTerminal = options.isTerminal ?? (() => process.stdout.isTTY === true);
89
+ const drain = options.drain;
90
+ let held = false;
91
+ yield* NativeLauncher.around({
92
+ reserve() {
93
+ return resource(function* (provide) {
94
+ if (!isTerminal()) {
95
+ throw new NativeLauncherUnavailableError(NO_TERMINAL);
96
+ }
97
+ if (held) {
98
+ throw new Error("another <Session.Launch> already holds this run's terminal — one " +
99
+ "native UI owns the terminal at a time");
100
+ }
101
+ held = true;
102
+ try {
103
+ yield* provide();
104
+ }
105
+ finally {
106
+ held = false;
107
+ }
108
+ });
109
+ },
110
+ *flush() {
111
+ if (drain) {
112
+ yield* drain();
113
+ }
114
+ yield* drainStream(process.stdout);
115
+ yield* drainStream(process.stderr);
116
+ },
117
+ *launch([request]) {
118
+ return yield* runForeground(request);
119
+ },
120
+ }, { at: "min" });
121
+ }
122
+ /**
123
+ * Wait until the host stream has written what was handed to it.
124
+ *
125
+ * A TTY write usually completes synchronously and `write()` reports it did;
126
+ * when it reports back-pressure instead the bytes are still queued, and the
127
+ * child would draw over them.
128
+ */
129
+ function drainStream(stream) {
130
+ return until(new Promise((resolve) => {
131
+ if (stream.writableLength === 0) {
132
+ resolve();
133
+ return;
134
+ }
135
+ stream.write("", () => resolve());
136
+ }));
137
+ }
138
+ function runForeground(request) {
139
+ return scoped(function* () {
140
+ const [command, ...args] = request.command;
141
+ if (command === undefined) {
142
+ throw new Error("native launch: command must not be empty");
143
+ }
144
+ const settled = withResolvers();
145
+ const failed = withResolvers();
146
+ let child;
147
+ // Interrupt, then insist. A cancelled document may not continue — or
148
+ // finish tearing down — while a child still holds the terminal, so this
149
+ // waits for the child to be gone rather than for the signal to have been
150
+ // sent. Registered before the spawn, because a halt between acquiring a
151
+ // process and registering its cleanup leaks the process.
152
+ yield* ensure(() => (child ? until(reap(child)) : undefined));
153
+ // `inherit` is the whole point: the child reads this terminal and draws on
154
+ // it directly, so nothing between it and the person using it can buffer,
155
+ // reorder, capture or journal what passes.
156
+ child = spawnChild(command, args, {
157
+ cwd: request.cwd,
158
+ env: request.env,
159
+ stdio: "inherit",
160
+ });
161
+ child.once("error", (error) => failed.reject(error));
162
+ child.once("exit", (code, signal) => {
163
+ const outcome = {};
164
+ if (code !== null) {
165
+ outcome.exitCode = code;
166
+ }
167
+ if (signal !== null) {
168
+ outcome.signal = signal;
169
+ }
170
+ settled.resolve(outcome);
171
+ });
172
+ return yield* race([settled.operation, failed.operation]);
173
+ });
174
+ }
175
+ /**
176
+ * End one foreground child and wait for it to be gone.
177
+ *
178
+ * Deliberately one promise rather than an Effection race: this runs while the
179
+ * scope is already being dismantled, and the cheapest correct thing to do
180
+ * there is to wait on the process's own events instead of starting more
181
+ * structured work beside them.
182
+ *
183
+ * A child that ignores the interrupt is killed outright once the grace period
184
+ * is spent — a native UI holding the terminal is not something a cancelled run
185
+ * can afford to wait on indefinitely.
186
+ */
187
+ function reap(child) {
188
+ const pid = child.pid;
189
+ if (pid === undefined || child.exitCode !== null || child.signalCode !== null) {
190
+ return Promise.resolve();
191
+ }
192
+ return new Promise((resolve, reject) => {
193
+ let settled = false;
194
+ // What the escalation established. Bounded settlement is only permitted on
195
+ // the strength of one of these: a fatal signal the kernel accepted, or a
196
+ // process that was already gone. Anything else — a refused delivery, a
197
+ // permission error — leaves a child that may still be running, and
198
+ // reporting that as a successful reap would let the document continue
199
+ // while a native UI still owns the terminal.
200
+ let fatal;
201
+ const done = (outcome) => {
202
+ if (settled) {
203
+ return;
204
+ }
205
+ settled = true;
206
+ clearInterval(poll);
207
+ clearTimeout(escalation);
208
+ clearTimeout(deadline);
209
+ // Deno's `node:child_process` stops reporting a child's exit once a
210
+ // signal that child ignored has been delivered, and holds the runtime
211
+ // open on the handle it will now never settle. Dropping the reference is
212
+ // what lets a cancelled run finish.
213
+ try {
214
+ child.unref();
215
+ }
216
+ catch {
217
+ // The runtime already released the handle, which is the state this
218
+ // was asking for.
219
+ }
220
+ if (outcome) {
221
+ reject(outcome);
222
+ return;
223
+ }
224
+ resolve();
225
+ };
226
+ child.once("exit", () => done());
227
+ // Reachability rather than the exit event, because that is the fact this
228
+ // has to establish and the event is not dependable across runtimes here.
229
+ const poll = setInterval(() => {
230
+ if (!isReachable(pid)) {
231
+ done();
232
+ }
233
+ }, REAP_POLL_MS);
234
+ const escalation = setTimeout(() => {
235
+ fatal = signal(pid, "SIGKILL");
236
+ }, INTERRUPT_GRACE_MS);
237
+ // A process does not survive SIGKILL, so once the kernel accepted one this
238
+ // stops waiting on a report rather than on the child. What it will not do
239
+ // is call an undelivered signal a termination.
240
+ const deadline = setTimeout(() => {
241
+ if (fatal === "delivered" || fatal === "absent") {
242
+ done();
243
+ return;
244
+ }
245
+ done(new Error(`native launch could not establish that process ${pid} stopped: ` +
246
+ `SIGKILL was ${fatal ?? "not delivered"}`));
247
+ }, INTERRUPT_GRACE_MS + KILL_SETTLE_MS);
248
+ signal(pid, "SIGINT");
249
+ });
250
+ }
251
+ /**
252
+ * Send one signal to the child by pid, and report what that established.
253
+ *
254
+ * Deliberately not `child.kill()`. Deno's `node:child_process` marks a child as
255
+ * killed after the first call and delivers nothing on any later one, so a child
256
+ * that ignores the interrupt could never be escalated through the handle — the
257
+ * run would keep waiting on a native UI still holding the terminal. Addressing
258
+ * the process directly is what makes escalation real.
259
+ */
260
+ function signal(pid, name) {
261
+ try {
262
+ process.kill(pid, name);
263
+ return "delivered";
264
+ }
265
+ catch (error) {
266
+ // Gone between the decision and the delivery is the outcome this was
267
+ // asking for. Anything else is a delivery that did not happen, and is not
268
+ // evidence of termination.
269
+ return isNoSuchProcess(error) ? "absent" : "refused";
270
+ }
271
+ }
272
+ function isNoSuchProcess(error) {
273
+ return (typeof error === "object" &&
274
+ error !== null &&
275
+ "code" in error &&
276
+ error.code === "ESRCH");
277
+ }
278
+ /**
279
+ * Whether a process still exists. Signal 0 delivers nothing: it asks the
280
+ * kernel whether the pid is reachable, which is the whole question here.
281
+ */
282
+ function isReachable(pid) {
283
+ try {
284
+ process.kill(pid, 0);
285
+ return true;
286
+ }
287
+ catch {
288
+ return false;
289
+ }
290
+ }
291
+ export function* installControlledLauncher(options = {}) {
292
+ let held = false;
293
+ yield* NativeLauncher.around({
294
+ reserve() {
295
+ return resource(function* (provide) {
296
+ if (held) {
297
+ throw new Error("another <Session.Launch> already holds this run's terminal — one " +
298
+ "native UI owns the terminal at a time");
299
+ }
300
+ held = true;
301
+ options.onReserve?.();
302
+ try {
303
+ yield* provide();
304
+ }
305
+ finally {
306
+ held = false;
307
+ }
308
+ });
309
+ },
310
+ // deno-lint-ignore require-yield
311
+ *flush() {
312
+ options.onFlush?.();
313
+ },
314
+ *launch([request]) {
315
+ options.record?.(request);
316
+ if (options.wait) {
317
+ yield* options.wait(request);
318
+ }
319
+ return options.outcome?.(request) ?? { exitCode: 0 };
320
+ },
321
+ }, { at: "min" });
322
+ }
package/esm/mod.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * Seven domain APIs:
9
9
  * - `API.Process` — subprocess execution (`exec`)
10
10
  * - `API.Fs` — the low-level host filesystem (`readTextFile`, `writeTextFile`,
11
- * `stat`, `glob`, `realpath`, `ensureDir`, `rename`, `remove`)
11
+ * `stat`, `lstat`, `glob`, `realpath`, `ensureDir`, `rename`, `remove`)
12
12
  * - `API.Files` — document filesystem access as whole semantic operations,
13
13
  * with no host default. `useHostFiles()` installs the host provider.
14
14
  * - `API.Fetch` — HTTP requests (`fetch`)
@@ -16,15 +16,23 @@
16
16
  * this xmd, and eval-block compilation
17
17
  * (`cwd`, `env`, `platform`, `command`, `compile`)
18
18
  * - `API.Service` — scoped attached service startup (`startService`)
19
+ * - `NativeLauncher` — handing one native agent UI the foreground terminal
20
+ * (`reserveTerminal`, `flushOutput`, `nativeLaunch`)
19
21
  * - `Config` — shared execution config (`timeout`, `timeoutExec`, `timeoutFetch`)
20
22
  *
21
23
  * See `apis.ts` for architecture rationale.
22
24
  * See `@executablemd/runtime/test` for composable test stubs.
23
25
  */
26
+ import "./_dnt.polyfills.js";
24
27
  export { API } from "./apis.js";
25
- export { exec, readTextFile, writeTextFile, stat, glob, realpath, ensureDir, rename, remove, fetch, cwd, env, platform, command, compile, useQuietProcessOutput, } from "./apis.js";
28
+ export { exec, readTextFile, writeTextFile, stat, lstat, glob, realpath, ensureDir, rename, remove, fetch, cwd, env, platform, command, compile, useQuietProcessOutput, } from "./apis.js";
26
29
  export { Service, SERVICE_HOSTNAME, SERVICE_READY_PREFIX, ServiceProcessExitBeforeReadyError, ServiceProtocolDuplicateError, ServiceProtocolHostnameMismatchError, ServiceProtocolIncompatibleError, ServiceProtocolMalformedError, ServiceProtocolTokenMismatchError, ServiceProviderError, ServiceStartupTimeoutError, ServiceTeardownError, ServiceUnexpectedExitError, parseServiceReadyRecord, startService, } from "./service.js";
27
30
  export { Config, timeout, timeoutExec, timeoutFetch } from "./config.js";
28
31
  export { asDuration, durationError, parseDuration } from "./duration.js";
29
32
  export { asFilesFatal, FILES_ERROR, FILES_ERROR_MESSAGE, FILES_FATAL, FILES_INVARIANT_MESSAGE, FILES_OPERATION_DENIED_MESSAGE, FILES_PROVIDER_UNAVAILABLE_MESSAGE, FILES_WRITE_SUCCESS, Files, FilesError, FilesInvariantError, FilesOperationDeniedError, FilesProviderUnavailableError, fileWriteFailure, fileWriteSuccess, filesFailure, isFilesFatal, parseFilesPhase, parseFilesReason, parseFileWriteFailure, parseFileWritePhase, parseFileWriteSuccess, parseFilesFailure, parseFilesFatal, } from "./files.js";
33
+ export { flushOutput, installControlledLauncher, installForegroundLauncher, NATIVE_LAUNCHER_UNAVAILABLE, NativeLauncher, NativeLauncherUnavailableError, nativeLaunch, NO_TERMINAL, reserveTerminal, } from "./launcher.js";
30
34
  export { hostFilesHandler, useHostFiles } from "./host-files.js";
35
+ export { AgentSessionBusy, agentSessionKeyDigest, AgentSessionRecoveryRequired, parseAgentSessionOwnership, serializeAgentSessionOwnership, } from "./agent-session-coordinator.js";
36
+ export { createDenoAgentSessionCoordinator, hasDenoAgentSessionCoordinator, } from "./deno-agent-session-coordinator.js";
37
+ export { ExecutableObservationError } from "./executable-observer.js";
38
+ export { createDenoExecutableObserver, hasDenoExecutableObserver, } from "./deno-executable-observer.js";
package/esm/test/mod.js CHANGED
@@ -8,4 +8,5 @@
8
8
  * - `useFailingExec(exitCode, stderr)` — always-failing exec
9
9
  * - `useStubService(endpoint)` — scoped provider-neutral service attachment
10
10
  */
11
+ import "../_dnt.polyfills.js";
11
12
  export { useStubFs, useEchoExec, useFailingExec, useStubService } from "./stubs.js";
package/esm/test/stubs.js CHANGED
@@ -43,6 +43,8 @@ export function* useStubService(endpoint) {
43
43
  *
44
44
  * - `readTextFile` returns content from the `files` map; throws ENOENT for missing keys.
45
45
  * - `stat` returns `{ exists: true, isFile: true }` for keys in the map.
46
+ * - `lstat` answers the same, with `isSymbolicLink: false`: an in-memory map
47
+ * holds file content, so nothing in it is a link to somewhere else.
46
48
  * - `glob` throws (not stubbed). Install `API.Fs.around()` directly if needed.
47
49
  * - the writing half — `writeTextFile`, `ensureDir`, `rename`, `remove`, and
48
50
  * `realpath` — is not stubbed and reaches the real filesystem. A test that
@@ -65,6 +67,10 @@ export function* useStubFs(files) {
65
67
  const exists = path in files;
66
68
  return { exists, isFile: exists, isDirectory: false };
67
69
  },
70
+ *lstat([path], _next) {
71
+ const exists = path in files;
72
+ return { exists, isFile: exists, isDirectory: false, isSymbolicLink: false };
73
+ },
68
74
  *glob(_args, _next) {
69
75
  throw new Error("glob not stubbed");
70
76
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@executablemd/runtime",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "Runtime host APIs for executable.md documents.",
5
5
  "homepage": "https://executable.md",
6
6
  "repository": {
@@ -0,0 +1,6 @@
1
+ declare global {
2
+ interface Error {
3
+ cause?: unknown;
4
+ }
5
+ }
6
+ export {};
@@ -0,0 +1 @@
1
+ export declare const dntGlobalThis: Omit<typeof globalThis, never>;