pi-crew 0.9.67 → 0.10.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.
Files changed (222) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/agents/analyst.md +1 -1
  3. package/agents/cold-verifier.md +3 -1
  4. package/agents/critic.md +1 -1
  5. package/agents/executor.md +1 -1
  6. package/agents/explorer.md +1 -1
  7. package/agents/planner.md +1 -1
  8. package/agents/reviewer.md +1 -1
  9. package/agents/security-reviewer.md +1 -1
  10. package/agents/test-engineer.md +1 -1
  11. package/agents/verifier.md +1 -1
  12. package/agents/writer.md +1 -1
  13. package/dist/index.mjs +65847 -60331
  14. package/docs/README.md +2 -0
  15. package/docs/actions-reference.md +31 -0
  16. package/docs/commands-reference.md +17 -6
  17. package/docs/resource-formats.md +13 -0
  18. package/package.json +9 -6
  19. package/schema.json +15 -1
  20. package/scripts/resource-sampler.mjs +36 -2
  21. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  22. package/skills/widget-rendering/SKILL.md +7 -7
  23. package/src/agents/agent-config.ts +36 -14
  24. package/src/agents/discover-agents.ts +23 -14
  25. package/src/config/config-merge.ts +183 -0
  26. package/src/config/config-validation.ts +681 -0
  27. package/src/config/config.ts +22 -864
  28. package/src/config/defaults.ts +35 -1
  29. package/src/config/drift-detector.ts +1 -1
  30. package/src/config/env-vars.ts +691 -0
  31. package/src/config/role-tools.ts +8 -8
  32. package/src/config/sanitize-project-config.ts +172 -0
  33. package/src/config/types.ts +30 -0
  34. package/src/extension/async-notifier.ts +25 -2
  35. package/src/extension/crew-vibes/config.ts +2 -1
  36. package/src/extension/plan-orchestrate.ts +132 -0
  37. package/src/extension/registration/commands/dashboard.ts +158 -0
  38. package/src/extension/registration/commands/index.ts +35 -0
  39. package/src/extension/registration/commands/manage.ts +303 -0
  40. package/src/extension/registration/commands/run.ts +214 -0
  41. package/src/extension/registration/commands/shared.ts +631 -0
  42. package/src/extension/registration/commands/status.ts +60 -0
  43. package/src/extension/registration/commands.ts +13 -1224
  44. package/src/extension/registration/lifecycle-handlers.ts +126 -15
  45. package/src/extension/registration/subagent-tools.ts +218 -9
  46. package/src/extension/rpc-hmac.ts +5 -3
  47. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  48. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  49. package/src/extension/team-tool/api/task-claims.ts +109 -40
  50. package/src/extension/team-tool/cancel.ts +84 -50
  51. package/src/extension/team-tool/dispatch/index.ts +1 -0
  52. package/src/extension/team-tool/dispatch/run.ts +4 -1
  53. package/src/extension/team-tool/doctor.ts +103 -1
  54. package/src/extension/team-tool/orchestrate.ts +66 -1
  55. package/src/extension/team-tool/plans.ts +192 -0
  56. package/src/extension/team-tool/respond.ts +197 -65
  57. package/src/extension/team-tool/run-deadline.ts +16 -1
  58. package/src/extension/team-tool/run-intent.ts +63 -0
  59. package/src/extension/team-tool/run.ts +48 -18
  60. package/src/extension/team-tool/status.ts +84 -26
  61. package/src/extension/team-tool.ts +11 -2
  62. package/src/hooks/registry.ts +1 -6
  63. package/src/i18n.ts +9 -0
  64. package/src/prompt/prompt-runtime.ts +521 -2
  65. package/src/prompt/worker-events-channel.ts +173 -0
  66. package/src/runtime/README.md +8 -8
  67. package/src/runtime/async-runner.ts +7 -3
  68. package/src/runtime/background-runner.ts +42 -14
  69. package/src/runtime/broker/broker-issuer.ts +9 -2
  70. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  71. package/src/runtime/broker/crew-broker.ts +838 -10
  72. package/src/runtime/broker/wait-status-cache.ts +157 -0
  73. package/src/runtime/budget-enforcement.ts +281 -0
  74. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  75. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  76. package/src/runtime/child-pi/child-pi.ts +97 -201
  77. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  78. package/src/runtime/crew-agent-records.ts +259 -14
  79. package/src/runtime/delegate-spawn.ts +148 -0
  80. package/src/runtime/deterministic-ast.ts +2 -1
  81. package/src/runtime/dispatch-batch.ts +945 -0
  82. package/src/runtime/finalize-run.ts +557 -0
  83. package/src/runtime/goal-workflow/adaptive-plan.ts +87 -13
  84. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -2
  85. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  86. package/src/runtime/group-join.ts +11 -125
  87. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  88. package/src/runtime/merge-gate.ts +7 -1
  89. package/src/runtime/merge-loop.ts +130 -0
  90. package/src/runtime/model/model-budget-summary.ts +53 -0
  91. package/src/runtime/model/model-fallback.ts +32 -2
  92. package/src/runtime/model/pi-args.ts +10 -0
  93. package/src/runtime/model/provider-extensions.ts +10 -0
  94. package/src/runtime/orphan-worker-registry.ts +1 -1
  95. package/src/runtime/output/output-validator.ts +45 -0
  96. package/src/runtime/parent-guard.ts +3 -1
  97. package/src/runtime/peer-dep.ts +2 -1
  98. package/src/runtime/per-write-validator.ts +0 -5
  99. package/src/runtime/pi-spawn.ts +61 -15
  100. package/src/runtime/plan-approval.ts +125 -0
  101. package/src/runtime/plan-replan.ts +151 -0
  102. package/src/runtime/recovery/checkpoint.ts +0 -18
  103. package/src/runtime/recovery/crash-recovery.ts +95 -46
  104. package/src/runtime/scheduler-context.ts +98 -0
  105. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  106. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  107. package/src/runtime/scheduling/nested-slots.ts +70 -0
  108. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  109. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  110. package/src/runtime/scratchpad/README.md +10 -5
  111. package/src/runtime/scratchpad/guest.ts +103 -2
  112. package/src/runtime/scratchpad/protocol.ts +13 -1
  113. package/src/runtime/scratchpad/transform.ts +206 -12
  114. package/src/runtime/settings-store.ts +219 -0
  115. package/src/runtime/spawn-policy.ts +217 -0
  116. package/src/runtime/stale-reconciler.ts +87 -6
  117. package/src/runtime/subagent-manager.ts +25 -1
  118. package/src/runtime/task-output-context.ts +230 -9
  119. package/src/runtime/task-packet.ts +23 -1
  120. package/src/runtime/task-runner/child-executor.ts +106 -7
  121. package/src/runtime/task-runner/post-execution.ts +125 -1
  122. package/src/runtime/task-runner/pre-execution.ts +39 -1
  123. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  124. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  125. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  126. package/src/runtime/task-runner/state-helpers.ts +26 -24
  127. package/src/runtime/task-runner.ts +11 -0
  128. package/src/runtime/team-runner.ts +129 -1671
  129. package/src/runtime/verification/spec-sandbox.ts +255 -0
  130. package/src/runtime/verification/verification-gates.ts +3 -2
  131. package/src/runtime/verification/verification-worktree.ts +2 -1
  132. package/src/runtime/workflow-phase-advance.ts +100 -0
  133. package/src/runtime/workspace-tree.ts +9 -0
  134. package/src/schema/config-schema.ts +65 -25
  135. package/src/schema/sensitive-config-paths.ts +64 -0
  136. package/src/schema/team-tool-schema.ts +13 -3
  137. package/src/state/README.md +4 -10
  138. package/src/state/atomic-write.ts +20 -3
  139. package/src/state/contracts.ts +38 -0
  140. package/src/state/coordination/mailbox.ts +12 -2
  141. package/src/state/event-log/cursor.ts +223 -0
  142. package/src/state/event-log/event-log-rotation.ts +12 -4
  143. package/src/state/event-log/event-log.ts +152 -369
  144. package/src/state/event-log/sequence-cache.ts +373 -0
  145. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  146. package/src/state/stores/active-run-registry.ts +3 -2
  147. package/src/state/stores/manifest-io.ts +237 -0
  148. package/src/state/stores/ownership-map.ts +162 -0
  149. package/src/state/stores/plan-store.ts +241 -0
  150. package/src/state/stores/run-cache.ts +0 -90
  151. package/src/state/stores/spec-store.ts +189 -0
  152. package/src/state/stores/state-store.ts +66 -229
  153. package/src/state/types.ts +197 -0
  154. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  155. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  156. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  157. package/src/ui/heartbeat-aggregator.ts +9 -1
  158. package/src/ui/keybinding-map.ts +54 -13
  159. package/src/ui/powerbar-publisher.ts +52 -1
  160. package/src/ui/run-dashboard.ts +31 -5
  161. package/src/ui/run-snapshot-cache.ts +57 -30
  162. package/src/ui/snapshot-types.ts +6 -1
  163. package/src/ui/widget/widget-renderer.ts +9 -1
  164. package/src/utils/file-coalescer.ts +0 -4
  165. package/src/utils/fs-errno.ts +66 -0
  166. package/src/utils/fs-watch.ts +1 -1
  167. package/src/utils/internal-error.ts +3 -1
  168. package/src/utils/paths.ts +11 -3
  169. package/src/utils/task-name-generator.ts +1 -8
  170. package/src/workflows/discover-workflows.ts +19 -2
  171. package/src/workflows/validate-workflow.ts +7 -1
  172. package/src/workflows/workflow-config.ts +10 -0
  173. package/src/workflows/workflow-serializer.ts +3 -0
  174. package/src/worktree/worktree-manager.ts +22 -0
  175. package/src/agents/agent-search.ts +0 -98
  176. package/src/benchmark/benchmark-runner.ts +0 -313
  177. package/src/benchmark/feedback-loop.ts +0 -73
  178. package/src/config/resilient-parser.ts +0 -117
  179. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  180. package/src/extension/result-watcher.ts +0 -139
  181. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  182. package/src/observability/metric-retention.ts +0 -64
  183. package/src/runtime/compaction/compaction-summary.ts +0 -278
  184. package/src/runtime/errors/crew-errors.ts +0 -162
  185. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  186. package/src/runtime/loop-gates.ts +0 -128
  187. package/src/runtime/metric-parser.ts +0 -36
  188. package/src/runtime/output/stream-preview.ts +0 -184
  189. package/src/runtime/output/tool-progress.ts +0 -278
  190. package/src/runtime/phase-tracker.ts +0 -385
  191. package/src/runtime/pipeline-runner.ts +0 -523
  192. package/src/runtime/process/process-lifecycle.ts +0 -491
  193. package/src/runtime/recovery/retry-runner.ts +0 -330
  194. package/src/runtime/run-drift.ts +0 -219
  195. package/src/runtime/scratchpad/snapshot-hmac.ts +0 -167
  196. package/src/runtime/task-quality.ts +0 -199
  197. package/src/runtime/task-runner/run-projection.ts +0 -128
  198. package/src/runtime/verification/post-checks.ts +0 -142
  199. package/src/state/coordination/schedule.ts +0 -166
  200. package/src/state/event-log/jsonl-writer.ts +0 -115
  201. package/src/state/hook-instinct-bridge.ts +0 -94
  202. package/src/state/hook-integrations.ts +0 -51
  203. package/src/state/session-state-map.ts +0 -51
  204. package/src/state/stores/blob-store.ts +0 -308
  205. package/src/state/stores/instinct-store.ts +0 -275
  206. package/src/state/stores/observation-store.ts +0 -176
  207. package/src/state/tiered-eval.ts +0 -480
  208. package/src/state/types-eval.ts +0 -58
  209. package/src/tools/safe-bash-extension.ts +0 -54
  210. package/src/tools/safe-bash.ts +0 -505
  211. package/src/ui/agent-management-overlay.ts +0 -160
  212. package/src/ui/crew-footer.ts +0 -102
  213. package/src/ui/crew-select-list.ts +0 -114
  214. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  215. package/src/ui/transcript-entries.ts +0 -256
  216. package/src/utils/conflict-detect.ts +0 -721
  217. package/src/utils/fingerprint.ts +0 -180
  218. package/src/utils/gh-protocol.ts +0 -556
  219. package/src/utils/project-detector.ts +0 -160
  220. package/src/utils/sse-parser.ts +0 -131
  221. package/src/workflows/cost-estimator.ts +0 -34
  222. package/src/workflows/intermediate-store.ts +0 -166
@@ -2,7 +2,7 @@ import type { AgentConfig } from "../../agents/agent-config.ts";
2
2
  import type { CrewReliabilityConfig, CrewRuntimeConfig } from "../../config/types.ts";
3
3
  import { appendEventAsync } from "../../state/event-log/event-log.ts";
4
4
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
5
- import { saveRunTasksAsync, updateRunStatus } from "../../state/stores/state-store.ts";
5
+ import { loadRunManifestById, saveRunTasksAsync, updateRunStatus } from "../../state/stores/state-store.ts";
6
6
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
7
7
  import type { WorkflowStep } from "../../workflows/workflow-config.ts";
8
8
  import { createWorkerHeartbeat, touchWorkerHeartbeat } from "../heartbeat/worker-heartbeat.ts";
@@ -115,17 +115,12 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
115
115
  heartbeatInFlight = true;
116
116
  heartbeatPromise = (async () => {
117
117
  try {
118
- updatedTasks = updatedTasks.map((t) => {
119
- if (!taskIds.includes(t.id)) return t;
120
- // FIND-06 belt-and-suspenders: never replace terminal state or its
121
- // resultArtifact with a heartbeat-only snapshot.
122
- if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
123
- return {
124
- ...t,
125
- heartbeat: touchWorkerHeartbeat(t.heartbeat ?? createWorkerHeartbeat(t.id), { alive: true }),
126
- };
127
- });
128
- await saveRunTasksAsync(manifest, updatedTasks);
118
+ // R15-3: persist heartbeats for ONLY this group's tasks via a fresh
119
+ // disk read. The old closure `updatedTasks` is a dispatch-time
120
+ // snapshot of the FULL task array — a sibling cancelled on disk after
121
+ // dispatch could be un-cancelled by a late heartbeat save (the map
122
+ // only mutated group tasks, but the SAVE wrote the whole stale array).
123
+ await persistGroupHeartbeats(manifest, taskIds);
129
124
  } catch {
130
125
  // Run may have been pruned mid-dispatch — best-effort only.
131
126
  } finally {
@@ -134,9 +129,12 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
134
129
  // save was in flight, re-write the (now-terminal) updatedTasks so
135
130
  // our late snapshot cannot leave stale pre-terminal state on disk.
136
131
  // Runs after our own save resolves, so it always lands last.
132
+ // R15-3: the repair now fresh-reads disk and re-applies ONLY the
133
+ // group's terminal state — the old full-array write could resurrect
134
+ // a sibling cancelled on disk (same flaw as the heartbeat save).
137
135
  if (finalWriteStarted) {
138
136
  try {
139
- await saveRunTasksAsync(manifest, updatedTasks);
137
+ await repairGroupTerminalWrite(manifest, taskIds, updatedTasks);
140
138
  } catch {
141
139
  // best-effort repair — same swallow policy as the heartbeat.
142
140
  }
@@ -298,6 +296,59 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
298
296
  return { manifest: updatedManifest, tasks: updatedTasks, taskIds, rawOutput, success };
299
297
  }
300
298
 
299
+ /**
300
+ * R15-3: heartbeat save scoped to this coalesced group only.
301
+ *
302
+ * Previously the heartbeat wrote the dispatch-time closure `updatedTasks` —
303
+ * the FULL task array including sibling tasks outside this group. A sibling
304
+ * cancelled on disk after dispatch (external cancel / reconciler) could be
305
+ * un-cancelled ("resurrected") by a late heartbeat save, because the map only
306
+ * mutated group tasks but the SAVE wrote the whole stale array. Fix: fresh
307
+ * re-read tasks from disk (loadRunManifestById — the established fresh-read
308
+ * pattern used by team-runner) and write back ONLY the group's own task ids
309
+ * with heartbeats touched; sibling tasks are preserved untouched from the
310
+ * fresh disk read. Keeps the FIND-06 'never replace terminal state' guard.
311
+ */
312
+ async function persistGroupHeartbeats(manifest: TeamRunManifest, taskIds: string[]): Promise<void> {
313
+ const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
314
+ if (!fresh) return; // run pruned mid-dispatch — best-effort only.
315
+ const merged = fresh.tasks.map((t) => {
316
+ if (!taskIds.includes(t.id)) return t; // sibling tasks: preserved untouched.
317
+ // FIND-06 belt-and-suspenders: never replace terminal state or its
318
+ // resultArtifact with a heartbeat-only snapshot.
319
+ if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
320
+ return {
321
+ ...t,
322
+ heartbeat: touchWorkerHeartbeat(t.heartbeat ?? createWorkerHeartbeat(t.id), { alive: true }),
323
+ };
324
+ });
325
+ await saveRunTasksAsync(manifest, merged);
326
+ }
327
+
328
+ /**
329
+ * R15-3: FIND-06 P1 repair — re-apply the group's terminal state after a late
330
+ * heartbeat save may have landed last. Fresh-reads disk so sibling tasks are
331
+ * preserved untouched; only the group's own terminal state from `updatedTasks`
332
+ * is re-applied, and a disk-terminal group task is never flipped (same
333
+ * belt-and-suspenders guard as the heartbeat).
334
+ */
335
+ async function repairGroupTerminalWrite(manifest: TeamRunManifest, taskIds: string[], updatedTasks: TeamTaskState[]): Promise<void> {
336
+ const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
337
+ if (!fresh) return; // run pruned mid-dispatch — best-effort only.
338
+ const groupById = new Map(updatedTasks.map((t) => [t.id, t] as const));
339
+ const merged = fresh.tasks.map((t) => {
340
+ if (!taskIds.includes(t.id)) return t; // sibling tasks: preserved untouched.
341
+ if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
342
+ return groupById.get(t.id) ?? t;
343
+ });
344
+ await saveRunTasksAsync(manifest, merged);
345
+ }
346
+
347
+ /** @internal 3.5 R15-3 test export — exercise the heartbeat/repair writes directly. */
348
+ export const __test__persistGroupHeartbeats = persistGroupHeartbeats;
349
+ /** @internal 3.5 R15-3 test export — exercise the terminal repair write directly. */
350
+ export const __test__repairGroupTerminalWrite = repairGroupTerminalWrite;
351
+
301
352
  async function buildCoalescedPrompt(
302
353
  manifest: TeamRunManifest,
303
354
  step: WorkflowStep,
@@ -48,16 +48,6 @@ export function buildTaskGraphIndex(tasks: TeamTaskState[]): TaskGraphIndex {
48
48
  return fresh;
49
49
  }
50
50
 
51
- /** Test/diagnostic helper — invalidate the WeakMap-backed index cache. Not
52
- * used by production code paths; the WeakMap self-invalidates when a tasks
53
- * array goes out of scope. Provided for parity with `clearStablePrefixCache`. */
54
- export function clearTaskGraphIndexCache(): void {
55
- // WeakMap has no `.clear()`; rely on GC. Exposed as a no-op stub so callers
56
- // that import `clearStablePrefixCache` (also a no-op stub for symmetry) can
57
- // adopt a parallel API if needed in the future. Documented as no-op rather
58
- // than removed so the API stays discoverable.
59
- }
60
-
61
51
  function taskById(tasks: TeamTaskState[]): Map<string, TeamTaskState> {
62
52
  return new Map(tasks.map((task) => [task.id, task]));
63
53
  }
@@ -124,11 +124,16 @@ attempt N+1 worker (scratchpad-lifecycle.ts)
124
124
  token), so this does not cross the existing boundary. An HMAC over the payload
125
125
  is a Phase 2.5/3 hardening if artifacts ever land in a shared location.
126
126
 
127
- > ⚠️ **NOT WIRED (2026-08-11):** `src/runtime/scratchpad/snapshot-hmac.ts` is an
128
- > unconnected helper — zero production call sites, 11 green unit tests in
129
- > isolation. Wire-or-delete decision in `docs/improvement-plan-2026-08-11.md`
130
- > §4 J1. Until then the note above (v8.deserialize unauthenticated) is exactly
131
- > how the code behaves.
127
+ > **REMOVED (2026-08-12):** `src/runtime/scratchpad/snapshot-hmac.ts` and its
128
+ > test were deleted. Decision (P3 of `docs/rlm-fixes-implementation-plan.md`):
129
+ > the HMAC threat is double-conditional — it only materializes when scratchpad
130
+ > has adoption (>0 cells; today: 0/83 runs) AND snapshots move to a
131
+ > shared/networked store (today: same-uid dev-machine). Hardening a
132
+ > 0-adoption surface that is itself at risk of removal (decision gate §3.3)
133
+ > is premature. The design is fully recorded in ADR
134
+ > `docs/decisions/2026-08-10-scratchpad-snapshot-hmac.md` (Superseded), so it
135
+ > can be re-added cleanly if/when both conditions hold. Until then the note
136
+ > below (v8.deserialize unauthenticated) remains exactly how the code behaves.
132
137
  - **Secret-at-rest under a benign key name** persists as base64 for the run's
133
138
  retention (structural redaction is key-name based, best-effort). A sanitized-
134
139
  namespace policy is a Phase 2.5 concern.
@@ -188,6 +188,49 @@ console.trace = consoleErr;
188
188
 
189
189
  const INTERNAL_BINDINGS = new Map<string, unknown>();
190
190
 
191
+ /**
192
+ * Protected Node globals — must never be persistently shadowed by a cell.
193
+ *
194
+ * Because the scope proxy's `get` trap checks the namespace BEFORE globalThis,
195
+ * a cell writing `const process = 'poisoned'` (transformed to a namespace
196
+ * assignment) would otherwise poison EVERY later cell in the same engine
197
+ * (the namespace is a module-level singleton) AND survive snapshot→restore
198
+ * (a serializable shadow revives). Result: silent corruption — e.g.
199
+ * `typeof process.env` becomes 'undefined' with no error.
200
+ *
201
+ * We register the LIVE global for each protected name in INTERNAL_BINDINGS +
202
+ * the namespace, so (a) restore's re-install overwrites any revived shadow,
203
+ * and (b) each cell starts with the real global. Within-cell shadowing still
204
+ * works (the cell's own write wins for that cell only); it just cannot leak
205
+ * to other cells or across restarts. Verified: probe reproduced the bug
206
+ * pre-fix; see test/unit/runtime/scratchpad/guest-global-shadow.test.ts.
207
+ */
208
+ const PROTECTED_GLOBALS: ReadonlyArray<string> = [
209
+ "process",
210
+ "Buffer",
211
+ "console",
212
+ "setTimeout",
213
+ "clearTimeout",
214
+ "setInterval",
215
+ "clearInterval",
216
+ "setImmediate",
217
+ "clearImmediate",
218
+ "queueMicrotask",
219
+ "structuredClone",
220
+ "AbortController",
221
+ "globalThis",
222
+ ];
223
+
224
+ function resetProtectedGlobals(): void {
225
+ const g = globalThis as Record<string, unknown>;
226
+ for (const name of PROTECTED_GLOBALS) {
227
+ const live = g[name];
228
+ if (live === undefined) continue;
229
+ INTERNAL_BINDINGS.set(name, live);
230
+ namespace[name] = live;
231
+ }
232
+ }
233
+
191
234
  /** Pattern-12 nullish guard: refuse undefined/null BEFORE spawning. */
192
235
  function assertShellArgNotNullish(name: string, value: unknown): void {
193
236
  if (value === undefined || value === null) {
@@ -235,6 +278,9 @@ function installBootstrapBindings(): void {
235
278
  // serialization, and so cells can call `sh(...)` directly.
236
279
  INTERNAL_BINDINGS.set("sh", sh);
237
280
  namespace.sh = sh;
281
+ // Overwrite any protected-global shadow (e.g. a revived `process='x'` from
282
+ // restore) with the live global — fixes the global shadow poisoning bug.
283
+ resetProtectedGlobals();
238
284
  }
239
285
 
240
286
  installBootstrapBindings();
@@ -246,14 +292,69 @@ const AsyncFunction = (async () => {}).constructor as new (...args: string[]) =>
246
292
 
247
293
  const liveCells = new Map<string, CellContext>();
248
294
 
295
+ // ── P6: remap transformed error-stack lines back to the cell's source ───────
296
+ // V8 reports a thrown line as `N = bodyLine + 2` (1-based) — the AsyncFunction
297
+ // wrapper adds 2 prefix lines (the anonymous function line + the `with (...)`
298
+ // line) before the cell body. `lineMap` (from transformCell) maps body lines
299
+ // that RECEIVED A REPLACEMENT back to the source line the model wrote. Lines
300
+ // between replacements (unreplaced spans — e.g. a bare `throw`) are identity:
301
+ // sourceLine = entry.sourceLine + (bodyLine - entry.bodyLine), using the
302
+ // nearest replacement entry at or before the body line.
303
+ //
304
+ // SECURITY/robustness (P6 review finding): only lines that look like a real
305
+ // V8 frame (` at ... <anonymous>:N:C)`) are remapped. The `Error: <message>`
306
+ // line and any cell text containing `<anonymous>:N:C)` (e.g. an error message
307
+ // that echoes code) must be left byte-identical — a crafted message must never
308
+ // get its numbers rewritten.
309
+ function remapStackLines(stack: string, lineMap: { sourceLine: number; bodyLine: number }[]): string[] {
310
+ const lines = stack.split("\n");
311
+ const sorted = [...lineMap].sort((a, b) => a.bodyLine - b.bodyLine);
312
+ return lines.map((line) => {
313
+ // Real V8 frame only: ` at <anything> <anonymous>:N:C)` — anchored to
314
+ // the leading frame prefix and a parenthesized trailing position. The
315
+ // message line (`Error: ...`) does not start with ` at `, so it is
316
+ // never matched; a crafted message containing `<anonymous>:N:C)` is
317
+ // likewise safe because it lacks the frame prefix.
318
+ const m = line.match(/^\s+at .*<anonymous>:(\d+):(\d+)\)/);
319
+ if (!m) return line;
320
+ const reported = Number(m[1]);
321
+ const bodyLine = reported - 2; // wrapper prefix offset (verified by probe)
322
+ // nearest replacement entry at or before bodyLine (lower bound)
323
+ let lo = 0;
324
+ let hi = sorted.length - 1;
325
+ let entry: { sourceLine: number; bodyLine: number } | undefined;
326
+ while (lo <= hi) {
327
+ const mid = (lo + hi) >> 1;
328
+ if (sorted[mid].bodyLine <= bodyLine) {
329
+ entry = sorted[mid];
330
+ lo = mid + 1;
331
+ } else {
332
+ hi = mid - 1;
333
+ }
334
+ }
335
+ if (!entry) return line; // before any replacement — leave as-is
336
+ const sourceLine = entry.sourceLine + (bodyLine - entry.bodyLine);
337
+ const col = m[2];
338
+ return line.replace(/<anonymous>:\d+:\d+/, `<anonymous>:${sourceLine}:${col}`);
339
+ });
340
+ }
341
+
249
342
  async function runCell(cellId: string, code: string): Promise<void> {
250
343
  const ctx = makeCellContext(cellId);
251
344
  activeCell = ctx;
252
345
  liveCells.set(cellId, ctx);
346
+ // Reset protected globals so a prior cell's shadow (e.g. `const process='x'`)
347
+ // cannot poison this cell — shadowing stays local to the cell that did it
348
+ // (the namespace is a module-level singleton shared across cells).
349
+ resetProtectedGlobals();
253
350
 
254
351
  let done: GuestToHostMessage;
352
+ // P6: lineMap from transformCell is needed in BOTH try (to build the
353
+ // wrapper) and catch (to remap the error stack), so hoist it here.
354
+ let lineMap: { sourceLine: number; bodyLine: number }[] = [];
255
355
  try {
256
- const { body } = transformCell(code, { ctxName: CTX_NAME });
356
+ const { body, lineMap: lm } = transformCell(code, { ctxName: CTX_NAME });
357
+ lineMap = lm;
257
358
  // Sloppy-mode wrapper so `with` is legal (AsyncFunction bodies are always
258
359
  // sloppy, even inside a strict ESM strip-types module); async for
259
360
  // top-level await.
@@ -271,7 +372,7 @@ async function runCell(cellId: string, code: string): Promise<void> {
271
372
  type: "done",
272
373
  cellId,
273
374
  status: ctx.aborted ? "aborted" : "error",
274
- error: { name: err.name, message: err.message, stack: (err.stack ?? "").split("\n") },
375
+ error: { name: err.name, message: err.message, stack: remapStackLines(err.stack ?? "", lineMap) },
275
376
  };
276
377
  } finally {
277
378
  if (activeCell === ctx) activeCell = undefined;
@@ -45,7 +45,19 @@ export interface GuestToHost {
45
45
  error?: { name: string; message: string; stack: string[] };
46
46
  };
47
47
  pong: { type: "pong"; id: string };
48
- host_request: { type: "host_request"; id: string; requestType: string; payload: Record<string, unknown> };
48
+ host_request: {
49
+ // RESERVED for future host bridge (rlm-deep-review-2026-08-12.md §5.2F /
50
+ // J2): guest-side cells would request host services (tools.read,
51
+ // tools.grep) so data enters the namespace WITHOUT crossing the
52
+ // transcript — pi-rlm's core token-saving value proposition. Declared
53
+ // here so the protocol type is stable, but NOT YET WIRED: engine.ts and
54
+ // guest.ts have no host_request dispatcher/handler. Blocked on scratchpad
55
+ // adoption > 0 (do not add a host execution surface nobody calls).
56
+ type: "host_request";
57
+ id: string;
58
+ requestType: string;
59
+ payload: Record<string, unknown>;
60
+ };
49
61
  snapshot_result: {
50
62
  type: "snapshot_result";
51
63
  id: string;
@@ -31,6 +31,15 @@ export interface TransformedCell {
31
31
  body: string;
32
32
  /** Top-level names this cell binds into the namespace. */
33
33
  declaredNames: string[];
34
+ /**
35
+ * P6: line-based position map — for each body line (1-based), the
36
+ * corresponding SOURCE line (1-based) the model wrote. Used to remap the
37
+ * V8-reported error line (relative to `body`) back to the cell's original
38
+ * line so stack traces point at the source the user wrote, not the
39
+ * transformed body. Built WITHOUT a source-map dependency by tracking
40
+ * replacement newline shifts against the source (acorn `locations`).
41
+ */
42
+ lineMap: { sourceLine: number; bodyLine: number }[];
34
43
  }
35
44
 
36
45
  export interface TransformOptions {
@@ -41,8 +50,16 @@ export interface TransformOptions {
41
50
  // esbuild's transform strips types but never drops side-effect-free trailing
42
51
  // expressions (no DCE in transform mode) — the exact thing we capture as the
43
52
  // cell result.
44
- function stripTypes(code: string): string {
45
- return transformSync(code, { loader: "ts" }).code;
53
+ // P6: `sourcemap: 'inline'` so we can map the stripped `js` lines back to the
54
+ // `rewritten` (pre-strip) lines — esbuild does NOT preserve line numbers
55
+ // across type stripping (probe: 7 input lines → 5 output lines). The inline
56
+ // sourcemap is stripped from the output (it would otherwise leak into the cell
57
+ // body as a trailing comment); we only keep the decoded line map.
58
+ function stripTypes(code: string): { code: string; lineMap: number[] } {
59
+ const out = transformSync(code, { loader: "ts", sourcemap: "inline" });
60
+ const lineMap = esbuildLineMap(out.code);
61
+ const codeWithoutMap = out.code.replace(/\/\/# sourceMappingURL=data:application\/json;base64,[A-Za-z0-9+/=]+\s*$/, "");
62
+ return { code: codeWithoutMap, lineMap };
46
63
  }
47
64
 
48
65
  // ── top-level import extraction ──────────────────────────────────────────────
@@ -271,14 +288,147 @@ function variableReplacement(decl: VariableDeclaration, source: string): string
271
288
  return statements.join(" ");
272
289
  }
273
290
 
291
+ // ── P6: line-based position map ─────────────────────────────────────────────
292
+ // V8 reports a cell error's line as 1-based relative to the TRANSFORMED `body`.
293
+ // The model wrote `code`; between them sit 3 transforms:
294
+ // 1. import pre-rewrite (acorn) — collapses multi-line imports to 1 line
295
+ // 2. esbuild strip-types — collapses type annotations/signatures
296
+ // 3. declaration → assignment + trailing-expr capture — may add/remove lines
297
+ // We build a map { sourceLine (1-based, in `code`) → bodyLine (1-based) } by
298
+ // tracking line deltas across each stage with acorn `locations` + newline
299
+ // counts. No source-map dependency (probe: esbuild strip-types does NOT
300
+ // preserve lines — 7→5 — so a naive single-layer sourcemap would be wrong).
301
+
302
+ function countNewlines(s: string): number {
303
+ let n = 0;
304
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
305
+ return n;
306
+ }
307
+
308
+ /** 1-based line of `pos` (char offset) in `text`. */
309
+ function lineAt(text: string, pos: number): number {
310
+ let line = 1;
311
+ for (let i = 0; i < pos && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
312
+ return line;
313
+ }
314
+
315
+ // ── P6: esbuild inline sourcemap → line map (js → rewritten) ───────────────
316
+ // esbuild `sourcemap: 'inline'` emits a base64 VLQ sourcemap whose `mappings`
317
+ // field encodes, per generated line, segments mapping back to source
318
+ // positions. We only need LINE fidelity (column is overkill for the bug), so
319
+ // we decode each generated line's first segment's source line. VLQ is the
320
+ // standard base64-variable-length encoding (no dependency needed).
321
+
322
+ const BASE64: string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
323
+ const VLQ: Map<string, number> = new Map([...BASE64].map((c, i) => [c, i]));
324
+
325
+ function decodeVlqSegment(segment: string): number[] {
326
+ const values: number[] = [];
327
+ let shift = 0;
328
+ let value = 0;
329
+ for (let i = 0; i < segment.length; i++) {
330
+ const digit = VLQ.get(segment[i]);
331
+ if (digit === undefined) break;
332
+ value |= (digit & 31) << shift;
333
+ if (digit & 32) {
334
+ shift += 5;
335
+ } else {
336
+ values.push(value & 1 ? -(value >>> 1) : value >>> 1);
337
+ value = 0;
338
+ shift = 0;
339
+ }
340
+ }
341
+ return values;
342
+ }
343
+
344
+ /**
345
+ * Decode an esbuild inline sourcemap's `mappings` into an array where
346
+ * `lineMapGenerated[generatedLine-1]` = the source LINE (1-based) that
347
+ * generated line originates from (first segment per generated line; falls back
348
+ * to the previous mapped line for continuation lines).
349
+ */
350
+ function esbuildLineMap(codeWithInlineMap: string): number[] {
351
+ const m = codeWithInlineMap.match(/\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/);
352
+ if (!m) return [];
353
+ let sm: { mappings?: string };
354
+ try {
355
+ sm = JSON.parse(Buffer.from(m[1], "base64").toString("utf8")) as { mappings?: string };
356
+ } catch {
357
+ return [];
358
+ }
359
+ if (!sm.mappings) return [];
360
+ const result: number[] = [];
361
+ let srcLine = 1;
362
+ let lastSrcLine = 1;
363
+ for (const genLine of sm.mappings.split(";")) {
364
+ const segs = genLine.split(",").filter(Boolean);
365
+ if (segs.length > 0) {
366
+ const vals = decodeVlqSegment(segs[0]);
367
+ // values: [genColDelta, srcIdxDelta, srcLineDelta, srcColDelta, ...]
368
+ if (vals.length >= 3) {
369
+ srcLine += vals[2];
370
+ lastSrcLine = srcLine;
371
+ }
372
+ }
373
+ result.push(lastSrcLine);
374
+ }
375
+ return result;
376
+ }
377
+
378
+ /**
379
+ * Build a `code`-line → `js`-line map across the import pre-rewrite.
380
+ * For each import replacement, its `code` span [codeStart, codeEnd] collapses
381
+ * to `js` lines [jsStart, jsEnd] (usually 1). Lines outside any replacement
382
+ * are identity-shifted by the cumulative delta.
383
+ */
384
+ function buildImportLineMap(
385
+ code: string,
386
+ rewritten: string,
387
+ importSpans: { codeStart: number; codeEnd: number; jsStart: number; jsEnd: number }[],
388
+ ): (src: number) => number {
389
+ if (importSpans.length === 0) return (src) => src;
390
+ // P6 (final): map a `rewritten` (post-import) LINE to the original `code`
391
+ // line. `rewritten` = code with each import statement replaced by its
392
+ // (usually 1-line) dynamic-import fragment. We walk both strings tracking
393
+ // newline counts: between imports the line numbers advance identically;
394
+ // inside an import span, all its code lines map to the replacement's first
395
+ // rewritten line (collapsed).
396
+ const rewrittenLines: string[] = rewritten.split("\n");
397
+ const codeLines: string[] = code.split("\n");
398
+ const map = new Map<number, number>(); // rewrittenLine → codeLine (1-based)
399
+ let codeIdx = 1;
400
+ let rewIdx = 1;
401
+ for (const span of importSpans) {
402
+ // Advance both to the span start, emitting identity mapping for the
403
+ // untouched lines between the previous span end and this span start.
404
+ const spanStartCodeLine = lineAt(code, span.codeStart);
405
+ const spanStartRewLine = lineAt(rewritten, span.jsStart);
406
+ for (; codeIdx < spanStartCodeLine && rewIdx < spanStartRewLine; codeIdx++, rewIdx++) {
407
+ map.set(rewIdx, codeIdx);
408
+ }
409
+ // Lines inside the import span (in code) collapse to the replacement's
410
+ // single starting rewritten line.
411
+ const spanEndCodeLine = lineAt(code, span.codeEnd);
412
+ for (; codeIdx <= spanEndCodeLine; codeIdx++) {
413
+ map.set(spanStartRewLine, spanStartCodeLine);
414
+ }
415
+ rewIdx = spanStartRewLine + 1;
416
+ }
417
+ // Tail: identity from wherever we stopped.
418
+ for (; rewIdx <= rewrittenLines.length; rewIdx++, codeIdx++) {
419
+ map.set(rewIdx, Math.min(codeIdx, codeLines.length));
420
+ }
421
+ return (rewLine: number) => {
422
+ const mapped = map.get(rewLine);
423
+ return mapped ?? Math.min(rewLine, codeLines.length);
424
+ };
425
+ }
426
+
274
427
  export function transformCell(code: string, options: TransformOptions = {}): TransformedCell {
275
428
  const ctxName = options.ctxName ?? "__ctx";
276
-
277
- // Top-level imports are rewritten into awaited dynamic imports BEFORE type
278
- // stripping: esbuild elides unused imports, and the reference behaviour
279
- // binds every imported name into the namespace so it persists across cells.
280
429
  let importDeclared: string[] = [];
281
430
  let rewritten = code;
431
+ const importSpans: { codeStart: number; codeEnd: number; jsStart: number; jsEnd: number }[] = [];
282
432
  try {
283
433
  const tokens = lexTopLevel(code);
284
434
  const imports = findImportStatements(tokens);
@@ -286,11 +436,21 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
286
436
  importDeclared = [];
287
437
  const pieces: string[] = [];
288
438
  let cursor = 0;
439
+ let jsCursor = 0;
289
440
  for (const stmt of imports) {
290
- pieces.push(rewritten.slice(cursor, stmt.start));
441
+ const pre = rewritten.slice(cursor, stmt.start);
442
+ pieces.push(pre);
443
+ jsCursor += pre.length;
291
444
  const rewrite = rewriteImport(code, stmt, tokens);
292
445
  importDeclared.push(...rewrite.declaredNames);
293
446
  pieces.push(rewrite.replacement);
447
+ importSpans.push({
448
+ codeStart: stmt.start,
449
+ codeEnd: stmt.end,
450
+ jsStart: jsCursor,
451
+ jsEnd: jsCursor + rewrite.replacement.length - 1,
452
+ });
453
+ jsCursor += rewrite.replacement.length;
294
454
  cursor = stmt.end;
295
455
  }
296
456
  pieces.push(rewritten.slice(cursor));
@@ -301,8 +461,15 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
301
461
  // the pre-rewrite; type stripping below surfaces the error.
302
462
  }
303
463
 
304
- const js = stripTypes(rewritten);
305
- const program: Program = parse(js, { ecmaVersion: "latest", sourceType: "module", allowAwaitOutsideFunction: true });
464
+ const { code: js, lineMap: esbLineMap } = stripTypes(rewritten);
465
+ const rewToCode = buildImportLineMap(code, rewritten, importSpans);
466
+ const program: Program = parse(js, {
467
+ ecmaVersion: "latest",
468
+ sourceType: "module",
469
+ allowAwaitOutsideFunction: true,
470
+ // P6: locations needed to know each top-level node's source lines.
471
+ locations: true,
472
+ });
306
473
 
307
474
  const declaredNames: string[] = [];
308
475
  const replacements: { start: number; end: number; text: string }[] = [];
@@ -353,11 +520,38 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
353
520
  replacements.sort((a, b) => a.start - b.start);
354
521
  let body = "";
355
522
  let cursor = 0;
523
+ // P6: build bodyLine → sourceLine map while splicing. `body` is what V8
524
+ // reports error lines against; `js` is the esbuild-stripped source; the map
525
+ // composes: bodyLine → jsLine (via newline deltas) → rewrittenLine (via
526
+ // esbuild inline sourcemap) → codeLine (via import pre-rewrite).
527
+ const lineMap: { sourceLine: number; bodyLine: number }[] = [];
528
+ let bodyLine = 1;
529
+ let jsLine = 1;
530
+ // jsLineOfBodyStart maps the js line at each body boundary; we track the
531
+ // cumulative js-line delta as body lines are emitted.
356
532
  for (const replacement of replacements) {
357
- body += js.slice(cursor, replacement.start) + replacement.text;
533
+ const pre = js.slice(cursor, replacement.start);
534
+ body += pre;
535
+ bodyLine += countNewlines(pre);
536
+ jsLine += countNewlines(pre);
537
+ // The replacement's first body line maps from the js line at its start.
538
+ const repJsStartLine = lineAt(js, replacement.start);
539
+ // Source line = compose: js → rewritten (esbuild) → code (imports).
540
+ const rewLine = esbLineMap.length > 0 ? esbLineMap[Math.min(repJsStartLine - 1, esbLineMap.length - 1)] : repJsStartLine;
541
+ const srcLine = rewToCode(rewLine);
542
+ lineMap.push({ sourceLine: srcLine, bodyLine: bodyLine });
543
+ body += replacement.text;
544
+ bodyLine += countNewlines(replacement.text);
545
+ // js line after the replacement: replacements are in js coordinate, so
546
+ // jsLine advances by the replacement's text newlines too.
547
+ jsLine += countNewlines(replacement.text);
358
548
  cursor = replacement.end;
359
549
  }
360
- body += js.slice(cursor);
550
+ const tail = js.slice(cursor);
551
+ body += tail;
552
+ bodyLine += countNewlines(tail);
553
+ // Any body line not covered by a replacement maps identity.
554
+ lineMap.sort((a, b) => a.bodyLine - b.bodyLine);
361
555
 
362
- return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])] };
556
+ return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])], lineMap };
363
557
  }