@volter/browser-wali 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +73 -0
  2. package/dist/browser-wali-command.d.ts +3 -0
  3. package/dist/browser-wali-command.d.ts.map +1 -1
  4. package/dist/browser-wali-command.js +2 -1
  5. package/dist/browser-wali-command.js.map +1 -1
  6. package/dist/capabilities.d.ts +73 -1
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +86 -2
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/child-processes.d.ts +24 -0
  11. package/dist/child-processes.d.ts.map +1 -0
  12. package/dist/child-processes.js +134 -0
  13. package/dist/child-processes.js.map +1 -0
  14. package/dist/codec-protocol.d.ts +66 -0
  15. package/dist/codec-protocol.d.ts.map +1 -0
  16. package/dist/codec-protocol.js +59 -0
  17. package/dist/codec-protocol.js.map +1 -0
  18. package/dist/codec-remote.d.ts +33 -0
  19. package/dist/codec-remote.d.ts.map +1 -0
  20. package/dist/codec-remote.js +190 -0
  21. package/dist/codec-remote.js.map +1 -0
  22. package/dist/codec-worker.d.ts +2 -0
  23. package/dist/codec-worker.d.ts.map +1 -0
  24. package/dist/codec-worker.js +438 -0
  25. package/dist/codec-worker.js.map +1 -0
  26. package/dist/coherent-file-locks.d.ts.map +1 -1
  27. package/dist/coherent-file-locks.js +17 -5
  28. package/dist/coherent-file-locks.js.map +1 -1
  29. package/dist/descriptors.d.ts +49 -0
  30. package/dist/descriptors.d.ts.map +1 -0
  31. package/dist/descriptors.js +115 -0
  32. package/dist/descriptors.js.map +1 -0
  33. package/dist/events.d.ts.map +1 -1
  34. package/dist/events.js +2 -2
  35. package/dist/events.js.map +1 -1
  36. package/dist/filesystem-protocol.d.ts +28 -1
  37. package/dist/filesystem-protocol.d.ts.map +1 -1
  38. package/dist/filesystem-protocol.js +18 -0
  39. package/dist/filesystem-protocol.js.map +1 -1
  40. package/dist/filesystem-remote.d.ts +26 -2
  41. package/dist/filesystem-remote.d.ts.map +1 -1
  42. package/dist/filesystem-remote.js +132 -23
  43. package/dist/filesystem-remote.js.map +1 -1
  44. package/dist/filesystem-worker.js +274 -34
  45. package/dist/filesystem-worker.js.map +1 -1
  46. package/dist/filesystem.d.ts +30 -1
  47. package/dist/filesystem.d.ts.map +1 -1
  48. package/dist/filesystem.js +180 -50
  49. package/dist/filesystem.js.map +1 -1
  50. package/dist/fork-worker.d.ts +2 -0
  51. package/dist/fork-worker.d.ts.map +1 -0
  52. package/dist/fork-worker.js +52 -0
  53. package/dist/fork-worker.js.map +1 -0
  54. package/dist/host.d.ts +36 -0
  55. package/dist/host.d.ts.map +1 -0
  56. package/dist/host.js +153 -0
  57. package/dist/host.js.map +1 -0
  58. package/dist/loopback-host.d.ts +25 -1
  59. package/dist/loopback-host.d.ts.map +1 -1
  60. package/dist/loopback-host.js +447 -13
  61. package/dist/loopback-host.js.map +1 -1
  62. package/dist/memory.d.ts +65 -2
  63. package/dist/memory.d.ts.map +1 -1
  64. package/dist/memory.js +306 -49
  65. package/dist/memory.js.map +1 -1
  66. package/dist/module-layout.d.ts +2 -0
  67. package/dist/module-layout.d.ts.map +1 -1
  68. package/dist/module-layout.js +4 -2
  69. package/dist/module-layout.js.map +1 -1
  70. package/dist/monotonic-clock.d.ts +12 -0
  71. package/dist/monotonic-clock.d.ts.map +1 -0
  72. package/dist/monotonic-clock.js +14 -0
  73. package/dist/monotonic-clock.js.map +1 -0
  74. package/dist/network-protocol.d.ts +12 -1
  75. package/dist/network-protocol.d.ts.map +1 -1
  76. package/dist/network-protocol.js +6 -0
  77. package/dist/network-protocol.js.map +1 -1
  78. package/dist/network-worker.js +149 -20
  79. package/dist/network-worker.js.map +1 -1
  80. package/dist/network.d.ts +15 -3
  81. package/dist/network.d.ts.map +1 -1
  82. package/dist/network.js +74 -39
  83. package/dist/network.js.map +1 -1
  84. package/dist/process-adapter.d.ts +4 -1
  85. package/dist/process-adapter.d.ts.map +1 -1
  86. package/dist/process-adapter.js +3 -3
  87. package/dist/process-adapter.js.map +1 -1
  88. package/dist/process-bridge.d.ts +43 -0
  89. package/dist/process-bridge.d.ts.map +1 -1
  90. package/dist/process-bridge.js +30 -1
  91. package/dist/process-bridge.js.map +1 -1
  92. package/dist/program.d.ts +60 -1
  93. package/dist/program.d.ts.map +1 -1
  94. package/dist/program.js +92 -31
  95. package/dist/program.js.map +1 -1
  96. package/dist/runtime.d.ts.map +1 -1
  97. package/dist/runtime.js +491 -134
  98. package/dist/runtime.js.map +1 -1
  99. package/dist/select.d.ts +16 -0
  100. package/dist/select.d.ts.map +1 -0
  101. package/dist/select.js +100 -0
  102. package/dist/select.js.map +1 -0
  103. package/dist/shared-pipes.d.ts +17 -6
  104. package/dist/shared-pipes.d.ts.map +1 -1
  105. package/dist/shared-pipes.js +94 -70
  106. package/dist/shared-pipes.js.map +1 -1
  107. package/dist/signals.d.ts +48 -0
  108. package/dist/signals.d.ts.map +1 -0
  109. package/dist/signals.js +232 -0
  110. package/dist/signals.js.map +1 -0
  111. package/dist/snapshot-filesystem.d.ts +128 -2
  112. package/dist/snapshot-filesystem.d.ts.map +1 -1
  113. package/dist/snapshot-filesystem.js +481 -53
  114. package/dist/snapshot-filesystem.js.map +1 -1
  115. package/dist/spawn-policy.d.ts +5 -0
  116. package/dist/spawn-policy.d.ts.map +1 -0
  117. package/dist/spawn-policy.js +29 -0
  118. package/dist/spawn-policy.js.map +1 -0
  119. package/dist/stdin.d.ts +2 -0
  120. package/dist/stdin.d.ts.map +1 -1
  121. package/dist/stdin.js +4 -0
  122. package/dist/stdin.js.map +1 -1
  123. package/dist/symlink.d.ts +8 -0
  124. package/dist/symlink.d.ts.map +1 -1
  125. package/dist/symlink.js +13 -0
  126. package/dist/symlink.js.map +1 -1
  127. package/dist/system.d.ts +152 -1
  128. package/dist/system.d.ts.map +1 -1
  129. package/dist/system.js +226 -7
  130. package/dist/system.js.map +1 -1
  131. package/dist/thread-coordinator-worker.d.ts +1 -0
  132. package/dist/thread-coordinator-worker.d.ts.map +1 -1
  133. package/dist/thread-coordinator-worker.js +1 -0
  134. package/dist/thread-coordinator-worker.js.map +1 -1
  135. package/dist/thread-worker.js +241 -122
  136. package/dist/thread-worker.js.map +1 -1
  137. package/dist/threads.d.ts +12 -2
  138. package/dist/threads.d.ts.map +1 -1
  139. package/dist/threads.js +30 -10
  140. package/dist/threads.js.map +1 -1
  141. package/dist/types.d.ts +39 -0
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/websocket-peer.d.ts.map +1 -1
  144. package/dist/websocket-peer.js +4 -14
  145. package/dist/websocket-peer.js.map +1 -1
  146. package/dist/worker-program-protocol.d.ts +70 -3
  147. package/dist/worker-program-protocol.d.ts.map +1 -1
  148. package/dist/worker-program-worker.js +115 -23
  149. package/dist/worker-program-worker.js.map +1 -1
  150. package/dist/worker-program.d.ts +25 -1
  151. package/dist/worker-program.d.ts.map +1 -1
  152. package/dist/worker-program.js +470 -61
  153. package/dist/worker-program.js.map +1 -1
  154. package/dist/worker-standby.d.ts +3 -0
  155. package/dist/worker-standby.d.ts.map +1 -0
  156. package/dist/worker-standby.js +17 -0
  157. package/dist/worker-standby.js.map +1 -0
  158. package/package.json +21 -7
@@ -1,16 +1,34 @@
1
+ import { normalizeProcessFilesystemPolicy, intersectProcessFilesystemPolicies, requireProcessFilesystemPolicySupport } from "@volter/browser-runtime/browser-process-filesystem-policy.js";
2
+ import { localBrowserFileSystemStore, readBrowserFileSystemSnapshot, readBrowserFileSystemEntry } from "@volter/browser-runtime/browser-filesystem.js";
3
+ import { BROWSER_PACK_RETENTION_ROOT, NODE_ENGINE_ROOT, PROJECT_ROOTS } from "@volter/browser-runtime/browser-pack-store.js";
4
+ import { browserProcessLineage } from "@volter/browser-runtime/browser-process.js";
1
5
  import { SharedWaliStdinWriter } from "./stdin.js";
2
- import { applyProcessFilesystemPatch, closeSharedPipe, createProcessFilesystemPatch, encodeProcessSnapshot, readSharedPipe, writeSharedPipe, } from "./process-bridge.js";
6
+ import { createWaliSignalState, defaultTerminates, WaliSignals } from "./signals.js";
7
+ import { waliSharedPipeDescribesFd } from "./shared-pipes.js";
8
+ import { applyProcessFilesystemPatch, closeSharedPipe, createProcessFilesystemPatch, encodeProcessPatch, readSharedPipe, writeSharedPipe, } from "./process-bridge.js";
3
9
  import { createBrowserFileLockChannel, sharedBrowserFileLockCoordinator, } from "@volter/browser-runtime/browser-file-locks.js";
4
10
  import { createBrowserFileCoherenceChannel, sharedBrowserFileCoherenceCoordinator, } from "@volter/browser-runtime/browser-file-coherence.js";
11
+ import { currentEntry, patchedSnapshot } from "@volter/browser-runtime/browser-process-filesystem.js";
12
+ import { pageWorker } from "@volter/browser-runtime/page-workers.js";
5
13
  /** Runs one unchanged WALI executable off the UI thread while retaining its compiled module. */
14
+ /**
15
+ * Each program's module, compiled by the first worker that ran it, for every
16
+ * worker after: a module is compiled once per page, not once per worker. Only
17
+ * a pinned artifact is kept; a `blob:` module is a URL of its own each run.
18
+ */
19
+ const compiledModules = new Map();
20
+ const compiledKey = (url, integrity) => integrity ? `${url}\n${integrity}` : undefined;
6
21
  export class BrowserWaliWorkerProgram {
7
22
  filesystem;
8
23
  program;
24
+ capabilities = Object.freeze({ filesystemPolicy: true });
25
+ /** Started by the first run, so a program the page offers and never runs costs no worker. */
9
26
  worker;
10
27
  pending;
11
28
  stdin;
12
29
  terminalSize = { columns: 80, rows: 24 };
13
30
  nextId = 1;
31
+ forkWorkers = new Map();
14
32
  childControllers = new Map();
15
33
  openUrlHandler;
16
34
  urlScanTail = "";
@@ -18,22 +36,55 @@ export class BrowserWaliWorkerProgram {
18
36
  processPorts = [];
19
37
  threadOutputPorts = [];
20
38
  filesystemUpdatePort;
39
+ traceId = crypto.randomUUID();
21
40
  preparing = false;
41
+ applyingFilesystem = false;
22
42
  constructor(filesystem, program) {
23
43
  this.filesystem = filesystem;
24
44
  this.program = program;
25
- this.worker = this.createWorker();
26
45
  }
27
46
  async run(argv, options = {}) {
47
+ if (options.filesystemPolicy !== undefined)
48
+ options = { ...options, filesystemPolicy: normalizeProcessFilesystemPolicy(options.filesystemPolicy) };
28
49
  if (this.pending || this.preparing)
29
50
  throw new Error("The browser WALI program is already running.");
30
51
  if (options.signal?.aborted)
31
52
  throw options.signal.reason;
53
+ // Started here so its boot overlaps the run's preparation.
54
+ this.worker ??= this.createWorker();
32
55
  this.preparing = true;
56
+ console.log("[wali-boot]", JSON.stringify({ stage: "prepare", run: this.traceId, argv, at: Date.now() }));
33
57
  try {
34
- const filesystem = this.program.prepareFilesystem
35
- ? await this.program.prepareFilesystem(this.filesystem.toSnapshot())
36
- : this.filesystem.toSnapshot();
58
+ // A page over a worker's store sends the worker to the store, not the
59
+ // tree: what the page has written must be in the store first, and the
60
+ // snapshot is the index, which the worker reads the bytes behind.
61
+ const store = localBrowserFileSystemStore(this.filesystem);
62
+ if (store)
63
+ await this.filesystem.flush?.();
64
+ // The worker reads every store root's bytes from the store itself, so
65
+ // those roots travel as metadata. The trees the program says it never
66
+ // reads travel the same way: their directories are there (a process may
67
+ // be started with one as its cwd: VS Code's extension host runs from the
68
+ // server's install tree and spawns git from it), their files are not.
69
+ console.log("[wali-boot]", JSON.stringify({ stage: "flushed", run: this.traceId, at: Date.now() }));
70
+ // A live pack's retention links are the shared store's references, not
71
+ // files a program reads; carried as content, one installed model is
72
+ // gigabytes in every run's image. The Node engine's prepared module
73
+ // bodies are its own: carried as content, an editor's 296 MB of them went
74
+ // into every `git` run's image, and two runs at once ended the page on
75
+ // its heap (measured 2026-09-24 in examples/volter-editor).
76
+ const unread = [BROWSER_PACK_RETENTION_ROOT, NODE_ENGINE_ROOT, ...this.program.excludedImagePaths?.() ?? []];
77
+ const metadataRoots = [...(store ? PROJECT_ROOTS : []), ...unread];
78
+ const image = await readBrowserFileSystemSnapshot(this.filesystem, "/", metadataRoots.length > 0 ? { metadataRoots } : {});
79
+ // The base every patch is diffed against is the image as the worker
80
+ // holds it: the unread trees' files are not in it (their directories
81
+ // are). Diffed against the page's own listing, the worker's final tree
82
+ // reported every one of those files removed, and the page deleted them
83
+ // (measured 2026-09-21: one git run's exit took 3,318 files of the
84
+ // GitLens and server trees).
85
+ const unreadFile = (path) => unread.some((root) => path === root || path.startsWith(`${root}/`));
86
+ const base = { files: image.files.filter((entry) => entry.type === "directory" || !unreadFile(entry.path)) };
87
+ const filesystem = this.program.prepareFilesystem ? await this.program.prepareFilesystem(image) : image;
37
88
  if (options.signal?.aborted)
38
89
  throw options.signal.reason;
39
90
  const id = this.nextId++;
@@ -41,25 +92,70 @@ export class BrowserWaliWorkerProgram {
41
92
  const lockChannel = createBrowserFileLockChannel();
42
93
  const releaseFileLocks = sharedBrowserFileLockCoordinator.attach(lockChannel.server, lockOwner);
43
94
  const coherenceChannel = createBrowserFileCoherenceChannel();
44
- const releaseFileCoherence = sharedBrowserFileCoherenceCoordinator.attach(coherenceChannel.server, this.program.coherenceFilesystem ?? this.filesystem, () => this.program.onFilesystemCommit?.(this.filesystem));
95
+ const coherenceFilesystem = this.program.coherenceFilesystem ?? this.filesystem;
96
+ const releaseFileCoherence = sharedBrowserFileCoherenceCoordinator.attach(coherenceChannel.server, coherenceFilesystem, () => this.program.onFilesystemCommit?.(this.filesystem),
97
+ // What coherence wrote is what the tree holds; the base records it so a
98
+ // later live patch on the same path is judged against the truth.
99
+ (path) => {
100
+ const pending = this.pending;
101
+ if (!pending || pending.id !== id)
102
+ return;
103
+ const entry = currentEntry(coherenceFilesystem, path);
104
+ pending.filesystemBase = patchedSnapshot(pending.filesystemBase, entry ? { files: [entry], removed: [] } : { files: [], removed: [path] });
105
+ });
45
106
  this.urlScanTail = "";
46
107
  this.openedUrls.clear();
47
108
  const stdin = new SharedWaliStdinWriter();
48
- stdin.resize(this.terminalSize.columns, this.terminalSize.rows);
109
+ const size = options.terminal ?? this.terminalSize;
110
+ stdin.resize(size.columns, size.rows);
49
111
  this.stdin = stdin;
112
+ const signalState = createWaliSignalState();
50
113
  return await new Promise((resolve, reject) => {
51
- const abort = () => {
114
+ // What `kill` sent (the abort reason's `signal`; an abort that names
115
+ // none ends the process, as SIGKILL). A handler the program installed
116
+ // runs, and the program ends as it chooses: git removes its lockfiles
117
+ // and raises the signal again, which then ends it. An AbortSignal fires
118
+ // once, so a signal the program survived leaves `redeliver` on the
119
+ // reason: the next `kill` (a SIGKILL after an ignored SIGTERM) still
120
+ // reaches it, and `handled` says whether the program ended by itself.
121
+ const reason = () => options.signal?.reason ?? new DOMException("The WALI process was interrupted.", "AbortError");
122
+ const end = () => {
123
+ const current = options.signal?.reason;
124
+ if (current && typeof current === "object")
125
+ current.handled = false;
52
126
  this.resetWorker();
53
127
  this.finishPending();
54
- reject(options.signal?.reason ?? new DOMException("The WALI process was interrupted.", "AbortError"));
128
+ reject(reason());
129
+ };
130
+ const survives = (signal) => {
131
+ if (signal === 9)
132
+ return false;
133
+ const disposition = new WaliSignals(signalState).raise(signal);
134
+ return disposition !== "default" || !defaultTerminates(signal);
135
+ };
136
+ const abort = () => {
137
+ const current = options.signal?.reason;
138
+ if (!survives(abortSignalNumber(current))) {
139
+ end();
140
+ return;
141
+ }
142
+ if (current && typeof current === "object") {
143
+ current.handled = true;
144
+ current.redeliver = (signal) => { if (this.pending && !survives(signal))
145
+ end(); };
146
+ }
55
147
  };
56
148
  options.signal?.addEventListener("abort", abort, { once: true });
57
149
  let releaseLoopback = () => { };
58
150
  const loopbackChannel = this.program.loopback ? new MessageChannel() : undefined;
59
151
  if (loopbackChannel)
60
152
  releaseLoopback = this.program.loopback.attach(loopbackChannel.port1);
153
+ let resolveFilesystemFinalization;
154
+ const filesystemFinalization = new Promise((resolve) => { resolveFilesystemFinalization = resolve; });
61
155
  this.pending = {
62
156
  id,
157
+ filesystemBase: base,
158
+ filesystemQueue: Promise.resolve(),
63
159
  resolve,
64
160
  reject,
65
161
  options,
@@ -69,17 +165,41 @@ export class BrowserWaliWorkerProgram {
69
165
  releaseLoopback,
70
166
  releaseFileLocks,
71
167
  releaseFileCoherence,
168
+ releaseFilesystemChanges: () => { },
169
+ changedPaths: new Set(),
170
+ importingFilesystem: false,
171
+ filesystemFinalized: false,
172
+ filesystemFinalization,
173
+ resolveFilesystemFinalization,
72
174
  };
73
175
  const request = {
74
176
  type: "run",
177
+ // A program the lane exec'd into an existing process runs AS that
178
+ // process: it takes its pid, the parent it kept, and the counter its
179
+ // own children are numbered from. A run that is not an exec carries
180
+ // no lineage and is the runtime's first process, as before.
181
+ ...(options[browserProcessLineage] ? {
182
+ pid: options[browserProcessLineage].pid,
183
+ parentPid: options[browserProcessLineage].parentPid,
184
+ childPidAllocator: options[browserProcessLineage].childPidAllocator,
185
+ } : {}),
75
186
  id,
187
+ ...(options.descriptorTable ? { descriptorTable: options.descriptorTable } : {}),
76
188
  url: this.program.url,
77
189
  ...(this.program.integrity ? { integrity: this.program.integrity } : {}),
190
+ ...(() => {
191
+ const key = compiledKey(this.program.url, this.program.integrity);
192
+ const compiled = key ? compiledModules.get(key) : undefined;
193
+ return compiled ? { compiled } : {};
194
+ })(),
78
195
  ...(options.executablePath ? { executablePath: options.executablePath } : {}),
79
196
  argv: [...argv],
80
197
  cwd: options.cwd ?? this.program.cwd ?? "/",
81
- env: { ...this.program.env },
198
+ env: { ...options.env, ...this.program.env },
82
199
  threadPoolSize: this.program.threadPoolSize ?? 18,
200
+ ...(this.program.codec ? { codec: this.program.codec } : {}),
201
+ ...(options.filesystemPolicy ? { filesystemPolicy: options.filesystemPolicy } : {}),
202
+ ...(this.program.spawn?.capabilities?.filesystemPolicy === true ? { childFilesystemPolicyVersion: 1 } : {}),
83
203
  ...(this.program.network.allowedHosts.length > 0 || loopbackChannel ? { network: {
84
204
  ...(this.program.network.relayUrl ? { relayUrl: this.program.network.relayUrl } : {}),
85
205
  allowedHosts: [...this.program.network.allowedHosts],
@@ -88,9 +208,12 @@ export class BrowserWaliWorkerProgram {
88
208
  } } : {}),
89
209
  ...(loopbackChannel ? { loopbackPort: loopbackChannel.port2 } : {}),
90
210
  stdin: stdin.buffer,
211
+ terminal: options.terminal !== undefined,
91
212
  filesystem,
213
+ ...(store ? { store: { projectId: store } } : {}),
92
214
  fileLocks: { channel: lockChannel.client, owner: lockOwner },
93
215
  fileCoherence: coherenceChannel.client,
216
+ signalState,
94
217
  };
95
218
  const channels = Array.from({ length: request.threadPoolSize }, () => new MessageChannel());
96
219
  this.processPorts = channels.map((channel) => channel.port1);
@@ -121,14 +244,17 @@ export class BrowserWaliWorkerProgram {
121
244
  this.emitStderr(active, `\nWALI pthread ${event.data.tid ?? "unknown"} error: ${event.data.message}\n`);
122
245
  return;
123
246
  }
124
- const chunk = new TextDecoder().decode(event.data.bytes);
247
+ if (event.data.type === "diagnostic") {
248
+ active.options.onDiagnostic?.(event.data.message);
249
+ return;
250
+ }
125
251
  if (event.data.type === "stdout")
126
- this.emitStdout(active, chunk);
252
+ this.emitStdout(active, event.data.bytes);
127
253
  else
128
- this.emitStderr(active, chunk);
254
+ this.emitStderr(active, new TextDecoder().decode(event.data.bytes));
129
255
  }
130
256
  finally {
131
- if (event.data.type !== "error" && event.data.ack) {
257
+ if (event.data.type !== "error" && event.data.type !== "diagnostic" && event.data.ack) {
132
258
  const ack = new Int32Array(event.data.ack);
133
259
  Atomics.store(ack, 0, 1);
134
260
  Atomics.notify(ack, 0);
@@ -141,11 +267,28 @@ export class BrowserWaliWorkerProgram {
141
267
  const filesystemUpdates = new MessageChannel();
142
268
  this.filesystemUpdatePort = filesystemUpdates.port1;
143
269
  this.filesystemUpdatePort.onmessage = (event) => {
144
- void this.applyLiveFilesystemPatch(id, event.data);
270
+ const active = this.pending;
271
+ if (active?.id === id)
272
+ active.filesystemQueue = active.filesystemQueue.then(() => this.applyLiveFilesystemPatch(id, event.data));
145
273
  };
146
274
  this.filesystemUpdatePort.start();
275
+ const changed = (path) => {
276
+ const active = this.pending;
277
+ if (!active || active.id !== id || this.applyingFilesystem)
278
+ return;
279
+ active.changedPaths.add(path);
280
+ this.scheduleFilesystemImport(active);
281
+ };
282
+ this.filesystem.on("change", changed);
283
+ this.filesystem.on("delete", changed);
284
+ this.pending.releaseFilesystemChanges = () => {
285
+ this.filesystem.off("change", changed);
286
+ this.filesystem.off("delete", changed);
287
+ };
147
288
  request.filesystemUpdatePort = filesystemUpdates.port2;
289
+ console.log("[wali-boot]", JSON.stringify({ stage: "dispatch", run: this.traceId, at: Date.now() }));
148
290
  this.worker.postMessage(request, [
291
+ ...(request.descriptorTable ? [request.descriptorTable.port] : []),
149
292
  ...request.browserProcessPorts,
150
293
  ...request.threadOutputPorts,
151
294
  request.filesystemUpdatePort,
@@ -153,14 +296,21 @@ export class BrowserWaliWorkerProgram {
153
296
  request.fileCoherence.port,
154
297
  ...(request.loopbackPort ? [request.loopbackPort] : []),
155
298
  ]);
156
- if (options.stdin !== undefined) {
157
- if (!stdin.write(options.stdin)) {
158
- this.resetWorker();
159
- this.finishPending();
160
- reject(new Error("Browser process stdin exceeds the WALI input buffer."));
161
- return;
162
- }
163
- stdin.close();
299
+ // THE INHERITED TABLE OWNS fd 0. When a run inherits a descriptor table
300
+ // whose fd 0 is an open pipe description, that pipe IS the run's stdin
301
+ // and the guest reads it itself. Pumping the host's own reader on the
302
+ // same description into this run's private stdin buffer puts two readers
303
+ // on one pipe, and the pump -- polling from launch, while the guest is
304
+ // still instantiating -- takes the bytes and drops them where nothing
305
+ // reads. Measured: git's remote helper never saw its `capabilities` line.
306
+ const inheritedPipes = inheritedSharedPipes(options.descriptorTable);
307
+ const inheritedStdin = inheritedPipes !== undefined && waliSharedPipeDescribesFd(inheritedPipes, 0);
308
+ if (inheritedStdin) {
309
+ // Nothing to pump: fd 0 is the description, not this run's stdin.
310
+ }
311
+ else if (options.stdin !== undefined) {
312
+ const value = options.stdin;
313
+ void this.forwardStdin(id, stdin, (async function* () { yield value; })());
164
314
  }
165
315
  else if (options.stdinStream) {
166
316
  void this.forwardStdin(id, stdin, options.stdinStream);
@@ -172,10 +322,21 @@ export class BrowserWaliWorkerProgram {
172
322
  }
173
323
  }
174
324
  async forwardStdin(id, stdin, stream) {
325
+ const encoder = new TextEncoder();
175
326
  try {
176
327
  for await (const chunk of stream) {
177
- while (this.pending?.id === id && !stdin.write(chunk)) {
178
- await new Promise((resolve) => setTimeout(resolve, 1));
328
+ const bytes = typeof chunk === "string" ? encoder.encode(chunk) : chunk;
329
+ let offset = 0;
330
+ while (this.pending?.id === id && offset < bytes.byteLength) {
331
+ const free = stdin.free();
332
+ if (free > 0) {
333
+ const length = Math.min(free, bytes.byteLength - offset);
334
+ stdin.write(bytes.subarray(offset, offset + length));
335
+ offset += length;
336
+ }
337
+ else {
338
+ await new Promise((resolve) => setTimeout(resolve, 1));
339
+ }
179
340
  }
180
341
  if (this.pending?.id !== id)
181
342
  return;
@@ -207,14 +368,22 @@ export class BrowserWaliWorkerProgram {
207
368
  terminate() {
208
369
  this.pending?.reject(new DOMException("The WALI program was terminated.", "AbortError"));
209
370
  this.finishPending();
210
- this.worker.terminate();
371
+ this.worker?.terminate();
211
372
  }
212
373
  createWorker() {
213
- const worker = new Worker(new URL("./worker-program-worker.js", import.meta.url), {
374
+ const worker = pageWorker(new Worker(new URL("./worker-program-worker.js", import.meta.url), {
214
375
  type: "module",
215
376
  name: "browser-wali-program",
377
+ }));
378
+ worker.addEventListener("message", (event) => {
379
+ void this.handleMessage(event.data).catch((cause) => {
380
+ if (this.pending?.id !== event.data.id)
381
+ return;
382
+ this.pending.reject(cause);
383
+ this.finishPending();
384
+ this.resetWorker();
385
+ });
216
386
  });
217
- worker.addEventListener("message", (event) => void this.handleMessage(event.data));
218
387
  worker.addEventListener("error", (event) => {
219
388
  this.pending?.reject(event.error ?? new Error(event.message));
220
389
  this.finishPending();
@@ -223,6 +392,12 @@ export class BrowserWaliWorkerProgram {
223
392
  return worker;
224
393
  }
225
394
  async handleMessage(message) {
395
+ if (message.type === "compiled") {
396
+ const key = compiledKey(this.program.url, this.program.integrity);
397
+ if (key)
398
+ compiledModules.set(key, message.compiled);
399
+ return;
400
+ }
226
401
  const pending = this.pending;
227
402
  if (!pending || message.id !== pending.id)
228
403
  return;
@@ -234,18 +409,46 @@ export class BrowserWaliWorkerProgram {
234
409
  this.emitStderr(pending, message.chunk);
235
410
  return;
236
411
  }
412
+ if (message.type === "diagnostic") {
413
+ pending.options.onDiagnostic?.(message.message);
414
+ return;
415
+ }
416
+ if (message.type === "fork") {
417
+ this.forkChild(message);
418
+ return;
419
+ }
237
420
  if (message.type === "spawn") {
238
421
  void this.spawnChild(message, pending);
239
422
  return;
240
423
  }
241
424
  if (message.type === "kill") {
242
- this.childControllers.get(message.pid)?.abort(new DOMException(`Signal ${message.signal}`, "AbortError"));
425
+ const controller = this.childControllers.get(message.pid);
426
+ if (!controller)
427
+ return;
428
+ // a child that survived an earlier signal takes the next through its reason
429
+ if (controller.signal.aborted)
430
+ controller.signal.reason?.redeliver?.(message.signal);
431
+ else
432
+ controller.abort(signalAbortReason(message.signal, `Signal ${message.signal}`));
243
433
  return;
244
434
  }
435
+ console.log("[wali-boot]", JSON.stringify({ stage: "result-received", run: this.traceId, at: Date.now() }));
245
436
  const safeFilesystem = this.program.sanitizeFilesystem
246
437
  ? await this.program.sanitizeFilesystem(message.filesystem)
247
438
  : message.filesystem;
248
- const patch = synchronizeFilesystem(this.filesystem, safeFilesystem);
439
+ if (message.filesystemFinalized)
440
+ await pending.filesystemFinalization;
441
+ await pending.filesystemQueue;
442
+ console.log("[wali-boot]", JSON.stringify({ stage: "filesystem-settled", run: this.traceId, at: Date.now() }));
443
+ if (this.pending !== pending)
444
+ return;
445
+ // A threaded process commits its final delta on the ordered update port.
446
+ // Its full snapshot may predate a peer import acknowledged on that port;
447
+ // diffing it against the advanced base would manufacture a deletion.
448
+ const patch = message.filesystemFinalized ? { files: [], removed: [] }
449
+ : createProcessFilesystemPatch(pending.filesystemBase, safeFilesystem);
450
+ if (!message.filesystemFinalized)
451
+ applyProcessFilesystemPatch(this.filesystem, patch, pending.filesystemBase);
249
452
  try {
250
453
  await this.program.onFilesystemCommit?.(this.filesystem, patch);
251
454
  }
@@ -255,6 +458,7 @@ export class BrowserWaliWorkerProgram {
255
458
  return;
256
459
  }
257
460
  this.finishPending();
461
+ console.log("[wali-boot]", JSON.stringify({ stage: "done", run: this.traceId, at: Date.now() }));
258
462
  if (message.result)
259
463
  pending.resolve({ ...message.result, stdout: pending.stdout, stderr: pending.stderr });
260
464
  else {
@@ -263,15 +467,76 @@ export class BrowserWaliWorkerProgram {
263
467
  pending.reject(new Error(streamedStderr ? `${streamedStderr}\n${detail}` : detail));
264
468
  }
265
469
  }
470
+ forkChild(message) {
471
+ const worker = pageWorker(new Worker(new URL("./fork-worker.js", import.meta.url), { type: "module", name: `wali-fork-${message.pid}` }));
472
+ this.forkWorkers.set(message.pid, worker);
473
+ const finish = () => { worker.terminate(); this.forkWorkers.delete(message.pid); };
474
+ worker.addEventListener("message", (event) => {
475
+ if (event.data.type === "fork-done") {
476
+ finish();
477
+ return;
478
+ }
479
+ void this.handleMessage(event.data).catch(error => {
480
+ this.pending?.reject(error);
481
+ this.finishPending();
482
+ this.resetWorker();
483
+ });
484
+ });
485
+ worker.addEventListener("error", event => {
486
+ this.pending?.reject(event.error ?? new Error(event.message));
487
+ this.finishPending();
488
+ this.resetWorker();
489
+ });
490
+ worker.postMessage(message, [message.descriptorTable.port,
491
+ ...(message.descriptorTable.releasePort ? [message.descriptorTable.releasePort] : []), message.forkState.memory]);
492
+ }
266
493
  async spawnChild(message, pending) {
267
494
  const backend = this.program.spawn;
268
495
  const controller = new AbortController();
269
496
  this.childControllers.set(message.pid, controller);
497
+ const trace = (stage) => console.log("[wali-boot]", JSON.stringify({ stage, run: this.traceId, child: message.pid, at: Date.now() }));
498
+ trace("spawn-received");
499
+ const releasePort = message.descriptorTable?.releasePort;
500
+ const descriptorTable = message.descriptorTable && {
501
+ port: message.descriptorTable.port, control: message.descriptorTable.control,
502
+ payload: message.descriptorTable.payload,
503
+ ...(message.descriptorTable.sharedPipes ? { sharedPipes: message.descriptorTable.sharedPipes } : {}),
504
+ };
505
+ let released;
506
+ const releaseDescriptors = () => released ??= releasePort ? new Promise((resolve) => {
507
+ releasePort.onmessage = () => { releasePort.close(); resolve(); };
508
+ releasePort.postMessage(true);
509
+ }) : Promise.resolve();
270
510
  let stdoutWrites = Promise.resolve();
271
511
  let stderrWrites = Promise.resolve();
272
512
  let stdoutStreamed = false;
273
513
  let stderrStreamed = false;
274
- let childFilesystem = this.filesystem.toSnapshot();
514
+ // The child's patch is a diff of the tree before and after it ran; the
515
+ // diff needs sizes and times, and only the entries it names need bytes.
516
+ const metadataSnapshot = () => readBrowserFileSystemSnapshot(this.filesystem, "/", { metadataRoots: ["/"] });
517
+ const patchAgainst = async (before) => {
518
+ const patch = createProcessFilesystemPatch(before, await metadataSnapshot());
519
+ const previous = new Map(before.files.map((entry) => [entry.path, entry]));
520
+ const files = [];
521
+ const removed = new Set(patch.removed);
522
+ for (const entry of patch.files) {
523
+ const current = previous.get(entry.path);
524
+ const bytesMoved = entry.type === "file" && (!current || current.type !== "file"
525
+ || current.size !== entry.size || current.mtimeMs !== entry.mtimeMs);
526
+ if (!bytesMoved) {
527
+ files.push(entry);
528
+ continue;
529
+ }
530
+ const captured = await readBrowserFileSystemEntry(this.filesystem, entry.path);
531
+ if (captured)
532
+ files.push(captured);
533
+ else if (current)
534
+ removed.add(entry.path);
535
+ }
536
+ return { files, removed: [...removed] };
537
+ };
538
+ let childFilesystem = await metadataSnapshot();
539
+ trace("spawn-snapshot");
275
540
  const stdout = (chunk) => {
276
541
  stdoutStreamed = true;
277
542
  if (message.stdout)
@@ -279,6 +544,10 @@ export class BrowserWaliWorkerProgram {
279
544
  else
280
545
  this.emitStdout(pending, chunk);
281
546
  };
547
+ const stdoutBytes = (bytes) => {
548
+ stdoutStreamed = true;
549
+ stdoutWrites = stdoutWrites.then(() => writeSharedPipe(message.stdout, bytes));
550
+ };
282
551
  const stderr = (chunk) => {
283
552
  stderrStreamed = true;
284
553
  if (message.stderr)
@@ -289,35 +558,50 @@ export class BrowserWaliWorkerProgram {
289
558
  try {
290
559
  if (!backend)
291
560
  throw new Error("No browser child-process backend is configured.");
561
+ const filesystemPolicy = pending.options.filesystemPolicy
562
+ ? intersectProcessFilesystemPolicies(pending.options.filesystemPolicy, message.filesystemPolicy)
563
+ : message.filesystemPolicy === undefined ? undefined : normalizeProcessFilesystemPolicy(message.filesystemPolicy);
564
+ requireProcessFilesystemPolicySupport(filesystemPolicy, backend.capabilities);
292
565
  const safePatch = this.program.sanitizeFilesystemPatch
293
566
  ? await this.program.sanitizeFilesystemPatch(message.filesystemPatch)
294
567
  : message.filesystemPatch;
295
- applyProcessFilesystemPatch(this.filesystem, safePatch);
296
- childFilesystem = this.filesystem.toSnapshot();
568
+ applyProcessFilesystemPatch(this.filesystem, safePatch, pending.filesystemBase);
569
+ pending.filesystemBase = patchedSnapshot(pending.filesystemBase, safePatch);
570
+ childFilesystem = await metadataSnapshot();
571
+ trace("spawn-patched");
297
572
  const stdinStream = message.stdin ? streamBrowserPipe(message.stdin, controller.signal) : undefined;
298
- const result = await backend.run(browserCommand(message), {
573
+ const spawned = browserCommand(message);
574
+ const result = await backend.run(spawned.command, {
575
+ [browserProcessLineage]: { pid: message.pid, parentPid: message.parentPid, childPidAllocator: message.childPidAllocator },
576
+ ...(filesystemPolicy ? { filesystemPolicy } : {}),
577
+ ...(descriptorTable ? { descriptorTable } : {}),
299
578
  cwd: message.cwd,
300
- executablePath: message.executablePath,
301
- env: message.env,
579
+ ...(spawned.executablePath ? { executablePath: spawned.executablePath } : {}),
580
+ env: message.inheritEnvironment ? { ...pending.options.env, ...this.program.env } : message.env,
302
581
  ...(stdinStream ? { stdinStream } : {}),
303
582
  signal: controller.signal,
583
+ ...(message.stdout ? { onStdoutBytes: stdoutBytes } : {}),
304
584
  onStdout: stdout,
305
585
  onStderr: stderr,
306
586
  });
307
- // AlmostNode streams long-running commands, but may only return buffered
587
+ // Tabnode streams long-running commands, but may only return buffered
308
588
  // output for short commands. Deliver exactly one form to the child pipe.
309
589
  if (result.stdout && !stdoutStreamed)
310
590
  stdout(result.stdout);
311
591
  if (result.stderr && !stderrStreamed)
312
592
  stderr(result.stderr);
593
+ trace("spawn-ran");
313
594
  await Promise.all([stdoutWrites, stderrWrites]);
314
- encodeProcessSnapshot(message, childFilesystem, this.filesystem.toSnapshot(), result.exitCode);
595
+ await releaseDescriptors();
596
+ encodeProcessPatch(message, await patchAgainst(childFilesystem), result.exitCode);
597
+ trace("spawn-returned");
315
598
  }
316
599
  catch (error) {
317
600
  const text = `${error instanceof Error ? error.message : String(error)}\n`;
318
601
  stderr(text);
319
602
  await stderrWrites;
320
- encodeProcessSnapshot(message, childFilesystem, this.filesystem.toSnapshot(), controller.signal.aborted ? 130 : 127);
603
+ await releaseDescriptors();
604
+ encodeProcessPatch(message, await patchAgainst(childFilesystem), controller.signal.aborted ? 130 : 127);
321
605
  }
322
606
  finally {
323
607
  controller.abort(new DOMException("Browser child process completed.", "AbortError"));
@@ -329,14 +613,77 @@ export class BrowserWaliWorkerProgram {
329
613
  }
330
614
  async applyLiveFilesystemPatch(id, message) {
331
615
  const pending = this.pending;
332
- if (!pending || pending.id !== id || message.type !== "filesystem")
616
+ if (!pending || pending.id !== id)
617
+ return;
618
+ if (message.type === "finalized") {
619
+ pending.filesystemFinalized = true;
620
+ pending.changedPaths.clear();
621
+ pending.releaseFilesystemChanges();
622
+ pending.resolveFilesystemFinalization();
623
+ return;
624
+ }
625
+ if (message.type === "checkpoint") {
626
+ // This method runs on the ordered filesystem queue, after prior writes
627
+ // and their durability callbacks have completed.
628
+ const control = new Int32Array(message.control);
629
+ Atomics.store(control, 0, 1);
630
+ Atomics.notify(control, 0);
631
+ return;
632
+ }
633
+ if (message.type === "imported") {
634
+ pending.filesystemBase = patchedSnapshot(pending.filesystemBase, message.patch);
635
+ pending.importingFilesystem = false;
636
+ this.scheduleFilesystemImport(pending);
637
+ return;
638
+ }
639
+ try {
640
+ if (message.type === "import-error")
641
+ throw new Error(message.message);
642
+ const safePatch = this.program.sanitizeFilesystemPatch
643
+ ? await this.program.sanitizeFilesystemPatch(message.patch)
644
+ : message.patch;
645
+ this.applyingFilesystem = true;
646
+ try {
647
+ applyProcessFilesystemPatch(this.filesystem, safePatch, pending.filesystemBase);
648
+ }
649
+ finally {
650
+ this.applyingFilesystem = false;
651
+ }
652
+ pending.filesystemBase = patchedSnapshot(pending.filesystemBase, safePatch);
653
+ }
654
+ catch (error) {
655
+ // THIS IS WHERE A LIVE PROGRAM BECOMES A DEAD ONE, and it is not an
656
+ // exit. `resetWorker()` terminates the worker from the HOST, so no
657
+ // guest exit path runs: instruments on `SYS_exit`, `SYS_exit_group` and
658
+ // musl's last-thread path are all silent through this, and the caller
659
+ // is told only that the program is gone.
660
+ //
661
+ // MEASURED 2026-09-19, the editor's Blender session on the WALI skew,
662
+ // the "one-session death" that had been attributed to a guest exit(0):
663
+ // it dies HERE, on `assertPatchBase`, at "write conflict at
664
+ // /work/.vgai-session/out/<n>.done". The directory channel's poll loop
665
+ // unlinks four files on the page after every answer, so a guest patch
666
+ // naming one of those paths crosses the page's own edit and the base
667
+ // the guest computed against no longer matches. Reproduced at session
668
+ // call 37 of a render loop (`base`: a file, `current`: null) and again
669
+ // at call 14 (`current`: the same file at a different mtime) -- the
670
+ // disagreement has more than one shape, which is why a targeted "drop
671
+ // the crossed paths" filter narrowed it without closing it (tried at
672
+ // both ends, reverted; it is not a one-line fix).
673
+ //
674
+ // The mechanism is a protocol one: TWO WRITERS SHARE ONE DIRECTORY with
675
+ // no ownership rule. The channel would not have this class of failure
676
+ // if the session owned its own files and the page only read them.
677
+ if (this.pending?.id !== id)
678
+ return;
679
+ this.finishPending();
680
+ this.resetWorker();
681
+ pending.reject(new Error(`WALI filesystem update could not be applied: ${error instanceof Error ? error.message : String(error)}`));
333
682
  return;
334
- const safePatch = this.program.sanitizeFilesystemPatch
335
- ? await this.program.sanitizeFilesystemPatch(message.patch)
336
- : message.patch;
337
- applyProcessFilesystemPatch(this.filesystem, safePatch);
683
+ }
338
684
  try {
339
- await this.program.onFilesystemCommit?.(this.filesystem, message.patch);
685
+ if (message.type === "filesystem")
686
+ await this.program.onFilesystemCommit?.(this.filesystem, message.patch);
340
687
  }
341
688
  catch (error) {
342
689
  if (this.pending?.id !== id)
@@ -346,11 +693,52 @@ export class BrowserWaliWorkerProgram {
346
693
  pending.reject(new Error(`WALI filesystem durability barrier failed: ${error instanceof Error ? error.message : String(error)}`));
347
694
  }
348
695
  }
696
+ scheduleFilesystemImport(pending) {
697
+ if (pending.filesystemFinalized || pending.importingFilesystem || pending.changedPaths.size === 0 || this.pending !== pending)
698
+ return;
699
+ pending.importingFilesystem = true;
700
+ pending.filesystemQueue = pending.filesystemQueue.then(async () => {
701
+ if (this.pending !== pending)
702
+ return;
703
+ const paths = new Set(pending.changedPaths);
704
+ pending.changedPaths.clear();
705
+ const before = { files: pending.filesystemBase.files.filter((entry) => paths.has(entry.path)) };
706
+ const after = { files: [] };
707
+ // THE CHANGED PATHS, ONE AT A TIME, never the whole tree. This read a
708
+ // full `toSnapshot()` to pick two entries out of it, and a page-side
709
+ // snapshot carries every file's bytes as base64: measured 2026-09-19
710
+ // with Blender's 78 MB runtime tree in the page's filesystem, that is
711
+ // ~100 MB of string built and thrown away PER PAGE WRITE, and a
712
+ // directory-channel session writes twice per request.
713
+ for (const path of paths) {
714
+ const entry = await readBrowserFileSystemEntry(this.filesystem, path);
715
+ if (entry)
716
+ after.files.push(entry);
717
+ }
718
+ if (this.pending !== pending)
719
+ return;
720
+ const patch = createProcessFilesystemPatch(before, after);
721
+ if (patch.files.length === 0 && patch.removed.length === 0) {
722
+ pending.importingFilesystem = false;
723
+ this.scheduleFilesystemImport(pending);
724
+ return;
725
+ }
726
+ this.filesystemUpdatePort?.postMessage({ type: "import", patch, base: before });
727
+ }).catch((error) => {
728
+ if (this.pending !== pending)
729
+ return;
730
+ this.finishPending();
731
+ this.resetWorker();
732
+ pending.reject(error);
733
+ });
734
+ }
349
735
  finishPending() {
736
+ this.pending?.resolveFilesystemFinalization();
350
737
  this.pending?.removeAbortListener();
351
738
  this.pending?.releaseLoopback();
352
739
  this.pending?.releaseFileLocks();
353
740
  this.pending?.releaseFileCoherence();
741
+ this.pending?.releaseFilesystemChanges();
354
742
  this.pending = undefined;
355
743
  this.stdin?.close();
356
744
  this.stdin = undefined;
@@ -364,9 +752,13 @@ export class BrowserWaliWorkerProgram {
364
752
  this.filesystemUpdatePort = undefined;
365
753
  }
366
754
  emitStdout(pending, chunk) {
367
- pending.stdout += chunk;
368
- this.scanOpenUrls(chunk);
369
- pending.options.onStdout?.(chunk);
755
+ const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
756
+ pending.stdout += text;
757
+ this.scanOpenUrls(text);
758
+ if (typeof chunk !== "string" && pending.options.onStdoutBytes)
759
+ pending.options.onStdoutBytes(chunk);
760
+ else
761
+ pending.options.onStdout?.(text);
370
762
  }
371
763
  emitStderr(pending, chunk) {
372
764
  pending.stderr += chunk;
@@ -387,13 +779,33 @@ export class BrowserWaliWorkerProgram {
387
779
  this.urlScanTail = text.slice(-2048);
388
780
  }
389
781
  resetWorker() {
782
+ for (const worker of this.forkWorkers.values())
783
+ worker.terminate();
784
+ this.forkWorkers.clear();
390
785
  for (const controller of this.childControllers.values())
391
786
  controller.abort();
392
787
  this.childControllers.clear();
393
- this.worker.terminate();
788
+ this.worker?.terminate();
394
789
  this.worker = this.createWorker();
395
790
  }
396
791
  }
792
+ /**
793
+ * An abort that is a signal: the reason carries its number as `signal`, which
794
+ * the shell's `kill` and a process's own kill of a child set.
795
+ */
796
+ export function signalAbortReason(signal, message) {
797
+ return Object.assign(new DOMException(message, "AbortError"), { signal });
798
+ }
799
+ function abortSignalNumber(reason) {
800
+ const signal = reason?.signal;
801
+ return typeof signal === "number" && Number.isInteger(signal) && signal > 0 && signal <= 64 ? signal : 9;
802
+ }
803
+ /**
804
+ * The process host's command for a spawn. A shell is handed over whole, as
805
+ * its argv, so the host reads its line and the `$0` and positional arguments
806
+ * after it (`sh -c '"$@"' editor file`); the path it was exec'd at named the
807
+ * shell, not what the line runs, so it does not go with it.
808
+ */
397
809
  function browserCommand(message) {
398
810
  const [program = "", ...args] = message.argv;
399
811
  const executable = message.executablePath || program;
@@ -404,22 +816,24 @@ function browserCommand(message) {
404
816
  if (script?.includes("__grok_user_cmd")) {
405
817
  const separator = args.indexOf("--", commandIndex + 2);
406
818
  if (separator >= 0 && args[separator + 1])
407
- return args[separator + 1];
819
+ return { command: args[separator + 1] };
408
820
  }
409
- if (script !== undefined)
410
- return script;
411
821
  // The browser shell is a registered process-host executable, not a file
412
- // at the native harness's host-specific absolute path. Preserve argv but
413
- // route probes such as `/bin/bash --version` through that executable.
414
- return [basename, ...args].map(shellQuote).join(" ");
822
+ // at the native harness's host-specific absolute path, so the shell goes
823
+ // by its name: `/bin/bash --version` is `bash --version`.
824
+ return { command: [basename, ...args].map(shellQuote).join(" ") };
415
825
  }
416
- return message.argv.map(shellQuote).join(" ");
826
+ return { command: message.argv.map(shellQuote).join(" "), ...(message.executablePath ? { executablePath: message.executablePath } : {}) };
417
827
  }
418
828
  function shellQuote(value) {
419
829
  if (/^[A-Za-z0-9_./:+,@%=-]+$/u.test(value))
420
830
  return value;
421
831
  return `'${value.replaceAll("'", `'\\''`)}'`;
422
832
  }
833
+ /** The WALI lane's own field on a run option's descriptor channel. */
834
+ function inheritedSharedPipes(channel) {
835
+ return channel?.sharedPipes;
836
+ }
423
837
  function closeExternalPipe(handle) {
424
838
  if (handle)
425
839
  closeSharedPipe(handle);
@@ -440,9 +854,4 @@ async function* streamBrowserPipe(handle, signal) {
440
854
  if (signal.aborted)
441
855
  throw signal.reason;
442
856
  }
443
- function synchronizeFilesystem(target, snapshot) {
444
- const patch = createProcessFilesystemPatch(target.toSnapshot(), snapshot);
445
- applyProcessFilesystemPatch(target, patch);
446
- return patch;
447
- }
448
857
  //# sourceMappingURL=worker-program.js.map