@worca/app 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -6,7 +6,7 @@
6
6
  // Shape of a pass: publishing routes tokens IMMEDIATELY (per-wire delivery counting
7
7
  // and gate checks happen AT DELIVERY), but FIRING happens only in the drain loop. A
8
8
  // pass walks `loops.launchOrder` ONCE; a node ready at its slot fires AT that slot —
9
- // agent nodes as an async `execute` under the semaphore, flow nodes inline,
9
+ // keyed nodes (agents, scripts) as an async `execute` under their pool's semaphore, flow nodes inline,
10
10
  // whose publishes route immediately and may make LATER slots ready within the same
11
11
  // pass. Earlier slots are never revisited mid-pass; passes repeat until a pass fires
12
12
  // nothing. The execution sequence is therefore the order of `execute` CALLS, and it
@@ -19,7 +19,7 @@
19
19
  // `rerunPending` coalescing is structural: a node that is already running is skipped
20
20
  // in the walk, and readiness is re-evaluated after every completion, so readiness
21
21
  // reached while running re-fires exactly once and never queues.
22
- import { FLOW_KINDS, DEFAULT_MAX_CYCLES, AWAIT_PORT } from '../../shared/graph/constants.mjs';
22
+ import { FLOW_KINDS, KEYED_KINDS, DEFAULT_MAX_CYCLES, AWAIT_PORT } from '../../shared/graph/constants.mjs';
23
23
  import { classifyLoops } from '../../shared/graph/loops.mjs';
24
24
  import { firedOutputs, resolveOrOutType } from '../../shared/graph/ports.mjs';
25
25
  import { blockingIssues, hasBlocking } from '../../shared/graph/verdict.mjs';
@@ -46,6 +46,11 @@ function defaultMaxParallel() {
46
46
  const n = Number(process.env.WORCA_MAX_PARALLEL);
47
47
  return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 4;
48
48
  }
49
+ /** D7: scripts have their own, smaller pool — they burn CPU, not tokens. */
50
+ function defaultMaxParallelScripts() {
51
+ const n = Number(process.env.WORCA_MAX_PARALLEL_SCRIPTS);
52
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 2;
53
+ }
49
54
 
50
55
  /** Non-interactive default: gates continue, clarify asks answer empty (v1 `_ask` auto). */
51
56
  const defaultAsk = async (ask) => (ask?.kind === 'gate' ? 'continue' : []);
@@ -96,6 +101,7 @@ function raceAbort(promise, signal) {
96
101
  * @param {(gate:{wireId,fromNode,toNode,askId}|null) => void} [opts.onGate]
97
102
  * @param {(ask:object) => Promise<any>} [opts.onAsk]
98
103
  * @param {number} [opts.maxParallel]
104
+ * @param {number} [opts.maxParallelScripts]
99
105
  * @param {(line:string, attrs?:object) => void} [opts.log]
100
106
  */
101
107
  export function createScheduler(opts) {
@@ -109,6 +115,7 @@ export function createScheduler(opts) {
109
115
  onGate = () => {},
110
116
  onAsk = defaultAsk,
111
117
  maxParallel = defaultMaxParallel(),
118
+ maxParallelScripts = defaultMaxParallelScripts(),
112
119
  log = () => {},
113
120
  } = opts || {};
114
121
 
@@ -149,7 +156,6 @@ export function createScheduler(opts) {
149
156
  const completions = [];
150
157
  const warnings = [];
151
158
  const controller = new AbortController();
152
- let activeAgents = 0;
153
159
  let ended = null;
154
160
  let gate = null; // the CURRENT gate for state.gate (P4 stamps it)
155
161
  let failure = null;
@@ -176,20 +182,27 @@ export function createScheduler(opts) {
176
182
  signalled = false;
177
183
  }
178
184
 
179
- // --- the agent semaphore -------------------------------------------------
180
- // A counting semaphore with a FIFO waiter queue. The drain walk POLLS it before
181
- // launching a node; composite slices AWAIT it (they are launched from inside an
182
- // already-running execution). The composite SHELL holds no slot, which is what
183
- // keeps a fan-out from deadlocking behind itself at maxParallel 1.
184
- const slotQueue = [];
185
- function takeSlot() {
186
- if (activeAgents < maxParallel) { activeAgents += 1; return Promise.resolve(); }
187
- return new Promise((resolve) => { slotQueue.push(resolve); });
185
+ // --- the two semaphores ---------------------------------------------------
186
+ // Agents and scripts are counted APART (D7): the agent pool caps LLM spend and
187
+ // rate-limit exposure, the script pool caps CPU. Each is a counting semaphore
188
+ // with a FIFO waiter queue. The drain walk POLLS a pool before launching a node;
189
+ // composite slices AWAIT the agent pool (they launch from inside a running
190
+ // execution). The composite SHELL holds no slot.
191
+ const pools = {
192
+ agent: { active: 0, max: maxParallel, queue: [] },
193
+ script: { active: 0, max: maxParallelScripts, queue: [] },
194
+ };
195
+ const poolOf = (node) => (node.kind === 'script' ? 'script' : 'agent');
196
+ function takeSlot(pool = 'agent') {
197
+ const p = pools[pool];
198
+ if (p.active < p.max) { p.active += 1; return Promise.resolve(); }
199
+ return new Promise((resolve) => { p.queue.push(resolve); });
188
200
  }
189
- function freeSlot() {
190
- const next = slotQueue.shift();
201
+ function freeSlot(pool = 'agent') {
202
+ const p = pools[pool];
203
+ const next = p.queue.shift();
191
204
  if (next) { next(); return; } // handed straight over: the count is unchanged
192
- activeAgents -= 1;
205
+ p.active -= 1;
193
206
  wake(); // a freed slot may unblock a queued launch
194
207
  }
195
208
 
@@ -227,7 +240,8 @@ export function createScheduler(opts) {
227
240
  kind: entry.kind,
228
241
  ordinal: entry.ordinal,
229
242
  status,
230
- agentKey: node.kind === 'agent' ? (node.key ?? null) : null, // flow rows carry none
243
+ agentKey: node.kind === 'agent' ? (node.key ?? null) : null, // agents only (D17)
244
+ key: KEYED_KINDS.includes(node.kind) ? (node.key ?? null) : null, // every keyed kind; flow rows carry none
231
245
  trigger: entry.trigger,
232
246
  // Composite sub-executions carry their slice identity; the UI collapses them
233
247
  // under the node and labels them by title (A9: taskIndex/taskTotal ride along).
@@ -353,7 +367,7 @@ export function createScheduler(opts) {
353
367
  /** Agent nodes take a semaphore slot; `execute` is called AT the slot. */
354
368
  function fireAgent(node) {
355
369
  const h = startExecution(node);
356
- if (!h.composite) activeAgents += 1;
370
+ if (!h.composite) pools[poolOf(node)].active += 1;
357
371
  let p;
358
372
  try { p = invoke(h); } catch (err) { p = Promise.reject(err); }
359
373
  Promise.resolve(p).then(
@@ -435,7 +449,7 @@ export function createScheduler(opts) {
435
449
  };
436
450
  delete entry.expandsPort;
437
451
  h.composite = false; // … so settle() frees the slot taken here
438
- await takeSlot();
452
+ await takeSlot('agent');
439
453
  h.args = argsFor(node, entry);
440
454
  return await execute(h.args);
441
455
  }
@@ -488,11 +502,11 @@ export function createScheduler(opts) {
488
502
  async function runSlice(h, portId, ph, task, index, phaseAbort) {
489
503
  const { node, entry } = h;
490
504
  const signal = AbortSignal.any([controller.signal, phaseAbort.signal]);
491
- await takeSlot();
505
+ await takeSlot('agent');
492
506
  // A slot handed over AFTER the phase (or the run) aborted: never launch. The slice
493
507
  // gets no ledger row (it never started) and rejects with the abort reason, so it
494
508
  // is not counted as the phase's failure.
495
- if (signal.aborted) { freeSlot(); throw signal.reason; }
509
+ if (signal.aborted) { freeSlot('agent'); throw signal.reason; }
496
510
  const sub = {
497
511
  executionId: sliceExecutionId(entry.executionId, task.id),
498
512
  nodeId: node.id,
@@ -550,13 +564,13 @@ export function createScheduler(opts) {
550
564
  if (!isAbortError(err)) phaseAbort.abort();
551
565
  throw err;
552
566
  } finally {
553
- freeSlot();
567
+ freeSlot('agent');
554
568
  }
555
569
  }
556
570
 
557
571
  function settle(h, res, err) {
558
572
  running.delete(h.node.id);
559
- if (!isFlow(h.node) && !h.composite) freeSlot();
573
+ if (!isFlow(h.node) && !h.composite) freeSlot(poolOf(h.node));
560
574
  if (err || res?.error) failExecution(h, err || res.error);
561
575
  else if (res?.paused === true) pausedExecution(h);
562
576
  else if (res?.skipped === true) skippedExecution(h);
@@ -907,7 +921,7 @@ export function createScheduler(opts) {
907
921
  if (!node) continue;
908
922
  running.set(node.id, entry.executionId);
909
923
  const h = { node, entry, args: argsFor(node, entry), composite: !!entry.expandsPort };
910
- if (!isFlow(node) && !h.composite) activeAgents += 1;
924
+ if (!isFlow(node) && !h.composite) pools[poolOf(node)].active += 1;
911
925
  let p;
912
926
  try { p = invoke(h); } catch (err) { p = Promise.reject(err); }
913
927
  Promise.resolve(p).then(
@@ -998,7 +1012,8 @@ export function createScheduler(opts) {
998
1012
  const node = nodeById.get(nodeId);
999
1013
  if (!node || running.has(nodeId) || !isReady(node)) continue;
1000
1014
  if (isFlow(node)) { await fireFlow(node); fired = true; continue; }
1001
- if (activeAgents >= maxParallel) continue; // capped: retried once a slot frees
1015
+ const pool = pools[poolOf(node)];
1016
+ if (pool.active >= pool.max) continue; // capped: retried once a slot frees
1002
1017
  fireAgent(node);
1003
1018
  fired = true;
1004
1019
  }
@@ -0,0 +1,81 @@
1
+ // src/core/graph/script-child.mjs
2
+ // The `node` runtime harness (spec §5.1, D24). Spawned as
3
+ // process.execPath script-child.mjs <program.mjs>
4
+ // with the envelope on stdin. Imports NOTHING from worca: it runs with
5
+ // WORCA_HOME stripped, so any store import would resolve the wrong home.
6
+ // Protocol: read ONE JSON envelope, import the program, call its default export
7
+ // with the api, write ONE JSON frame to stdout, exit 0. stdout is protocol-
8
+ // reserved, so console.* is routed to stderr (the run log). The child ALWAYS
9
+ // exits 0 after a frame; a non-zero exit means "crashed before the frame" and
10
+ // the parent reports "no result frame (exit N)".
11
+ import { pathToFileURL } from 'node:url';
12
+
13
+ // The REAL writers, bound before the program is imported: the exit flush below
14
+ // must not call a `process.stderr.write` the user's program replaced (muting a
15
+ // noisy dependency is a one-liner, and console.* rides stderr here) — that call
16
+ // waits for a callback the program controls, and the setInterval below holds the
17
+ // loop open, so the parent's timeout kill was the only way out.
18
+ const outWrite = process.stdout.write.bind(process.stdout);
19
+ const errWrite = process.stderr.write.bind(process.stderr);
20
+ const logs = [];
21
+ for (const m of ['log', 'info', 'warn', 'error', 'debug']) {
22
+ console[m] = (...a) => { process.stderr.write(a.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(' ') + '\n'); };
23
+ }
24
+
25
+ async function main() {
26
+ process.stdin.setEncoding('utf8');
27
+ let raw = '';
28
+ for await (const chunk of process.stdin) raw += chunk;
29
+ const envelope = JSON.parse(raw);
30
+ const file = process.argv[2];
31
+ if (!file) throw new Error('script-child: no program file argument');
32
+ const mod = await import(pathToFileURL(file).href);
33
+ if (typeof mod.default !== 'function') throw new Error(`script module has no default-export function: ${file}`);
34
+ const api = {
35
+ inputs: envelope.inputs || {},
36
+ outputs: envelope.outputs || {},
37
+ params: envelope.params || {},
38
+ ctx: envelope.ctx || {},
39
+ verdictPath: envelope.verdictPath ?? null,
40
+ node: envelope.node || {},
41
+ execution: envelope.execution || {},
42
+ log: (level, msg) => { logs.push({ level: String(level), msg: String(msg) }); },
43
+ };
44
+ const result = await mod.default(api);
45
+ const r = result && typeof result === 'object' ? result : {};
46
+ const frame = { ok: true, logs };
47
+ if (r.outputs && typeof r.outputs === 'object') frame.outputs = r.outputs;
48
+ if (r.verdict !== undefined) frame.verdict = r.verdict;
49
+ if (typeof r.summary === 'string') frame.summary = r.summary;
50
+ return frame;
51
+ }
52
+
53
+ // A pending promise alone does not keep the event loop alive: a hung script with
54
+ // no live handles would exit 0 with NO frame. Hold the loop open so a hung script
55
+ // stays hung and the parent's timeout kill is the only way out.
56
+ setInterval(() => {}, 1 << 30);
57
+
58
+ main()
59
+ .catch((err) => ({ ok: false, error: { message: err?.message || String(err), stack: err?.stack || '' }, logs }))
60
+ .then((frame) => {
61
+ let text;
62
+ try { text = JSON.stringify(frame); } catch (err) {
63
+ text = JSON.stringify({ ok: false, error: { message: `frame is not serializable: ${err?.message || err}` }, logs });
64
+ }
65
+ // BOTH pipes before the exit. console.* rides STDERR, which is asynchronous on
66
+ // a macOS pipe: exiting on the stdout callback alone discarded every line still
67
+ // queued there, so a chatty script silently lost most of its live output
68
+ // whenever the host drained slower than the child wrote it.
69
+ // BOUNDED, and through the writers captured above: a program that muted or
70
+ // corked stderr never fires the flush callback, and the flush may never
71
+ // outlive the frame it is flushing.
72
+ // The bound is armed FROM the stdout callback, never beside the write: stdout
73
+ // is a pipe with a 64 KiB kernel buffer, so a frame bigger than that waits for
74
+ // the parent to read it — a timer racing the write exits mid-frame whenever the
75
+ // parent's loop is blocked longer than the bound, and the parent then reports
76
+ // `no result frame (exit 0)` for a run that produced one. The bound belongs to
77
+ // the STDERR flush, which is the only part the user's program can stall.
78
+ let exited = false;
79
+ const bye = () => { if (!exited) { exited = true; process.exit(0); } };
80
+ outWrite(text, () => { setTimeout(bye, 1000); errWrite('', bye); });
81
+ });