pi-crew 0.9.41 → 0.9.44

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 (170) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +14 -0
  3. package/dist/build-meta.json +6450 -12419
  4. package/dist/index.mjs +57998 -68417
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/migration/atomic-write-v2-migration.md +2 -0
  7. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  8. package/package.json +4 -2
  9. package/scripts/README.md +6 -0
  10. package/scripts/build-bundle.mjs +2 -1
  11. package/scripts/check-all-skills.ts +3 -3
  12. package/scripts/dev-runner.mjs +106 -0
  13. package/scripts/postinstall.mjs +6 -2
  14. package/scripts/verify-skill.ts +2 -2
  15. package/src/agents/agent-config.ts +1 -1
  16. package/src/config/config.ts +20 -1
  17. package/src/config/drift-detector.ts +97 -15
  18. package/src/config/resilient-parser.ts +4 -4
  19. package/src/config/types.ts +0 -2
  20. package/src/extension/crew-cleanup.ts +17 -3
  21. package/src/extension/crew-vibes/config.ts +1 -1
  22. package/src/extension/crew-vibes/font-detect.ts +16 -3
  23. package/src/extension/crew-vibes/index.ts +1 -1
  24. package/src/extension/cross-extension-rpc.ts +26 -9
  25. package/src/extension/management.ts +6 -5
  26. package/src/extension/register.ts +58 -1573
  27. package/src/extension/registration/command-registration.ts +57 -0
  28. package/src/extension/registration/commands.ts +7 -2
  29. package/src/extension/registration/context-builder.ts +131 -0
  30. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  31. package/src/extension/registration/foreground-run-controller.ts +238 -0
  32. package/src/extension/registration/hook-registration.ts +119 -0
  33. package/src/extension/registration/lazy-configurers.ts +124 -0
  34. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  35. package/src/extension/registration/observability.ts +33 -4
  36. package/src/extension/registration/registration-types.ts +149 -0
  37. package/src/extension/registration/runtime-cleanup.ts +230 -0
  38. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  39. package/src/extension/registration/tool-registration.ts +50 -0
  40. package/src/extension/registration/wire-cross-extension.ts +37 -0
  41. package/src/extension/rpc-hmac.ts +21 -1
  42. package/src/extension/run-import.ts +3 -3
  43. package/src/extension/team-tool/auto-summarize.ts +4 -4
  44. package/src/extension/team-tool/cancel.ts +5 -5
  45. package/src/extension/team-tool/chain-executor.ts +5 -4
  46. package/src/extension/team-tool/context.ts +11 -1
  47. package/src/extension/team-tool/doctor.ts +2 -1
  48. package/src/extension/team-tool/goal-wrap.ts +2 -2
  49. package/src/extension/team-tool/goal.ts +1 -1
  50. package/src/extension/team-tool/handle-schedule.ts +25 -36
  51. package/src/extension/team-tool/orchestrate.ts +0 -1
  52. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  53. package/src/extension/team-tool/run.ts +220 -214
  54. package/src/extension/team-tool/workflow-manage.ts +1 -1
  55. package/src/extension/team-tool.ts +69 -16
  56. package/src/hooks/registry.ts +7 -2
  57. package/src/observability/correlation.ts +4 -3
  58. package/src/observability/event-bus.ts +10 -1
  59. package/src/observability/event-to-metric.ts +8 -7
  60. package/src/observability/exporters/adapter.ts +6 -9
  61. package/src/observability/exporters/otlp-exporter.ts +81 -0
  62. package/src/observability/metric-retention.ts +4 -0
  63. package/src/observability/metric-sink.ts +14 -4
  64. package/src/prompt/prompt-runtime.ts +15 -0
  65. package/src/runtime/adaptive-plan.ts +1 -1
  66. package/src/runtime/async-runner.ts +13 -4
  67. package/src/runtime/background-runner.ts +32 -23
  68. package/src/runtime/capability-inventory.ts +1 -1
  69. package/src/runtime/chain-runner.ts +5 -5
  70. package/src/runtime/checkpoint.ts +6 -16
  71. package/src/runtime/child-pi-constants.ts +42 -0
  72. package/src/runtime/child-pi-kill.ts +180 -0
  73. package/src/runtime/child-pi-spawn.ts +234 -0
  74. package/src/runtime/child-pi-steering.ts +128 -0
  75. package/src/runtime/child-pi-streams.ts +296 -0
  76. package/src/runtime/child-pi-transcript.ts +169 -0
  77. package/src/runtime/child-pi.ts +80 -822
  78. package/src/runtime/coalesce-tasks.ts +1 -1
  79. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  80. package/src/runtime/crash-recovery.ts +63 -47
  81. package/src/runtime/delivery-coordinator.ts +1 -1
  82. package/src/runtime/deterministic-ast.ts +2 -2
  83. package/src/runtime/diagnostic-export.ts +2 -1
  84. package/src/runtime/dwf-state-store.ts +5 -0
  85. package/src/runtime/dynamic-workflow-context.ts +78 -18
  86. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  87. package/src/runtime/foreground-control.ts +3 -2
  88. package/src/runtime/goal-evaluator.ts +59 -27
  89. package/src/runtime/goal-loop-runner.ts +46 -13
  90. package/src/runtime/goal-state-store.ts +1 -1
  91. package/src/runtime/handoff-manager.ts +0 -2
  92. package/src/runtime/intercom-bridge.ts +0 -2
  93. package/src/runtime/live-agent-manager.ts +30 -5
  94. package/src/runtime/live-extension-bridge.ts +0 -2
  95. package/src/runtime/live-session-runtime.ts +65 -57
  96. package/src/runtime/manifest-cache.ts +59 -0
  97. package/src/runtime/mcp-proxy.ts +1 -1
  98. package/src/runtime/orphan-worker-registry.ts +3 -3
  99. package/src/runtime/output-validator.ts +1 -1
  100. package/src/runtime/path-overlap.ts +1 -1
  101. package/src/runtime/pi-args.ts +3 -3
  102. package/src/runtime/pi-json-output.ts +4 -3
  103. package/src/runtime/pi-spawn.ts +7 -1
  104. package/src/runtime/pipeline-runner.ts +1 -4
  105. package/src/runtime/post-checks.ts +0 -1
  106. package/src/runtime/retry-runner.ts +1 -1
  107. package/src/runtime/run-coalesced-task-group.ts +18 -23
  108. package/src/runtime/scheduler.ts +2 -0
  109. package/src/runtime/session-resources.ts +1 -1
  110. package/src/runtime/settings-store.ts +31 -1
  111. package/src/runtime/skill-effectiveness.ts +2 -2
  112. package/src/runtime/stale-reconciler.ts +23 -53
  113. package/src/runtime/subagent-manager.ts +19 -3
  114. package/src/runtime/task-output-context.ts +2 -1
  115. package/src/runtime/task-runner/prompt-builder.ts +3 -3
  116. package/src/runtime/task-runner/run-projection.ts +3 -1
  117. package/src/runtime/task-runner/state-helpers.ts +13 -1
  118. package/src/runtime/task-runner.ts +31 -6
  119. package/src/runtime/team-runner.ts +8 -2
  120. package/src/runtime/verification-gates.ts +54 -5
  121. package/src/schema/config-schema.ts +19 -1
  122. package/src/schema/team-tool-schema.ts +2 -0
  123. package/src/skills/discover-skills.ts +25 -15
  124. package/src/state/active-run-registry.ts +0 -1
  125. package/src/state/atomic-write.ts +37 -11
  126. package/src/state/blob-store.ts +3 -9
  127. package/src/state/contracts.ts +5 -1
  128. package/src/state/crew-init.ts +3 -2
  129. package/src/state/decision-ledger.ts +2 -2
  130. package/src/state/event-log-rotation.ts +1 -1
  131. package/src/state/event-log.ts +4 -3
  132. package/src/state/gitignore-manager.ts +2 -1
  133. package/src/state/health-store.ts +5 -2
  134. package/src/state/instinct-store.ts +11 -2
  135. package/src/state/locks.ts +132 -49
  136. package/src/state/mailbox.ts +3 -4
  137. package/src/state/observation-store.ts +1 -13
  138. package/src/state/run-cache.ts +12 -12
  139. package/src/state/run-graph.ts +2 -1
  140. package/src/state/run-metrics.ts +1 -1
  141. package/src/state/schedule.ts +7 -1
  142. package/src/state/state-store.ts +9 -3
  143. package/src/state/tiered-eval.ts +1 -1
  144. package/src/state/worker-atomic-writer.ts +1 -0
  145. package/src/ui/dynamic-border.ts +13 -3
  146. package/src/ui/live-conversation-overlay.ts +7 -27
  147. package/src/ui/live-duration.ts +2 -2
  148. package/src/ui/live-run-sidebar.ts +56 -32
  149. package/src/ui/mascot.ts +19 -13
  150. package/src/ui/powerbar-publisher.ts +244 -178
  151. package/src/ui/render-coalescer.ts +44 -3
  152. package/src/ui/render-diff.ts +2 -2
  153. package/src/ui/run-dashboard.ts +122 -21
  154. package/src/ui/run-event-bus.ts +66 -0
  155. package/src/ui/run-snapshot-cache.ts +84 -81
  156. package/src/ui/settings-overlay.ts +3 -3
  157. package/src/ui/status-colors.ts +24 -0
  158. package/src/ui/tool-progress-formatter.ts +1 -1
  159. package/src/ui/tool-render.ts +3 -3
  160. package/src/ui/transcript-cache.ts +5 -10
  161. package/src/ui/transcript-entries.ts +68 -72
  162. package/src/ui/transcript-viewer.ts +54 -14
  163. package/src/ui/widget/index.ts +26 -15
  164. package/src/ui/widget/widget-model.ts +18 -9
  165. package/src/ui/widget/widget-renderer.ts +2 -5
  166. package/src/utils/fingerprint.ts +1 -1
  167. package/src/utils/paths.ts +1 -1
  168. package/src/utils/project-detector.ts +1 -1
  169. package/src/worktree/worktree-manager.ts +47 -19
  170. package/src/state/atomic-write-v2.ts +0 -108
@@ -10,7 +10,6 @@ import { atomicWriteJson } from "../../state/atomic-write.ts";
10
10
  import { createRunManifest, loadRunManifestById, updateRunStatus } from "../../state/state-store.ts";
11
11
  import { allTeams, discoverTeams } from "../../teams/discover-teams.ts";
12
12
  import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workflows.ts";
13
- import { validateWorkflowForTeam } from "../../workflows/validate-workflow.ts";
14
13
  import { assertCleanLeaderAsync, findGitRootAsync } from "../../worktree/worktree-manager.ts";
15
14
 
16
15
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type-only import for TS inference
@@ -29,10 +28,10 @@ async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Pro
29
28
  return _cachedExecuteTeamRun(...args);
30
29
  }
31
30
 
32
- import { expandParallelResearchWorkflow } from "../../runtime/parallel-research.ts";
33
31
  import { resolveCrewRuntime, runtimeResolutionState } from "../../runtime/runtime-resolver.ts";
34
- import { normalizeSkillOverride } from "../../runtime/skill-instructions.ts";
35
32
  import { appendEventAsync, readEvents } from "../../state/event-log.ts";
33
+ import type { RunMetrics } from "../../state/run-metrics.ts";
34
+ import type { RuntimeResolutionState, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
36
35
  import { spawnBackgroundTeamRun } from "../../subagents/async-entry.ts";
37
36
 
38
37
  /**
@@ -197,6 +196,182 @@ function resolveAnalysisText(
197
196
  return { text: sanitized, source: "inline" };
198
197
  }
199
198
 
199
+ /**
200
+ * Options for {@link formatRunResult} (EXT-2 refactor).
201
+ */
202
+ export interface FormatRunResultOptions {
203
+ /** Task states for the completed run (used to render the per-task summary in "waited" mode). */
204
+ tasks: TeamTaskState[];
205
+ /** Pre-computed run metrics snapshot; omitted when the run finished before metrics were recorded. */
206
+ metrics: RunMetrics | undefined;
207
+ /** Goal text — truncated to 100 chars in the "waited" banner. */
208
+ goal: string;
209
+ /** Team name (for the banner). */
210
+ team: string;
211
+ /** Workflow name (for the scaffold banner). */
212
+ workflow: string;
213
+ /** Optional workspace identifier (session id / cwd) — surfaced in `details.data` for traceability. */
214
+ workspaceId?: string;
215
+ /** Runtime resolution — required for `mode: "scaffold"`, optional otherwise. */
216
+ runtime?: RuntimeResolutionState;
217
+ /** Output mode:
218
+ * - `"waited"` (default) — detailed per-task summary after `waitForRun` resolved
219
+ * (replaces the former async + foreground completion blocks).
220
+ * - `"scaffold"` — concise "Created pi-crew run" banner for runs that completed
221
+ * without worker execution (scaffold mode / `executeWorkers=false`).
222
+ */
223
+ mode?: "waited" | "scaffold";
224
+ }
225
+
226
+ /**
227
+ * Format the text + {@link PiTeamsToolResult} for a completed team run.
228
+ *
229
+ * Replaces three near-identical inline blocks in `handleRun` (EXT-2 finding):
230
+ * 1. Async-spawned run completion (after `waitForRun`).
231
+ * 2. Foreground-run completion (after `waitForRun`).
232
+ * 3. Scaffold / no-executeWorkers completion (after `executeTeamRun` returns
233
+ * synchronously without launching workers).
234
+ *
235
+ * The output is byte-identical to the prior inline implementations when
236
+ * `workspaceId` is unset. When `workspaceId` is provided it is included in
237
+ * `details.data.workspaceId` so downstream consumers (TUI widgets, audit logs)
238
+ * can attribute the result to the originating session/cwd.
239
+ */
240
+ function formatRunResult(manifest: TeamRunManifest, options: FormatRunResultOptions): PiTeamsToolResult {
241
+ const { tasks, metrics, goal, team, workflow, workspaceId, runtime, mode = "waited" } = options;
242
+
243
+ if (mode === "scaffold") {
244
+ // Scaffold / no-worker banner. `runtime` is required in this mode — callers
245
+ // must provide it so we can render the runtime kind + explanation.
246
+ const runtimeLine = runtime
247
+ ? `Runtime: ${runtime.kind}${runtime.fallback ? ` (fallback from ${runtime.requestedMode})` : ""}${runtime.reason ? ` - ${runtime.reason}` : ""}`
248
+ : "Runtime: unknown";
249
+ const runtimeExplanation = !runtime
250
+ ? ""
251
+ : runtime.kind === "child-process"
252
+ ? "Child Pi worker execution is enabled by default; each task is launched as a separate Pi process. Set runtime.mode=scaffold or executeWorkers=false only for dry runs."
253
+ : runtime.kind === "live-session"
254
+ ? "Experimental live-session worker execution was enabled."
255
+ : "Safe scaffold mode: child Pi workers were not launched because runtime.mode=scaffold or executeWorkers=false was configured.";
256
+ const text = [
257
+ `Created pi-crew run ${manifest.runId}.`,
258
+ `Team: ${team}`,
259
+ `Workflow: ${workflow}`,
260
+ `Status: ${manifest.status}`,
261
+ `Tasks: ${tasks.length}`,
262
+ `State: ${manifest.stateRoot}`,
263
+ `Artifacts: ${manifest.artifactsRoot}`,
264
+ "",
265
+ runtimeLine,
266
+ runtimeExplanation,
267
+ ]
268
+ .filter((line) => line !== "")
269
+ .join("\n");
270
+ const isError = manifest.status === "failed";
271
+ return result(
272
+ text,
273
+ {
274
+ action: "run",
275
+ status: isError ? "error" : "ok",
276
+ runId: manifest.runId,
277
+ artifactsRoot: manifest.artifactsRoot,
278
+ metrics,
279
+ ...(workspaceId ? { data: { workspaceId } } : {}),
280
+ },
281
+ isError,
282
+ );
283
+ }
284
+
285
+ // mode === "waited" — detailed per-task summary.
286
+ const lines: string[] = [`pi-crew run ${manifest.status}: ${manifest.runId} (${team})`, `Goal: ${goal.slice(0, 100)}`];
287
+ if (metrics) {
288
+ lines.push("");
289
+ lines.push(
290
+ `Metrics: ${metrics.completedCount}/${metrics.taskCount} tasks, ${metrics.totalTokens} tokens, ${metrics.durationMs}ms, consistency=${metrics.consistencyScore}`,
291
+ );
292
+ }
293
+
294
+ if (tasks.length > 0) {
295
+ // Read run-level summary artifact if present
296
+ let summaryContent: string | undefined;
297
+ const summaryArtifact = manifest.artifacts?.find((a: { kind?: string }) => a.kind === "summary");
298
+ if (summaryArtifact) {
299
+ try {
300
+ const sumPath = path.join(manifest.artifactsRoot, summaryArtifact.path);
301
+ summaryContent = fs.readFileSync(sumPath, "utf-8").trim().slice(0, 4000);
302
+ } catch {
303
+ /* summary unavailable */
304
+ }
305
+ }
306
+
307
+ const taskLines: string[] = [];
308
+ let failedCount = 0;
309
+ const failedIds: string[] = [];
310
+ for (const task of tasks) {
311
+ let resultExcerpt = "";
312
+ if (task.resultArtifact?.path) {
313
+ try {
314
+ // H-3 fix (code-review 2026-06-23): resolve the result artifact path
315
+ // inside artifactsRoot via the project's safe-path primitive. Rejects
316
+ // absolute paths (/etc/passwd) and ../ traversal that the old
317
+ // path.isAbsolute shortcut + bare path.join allowed.
318
+ const resPath = resolveRealContainedPath(manifest.artifactsRoot, task.resultArtifact.path);
319
+ resultExcerpt = fs.readFileSync(resPath, "utf-8").trim().slice(0, 2000);
320
+ } catch {
321
+ resultExcerpt = "(result unavailable)";
322
+ }
323
+ }
324
+ const shortResult = resultExcerpt.slice(0, 500);
325
+ const statusTag = task.status === "completed" ? "✓" : task.status === "failed" ? "✗" : task.status === "cancelled" ? "⊘" : "·";
326
+ taskLines.push(
327
+ `- ${statusTag} ${task.id} [${task.role}]: ${task.status}${shortResult ? " — " + shortResult : ""}${task.error ? ` | Error: ${task.error.slice(0, 200)}` : ""}`,
328
+ );
329
+ if (task.status === "failed" || task.status === "needs_attention") {
330
+ failedCount++;
331
+ failedIds.push(task.id);
332
+ }
333
+ }
334
+
335
+ lines.push("");
336
+ lines.push(`Tasks (${tasks.length}):`);
337
+ lines.push(...taskLines);
338
+
339
+ if (summaryContent) {
340
+ lines.push("");
341
+ lines.push("Summary:");
342
+ lines.push(summaryContent.slice(0, 2000));
343
+ }
344
+
345
+ if (failedCount === 0) {
346
+ lines.push("");
347
+ lines.push("All tasks completed successfully.");
348
+ } else {
349
+ lines.push("");
350
+ lines.push(`${failedCount} task(s) failed: ${failedIds.join(", ")}. Consider retrying.`);
351
+ }
352
+ } else {
353
+ lines.push(
354
+ manifest.status === "completed"
355
+ ? "Run completed with no task results."
356
+ : `The run ended with status: ${manifest.status}. Check the run artifacts for details.`,
357
+ );
358
+ }
359
+
360
+ const runFailed = manifest.status === "failed" || manifest.status === "blocked";
361
+ return result(
362
+ lines.join("\n"),
363
+ {
364
+ action: "run",
365
+ status: runFailed ? "error" : "ok",
366
+ runId: manifest.runId,
367
+ artifactsRoot: manifest.artifactsRoot,
368
+ metrics,
369
+ ...(workspaceId ? { data: { workspaceId } } : {}),
370
+ },
371
+ runFailed,
372
+ );
373
+ }
374
+
200
375
  export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
201
376
  // CHAIN DISPATCH: runs before goal validation since a chain has no top-level
202
377
  // goal. The injected handleRun reference breaks the run.ts ↔ chain-dispatch.ts
@@ -319,6 +494,17 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
319
494
  // LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { expandParallelResearchWorkflow }` above (issue #28, RFC 17). At call time the module body has fully evaluated, so the dynamic import returns a live binding. Multi-line form breaks scripts/check-lazy-imports.mjs (which does `lines[lineNum - 2]`), so keep destructuring + await import on one line.
320
495
  const { expandParallelResearchWorkflow: expandParallelResearch } = await import("../../runtime/parallel-research.ts");
321
496
  const workflow = directAgent ? baseWorkflow : expandParallelResearch(baseWorkflow, resolvedCtx.cwd);
497
+ const isDynamicWorkflow =
498
+ !directAgent && (workflow as import("../../workflows/workflow-config.ts").WorkflowConfig).runtime === "dynamic";
499
+ const effectiveRunKind = isDynamicWorkflow ? params.runKind : undefined;
500
+ if (params.runKind !== undefined && !isDynamicWorkflow) {
501
+ logInternalError(
502
+ "team-tool.run.runKindIgnored",
503
+ new Error(`Ignoring runKind='${params.runKind}' because workflow '${workflow.name}' is not dynamic.`),
504
+ undefined,
505
+ "warn",
506
+ );
507
+ }
322
508
 
323
509
  // PREFLIGHT (advisory only, since v0.9.15): classify workflow topology and emit
324
510
  // informational notes per the rule in .crew/knowledge.md "pi-crew USAGE THRESHOLD
@@ -396,7 +582,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
396
582
  goal,
397
583
  workspaceMode: params.workspaceMode,
398
584
  ownerSessionId: ctx.sessionId,
399
- runKind: params.runKind,
585
+ runKind: effectiveRunKind,
400
586
  args: params.args,
401
587
  });
402
588
  const goalArtifact = writeArtifact(paths.artifactsRoot, {
@@ -431,7 +617,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
431
617
  // run it via runDynamicWorkflow instead of the static executeTeamRun path. The script
432
618
  // orchestrates subagents via ctx.agent(); only ctx.setResult() reaches the main context.
433
619
  // Placed AFTER manifest creation so runId/paths/artifactsRoot are available.
434
- if (!directAgent && (workflow as import("../../workflows/workflow-config.ts").DynamicWorkflowConfig).runtime === "dynamic") {
620
+ if (isDynamicWorkflow) {
621
+ console.warn(
622
+ `[pi-crew SECURITY] Dynamic workflow '${workflow.name}' executes as trusted Node.js code with full process/require/import access; run only reviewed .dwf.ts files.`,
623
+ );
435
624
  // LAZY: defer dynamic import of ../../runtime/dynamic-workflow-runner.ts to its call site.
436
625
  const { runDynamicWorkflow } = await import("../../runtime/dynamic-workflow-runner.ts");
437
626
  // Re-synthesize a dynamic-team (§0c C9) for role resolution.
@@ -658,97 +847,14 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
658
847
  // Wait for the async run to complete and return actual results.
659
848
  try {
660
849
  const completed = await waitForRun(updatedManifest.runId, resolvedCtx.cwd, { timeoutMs: 3600000 });
661
- const metrics = collectRunMetrics(resolvedCtx.cwd, completed.manifest.runId);
662
- const lines: string[] = [
663
- `pi-crew run ${completed.manifest.status}: ${completed.manifest.runId} (${team.name})`,
664
- `Goal: ${goal.slice(0, 100)}`,
665
- ];
666
- if (metrics) {
667
- lines.push("");
668
- lines.push(
669
- `Metrics: ${metrics.completedCount}/${metrics.taskCount} tasks, ${metrics.totalTokens} tokens, ${metrics.durationMs}ms, consistency=${metrics.consistencyScore}`,
670
- );
671
- }
672
-
673
- if (completed.tasks.length > 0) {
674
- // Read run-level summary artifact if present
675
- let summaryContent: string | undefined;
676
- const summaryArtifact = completed.manifest.artifacts?.find((a: { kind?: string }) => a.kind === "summary");
677
- if (summaryArtifact) {
678
- try {
679
- const sumPath = path.join(completed.manifest.artifactsRoot, summaryArtifact.path);
680
- summaryContent = fs.readFileSync(sumPath, "utf-8").trim().slice(0, 4000);
681
- } catch {
682
- /* summary unavailable */
683
- }
684
- }
685
-
686
- const taskLines: string[] = [];
687
- let failedCount = 0;
688
- const failedIds: string[] = [];
689
- for (const task of completed.tasks) {
690
- let resultExcerpt = "";
691
- if (task.resultArtifact?.path) {
692
- try {
693
- // H-3 fix (code-review 2026-06-23): resolve the result artifact path
694
- // inside artifactsRoot via the project's safe-path primitive. Rejects
695
- // absolute paths (/etc/passwd) and ../ traversal that the old
696
- // path.isAbsolute shortcut + bare path.join allowed.
697
- const resPath = resolveRealContainedPath(completed.manifest.artifactsRoot, task.resultArtifact.path);
698
- resultExcerpt = fs.readFileSync(resPath, "utf-8").trim().slice(0, 2000);
699
- } catch {
700
- resultExcerpt = "(result unavailable)";
701
- }
702
- }
703
- const shortResult = resultExcerpt.slice(0, 500);
704
- const statusTag =
705
- task.status === "completed" ? "✓" : task.status === "failed" ? "✗" : task.status === "cancelled" ? "⊘" : "·";
706
- taskLines.push(
707
- `- ${statusTag} ${task.id} [${task.role}]: ${task.status}${shortResult ? " — " + shortResult : ""}${task.error ? ` | Error: ${task.error.slice(0, 200)}` : ""}`,
708
- );
709
- if (task.status === "failed" || task.status === "needs_attention") {
710
- failedCount++;
711
- failedIds.push(task.id);
712
- }
713
- }
714
-
715
- lines.push("");
716
- lines.push(`Tasks (${completed.tasks.length}):`);
717
- lines.push(...taskLines);
718
-
719
- if (summaryContent) {
720
- lines.push("");
721
- lines.push("Summary:");
722
- lines.push(summaryContent.slice(0, 2000));
723
- }
724
-
725
- if (failedCount === 0) {
726
- lines.push("");
727
- lines.push("All tasks completed successfully.");
728
- } else {
729
- lines.push("");
730
- lines.push(`${failedCount} task(s) failed: ${failedIds.join(", ")}. Consider retrying.`);
731
- }
732
- } else {
733
- lines.push(
734
- completed.manifest.status === "completed"
735
- ? "Run completed with no task results."
736
- : `The run ended with status: ${completed.manifest.status}. Check the run artifacts for details.`,
737
- );
738
- }
739
-
740
- const runFailed = completed.manifest.status === "failed" || completed.manifest.status === "blocked";
741
- return result(
742
- lines.join("\n"),
743
- {
744
- action: "run",
745
- status: runFailed ? "error" : "ok",
746
- runId: completed.manifest.runId,
747
- artifactsRoot: completed.manifest.artifactsRoot,
748
- metrics,
749
- },
750
- runFailed,
751
- );
850
+ return formatRunResult(completed.manifest, {
851
+ tasks: completed.tasks,
852
+ metrics: collectRunMetrics(resolvedCtx.cwd, completed.manifest.runId),
853
+ goal,
854
+ team: team.name,
855
+ workflow: workflow.name,
856
+ workspaceId: ctx.sessionId ?? ctx.cwd,
857
+ });
752
858
  } catch (waitError: unknown) {
753
859
  const errorMessage = waitError instanceof Error ? waitError.message : String(waitError);
754
860
  return result(
@@ -855,97 +961,14 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
855
961
  // Wait for the foreground run to complete and return actual results.
856
962
  try {
857
963
  const completed = await waitForRun(updatedManifest.runId, resolvedCtx.cwd, { timeoutMs: 3600000 });
858
- const metrics = collectRunMetrics(resolvedCtx.cwd, completed.manifest.runId);
859
- const lines: string[] = [
860
- `pi-crew run ${completed.manifest.status}: ${completed.manifest.runId} (${team.name})`,
861
- `Goal: ${goal.slice(0, 100)}`,
862
- ];
863
- if (metrics) {
864
- lines.push("");
865
- lines.push(
866
- `Metrics: ${metrics.completedCount}/${metrics.taskCount} tasks, ${metrics.totalTokens} tokens, ${metrics.durationMs}ms, consistency=${metrics.consistencyScore}`,
867
- );
868
- }
869
-
870
- if (completed.tasks.length > 0) {
871
- // Read run-level summary artifact if present
872
- let summaryContent: string | undefined;
873
- const summaryArtifact = completed.manifest.artifacts?.find((a: { kind?: string }) => a.kind === "summary");
874
- if (summaryArtifact) {
875
- try {
876
- const sumPath = path.join(completed.manifest.artifactsRoot, summaryArtifact.path);
877
- summaryContent = fs.readFileSync(sumPath, "utf-8").trim().slice(0, 4000);
878
- } catch {
879
- /* summary unavailable */
880
- }
881
- }
882
-
883
- const taskLines: string[] = [];
884
- let failedCount = 0;
885
- const failedIds: string[] = [];
886
- for (const task of completed.tasks) {
887
- let resultExcerpt = "";
888
- if (task.resultArtifact?.path) {
889
- try {
890
- // H-3 fix (code-review 2026-06-23): resolve the result artifact path
891
- // inside artifactsRoot via the project's safe-path primitive. Rejects
892
- // absolute paths (/etc/passwd) and ../ traversal that the old
893
- // path.isAbsolute shortcut + bare path.join allowed.
894
- const resPath = resolveRealContainedPath(completed.manifest.artifactsRoot, task.resultArtifact.path);
895
- resultExcerpt = fs.readFileSync(resPath, "utf-8").trim().slice(0, 2000);
896
- } catch {
897
- resultExcerpt = "(result unavailable)";
898
- }
899
- }
900
- const shortResult = resultExcerpt.slice(0, 500);
901
- const statusTag =
902
- task.status === "completed" ? "✓" : task.status === "failed" ? "✗" : task.status === "cancelled" ? "⊘" : "·";
903
- taskLines.push(
904
- `- ${statusTag} ${task.id} [${task.role}]: ${task.status}${shortResult ? " — " + shortResult : ""}${task.error ? ` | Error: ${task.error.slice(0, 200)}` : ""}`,
905
- );
906
- if (task.status === "failed" || task.status === "needs_attention") {
907
- failedCount++;
908
- failedIds.push(task.id);
909
- }
910
- }
911
-
912
- lines.push("");
913
- lines.push(`Tasks (${completed.tasks.length}):`);
914
- lines.push(...taskLines);
915
-
916
- if (summaryContent) {
917
- lines.push("");
918
- lines.push("Summary:");
919
- lines.push(summaryContent.slice(0, 2000));
920
- }
921
-
922
- if (failedCount === 0) {
923
- lines.push("");
924
- lines.push("All tasks completed successfully.");
925
- } else {
926
- lines.push("");
927
- lines.push(`${failedCount} task(s) failed: ${failedIds.join(", ")}. Consider retrying.`);
928
- }
929
- } else {
930
- lines.push(
931
- completed.manifest.status === "completed"
932
- ? "Run completed with no task results."
933
- : `The run ended with status: ${completed.manifest.status}. Check the run artifacts for details.`,
934
- );
935
- }
936
-
937
- const runFailed = completed.manifest.status === "failed" || completed.manifest.status === "blocked";
938
- return result(
939
- lines.join("\n"),
940
- {
941
- action: "run",
942
- status: runFailed ? "error" : "ok",
943
- runId: completed.manifest.runId,
944
- artifactsRoot: completed.manifest.artifactsRoot,
945
- metrics,
946
- },
947
- runFailed,
948
- );
964
+ return formatRunResult(completed.manifest, {
965
+ tasks: completed.tasks,
966
+ metrics: collectRunMetrics(resolvedCtx.cwd, completed.manifest.runId),
967
+ goal,
968
+ team: team.name,
969
+ workflow: workflow.name,
970
+ workspaceId: ctx.sessionId ?? ctx.cwd,
971
+ });
949
972
  } catch (waitError: unknown) {
950
973
  const errorMessage = waitError instanceof Error ? waitError.message : String(waitError);
951
974
  return result(
@@ -998,31 +1021,14 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
998
1021
  } finally {
999
1022
  unregisterActiveRun(updatedManifest.runId);
1000
1023
  }
1001
- const text = [
1002
- `Created pi-crew run ${executed.manifest.runId}.`,
1003
- `Team: ${team.name}`,
1004
- `Workflow: ${workflow.name}`,
1005
- `Status: ${executed.manifest.status}`,
1006
- `Tasks: ${executed.tasks.length}`,
1007
- `State: ${executed.manifest.stateRoot}`,
1008
- `Artifacts: ${executed.manifest.artifactsRoot}`,
1009
- "",
1010
- `Runtime: ${runtime.kind}${runtime.fallback ? ` (fallback from ${runtime.requestedMode})` : ""}${runtime.reason ? ` - ${runtime.reason}` : ""}`,
1011
- runtime.kind === "child-process"
1012
- ? "Child Pi worker execution is enabled by default; each task is launched as a separate Pi process. Set runtime.mode=scaffold or executeWorkers=false only for dry runs."
1013
- : runtime.kind === "live-session"
1014
- ? "Experimental live-session worker execution was enabled."
1015
- : "Safe scaffold mode: child Pi workers were not launched because runtime.mode=scaffold or executeWorkers=false was configured.",
1016
- ].join("\n");
1017
- return result(
1018
- text,
1019
- {
1020
- action: "run",
1021
- status: executed.manifest.status === "failed" ? "error" : "ok",
1022
- runId: executed.manifest.runId,
1023
- artifactsRoot: executed.manifest.artifactsRoot,
1024
- metrics: collectRunMetrics(resolvedCtx.cwd, executed.manifest.runId),
1025
- },
1026
- executed.manifest.status === "failed",
1027
- );
1024
+ return formatRunResult(executed.manifest, {
1025
+ tasks: executed.tasks,
1026
+ metrics: collectRunMetrics(resolvedCtx.cwd, executed.manifest.runId),
1027
+ goal,
1028
+ team: team.name,
1029
+ workflow: workflow.name,
1030
+ workspaceId: ctx.cwd,
1031
+ runtime: runtimeResolutionState(runtime),
1032
+ mode: "scaffold",
1033
+ });
1028
1034
  }
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
- import { basename, dirname, join } from "node:path";
18
+ import { dirname, join } from "node:path";
19
19
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
20
20
  import { logInternalError } from "../../utils/internal-error.ts";
21
21
  import { packageRoot, projectCrewRoot, userPiRoot } from "../../utils/paths.ts";
@@ -8,13 +8,14 @@ import { loadConfig, updateAutonomousConfig, updateConfig } from "../config/conf
8
8
  import type { executeTeamRun as _executeTeamRunFn } from "../runtime/team-runner.ts";
9
9
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
10
10
  import { writeArtifact } from "../state/artifact-store.ts";
11
+ import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
11
12
  import { appendEvent, appendEventFireAndForget } from "../state/event-log.ts";
12
13
  import { withRunLock } from "../state/locks.ts";
13
14
  import { replayPendingMailboxMessages } from "../state/mailbox.ts";
14
15
  import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
15
16
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
16
17
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
17
- import { assertSafePathId } from "../utils/safe-paths.ts";
18
+ import { assertSafePathId, resolveContainedPath } from "../utils/safe-paths.ts";
18
19
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
19
20
  import { piTeamsHelp } from "./help.ts";
20
21
  import { handleCreate, handleDelete, handleUpdate } from "./management.ts";
@@ -250,8 +251,8 @@ async function recoverCheckpointedTasks(
250
251
  if (files.length > 0) {
251
252
  // Sort by attempt index descending to get the most recent
252
253
  files.sort((a, b) => {
253
- const idxA = parseInt(a.match(/\.attempt-(\d+)\./)?.[1] ?? "0");
254
- const idxB = parseInt(b.match(/\.attempt-(\d+)\./)?.[1] ?? "0");
254
+ const idxA = parseInt(a.match(/\.attempt-(\d+)\./)?.[1] ?? "0", 10);
255
+ const idxB = parseInt(b.match(/\.attempt-(\d+)\./)?.[1] ?? "0", 10);
255
256
  return idxB - idxA;
256
257
  });
257
258
  transcriptPath = path.join(transcriptsDir, files[0]);
@@ -501,6 +502,10 @@ export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTe
501
502
  const task = loaded.tasks.find((t) => t.id === taskId);
502
503
  if (!task) return result(`Task '${taskId}' not found`, { action: "steer", status: "error" }, true);
503
504
  if (!task.pendingSteers) task.pendingSteers = [];
505
+ // T-S1: do not allow steering a task that has already reached a terminal status.
506
+ if (TEAM_TERMINAL_TASK_STATUSES.has(task.status)) {
507
+ return result(`Task '${taskId}' is ${task.status}; cannot steer.`, { action: "steer", status: "error" }, true);
508
+ }
504
509
  // HIGH-04: Cap pendingSteers array to prevent unbounded memory growth
505
510
  const MAX_PENDING_STEERS = 100;
506
511
  if (task.pendingSteers.length >= MAX_PENDING_STEERS) {
@@ -523,10 +528,12 @@ export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTe
523
528
  try {
524
529
  const steeringDir = `${loaded.manifest.artifactsRoot}/steering`;
525
530
  fs.mkdirSync(steeringDir, { recursive: true });
526
- fs.appendFileSync(
527
- `${steeringDir}/${taskId}.jsonl`,
528
- JSON.stringify({ type: "steer", message, ts: new Date().toISOString() }) + "\n",
529
- );
531
+ // AUDIT-08 defense-in-depth: validate the steering-file path is contained
532
+ // within steeringDir. taskId is currently sanitized via createTaskId, but this
533
+ // guards against future changes to task-id generation (e.g. if it ever
534
+ // accepted user input).
535
+ const safeSteeringPath = resolveContainedPath(steeringDir, `${taskId}.jsonl`);
536
+ fs.appendFileSync(safeSteeringPath, JSON.stringify({ type: "steer", message, ts: new Date().toISOString() }) + "\n");
530
537
  } catch {
531
538
  // Best-effort: file write failure doesn't block the steer from pending array
532
539
  }
@@ -1042,19 +1049,65 @@ function getCrewGlobalRegistry(): CrewRegistry | undefined {
1042
1049
  return (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] as CrewRegistry | undefined;
1043
1050
  }
1044
1051
 
1045
- /** Create and install the global CrewRegistry singleton. Call once at extension init. */
1046
- export function installCrewGlobalRegistry(): void {
1047
- registerCrewGlobalRegistry({
1052
+ /** Manifest cache shape needed to construct the global registry's read-side. */
1053
+ interface ManifestCacheForRegistry {
1054
+ get: (runId: string) => TeamRunManifest | undefined;
1055
+ list: (limit: number) => TeamRunManifest[];
1056
+ }
1057
+
1058
+ /**
1059
+ * Build and install the global CrewRegistry singleton in a single atomic step.
1060
+ *
1061
+ * EXT-7 (Round 3): The previous design called `installCrewGlobalRegistry()` to
1062
+ * install stubs, then patched the manifest-backed methods asynchronously inside
1063
+ * `register.ts`. That left a window where cross-extension consumers could observe
1064
+ * the stub object on `globalThis[Symbol.for("pi-crew:registry")]`. By taking the
1065
+ * real dependencies up-front, we install the registry once with no stub phase —
1066
+ * callers see either no registry (pre-init) or the fully-real registry.
1067
+ */
1068
+ export function installCrewGlobalRegistry(deps?: { manifestCache: ManifestCacheForRegistry; cwdProvider: () => string }): void {
1069
+ const manifestCache = deps?.manifestCache;
1070
+ const cwdProvider = deps?.cwdProvider ?? ((): string => process.cwd());
1071
+ const registry: CrewRegistry = {
1048
1072
  version: 2,
1049
- getRecord: (runId: string) => undefined as unknown as TeamRunManifest,
1050
- listRuns: () => [],
1051
- appendEvent: () => {},
1052
- waitForAll: async () => {},
1053
- hasRunning: () => false,
1073
+ getRecord: (runId: string) => manifestCache?.get(runId),
1074
+ listRuns: () =>
1075
+ manifestCache
1076
+ ? manifestCache.list(100).map((m) => ({ runId: m.runId, status: m.status, goal: m.goal }))
1077
+ : ([] as Array<{ runId: string; status: string; goal: string }>),
1078
+ appendEvent: (runId: string, event: Record<string, unknown>) => {
1079
+ if (!manifestCache) return;
1080
+ const manifest = manifestCache.get(runId);
1081
+ if (manifest) {
1082
+ // LAZY: event-log is already loaded at module top, so use the
1083
+ // pre-resolved appendEventFireAndForget instead of re-importing.
1084
+ appendEventFireAndForget(manifest.eventsPath, event as Parameters<typeof appendEventFireAndForget>[1]);
1085
+ }
1086
+ },
1087
+ waitForAll: async (runId: string) => {
1088
+ if (!manifestCache) return;
1089
+ // LAZY: state-store is already loaded at module top; use the pre-resolved loadRunManifestById.
1090
+ const check = (): boolean => {
1091
+ const loaded = loadRunManifestById(cwdProvider(), runId);
1092
+ if (!loaded) return true;
1093
+ return !loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
1094
+ };
1095
+ while (!check()) await new Promise((resolve) => setTimeout(resolve, 500));
1096
+ },
1097
+ hasRunning: (runId: string) => {
1098
+ if (!manifestCache) return false;
1099
+ const manifest = manifestCache.get(runId);
1100
+ if (!manifest) return false;
1101
+ // LAZY: state-store is already loaded at module top; use the pre-resolved loadRunManifestById.
1102
+ const loaded = loadRunManifestById(cwdProvider(), runId);
1103
+ if (!loaded) return false;
1104
+ return loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
1105
+ },
1054
1106
  registerAgent: registerDynamicAgent,
1055
1107
  unregisterAgent: unregisterDynamicAgent,
1056
1108
  listDynamicAgents,
1057
- });
1109
+ };
1110
+ registerCrewGlobalRegistry(registry);
1058
1111
  }
1059
1112
 
1060
1113
  /** Remove the global CrewRegistry singleton. Call during session cleanup. */
@@ -141,8 +141,13 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
141
141
  };
142
142
  }
143
143
  if (result.outcome === "modify" && result.data) {
144
- Object.assign(ctx, sanitizeMergeData(result.data));
145
- capturedModifications = { ...result.data };
144
+ // EXT-13 (Round 3): Deep-clone hook result.data so subsequent hooks
145
+ // (or the same hook if reused) cannot mutate shared ctx via shared
146
+ // nested-object references. sanitizeMergeData already produces a
147
+ // fresh top-level object, but nested values would still alias.
148
+ const clonedData = sanitizeMergeData(result.data);
149
+ Object.assign(ctx, clonedData);
150
+ capturedModifications = clonedData;
146
151
  }
147
152
  } catch (error) {
148
153
  const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));