pi-crew 0.6.0 → 0.6.3

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 (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. package/test-integration-check.ts +4 -0
@@ -18,7 +18,7 @@ import {
18
18
  import type { executeTeamRun as _executeTeamRunFn } from "../runtime/team-runner.ts";
19
19
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
20
20
  import { writeArtifact } from "../state/artifact-store.ts";
21
- import { appendEvent } from "../state/event-log.ts";
21
+ import { appendEvent, appendEventFireAndForget } from "../state/event-log.ts";
22
22
  import { withRunLock } from "../state/locks.ts";
23
23
  import { replayPendingMailboxMessages } from "../state/mailbox.ts";
24
24
  import {
@@ -33,6 +33,7 @@ import type {
33
33
  TeamTaskState,
34
34
  } from "../state/types.ts";
35
35
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
36
+ import { assertSafePathId } from "../utils/safe-paths.ts";
36
37
  import {
37
38
  allWorkflows,
38
39
  discoverWorkflows,
@@ -99,10 +100,31 @@ async function handleRun(
99
100
  return _cachedHandleRun(...args);
100
101
  }
101
102
 
103
+ import { FileCheckpointStore } from "../runtime/checkpoint.ts";
102
104
  import { waitForRun } from "../runtime/run-tracker.ts";
103
105
  import { normalizeSkillOverride } from "../runtime/skill-instructions.ts";
106
+ import {
107
+ computeRunCacheKey,
108
+ getCachedRun,
109
+ getCacheStats,
110
+ } from "../state/run-cache.ts";
111
+ import { listRunGraphs, loadRunGraph } from "../state/run-graph.ts";
104
112
  import { searchAgents, searchTeams } from "../utils/bm25-search.ts";
105
113
  import { projectCrewRoot } from "../utils/paths.ts";
114
+ import { buildTeamOnboarding } from "./team-onboard.ts";
115
+ import {
116
+ handleAnchorAccumulate,
117
+ handleAnchorClear,
118
+ handleAnchorSet,
119
+ handleAnchorStatus,
120
+ } from "./team-tool/anchor.ts";
121
+ import {
122
+ createAutoSummarizeService,
123
+ handleAutoSummarizeConfig,
124
+ handleAutoSummarizeOff,
125
+ handleAutoSummarizeOn,
126
+ handleAutoSummarizeStatus,
127
+ } from "./team-tool/auto-summarize.ts";
106
128
  import {
107
129
  type CacheControlDeps,
108
130
  invalidateSnapshot,
@@ -110,6 +132,10 @@ import {
110
132
  import { handleCancel, handleRetry } from "./team-tool/cancel.ts";
111
133
  import { handleDoctor } from "./team-tool/doctor.ts";
112
134
  import { handleExplain } from "./team-tool/explain.ts";
135
+ import {
136
+ handleListScheduled,
137
+ handleSchedule,
138
+ } from "./team-tool/handle-schedule.ts";
113
139
  import { handleHealthMonitor } from "./team-tool/health-monitor.ts";
114
140
  import {
115
141
  handleArtifacts,
@@ -125,30 +151,17 @@ import {
125
151
  handlePrune,
126
152
  handleWorktrees,
127
153
  } from "./team-tool/lifecycle-actions.ts";
128
- import {
129
- getCachedRun,
130
- computeRunCacheKey,
131
- getCacheStats,
132
- } from "../state/run-cache.ts";
133
- import {
134
- loadRunGraph,
135
- listRunGraphs,
136
- } from "../state/run-graph.ts";
137
- import { FileCheckpointStore } from "../runtime/checkpoint.ts";
138
- import { buildTeamOnboarding } from "./team-onboard.ts";
154
+ import { handleOrchestrate } from "./team-tool/orchestrate.ts";
139
155
  import { handleParallel } from "./team-tool/parallel-dispatch.ts";
140
- import { handleSchedule, handleListScheduled } from "./team-tool/handle-schedule.ts";
141
156
  import { handlePlan } from "./team-tool/plan.ts";
142
- import { handleOrchestrate } from "./team-tool/orchestrate.ts";
143
157
  import { handleRespond } from "./team-tool/respond.ts";
144
158
  import { handleStatus } from "./team-tool/status.ts";
145
- import { handleAnchorSet, handleAnchorClear, handleAnchorStatus, handleAnchorAccumulate } from "./team-tool/anchor.ts";
146
- import { handleAutoSummarizeOn, handleAutoSummarizeOff, handleAutoSummarizeStatus, handleAutoSummarizeConfig, createAutoSummarizeService } from "./team-tool/auto-summarize.ts";
147
159
 
148
160
  export { handleApi } from "./team-tool/api.ts";
149
161
  export { handleRetry } from "./team-tool/cancel.ts";
150
162
  export type { TeamContext } from "./team-tool/context.ts";
151
163
  export { handleDoctor } from "./team-tool/doctor.ts";
164
+ export { handleSchedule } from "./team-tool/handle-schedule.ts";
152
165
  export {
153
166
  handleArtifacts,
154
167
  handleEvents,
@@ -163,12 +176,11 @@ export {
163
176
  handlePrune,
164
177
  handleWorktrees,
165
178
  } from "./team-tool/lifecycle-actions.ts";
166
- export { handleSchedule } from "./team-tool/handle-schedule.ts";
179
+ export { handleOrchestrate } from "./team-tool/orchestrate.ts";
167
180
  export { handlePlan } from "./team-tool/plan.ts";
168
181
  export { handleStatus } from "./team-tool/status.ts";
169
182
  export type { TeamToolDetails } from "./team-tool-types.ts";
170
183
  export { handleRun };
171
- export { handleOrchestrate } from "./team-tool/orchestrate.ts";
172
184
 
173
185
  export function handleList(
174
186
  params: TeamToolParamsValue,
@@ -451,7 +463,7 @@ export async function handleResume(
451
463
  { action: "resume", status: "error" },
452
464
  true,
453
465
  );
454
- const loaded = loadRunManifestById(runCwd, params.runId);
466
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
455
467
  if (!loaded)
456
468
  return result(
457
469
  `Run '${params.runId}' not found.`,
@@ -674,7 +686,7 @@ export function handleSteer(
674
686
  { action: "steer", status: "error" },
675
687
  true,
676
688
  );
677
- const loaded = loadRunManifestById(runCwd, runId);
689
+ const loaded = loadRunManifestById(runCwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
678
690
  if (!loaded)
679
691
  return result(
680
692
  `Run '${runId}' not found`,
@@ -689,12 +701,23 @@ export function handleSteer(
689
701
  true,
690
702
  );
691
703
  if (!task.pendingSteers) task.pendingSteers = [];
692
- // HIGH-04: Cap pendingSteers array to prevent unbounded memory growth
693
- const MAX_PENDING_STEERS = 100;
694
- if (task.pendingSteers.length >= MAX_PENDING_STEERS) {
695
- task.pendingSteers = task.pendingSteers.slice(-(MAX_PENDING_STEERS - 1));
696
- }
697
- task.pendingSteers.push(message);
704
+ // HIGH-04: Cap pendingSteers array to prevent unbounded memory growth
705
+ const MAX_PENDING_STEERS = 100;
706
+ if (task.pendingSteers.length >= MAX_PENDING_STEERS) {
707
+ // Log warning before dropping the oldest message
708
+ appendEventFireAndForget(loaded.manifest.eventsPath, {
709
+ type: "task.steer_dropped",
710
+ runId,
711
+ taskId,
712
+ data: {
713
+ droppedMessage: task.pendingSteers[0],
714
+ reason: "pendingSteers cap exceeded",
715
+ queueDepth: task.pendingSteers.length,
716
+ },
717
+ });
718
+ task.pendingSteers = task.pendingSteers.slice(-(MAX_PENDING_STEERS - 1));
719
+ }
720
+ task.pendingSteers.push(message);
698
721
  saveRunTasks(loaded.manifest, loaded.tasks);
699
722
  appendEvent(loaded.manifest.eventsPath, {
700
723
  type: "task.steer_queued",
@@ -751,17 +774,57 @@ function handleInvalidate(
751
774
  /**
752
775
  * Locate the CWD where a run's state is stored.
753
776
  * Tries ctx.cwd first, then scans immediate child directories for .crew/state/runs/<runId>.
777
+ *
778
+ * Defensive bounds (prevent hang on large dirs like /tmp in CI):
779
+ * - Skips entries that are well-known system/ephemeral dirs (e.g. .npm, node_modules, .git)
780
+ * - Caps the scan at MAX_SCAN_ENTRIES to avoid pathological scans
781
+ * - Skips hidden entries (starting with `.`) unless they look like run directories
782
+ * (e.g. .crew, .pi, .tmp-crew-runs)
754
783
  */
755
- export function locateRunCwd(runId: string, baseCwd: string): string | undefined {
784
+ const MAX_SCAN_ENTRIES = 1000;
785
+ const SKIP_SCAN_DIRS = new Set([
786
+ "node_modules",
787
+ ".git",
788
+ ".npm",
789
+ ".cache",
790
+ ".local",
791
+ "proc",
792
+ "sys",
793
+ "dev",
794
+ "Library",
795
+ "Applications",
796
+ ]);
797
+
798
+ export function locateRunCwd(
799
+ runId: string,
800
+ baseCwd: string,
801
+ ): string | undefined {
756
802
  // Fast path: run is in the current CWD
757
- if (loadRunManifestById(baseCwd, runId)) return baseCwd;
803
+ if (loadRunManifestById(baseCwd, runId)) {
804
+ return baseCwd;
805
+ }
758
806
 
759
- // Scan immediate child directories
807
+ // Scan immediate child directories, but with defensive bounds.
760
808
  try {
761
- for (const entry of fs.readdirSync(baseCwd, { withFileTypes: true })) {
809
+ const entries = fs.readdirSync(baseCwd, { withFileTypes: true });
810
+ const boundedEntries = entries.length > MAX_SCAN_ENTRIES
811
+ ? entries.slice(0, MAX_SCAN_ENTRIES)
812
+ : entries;
813
+ for (const entry of boundedEntries) {
762
814
  if (!entry.isDirectory()) continue;
815
+ if (SKIP_SCAN_DIRS.has(entry.name)) continue;
816
+ // Skip hidden entries except well-known run-storage prefixes
817
+ if (entry.name.startsWith(".")) {
818
+ if (
819
+ !entry.name.startsWith(".crew") &&
820
+ !entry.name.startsWith(".pi") &&
821
+ !entry.name.startsWith(".tmp-crew")
822
+ ) continue;
823
+ }
763
824
  const candidate = path.join(baseCwd, entry.name);
764
- if (loadRunManifestById(candidate, runId)) return candidate;
825
+ if (loadRunManifestById(candidate, runId)) {
826
+ return candidate;
827
+ }
765
828
  }
766
829
  } catch {
767
830
  /* ignore unreadable dirs */
@@ -1090,23 +1153,32 @@ export async function handleTeamTool(
1090
1153
  return handleWait(params, ctx);
1091
1154
  case "graph": {
1092
1155
  if (params.runId) {
1156
+ assertSafePathId("runId", params.runId);
1093
1157
  const graph = loadRunGraph(ctx.cwd, params.runId);
1094
1158
  return result(
1095
- graph ? JSON.stringify(graph, null, 2) : "No graph found for this run.",
1159
+ graph
1160
+ ? JSON.stringify(graph, null, 2)
1161
+ : "No graph found for this run.",
1096
1162
  { action: "graph", status: graph ? "ok" : "error" },
1097
1163
  !graph,
1098
1164
  );
1099
1165
  }
1100
1166
  const graphs = listRunGraphs(ctx.cwd);
1101
1167
  return result(
1102
- graphs.length ? `Available graphs:\n${graphs.join("\n")}` : "No graphs available.",
1168
+ graphs.length
1169
+ ? `Available graphs:\n${graphs.join("\n")}`
1170
+ : "No graphs available.",
1103
1171
  { action: "graph", status: "ok" },
1104
1172
  );
1105
1173
  }
1106
1174
  case "search": {
1107
1175
  const query = params.goal ?? params.task ?? "";
1108
1176
  if (!query) {
1109
- return result("Search requires goal or task query.", { action: "search", status: "error" }, true);
1177
+ return result(
1178
+ "Search requires goal or task query.",
1179
+ { action: "search", status: "error" },
1180
+ true,
1181
+ );
1110
1182
  }
1111
1183
  try {
1112
1184
  const [agentResults, teamResults] = await Promise.all([
@@ -1117,19 +1189,30 @@ export async function handleTeamTool(
1117
1189
  if (teamResults.length) {
1118
1190
  lines.push("## Teams");
1119
1191
  for (const r of teamResults) {
1120
- lines.push(`- [${r.team.name}] score=${r.score.toFixed(2)}: ${r.team.description ?? "(no description)"}`);
1192
+ lines.push(
1193
+ `- [${r.team.name}] score=${r.score.toFixed(2)}: ${r.team.description ?? "(no description)"}`,
1194
+ );
1121
1195
  }
1122
1196
  }
1123
1197
  if (agentResults.length) {
1124
1198
  lines.push("## Agents");
1125
1199
  for (const r of agentResults) {
1126
- lines.push(`- [${r.agent.name}] score=${r.score.toFixed(2)}: ${r.agent.description ?? "(no description)"}`);
1200
+ lines.push(
1201
+ `- [${r.agent.name}] score=${r.score.toFixed(2)}: ${r.agent.description ?? "(no description)"}`,
1202
+ );
1127
1203
  }
1128
1204
  }
1129
- return result(lines.length ? lines.join("\n") : "No results found.", { action: "search", status: "ok" });
1205
+ return result(
1206
+ lines.length ? lines.join("\n") : "No results found.",
1207
+ { action: "search", status: "ok" },
1208
+ );
1130
1209
  } catch (err) {
1131
1210
  const msg = err instanceof Error ? err.message : String(err);
1132
- return result(`Search failed: ${msg}`, { action: "search", status: "error" }, true);
1211
+ return result(
1212
+ `Search failed: ${msg}`,
1213
+ { action: "search", status: "error" },
1214
+ true,
1215
+ );
1133
1216
  }
1134
1217
  }
1135
1218
  case "schedule":
@@ -1137,7 +1220,10 @@ export async function handleTeamTool(
1137
1220
  case "scheduled":
1138
1221
  return handleListScheduled(params, ctx);
1139
1222
  case "anchor": {
1140
- const subAction = typeof params.config?.subAction === "string" ? params.config.subAction : "status";
1223
+ const subAction =
1224
+ typeof params.config?.subAction === "string"
1225
+ ? params.config.subAction
1226
+ : "status";
1141
1227
  switch (subAction) {
1142
1228
  case "set":
1143
1229
  return handleAnchorSet(params, ctx);
@@ -1151,7 +1237,12 @@ export async function handleTeamTool(
1151
1237
  }
1152
1238
  case "auto-summarize":
1153
1239
  case "auto_boomerang": {
1154
- const subAction = typeof params.config?.subAction === "string" ? params.config.subAction : ((params.action as string) === "auto_boomerang" ? "toggle" : "status");
1240
+ const subAction =
1241
+ typeof params.config?.subAction === "string"
1242
+ ? params.config.subAction
1243
+ : (params.action as string) === "auto_boomerang"
1244
+ ? "toggle"
1245
+ : "status";
1155
1246
  switch (subAction) {
1156
1247
  case "on":
1157
1248
  return handleAutoSummarizeOn(params, ctx);
@@ -1178,7 +1269,14 @@ export async function handleTeamTool(
1178
1269
  }
1179
1270
  case "explain": {
1180
1271
  const explainResult = handleExplain(params, ctx.cwd);
1181
- return result(explainResult.text, { action: "explain", status: explainResult.isError ? "error" : "ok" }, explainResult.isError);
1272
+ return result(
1273
+ explainResult.text,
1274
+ {
1275
+ action: "explain",
1276
+ status: explainResult.isError ? "error" : "ok",
1277
+ },
1278
+ explainResult.isError,
1279
+ );
1182
1280
  }
1183
1281
  case "cache": {
1184
1282
  if (params.goal) {
@@ -1192,10 +1290,24 @@ export async function handleTeamTool(
1192
1290
  if (cached) {
1193
1291
  return result(
1194
1292
  `Cached run found (${new Date(cached.cachedAt).toISOString()}): runId=${cached.runId}, status=${cached.status}, ${cached.tasks.length} tasks`,
1195
- { action: "cache", status: "ok", data: { cacheKey: key, cacheHit: true, runId: cached.runId, status: cached.status, taskCount: cached.tasks.length } },
1293
+ {
1294
+ action: "cache",
1295
+ status: "ok",
1296
+ data: {
1297
+ cacheKey: key,
1298
+ cacheHit: true,
1299
+ runId: cached.runId,
1300
+ status: cached.status,
1301
+ taskCount: cached.tasks.length,
1302
+ },
1303
+ },
1196
1304
  );
1197
1305
  }
1198
- return result(`No cached result for key: ${key}`, { action: "cache", status: "ok", data: { cacheKey: key, cacheHit: false } });
1306
+ return result(`No cached result for key: ${key}`, {
1307
+ action: "cache",
1308
+ status: "ok",
1309
+ data: { cacheKey: key, cacheHit: false },
1310
+ });
1199
1311
  }
1200
1312
  const stats = getCacheStats(ctx.cwd);
1201
1313
  return result(
@@ -1205,13 +1317,28 @@ export async function handleTeamTool(
1205
1317
  }
1206
1318
  case "checkpoint": {
1207
1319
  if (!params.runId || !params.taskId) {
1208
- return result("Checkpoint requires runId and taskId.", { action: "checkpoint", status: "error" }, true);
1320
+ return result(
1321
+ "Checkpoint requires runId and taskId.",
1322
+ { action: "checkpoint", status: "error" },
1323
+ true,
1324
+ );
1209
1325
  }
1210
- const stateRoot = path.join(projectCrewRoot(ctx.cwd), "state", "runs", params.runId);
1326
+ assertSafePathId("runId", params.runId);
1327
+ assertSafePathId("taskId", params.taskId);
1328
+ const stateRoot = path.join(
1329
+ projectCrewRoot(ctx.cwd),
1330
+ "state",
1331
+ "runs",
1332
+ params.runId,
1333
+ );
1211
1334
  const store = new FileCheckpointStore(stateRoot);
1212
1335
  const checkpoint = store.load(params.runId, params.taskId);
1213
1336
  if (!checkpoint) {
1214
- return result("No checkpoint found.", { action: "checkpoint", status: "error" }, true);
1337
+ return result(
1338
+ "No checkpoint found.",
1339
+ { action: "checkpoint", status: "error" },
1340
+ true,
1341
+ );
1215
1342
  }
1216
1343
  return result(
1217
1344
  `Checkpoint: step=${checkpoint.step}, progress=${checkpoint.progress}, savedAt=${new Date(checkpoint.savedAt).toISOString()}`,
@@ -1258,7 +1385,8 @@ export function registerCrewGlobalRegistry(registry: CrewRegistry): void {
1258
1385
  registry;
1259
1386
  }
1260
1387
 
1261
- export function getCrewGlobalRegistry(): CrewRegistry | undefined {
1388
+ /** @internal */
1389
+ function getCrewGlobalRegistry(): CrewRegistry | undefined {
1262
1390
  return (globalThis as Record<symbol | string, unknown>)[
1263
1391
  CREW_REGISTRY_KEY
1264
1392
  ] as CrewRegistry | undefined;
@@ -5,19 +5,42 @@ import { runEventBus } from "../ui/run-event-bus.ts";
5
5
 
6
6
  const registry = new Map<HookName, HookDefinition[]>();
7
7
 
8
+ // Track hook IDs registered by pi-crew for scope-aware cleanup
9
+ const _piCrewHookIds = new Set<number>();
10
+ let _nextHookId = 1;
11
+
8
12
  // SECURITY: Hooks are currently global (registered once, applied to all workspaces).
9
13
  // For multi-workspace environments, consider filtering hooks by workspace scope:
10
14
  // const workspaceHooks = getHooks(name).filter(h => !h.workspaceId || h.workspaceId === ctx.workspaceId);
11
15
  // This prevents globally-registered hooks from operating on runs they weren't designed for.
12
16
 
13
- export function registerHook(definition: HookDefinition): void {
17
+ export function registerHook(definition: HookDefinition): number {
18
+ const hookId = _nextHookId++;
19
+ _piCrewHookIds.add(hookId);
14
20
  const hooks = registry.get(definition.name) ?? [];
15
- hooks.push(definition);
21
+ hooks.push({ ...definition, _hookId: hookId });
16
22
  registry.set(definition.name, hooks);
23
+ return hookId;
17
24
  }
18
25
 
19
26
  export function clearHooks(): void {
20
27
  registry.clear();
28
+ _piCrewHookIds.clear();
29
+ _nextHookId = 1;
30
+ }
31
+
32
+ // Scope-aware hook clearing: only removes hooks registered by pi-crew
33
+ export function clearHooksScoped(): void {
34
+ for (const [name, hooks] of registry) {
35
+ const remaining = hooks.filter((h) => !("_hookId" in h && _piCrewHookIds.has((h as { _hookId?: number })._hookId ?? -1)));
36
+ if (remaining.length === 0) {
37
+ registry.delete(name);
38
+ } else {
39
+ registry.set(name, remaining);
40
+ }
41
+ }
42
+ _piCrewHookIds.clear();
43
+ _nextHookId = 1;
21
44
  }
22
45
 
23
46
  export function getHooks(name: HookName): HookDefinition[] {
@@ -27,29 +50,78 @@ export function getHooks(name: HookName): HookDefinition[] {
27
50
  export async function executeHook(name: HookName, ctx: HookContext): Promise<HookExecutionReport> {
28
51
  const hooks = getHooks(name);
29
52
  if (hooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
30
- // SECURITY: If ctx contains a workspaceId, filter hooks to only those scoped to
31
- // this workspace. This prevents globally-registered hooks from operating on runs
32
- // they weren't designed for.
33
- // SECURITY: Hooks without workspaceId match ALL workspaces. This is intentional
34
- // for globally-applicable hooks (e.g., logging, metrics). For multi-tenant
35
- // environments, all hooks should set workspaceId to prevent cross-workspace access.
36
- const scopedHooks = hooks.filter((h) => !h.workspaceId || h.workspaceId === ctx.workspaceId);
53
+ // SECURITY: Filter hooks by workspace scope.
54
+ // - Global hooks (no workspaceId) match all contexts UNLESS ctx explicitly
55
+ // opts out via `includeGlobalHooks: false`. This is the documented
56
+ // behavior: "Hooks without workspaceId match ALL workspaces".
57
+ // - Scoped hooks (workspaceId set) require an exact match with ctx.workspaceId.
58
+ // - If ctx has no workspaceId, scoped hooks are excluded (they can't match).
59
+ // - `includeGlobalHooks: true` on ctx forces inclusion of global hooks even
60
+ // when ctx has a workspaceId.
61
+ const scopedHooks = hooks.filter((h) => {
62
+ // Scoped hook: must match ctx.workspaceId exactly
63
+ if (h.workspaceId !== undefined) {
64
+ return h.workspaceId === ctx.workspaceId;
65
+ }
66
+ // Global hook (no workspaceId): include unless ctx explicitly excludes
67
+ return ctx.includeGlobalHooks !== false;
68
+ });
37
69
  if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
70
+ const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype", "hasOwnProperty", "toString", "valueOf", "isPrototypeOf", "propertyIsEnumerable", "__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__"].map((k) => k.toLowerCase().normalize("NFKC")));
71
+ function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
72
+ const clean: Record<string, unknown> = {};
73
+ for (const [k, v] of Object.entries(data)) {
74
+ if (!POLLUTED_KEYS.has(k.toLowerCase())) {
75
+ if (v !== null && typeof v === "object") {
76
+ if (Array.isArray(v)) {
77
+ // Sanitize array elements that are objects
78
+ clean[k] = v.map((item) => (item !== null && typeof item === "object" && !Array.isArray(item) ? sanitizeMergeData(item as Record<string, unknown>) : item));
79
+ } else {
80
+ clean[k] = sanitizeMergeData(v as Record<string, unknown>);
81
+ }
82
+ } else {
83
+ clean[k] = v;
84
+ }
85
+ }
86
+ }
87
+ return clean;
88
+ }
89
+ // Sanitize ctx by stripping dangerous property names before passing to handlers.
90
+ // Hook authors must NOT set these keys directly on ctx: [...POLLUTED_KEYS]
91
+ // This sanitization runs at the start of executeHook to prevent prototype pollution attacks.
92
+ function sanitizeContext(ctx: HookContext): HookContext {
93
+ for (const key of Object.keys(ctx)) {
94
+ if (POLLUTED_KEYS.has(key.toLowerCase())) {
95
+ delete ctx[key];
96
+ }
97
+ }
98
+ return ctx;
99
+ }
100
+ function sanitizeErrorMessage(message: string): string {
101
+ // Remove file paths, environment variable references, and other potentially sensitive data
102
+ return message
103
+ .replace(/\/[^:\s]+/g, "[path]")
104
+ .replace(/\b[A-Z_0-9]+\s*=/g, "[env]")
105
+ .replace(/\b\d+\.\d+\.\d+\.\d+\b/g, "[ip]");
106
+ }
38
107
  const start = Date.now();
39
108
  const diagnostics: string[] = [];
40
109
  let capturedModifications: Record<string, unknown> | undefined;
41
110
  for (const hook of scopedHooks) {
42
111
  try {
43
- const result: HookResult = await hook.handler(ctx);
112
+ const result: HookResult = await hook.handler(sanitizeContext(ctx));
113
+ // SECURITY: Sanitize any direct mutations the handler may have made to ctx.
114
+ // This prevents hooks from injecting dangerous properties via direct ctx assignment.
115
+ sanitizeContext(ctx);
44
116
  if (hook.mode === "blocking" && result.outcome === "block") {
45
117
  return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: result.reason };
46
118
  }
47
119
  if (result.outcome === "modify" && result.data) {
48
- Object.assign(ctx, result.data);
120
+ Object.assign(ctx, sanitizeMergeData(result.data));
49
121
  capturedModifications = { ...result.data };
50
122
  }
51
123
  } catch (error) {
52
- const message = error instanceof Error ? error.message : String(error);
124
+ const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
53
125
  if (hook.mode === "blocking") {
54
126
  return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: `Hook error: ${message}` };
55
127
  }
@@ -20,9 +20,9 @@ export type HookName =
20
20
  * - 1 = warn (non-blocking error, continue)
21
21
  * - 2 = block (blocking error, stop)
22
22
  */
23
- export const HOOK_EXIT_SUCCESS = 0 as const;
24
- export const HOOK_EXIT_WARN = 1 as const;
25
- export const HOOK_EXIT_BLOCK = 2 as const;
23
+ /** @internal */ const HOOK_EXIT_SUCCESS = 0 as const;
24
+ /** @internal */ const HOOK_EXIT_WARN = 1 as const;
25
+ /** @internal */ const HOOK_EXIT_BLOCK = 2 as const;
26
26
 
27
27
  export type HookMode = "blocking" | "non_blocking";
28
28
  export type HookOutcome = "allow" | "block" | "modify" | "diagnostic";
@@ -31,6 +31,13 @@ export interface HookContext {
31
31
  runId: string;
32
32
  taskId?: string;
33
33
  cwd: string;
34
+ // NOTE: Hooks receive a shared mutable context object. A hook may directly set
35
+ // properties on ctx, but MUST NOT set dangerous names like "__proto__",
36
+ // "constructor", "prototype", etc. (see registry.ts POLLUTED_KEYS).
37
+ // Hook authors are trusted but must avoid prototype pollution attacks.
38
+ // The sanitizeMergeData function prevents dangerous properties from propagating
39
+ // via result.data merge, and a runtime guard sanitizes ctx before passing to
40
+ // handlers AND after handler execution to catch direct mutations.
34
41
  [key: string]: unknown;
35
42
  }
36
43
 
@@ -47,6 +54,8 @@ export interface HookDefinition {
47
54
  // SECURITY: Optional workspace scoping. When set, the hook only executes for
48
55
  // runs in the specified workspace. When absent, the hook applies to all runs.
49
56
  workspaceId?: string;
57
+ /** @internal Internal hook ID for scope-aware cleanup. */
58
+ _hookId?: number;
50
59
  }
51
60
 
52
61
  export interface HookExecutionReport {
package/src/i18n.ts CHANGED
@@ -129,13 +129,26 @@ export function t(key: Key, params?: Params): string {
129
129
  * @example
130
130
  * addTranslations("vi", { "agent.requiresPrompt": "Agent cần prompt." })
131
131
  */
132
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
133
+
134
+ function stripDangerousKeys<T extends Record<string, unknown>>(obj: T): T {
135
+ const safe: Record<string, unknown> = {};
136
+ for (const key of Object.keys(obj)) {
137
+ if (!DANGEROUS_KEYS.has(key)) {
138
+ safe[key] = obj[key];
139
+ }
140
+ }
141
+ return safe as T;
142
+ }
143
+
132
144
  export function addTranslations(locale: string, bundle: Partial<Record<Key, string>>): void {
133
145
  if (!locale) return;
146
+ const safeBundle = stripDangerousKeys(bundle as Record<string, unknown>) as Partial<Record<Key, string>>;
134
147
  const existing = translations[locale];
135
148
  if (existing) {
136
- Object.assign(existing, bundle);
149
+ Object.assign(existing, safeBundle);
137
150
  } else {
138
- translations[locale] = { ...bundle };
151
+ translations[locale] = { ...safeBundle };
139
152
  }
140
153
  }
141
154
 
@@ -8,6 +8,78 @@ import type { MetricExporter } from "./adapter.ts";
8
8
 
9
9
  const gzipAsync = promisify(gzip);
10
10
 
11
+ /**
12
+ * SSRF protection: validate that an OTLP endpoint URL does not target
13
+ * private/reserved networks or dangerous protocols.
14
+ * Rejects: localhost, loopback, private IPs, link-local, cloud metadata,
15
+ * IPv6 private, file:// and javascript:// protocols.
16
+ * Only http:// and https:// to public hostnames are allowed.
17
+ */
18
+ export function validateEndpoint(endpoint: string): void {
19
+ let url: URL;
20
+ try {
21
+ url = new URL(endpoint);
22
+ } catch {
23
+ throw new Error(`Invalid OTLP endpoint URL: ${endpoint}`);
24
+ }
25
+
26
+ // Only allow http and https protocols
27
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
28
+ throw new Error(
29
+ `OTLP endpoint must use http:// or https:// protocol, got: ${url.protocol}`,
30
+ );
31
+ }
32
+
33
+ const hostname = url.hostname.toLowerCase();
34
+
35
+ // Reject known localhost names
36
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
37
+ throw new Error(`OTLP endpoint must not target localhost: ${endpoint}`);
38
+ }
39
+
40
+ // Reject IPv6 loopback and private
41
+ if (hostname.startsWith("[")) {
42
+ const bare = hostname.replace(/^\[|\]$/g, "");
43
+ if (bare === "::1") {
44
+ throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
45
+ }
46
+ if (bare.toLowerCase().startsWith("fd") || bare.toLowerCase().startsWith("fc")) {
47
+ throw new Error(`OTLP endpoint must not target private IPv6 address: ${endpoint}`);
48
+ }
49
+ }
50
+
51
+ // Reject IPv4 private/reserved ranges
52
+ // Match plain IPv4 addresses (not hostnames that look like IPs)
53
+ const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
54
+ if (ipv4Match) {
55
+ const octets = [Number(ipv4Match[1]), Number(ipv4Match[2]), Number(ipv4Match[3]), Number(ipv4Match[4])];
56
+ // 127.x.x.x - loopback
57
+ if (octets[0] === 127) {
58
+ throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
59
+ }
60
+ // 10.x.x.x - private class A
61
+ if (octets[0] === 10) {
62
+ throw new Error(`OTLP endpoint must not target private network (10.0.0.0/8): ${endpoint}`);
63
+ }
64
+ // 172.16.x.x - 172.31.x.x - private class B
65
+ if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
66
+ throw new Error(`OTLP endpoint must not target private network (172.16.0.0/12): ${endpoint}`);
67
+ }
68
+ // 192.168.x.x - private class C
69
+ if (octets[0] === 192 && octets[1] === 168) {
70
+ throw new Error(`OTLP endpoint must not target private network (192.168.0.0/16): ${endpoint}`);
71
+ }
72
+ // 169.254.x.x - link-local / cloud metadata
73
+ if (octets[0] === 169 && octets[1] === 254) {
74
+ throw new Error(`OTLP endpoint must not target link-local/metadata endpoint (169.254.0.0/16): ${endpoint}`);
75
+ }
76
+ // 0.x.x.x - this network
77
+ if (octets[0] === 0) {
78
+ throw new Error(`OTLP endpoint must not target this-network address (0.0.0.0/8): ${endpoint}`);
79
+ }
80
+ }
81
+ }
82
+
11
83
  // FIX (Round 15): Cap the number of snapshots per push to prevent OOM when
12
84
  // the metric registry has grown large. The OTLP HTTP spec allows many metrics
13
85
  // in one payload, but a single push > 10_000 metrics would balloon the
@@ -71,6 +143,7 @@ export class OTLPExporter implements MetricExporter {
71
143
  private readonly registry: MetricRegistry;
72
144
 
73
145
  constructor(opts: OTLPExporterOptions, registry: MetricRegistry) {
146
+ validateEndpoint(opts.endpoint);
74
147
  this.opts = opts;
75
148
  this.registry = registry;
76
149
  }