pi-crew 0.9.40 → 0.9.42

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 (150) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/build-meta.json +7089 -6728
  3. package/dist/index.mjs +45845 -45192
  4. package/dist/index.mjs.map +4 -4
  5. package/docs/migration/atomic-write-v2-migration.md +2 -0
  6. package/docs/optimization-plan-2026-07.md +366 -0
  7. package/docs/phase4-triage.md +58 -0
  8. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  9. package/package.json +4 -2
  10. package/scripts/README.md +6 -0
  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/config/config.ts +19 -0
  16. package/src/config/drift-detector.ts +97 -15
  17. package/src/config/resilient-parser.ts +4 -4
  18. package/src/extension/crew-cleanup.ts +17 -3
  19. package/src/extension/crew-vibes/index.ts +1 -1
  20. package/src/extension/cross-extension-rpc.ts +18 -5
  21. package/src/extension/management.ts +6 -5
  22. package/src/extension/register.ts +58 -1573
  23. package/src/extension/registration/command-registration.ts +57 -0
  24. package/src/extension/registration/commands.ts +2 -1
  25. package/src/extension/registration/context-builder.ts +131 -0
  26. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  27. package/src/extension/registration/foreground-run-controller.ts +210 -0
  28. package/src/extension/registration/hook-registration.ts +119 -0
  29. package/src/extension/registration/lazy-configurers.ts +124 -0
  30. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  31. package/src/extension/registration/observability.ts +33 -4
  32. package/src/extension/registration/registration-types.ts +149 -0
  33. package/src/extension/registration/runtime-cleanup.ts +230 -0
  34. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  35. package/src/extension/registration/tool-registration.ts +50 -0
  36. package/src/extension/registration/wire-cross-extension.ts +37 -0
  37. package/src/extension/rpc-hmac.ts +21 -1
  38. package/src/extension/run-import.ts +3 -3
  39. package/src/extension/team-tool/auto-summarize.ts +4 -4
  40. package/src/extension/team-tool/cancel.ts +5 -5
  41. package/src/extension/team-tool/chain-executor.ts +5 -4
  42. package/src/extension/team-tool/context.ts +11 -1
  43. package/src/extension/team-tool/doctor.ts +2 -1
  44. package/src/extension/team-tool/goal-wrap.ts +2 -2
  45. package/src/extension/team-tool/goal.ts +1 -1
  46. package/src/extension/team-tool/handle-schedule.ts +25 -36
  47. package/src/extension/team-tool/orchestrate.ts +0 -1
  48. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  49. package/src/extension/team-tool/run.ts +220 -214
  50. package/src/extension/team-tool/workflow-manage.ts +1 -1
  51. package/src/extension/team-tool.ts +64 -16
  52. package/src/hooks/registry.ts +7 -2
  53. package/src/observability/correlation.ts +4 -3
  54. package/src/observability/event-bus.ts +10 -1
  55. package/src/observability/event-to-metric.ts +8 -7
  56. package/src/observability/exporters/adapter.ts +6 -9
  57. package/src/observability/exporters/otlp-exporter.ts +81 -0
  58. package/src/observability/metric-retention.ts +4 -0
  59. package/src/observability/metric-sink.ts +14 -4
  60. package/src/runtime/adaptive-plan.ts +8 -8
  61. package/src/runtime/async-runner.ts +13 -4
  62. package/src/runtime/background-runner.ts +32 -23
  63. package/src/runtime/capability-inventory.ts +1 -1
  64. package/src/runtime/chain-runner.ts +5 -5
  65. package/src/runtime/checkpoint.ts +6 -16
  66. package/src/runtime/child-pi.ts +157 -71
  67. package/src/runtime/coalesce-tasks.ts +1 -1
  68. package/src/runtime/crash-recovery.ts +63 -47
  69. package/src/runtime/delivery-coordinator.ts +1 -1
  70. package/src/runtime/deterministic-ast.ts +2 -2
  71. package/src/runtime/diagnostic-export.ts +2 -1
  72. package/src/runtime/foreground-control.ts +3 -2
  73. package/src/runtime/goal-loop-runner.ts +6 -2
  74. package/src/runtime/goal-state-store.ts +1 -1
  75. package/src/runtime/handoff-manager.ts +0 -2
  76. package/src/runtime/heartbeat-watcher.ts +5 -0
  77. package/src/runtime/intercom-bridge.ts +0 -2
  78. package/src/runtime/live-agent-manager.ts +12 -32
  79. package/src/runtime/live-extension-bridge.ts +0 -2
  80. package/src/runtime/live-session-runtime.ts +65 -57
  81. package/src/runtime/manifest-cache.ts +59 -0
  82. package/src/runtime/mcp-proxy.ts +1 -1
  83. package/src/runtime/orphan-worker-registry.ts +3 -3
  84. package/src/runtime/output-validator.ts +1 -1
  85. package/src/runtime/path-overlap.ts +1 -1
  86. package/src/runtime/pi-args.ts +3 -3
  87. package/src/runtime/pi-json-output.ts +2 -2
  88. package/src/runtime/pipeline-runner.ts +1 -4
  89. package/src/runtime/post-checks.ts +0 -1
  90. package/src/runtime/retry-runner.ts +1 -1
  91. package/src/runtime/run-coalesced-task-group.ts +0 -13
  92. package/src/runtime/scheduler.ts +2 -0
  93. package/src/runtime/session-resources.ts +1 -1
  94. package/src/runtime/settings-store.ts +31 -1
  95. package/src/runtime/skill-effectiveness.ts +2 -2
  96. package/src/runtime/stale-reconciler.ts +23 -53
  97. package/src/runtime/subagent-manager.ts +19 -3
  98. package/src/runtime/task-output-context.ts +2 -1
  99. package/src/runtime/task-runner/prompt-builder.ts +1 -1
  100. package/src/runtime/task-runner/run-projection.ts +3 -1
  101. package/src/runtime/task-runner/state-helpers.ts +13 -1
  102. package/src/runtime/task-runner.ts +31 -6
  103. package/src/runtime/team-runner.ts +3 -2
  104. package/src/runtime/verification-gates.ts +54 -5
  105. package/src/schema/config-schema.ts +19 -1
  106. package/src/schema/team-tool-schema.ts +2 -0
  107. package/src/skills/discover-skills.ts +25 -15
  108. package/src/state/active-run-registry.ts +0 -1
  109. package/src/state/atomic-write.ts +37 -11
  110. package/src/state/blob-store.ts +3 -9
  111. package/src/state/contracts.ts +5 -1
  112. package/src/state/crew-init.ts +3 -2
  113. package/src/state/decision-ledger.ts +2 -2
  114. package/src/state/event-log-rotation.ts +1 -1
  115. package/src/state/event-log.ts +3 -3
  116. package/src/state/gitignore-manager.ts +2 -1
  117. package/src/state/health-store.ts +5 -2
  118. package/src/state/instinct-store.ts +11 -2
  119. package/src/state/mailbox.ts +3 -4
  120. package/src/state/observation-store.ts +1 -13
  121. package/src/state/run-cache.ts +12 -12
  122. package/src/state/run-graph.ts +2 -1
  123. package/src/state/run-metrics.ts +1 -1
  124. package/src/state/schedule.ts +7 -1
  125. package/src/state/state-store.ts +9 -3
  126. package/src/state/tiered-eval.ts +1 -1
  127. package/src/ui/dynamic-border.ts +13 -3
  128. package/src/ui/live-conversation-overlay.ts +7 -27
  129. package/src/ui/live-duration.ts +2 -2
  130. package/src/ui/live-run-sidebar.ts +56 -32
  131. package/src/ui/mascot.ts +19 -13
  132. package/src/ui/powerbar-publisher.ts +244 -178
  133. package/src/ui/render-coalescer.ts +44 -3
  134. package/src/ui/render-diff.ts +2 -2
  135. package/src/ui/run-dashboard.ts +122 -21
  136. package/src/ui/run-event-bus.ts +66 -0
  137. package/src/ui/run-snapshot-cache.ts +84 -81
  138. package/src/ui/settings-overlay.ts +3 -3
  139. package/src/ui/status-colors.ts +24 -0
  140. package/src/ui/tool-progress-formatter.ts +1 -1
  141. package/src/ui/tool-render.ts +3 -3
  142. package/src/ui/transcript-cache.ts +5 -10
  143. package/src/ui/transcript-entries.ts +68 -72
  144. package/src/ui/transcript-viewer.ts +54 -14
  145. package/src/ui/widget/index.ts +26 -15
  146. package/src/ui/widget/widget-model.ts +18 -9
  147. package/src/ui/widget/widget-renderer.ts +2 -5
  148. package/src/utils/fingerprint.ts +1 -1
  149. package/src/utils/project-detector.ts +1 -1
  150. package/src/state/atomic-write-v2.ts +0 -108
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { errors } from "../errors.ts";
5
- import { atomicWriteJson } from "../state/atomic-write.ts";
5
+ import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
6
6
  import { saveRunManifest } from "../state/state-store.ts";
7
7
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
8
8
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
@@ -152,29 +152,32 @@ function checkPidLiveness(
152
152
  }
153
153
  // If process is alive per kill(0), we're done.
154
154
  if (liveness.alive) return { alive: true, detail: liveness.detail };
155
- // Process is dead per kill(0). Check heartbeat as corroborating evidence.
155
+ // Process is dead per kill(0). ESRCH is AUTHORITATIVE — the PID does not exist.
156
+ // A frozen/zombie process still has a PID (kill(0) returns 0, or EPERM if we lack
157
+ // permission to signal it), so reaching here with alive=false means the process
158
+ // is genuinely gone. PID recycling is already handled above via the startTime
159
+ // TOCTOU check, so the heartbeat file must NOT override the dead verdict.
160
+ //
161
+ // HISTORY: a <5min-old heartbeat.json previously OVERRRODE this verdict (returned
162
+ // alive:true "process dead but heartbeat Xs old"), delaying repair by ~5min.
163
+ // Combined with the 60s reconcile cadence this produced a ~300-360s detection lag
164
+ // for dead workers (observed 353s in production). The heartbeat is now used only
165
+ // to enrich the detail string, never to override the authoritative kill(0) verdict.
166
+ let heartbeatNote = "";
156
167
  if (stateRoot) {
157
- const heartbeatPath = path.join(stateRoot, "heartbeat.json");
158
168
  try {
169
+ const heartbeatPath = path.join(stateRoot, "heartbeat.json");
159
170
  if (fs.existsSync(heartbeatPath)) {
160
171
  const hb = JSON.parse(fs.readFileSync(heartbeatPath, "utf-8")) as { pid?: number; at?: number };
161
172
  if (hb?.pid === pid && hb?.at) {
162
- const ageMs = Date.now() - hb.at;
163
- // Heartbeat written < 5 min ago → process was alive recently.
164
- // Don't repair yet; let the next reconciliation cycle catch it.
165
- if (ageMs < 5 * 60_000) {
166
- return {
167
- alive: true,
168
- detail: `process dead but heartbeat ${Math.round(ageMs / 1000)}s old`,
169
- };
170
- }
173
+ heartbeatNote = ` (heartbeat was ${Math.round((Date.now() - hb.at) / 1000)}s old)`;
171
174
  }
172
175
  }
173
176
  } catch {
174
177
  /* ignore — best-effort */
175
178
  }
176
179
  }
177
- return { alive: liveness.alive, detail: liveness.detail };
180
+ return { alive: false, detail: `${liveness.detail}${heartbeatNote}` };
178
181
  }
179
182
 
180
183
  /**
@@ -267,11 +270,6 @@ function buildStaleReconcileError(task: TeamTaskState, reason: string): Error {
267
270
  return errors.runStale(reason, heartbeatAgeSeconds);
268
271
  }
269
272
 
270
- /** @deprecated use buildStaleReconcileError (returns a structured CrewError). Kept for any external callers. */
271
- function formatStaleReconcileError(task: TeamTaskState, reason: string): string {
272
- return buildStaleReconcileError(task, reason).message;
273
- }
274
-
275
273
  function repairStaleRun(manifest: TeamRunManifest, tasks: TeamTaskState[], reason: string): TeamTaskState[] {
276
274
  const now = new Date().toISOString();
277
275
  const repairedTasks = tasks.map((task) => {
@@ -592,9 +590,13 @@ export function reconcileOrphanedTempWorkspaces(
592
590
  if (!fs.existsSync(workspaceDir)) {
593
591
  fs.mkdirSync(workspaceDir, { recursive: true });
594
592
  }
595
- fs.writeFileSync(sentinelPath, JSON.stringify({ startedAt: now }), {
596
- flag: "wx",
597
- });
593
+ // Atomic exclusive-create: reserve the sentinel (fails with EEXIST if
594
+ // another cleanup is in progress), then fill it atomically. The brief
595
+ // empty-file window is safe — the sentinel "exists" (correctly
596
+ // signalling in-progress cleanup).
597
+ const sentinelFd = fs.openSync(sentinelPath, "wx");
598
+ fs.closeSync(sentinelFd);
599
+ atomicWriteFile(sentinelPath, JSON.stringify({ startedAt: now }));
598
600
  } catch {
599
601
  // Sentinel already exists (another cleanup in progress) — skip
600
602
  canCleanup = false;
@@ -661,38 +663,6 @@ export function reconcileOrphanedTempWorkspaces(
661
663
  } catch {
662
664
  /* ignore if already gone */
663
665
  }
664
- } else if (canCleanup) {
665
- // Sentinel already existed — another cleanup attempt is in progress.
666
- // Perform a simplified TOCTOU check: if any manifest is running,
667
- // do NOT remove the sentinel (let the first attempt finish its scan).
668
- let hasRunningManifest = false;
669
- if (fs.existsSync(stateRunsDir)) {
670
- try {
671
- for (const runDir of fs.readdirSync(stateRunsDir)) {
672
- const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
673
- if (!fs.existsSync(manifestPath)) continue;
674
- try {
675
- const m = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as TeamRunManifest;
676
- if (m.status === "running") {
677
- hasRunningManifest = true;
678
- break;
679
- }
680
- } catch {
681
- /* skip on parse error */
682
- }
683
- }
684
- } catch {
685
- /* skip if runs dir unreadable */
686
- }
687
- }
688
- // Only clean up sentinel if no running manifests exist
689
- if (!hasRunningManifest) {
690
- try {
691
- fs.unlinkSync(sentinelPath);
692
- } catch {
693
- /* ignore if already gone */
694
- }
695
- }
696
666
  }
697
667
  }
698
668
  } catch {
@@ -439,13 +439,29 @@ export class SubagentManager {
439
439
  private drainQueue(): void {
440
440
  while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
441
441
  const next = this.queue.shift();
442
- if (!next || next.record.status !== "queued") continue;
442
+ if (next?.record.status !== "queued") continue;
443
443
  this.start(next.record, next.options, next.runner, next.signal);
444
444
  }
445
445
  }
446
446
 
447
447
  private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
448
+ // Safety: max 30 minutes (1800 polls at 1s interval) to prevent infinite
449
+ // polling if manifest file is deleted or run state becomes unrecoverable.
450
+ const MAX_POLL_COUNT = 1800;
451
+ let pollCount = 0;
448
452
  while (record.runId && (record.status === "running" || record.status === "blocked")) {
453
+ if (++pollCount > MAX_POLL_COUNT) {
454
+ logInternalError(
455
+ "subagent-manager.poll-timeout",
456
+ new Error(`pollRunToTerminal exceeded ${MAX_POLL_COUNT} polls for runId=${record.runId}`),
457
+ `id=${record.id}`,
458
+ );
459
+ record.status = "error";
460
+ record.error = `Poll timeout: run did not reach terminal state after ${MAX_POLL_COUNT} seconds`;
461
+ record.completedAt = Date.now();
462
+ savePersistedSubagentRecord(cwd, record);
463
+ return;
464
+ }
449
465
  const loaded = loadRunManifestById(cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
450
466
  if (!loaded) {
451
467
  await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
@@ -488,7 +504,7 @@ export class SubagentManager {
488
504
  private scheduleBlockedTerminalPoll(cwd: string, record: SubagentRecord): void {
489
505
  const poll = (): void => {
490
506
  const current = this.records.get(record.id);
491
- if (!current || current.status !== "blocked" || !current.runId) return;
507
+ if (current?.status !== "blocked" || !current.runId) return;
492
508
  const loaded = loadRunManifestById(cwd, current.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
493
509
  if (
494
510
  !loaded ||
@@ -524,7 +540,7 @@ export class SubagentManager {
524
540
  const threshold = DEFAULT_SUBAGENT.stuckBlockedNotifyMs;
525
541
  const fire = (): void => {
526
542
  const current = this.records.get(record.id);
527
- if (!current || current.status !== "blocked" || !current.blockedAt || current.stuckNotified) return;
543
+ if (current?.status !== "blocked" || !current.blockedAt || current.stuckNotified) return;
528
544
  current.stuckNotified = true;
529
545
  this.onEvent?.("subagent.stuck-blocked", {
530
546
  event: "subagent.stuck-blocked",
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { DEFAULT_OUTPUT_CONTEXT } from "../config/defaults.ts";
4
4
  import { writeArtifact } from "../state/artifact-store.ts";
5
+ import { atomicWriteFile } from "../state/atomic-write.ts";
5
6
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
6
7
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
7
8
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
@@ -155,7 +156,7 @@ export function teePathForArtifact(artifactsRoot: string, taskId: string, artifa
155
156
  function writeTeeFile(fullOutputPath: string, content: string): boolean {
156
157
  try {
157
158
  fs.mkdirSync(path.dirname(fullOutputPath), { recursive: true });
158
- fs.writeFileSync(fullOutputPath, content, "utf-8");
159
+ atomicWriteFile(fullOutputPath, content);
159
160
  return true;
160
161
  } catch {
161
162
  return false;
@@ -14,7 +14,7 @@ import { renderSuggestedFilesSection, runRetrievalCycle } from "./retrieval-orch
14
14
  * filtering at the Pi level is a future optimisation (Phase 3.2+).
15
15
  */
16
16
  export function toolGuidanceBlock(agent?: AgentConfig): string {
17
- if (!agent || agent.loadMode !== "lean" || !agent.defaultTools?.length) return "";
17
+ if (agent?.loadMode !== "lean" || !agent.defaultTools?.length) return "";
18
18
  return [
19
19
  "# Tool Guidance",
20
20
  `This role uses a focused tool set. Preferred tools: ${agent.defaultTools.join(", ")}.`,
@@ -1,5 +1,6 @@
1
1
  import type { MailboxMessage } from "../../state/mailbox.ts";
2
2
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
3
+ import { sanitizeTaskText } from "../task-packet.ts";
3
4
 
4
5
  export interface RunProjectionSource {
5
6
  kind: "events" | "mailbox" | "artifacts" | "ui_metadata" | "runtime_metadata";
@@ -64,7 +65,8 @@ export function transformRunContextBeforeWorkerStart(input: {
64
65
  });
65
66
  lines.push(`Pending messages (${messages.length}/${input.pendingMailbox.length}):`);
66
67
  for (const msg of messages) {
67
- lines.push(`- ${msg.kind ?? "message"}: ${msg.body.slice(0, 100)}`);
68
+ const safeBody = sanitizeTaskText(msg.body.slice(0, 100));
69
+ lines.push(`- ${msg.kind ?? "message"}: ${safeBody}`);
68
70
  }
69
71
  }
70
72
 
@@ -24,6 +24,14 @@ export function updateTask(tasks: TeamTaskState[], updated: TeamTaskState): Team
24
24
  * committed first; retry with the fresh state. This is O(retry) under contention but
25
25
  * converges in the normal single-writer case.
26
26
  *
27
+ * ST-7: pass `skipCoalesce: true` when persisting a TERMINAL task status
28
+ * (completed/failed/cancelled/needs_attention/skipped). Without this the
29
+ * underlying `saveRunTasksCoalesced` would buffer the write for 50ms; a
30
+ * SIGKILL in that window loses the terminal update and crash recovery
31
+ * would see "running" in tasks.json while events.jsonl already shows
32
+ * "completed". For non-terminal transitions (heartbeat, progress) the
33
+ * default buffered write is fine and matches prior behavior.
34
+ *
27
35
  * @param checkpointPhase - Optional checkpoint phase to include in the task state alongside the update.
28
36
  */
29
37
  export function persistSingleTaskUpdate(
@@ -31,6 +39,7 @@ export function persistSingleTaskUpdate(
31
39
  fallbackTasks: TeamTaskState[],
32
40
  updated: TeamTaskState,
33
41
  checkpointPhase?: TaskCheckpointState["phase"],
42
+ skipCoalesce: boolean = false,
34
43
  ): TeamTaskState[] {
35
44
  const MAX_CAS_ATTEMPTS = 100;
36
45
  let baseMtime = 0;
@@ -126,7 +135,10 @@ export function persistSingleTaskUpdate(
126
135
  // non-coalesced writers; the flushPendingAtomicWrites() guard at
127
136
  // the top of the retry loop ensures reads see any other coalesced
128
137
  // writer's flushed-before-this-call state.
129
- saveRunTasksCoalesced(manifest, merged);
138
+ // ST-7: terminal transitions (skipCoalesce=true) bypass the 50ms
139
+ // coalesce window so a SIGKILL after the persist completes cannot
140
+ // leave tasks.json stale with a non-terminal status.
141
+ saveRunTasksCoalesced(manifest, merged, skipCoalesce);
130
142
  } catch (err) {
131
143
  logInternalError("persistSingleTaskUpdate", err, undefined, "error");
132
144
  throw err;
@@ -15,11 +15,10 @@ import type {
15
15
  OperationTerminalEvidence,
16
16
  TeamRunManifest,
17
17
  TeamTaskState,
18
- UsageState,
19
18
  VerificationEvidence,
20
19
  } from "../state/types.ts";
21
20
  import { logInternalError } from "../utils/internal-error.ts";
22
- import { resolveRealContainedPath } from "../utils/safe-paths.ts";
21
+ import { resolveContainedPath, resolveRealContainedPath } from "../utils/safe-paths.ts";
23
22
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
24
23
  import { captureWorktreeDiffAsync, captureWorktreeDiffStatAsync, prepareTaskWorkspaceAsync } from "../worktree/worktree-manager.ts";
25
24
  import { reserveControlChannel } from "./agent-control.ts";
@@ -35,7 +34,7 @@ import {
35
34
  recordFromTask,
36
35
  upsertCrewAgent,
37
36
  } from "./crew-agent-records.ts";
38
- import type { CrewAgentProgress, CrewRuntimeKind } from "./crew-agent-runtime.ts";
37
+ import type { CrewRuntimeKind } from "./crew-agent-runtime.ts";
39
38
  import { crewHooks } from "./crew-hooks.ts";
40
39
  import { bridgeEventFromJsonEvent, registerStreamBridge } from "./event-stream-bridge.ts";
41
40
  import { createVerificationEvidence } from "./green-contract.ts";
@@ -87,7 +86,7 @@ registerYieldTool();
87
86
  async function appendSteeringAsync(steeringDir: string, taskId: string, steers: string[]): Promise<void> {
88
87
  try {
89
88
  await fs.promises.mkdir(steeringDir, { recursive: true });
90
- const steeringPath = `${steeringDir}/${taskId}.jsonl`;
89
+ const steeringPath = resolveContainedPath(steeringDir, `${taskId}.jsonl`);
91
90
  const lines = steers
92
91
  .map(
93
92
  (msg) =>
@@ -254,10 +253,18 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
254
253
  if (input.step.preStepScript) {
255
254
  const scriptTimeout = input.step.preStepTimeout ?? 30_000;
256
255
  const scriptArgs = input.step.preStepArgs ?? [];
256
+ appendEventFireAndForget(manifest.eventsPath, {
257
+ type: "hook.pre_step_started",
258
+ runId: manifest.runId,
259
+ taskId: task.id,
260
+ data: { script: input.step.preStepScript, argCount: scriptArgs.length, timeoutMs: scriptTimeout },
261
+ });
257
262
  // SECURITY (M-1 fix, code-review 2026-06-23): use the project's safe-path
258
263
  // primitive instead of a hand-rolled path.resolve + startsWith check.
259
264
  // The lexical check passed a symlinked ancestor, letting execFileSync
260
265
  // follow it and execute a script outside cwd. Throws on escape.
266
+ // Keep validation outside the optional-execution catch: preStepOptional
267
+ // must never bypass a path-containment failure.
261
268
  resolveRealContainedPath(manifest.cwd, input.step.preStepScript);
262
269
  try {
263
270
  // LAZY: defer dynamic import of node:child_process to its call site.
@@ -268,9 +275,22 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
268
275
  cwd: manifest.cwd,
269
276
  maxBuffer: 1024 * 1024, // 1MB cap
270
277
  });
278
+ appendEventFireAndForget(manifest.eventsPath, {
279
+ type: "hook.pre_step_completed",
280
+ runId: manifest.runId,
281
+ taskId: task.id,
282
+ data: { script: input.step.preStepScript, outputBytes: Buffer.byteLength(preStepOutput, "utf8") },
283
+ });
271
284
  } catch (err) {
272
285
  const msg = err instanceof Error ? err.message : String(err);
273
286
  const exitCode = (err as NodeJS.ErrnoException & { status?: number }).status;
287
+ appendEventFireAndForget(manifest.eventsPath, {
288
+ type: "hook.pre_step_failed",
289
+ runId: manifest.runId,
290
+ taskId: task.id,
291
+ message: `pre-step hook failed (exit ${exitCode ?? "?"})`,
292
+ data: { script: input.step.preStepScript, exitCode: exitCode ?? null, optional: input.step.preStepOptional === true },
293
+ });
274
294
  // E1 (Round 15): structured CrewError with code E009 + help hint,
275
295
  // instead of a raw Error. Surfaces the script path, exit code, and stderr.
276
296
  // Round 21 (E4): if preStepOptional is set, a failing hook is NON-FATAL.
@@ -484,7 +504,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
484
504
  runId: manifest.runId,
485
505
  agentId: task.id,
486
506
  artifactsRoot: manifest.artifactsRoot,
487
- steeringFile: `${manifest.artifactsRoot}/steering/${task.id}.jsonl`,
507
+ steeringFile: resolveContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
488
508
  onSpawn: (pid) => {
489
509
  try {
490
510
  ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
@@ -1222,9 +1242,14 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
1222
1242
  // stale manifest and overwrite this task's freshly-written artifacts, silently
1223
1243
  // losing them. persistSingleTaskUpdate is re-entrance-safe (runLockHeldByUs guard),
1224
1244
  // so nesting it inside this lock is a no-op re-acquire, not a deadlock.
1245
+ // ST-7: this is a terminal transition (task.status is completed/failed/needs_attention),
1246
+ // pass skipCoalesce=true so the terminal update is durable — a SIGKILL in the
1247
+ // 50ms coalesce window must NOT leave tasks.json showing the prior non-terminal
1248
+ // status, otherwise crash recovery would see inconsistency between tasks.json
1249
+ // (running) and events.jsonl (terminal).
1225
1250
  tasks = await withRunLock(manifest, async () => {
1226
1251
  await saveRunManifestAsync(manifest);
1227
- return persistSingleTaskUpdate(manifest, tasks, task);
1252
+ return persistSingleTaskUpdate(manifest, tasks, task, undefined, true);
1228
1253
  });
1229
1254
  upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
1230
1255
  // Execute task_result hook before emitting terminal event
@@ -9,6 +9,7 @@ import type { MetricRegistry } from "../observability/metric-registry.ts";
9
9
  import { PluginRegistry } from "../plugins/plugin-registry.ts";
10
10
  import { NextJsPlugin, VitePlugin, VitestPlugin } from "../plugins/plugins/index.ts";
11
11
  import { hashArtifactContent as hashContent, writeArtifact } from "../state/artifact-store.ts";
12
+ import { atomicWriteFile } from "../state/atomic-write.ts";
12
13
  import { appendEvent, appendEventAsync, appendEventBuffered, appendEventFireAndForget, flushEventLogBuffer } from "../state/event-log.ts";
13
14
  import { HealthStore } from "../state/health-store.ts";
14
15
  import { withRunLock } from "../state/locks.ts";
@@ -84,7 +85,7 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
84
85
  // captured manifest.updatedAt once at startup, making the value
85
86
  // permanently stale throughout the run.
86
87
  const now = new Date().toISOString();
87
- fs.writeFileSync(
88
+ atomicWriteFile(
88
89
  heartbeatPath,
89
90
  JSON.stringify({
90
91
  pid: process.pid,
@@ -93,7 +94,7 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
93
94
  kind: "team-runner",
94
95
  lastTaskUpdateAt: now,
95
96
  }),
96
- { encoding: "utf-8", mode: 0o600 },
97
+ { mode: 0o600 },
97
98
  );
98
99
  } catch {
99
100
  // best-effort
@@ -13,7 +13,7 @@ import { spawn } from "node:child_process";
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import { writeArtifact } from "../state/artifact-store.ts";
16
- import type { ArtifactDescriptor, GreenLevel, VerificationCommandResult, VerificationContract } from "../state/types.ts";
16
+ import type { GreenLevel, VerificationCommandResult, VerificationContract } from "../state/types.ts";
17
17
  import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
18
18
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
19
19
  import { redactSecretString } from "../utils/redaction.ts";
@@ -154,13 +154,61 @@ export const CARGO_RUST_GATES: Array<{
154
154
  // `npm test > ~/.ssh/authorized_keys`) and used char-class semantics instead of
155
155
  // a lookahead. Now `[<>](?![&\d])` blocks BOTH `<` and `>` file redirects while
156
156
  // still permitting fd-duplication forms (`2>&1`, `1>&2`, `>&2`, `<&3`) via the
157
- // negative lookahead (a `>`/`<` followed by `&` or a digit is allowed).
158
- const DANGEROUS_SHELL_PATTERNS = /(?:;|&&|\|\||\$\(|`|\$\{|\$\w|\b(eval|exec)\b|[<>](?![&\d])|[\r\n])/;
157
+ // negative lookahead (a `>`/`<` followed by `&` or a digit is allowed). A bare
158
+ // `&` is also blocked so an allowed gate cannot background itself and start a
159
+ // second command.
160
+ const DANGEROUS_SHELL_PATTERNS = /(?:;|&&|\|\||\$\(|`|\$\{|\$\w|\b(eval|exec)\b|[<>](?![&\d])|(?<![<>])&|[\r\n])/;
161
+
162
+ /**
163
+ * Verification gates intentionally execute project-controlled code, but they
164
+ * must enter through a known build/test runner. Interpreter eval modes and
165
+ * arbitrary utilities are excluded: `node -e`, `python -c`, `curl`, etc. can
166
+ * otherwise execute attacker-provided code without any shell metacharacters.
167
+ *
168
+ * Prefixes are token-bound and each side of a pipeline is checked separately,
169
+ * preventing an allowed prefix such as `npm test | node -e ...` from bypassing
170
+ * the restriction. Keep this list focused on common verification commands.
171
+ */
172
+ const ALLOWED_GATE_COMMAND_PATTERNS: readonly RegExp[] = [
173
+ /^npm\s+(?:test(?:\s|$)|run(?:-script)?\s+[A-Za-z0-9_.:-]+(?:\s|$))/,
174
+ /^pnpm\s+(?:test(?:\s|$)|run\s+[A-Za-z0-9_.:-]+(?:\s|$))/,
175
+ /^yarn\s+(?:test(?:\s|$)|run\s+[A-Za-z0-9_.:-]+(?:\s|$))/,
176
+ /^npx\s+(?:--no-install\s+)?(?:biome|eslint|tsc)(?:\s|$)/,
177
+ /^(?:biome|eslint|tsc)(?:\s|$)/,
178
+ /^node\s+(?:(?:--experimental-strip-types|--no-warnings)\s+)*--test(?:\s|$)/,
179
+ /^python(?:3(?:\.\d+)*)?\s+-m\s+(?:pytest|unittest)(?:\s|$)/,
180
+ /^pytest(?:\s|$)/,
181
+ /^cargo\s+(?:build|check|clippy|fmt|test)(?:\s|$)/,
182
+ /^go\s+(?:build|test|vet)(?:\s|$)/,
183
+ /^bun\s+test(?:\s|$)/,
184
+ /^deno\s+(?:check|lint|test)(?:\s|$)/,
185
+ /^dotnet\s+(?:build|test)(?:\s|$)/,
186
+ /^(?:gradle|\.\/gradlew)\s+(?:build|check|test)(?:\s|$)/,
187
+ /^(?:mvn|\.\/mvnw)\s+(?:package|test|verify)(?:\s|$)/,
188
+ /^make\s+(?:build|check|lint|test)(?:\s|$)/,
189
+ /^(?:echo|false|printenv|printf|true)(?:\s|$)/,
190
+ /^(?:grep|head|rg|tail)(?:\s|$)/,
191
+ ];
192
+
193
+ const NODE_EVAL_MODE_PATTERN = /(?:^|\s)["']?(?:(?:-e|-p)(?!-)|--(?:eval|print)(?:=|\s|["']|$))/;
194
+
195
+ function validateAllowedGateCommands(normalized: string, original: string): void {
196
+ const pipelineCommands = normalized.split("|").map((part) => part.trim());
197
+ for (const pipelineCommand of pipelineCommands) {
198
+ if (/^node(?:\s|$)/.test(pipelineCommand) && NODE_EVAL_MODE_PATTERN.test(pipelineCommand)) {
199
+ throw new Error(`Security: verification gate command rejected (Node eval/print mode is not allowed): ${original}`);
200
+ }
201
+ if (!pipelineCommand || !ALLOWED_GATE_COMMAND_PATTERNS.some((pattern) => pattern.test(pipelineCommand))) {
202
+ throw new Error(`Security: verification gate command rejected (command is not allowlisted): ${original}`);
203
+ }
204
+ }
205
+ }
159
206
 
160
207
  /**
161
208
  * Validate a verification gate command is safe to execute.
162
- * Rejects commands with shell metacharacters that could enable injection.
163
- * Allows: pipes (|), redirection of stderr (2>&1), and basic npm/cargo/npx commands.
209
+ * Rejects commands with shell metacharacters that could enable injection and
210
+ * commands outside the verification-runner allowlist. Allows safe pipelines
211
+ * and stderr fd duplication (`2>&1`).
164
212
  */
165
213
  /** @internal — exported for injection-guard unit testing (Round 25). */
166
214
  export function __test__validateGateCommand(command: string): void {
@@ -187,6 +235,7 @@ function validateGateCommand(command: string): void {
187
235
  if (DANGEROUS_SHELL_PATTERNS.test(normalized)) {
188
236
  throw new Error(`Security: verification gate command rejected (dangerous shell pattern): ${command}`);
189
237
  }
238
+ validateAllowedGateCommands(normalized, command);
190
239
  }
191
240
 
192
241
  async function executeCommand(
@@ -37,6 +37,7 @@ export const PiTeamsLimitsConfigSchema = Type.Object(
37
37
  maxRetriesPerTask: Type.Optional(Type.Integer({ minimum: 1 })),
38
38
  maxTasksPerRun: Type.Optional(Type.Integer({ minimum: 1 })),
39
39
  heartbeatStaleMs: Type.Optional(Type.Integer({ minimum: 1 })),
40
+ serializeOnPathOverlap: Type.Optional(Type.Boolean()),
40
41
  },
41
42
  { additionalProperties: false },
42
43
  );
@@ -59,6 +60,17 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
59
60
  effectivenessGuard: Type.Optional(
60
61
  Type.Union([Type.Literal("off"), Type.Literal("warn"), Type.Literal("block"), Type.Literal("fail")]),
61
62
  ),
63
+ yield: Type.Optional(
64
+ Type.Object(
65
+ {
66
+ enabled: Type.Optional(Type.Boolean()),
67
+ maxReminders: Type.Optional(Type.Integer({ minimum: 0 })),
68
+ reminderPrompt: Type.Optional(Type.String({ maxLength: 1000 })),
69
+ },
70
+ { additionalProperties: false },
71
+ ),
72
+ ),
73
+ excludeContextBash: Type.Optional(Type.Boolean()),
62
74
  isolationPolicy: Type.Optional(
63
75
  Type.Object(
64
76
  {
@@ -182,7 +194,7 @@ export const PiTeamsObservabilityConfigSchema = Type.Object(
182
194
  {
183
195
  enabled: Type.Optional(Type.Boolean()),
184
196
  pollIntervalMs: Type.Optional(Type.Integer({ minimum: 1000, maximum: 60000 })),
185
- metricRetentionDays: Type.Optional(Type.Integer({ minimum: 1, maximum: 365 })),
197
+ metricRetentionDays: Type.Optional(Type.Integer({ minimum: 1, maximum: 90 })),
186
198
  },
187
199
  { additionalProperties: false },
188
200
  );
@@ -205,6 +217,11 @@ export const PiTeamsReliabilityConfigSchema = Type.Object(
205
217
  autoRecover: Type.Optional(Type.Boolean()),
206
218
  deadletterThreshold: Type.Optional(Type.Integer({ minimum: 1 })),
207
219
  cleanupOrphanedTempDirs: Type.Optional(Type.Boolean()),
220
+ autoRepairIntervalMs: Type.Optional(Type.Integer({ minimum: 0 })),
221
+ forcePreflight: Type.Optional(Type.Boolean()),
222
+ ambientStatusInjection: Type.Optional(Type.Boolean()),
223
+ perWriteValidation: Type.Optional(Type.Boolean()),
224
+ scopeModels: Type.Optional(Type.Boolean()),
208
225
  },
209
226
  { additionalProperties: false },
210
227
  );
@@ -235,6 +252,7 @@ export const PiTeamsUiConfigSchema = Type.Object(
235
252
  dashboardLiveRefreshMs: Type.Optional(Type.Integer({ minimum: 250, maximum: 60000 })),
236
253
  autoOpenDashboard: Type.Optional(Type.Boolean()),
237
254
  autoOpenDashboardForForegroundRuns: Type.Optional(Type.Boolean()),
255
+ autoCloseDashboardMs: Type.Optional(Type.Integer({ minimum: 0 })),
238
256
  showModel: Type.Optional(Type.Boolean()),
239
257
  showTokens: Type.Optional(Type.Boolean()),
240
258
  showTools: Type.Optional(Type.Boolean()),
@@ -194,6 +194,8 @@ export const TeamToolParams = Type.Object({
194
194
  description: "Path to a markdown plan document for orchestration.",
195
195
  }),
196
196
  ),
197
+ subAction: Type.Optional(Type.String({ description: "Sub-action for schedule management (remove, disable, enable, update)." })),
198
+ jobId: Type.Optional(Type.String({ description: "Job ID for schedule management actions." })),
197
199
  cron: Type.Optional(
198
200
  Type.String({
199
201
  description: "Cron expression for recurring scheduled runs (e.g., '0 9 * * MON').",
@@ -28,23 +28,34 @@ export interface SkillDescriptor {
28
28
  }
29
29
 
30
30
  /**
31
- * F6 (v0.7.9): discover skills from all five roots (matching pi-subagents'
32
- * skill-loader so users authoring skills under either convention find them).
33
- * Roots, in precedence order (first hit wins):
34
- * 1. <cwd>/.pi/skills (project, Pi standard)
35
- * 2. <cwd>/.agents/skills (project, Agent Skills spec — agentskills.io)
36
- * 3. <cwd>/skills (project, legacy pi-crew convention)
37
- * 4. <getAgentDir>/skills (user, Pi standard)
38
- * 5. <homedir>/.agents/skills (user, Agent Skills spec)
39
- * 6. <homedir>/.pi/skills (user, legacy Pi — pre-standard)
40
- * 7. PACKAGE_SKILLS_DIR (bundled, trusted)
41
- * The `PACKAGE_SKILLS_DIR` (bundled) and the legacy `<cwd>/skills` root are
42
- * kept as separate `source` values to preserve the existing capability
43
- * inventory shape — callers that key on `source === "package"` / `source ===
44
- * "project"` keep working.
31
+ * F6 (v0.7.9): discover skills from all roots (matching pi-subagents' skill-loader
32
+ * so users authoring skills under either convention find them).
33
+ *
34
+ * IMPORTANT — inventory vs injection (SEC-003): this function returns EVERY
35
+ * skill found across roots (NO first-wins dedup) because it feeds the capability
36
+ * INVENTORY, which intentionally surfaces all skills (incl. project shadows) for
37
+ * transparency; `capability-inventory.ts` then marks project/user items that
38
+ * shadow a package/builtin one and re-sorts by id.
39
+ *
40
+ * The actual skill-content INJECTION path is separate: `skill-instructions.ts`
41
+ * `candidateSkillDirs` + `readSkillMarkdown` use first-wins with PACKAGE FIRST
42
+ * (SEC-003 fix, regression-tested in test/unit/skill-instructions.test.ts) so a
43
+ * project skill can NEVER override a trusted package skill's content in worker
44
+ * prompts. The package-first order below mirrors that injection path for
45
+ * consistency/defense-in-depth.
46
+ *
47
+ * Roots:
48
+ * 1. PACKAGE_SKILLS_DIR (bundled, trusted)
49
+ * 2. <cwd>/.pi/skills (project, Pi standard)
50
+ * 3. <cwd>/.agents/skills (project, Agent Skills spec — agentskills.io)
51
+ * 4. <cwd>/skills (project, legacy pi-crew convention)
52
+ * 5. <getAgentDir>/skills (user, Pi standard)
53
+ * 6. <homedir>/.agents/skills (user, Agent Skills spec)
54
+ * 7. <homedir>/.pi/skills (user, legacy Pi — pre-standard)
45
55
  */
46
56
  function listSkillDirs(cwd: string): Array<{ root: string; source: SkillDescriptor["source"] }> {
47
57
  return [
58
+ { root: PACKAGE_SKILLS_DIR, source: "package" },
48
59
  { root: path.resolve(cwd, ".pi", "skills"), source: "project-pi" },
49
60
  {
50
61
  root: path.resolve(cwd, ".agents", "skills"),
@@ -57,7 +68,6 @@ function listSkillDirs(cwd: string): Array<{ root: string; source: SkillDescript
57
68
  source: "user-agents",
58
69
  },
59
70
  { root: path.join(os.homedir(), ".pi", "skills"), source: "user-pi" },
60
- { root: PACKAGE_SKILLS_DIR, source: "package" },
61
71
  ];
62
72
  }
63
73
 
@@ -2,7 +2,6 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
5
- import { logInternalError } from "../utils/internal-error.ts";
6
5
  import { userCrewRoot } from "../utils/paths.ts";
7
6
  import { isSafePathId } from "../utils/safe-paths.ts";
8
7
  import { sharedScanCache } from "../utils/scan-cache.ts";