pi-crew 0.6.1 → 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 (102) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +70 -33
  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/config/config.ts +106 -15
  7. package/src/errors.ts +107 -0
  8. package/src/extension/async-notifier.ts +6 -2
  9. package/src/extension/crew-cleanup.ts +8 -5
  10. package/src/extension/management.ts +464 -109
  11. package/src/extension/register.ts +194 -34
  12. package/src/extension/registration/commands.ts +2 -2
  13. package/src/extension/registration/subagent-helpers.ts +2 -2
  14. package/src/extension/registration/subagent-tools.ts +1 -1
  15. package/src/extension/registration/team-tool.ts +1 -1
  16. package/src/extension/registration/viewers.ts +2 -2
  17. package/src/extension/team-tool/api.ts +3 -3
  18. package/src/extension/team-tool/cancel.ts +3 -3
  19. package/src/extension/team-tool/explain.ts +1 -1
  20. package/src/extension/team-tool/handle-schedule.ts +40 -0
  21. package/src/extension/team-tool/inspect.ts +3 -3
  22. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  23. package/src/extension/team-tool/respond.ts +2 -2
  24. package/src/extension/team-tool/run.ts +60 -11
  25. package/src/extension/team-tool/status.ts +1 -1
  26. package/src/extension/team-tool.ts +173 -46
  27. package/src/hooks/registry.ts +77 -13
  28. package/src/hooks/types.ts +9 -0
  29. package/src/plugins/plugin-define.ts +6 -0
  30. package/src/plugins/plugin-registry.ts +32 -0
  31. package/src/plugins/plugins/index.ts +3 -0
  32. package/src/plugins/plugins/nextjs.ts +19 -0
  33. package/src/plugins/plugins/vite.ts +14 -0
  34. package/src/plugins/plugins/vitest.ts +14 -0
  35. package/src/runtime/async-runner.ts +35 -7
  36. package/src/runtime/background-runner.ts +529 -144
  37. package/src/runtime/chain-parser.ts +5 -1
  38. package/src/runtime/chain-runner.ts +9 -3
  39. package/src/runtime/checkpoint.ts +262 -180
  40. package/src/runtime/child-pi.ts +164 -37
  41. package/src/runtime/crash-recovery.ts +25 -14
  42. package/src/runtime/crew-agent-records.ts +2 -0
  43. package/src/runtime/diagnostic-export.ts +1 -1
  44. package/src/runtime/dynamic-script-runner.ts +10 -7
  45. package/src/runtime/foreground-watchdog.ts +1 -1
  46. package/src/runtime/heartbeat-watcher.ts +19 -2
  47. package/src/runtime/intercom-bridge.ts +7 -0
  48. package/src/runtime/iteration-hooks.ts +1 -1
  49. package/src/runtime/manifest-cache.ts +4 -0
  50. package/src/runtime/orphan-worker-registry.ts +444 -0
  51. package/src/runtime/parent-guard.ts +70 -16
  52. package/src/runtime/pi-args.ts +437 -14
  53. package/src/runtime/pi-spawn.ts +1 -0
  54. package/src/runtime/post-checks.ts +1 -1
  55. package/src/runtime/retry-runner.ts +9 -3
  56. package/src/runtime/run-tracker.ts +38 -10
  57. package/src/runtime/sandbox.ts +17 -2
  58. package/src/runtime/scheduler.ts +25 -2
  59. package/src/runtime/skill-effectiveness.ts +105 -62
  60. package/src/runtime/skill-instructions.ts +110 -32
  61. package/src/runtime/stale-reconciler.ts +310 -69
  62. package/src/runtime/subagent-manager.ts +251 -57
  63. package/src/runtime/task-health.ts +76 -0
  64. package/src/runtime/task-id.ts +20 -0
  65. package/src/runtime/task-runner/live-executor.ts +1 -1
  66. package/src/runtime/task-runner/progress.ts +1 -1
  67. package/src/runtime/task-runner/state-helpers.ts +110 -6
  68. package/src/runtime/task-runner.ts +92 -70
  69. package/src/runtime/team-runner.ts +186 -20
  70. package/src/schema/team-tool-schema.ts +27 -9
  71. package/src/skills/discover-skills.ts +9 -3
  72. package/src/state/active-run-registry.ts +170 -38
  73. package/src/state/artifact-store.ts +25 -13
  74. package/src/state/atomic-write-v2.ts +86 -0
  75. package/src/state/atomic-write.ts +346 -55
  76. package/src/state/blob-store.ts +178 -10
  77. package/src/state/crew-init.ts +161 -28
  78. package/src/state/decision-ledger.ts +172 -111
  79. package/src/state/event-log-rotation.ts +82 -52
  80. package/src/state/event-log.ts +254 -70
  81. package/src/state/health-store.ts +71 -0
  82. package/src/state/locks.ts +102 -20
  83. package/src/state/mailbox.ts +45 -7
  84. package/src/state/observation-store.ts +4 -1
  85. package/src/state/run-graph.ts +141 -130
  86. package/src/state/run-metrics.ts +24 -8
  87. package/src/state/state-store.ts +330 -43
  88. package/src/ui/live-run-sidebar.ts +1 -1
  89. package/src/ui/loaders.ts +4 -0
  90. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  91. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  92. package/src/ui/run-action-dispatcher.ts +2 -2
  93. package/src/ui/run-snapshot-cache.ts +1 -1
  94. package/src/utils/env-filter.ts +66 -0
  95. package/src/utils/file-coalescer.ts +9 -1
  96. package/src/utils/paths.ts +80 -5
  97. package/src/utils/redaction.ts +6 -1
  98. package/src/utils/safe-paths.ts +281 -10
  99. package/src/worktree/cleanup.ts +112 -24
  100. package/src/worktree/worktree-manager.ts +128 -15
  101. package/test-bugs-all.mjs +10 -6
  102. package/test-integration-check.ts +4 -0
@@ -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,28 +50,69 @@ 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 };
38
- const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
70
+ const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype", "hasOwnProperty", "toString", "valueOf", "isPrototypeOf", "propertyIsEnumerable", "__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__"].map((k) => k.toLowerCase().normalize("NFKC")));
39
71
  function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
40
72
  const clean: Record<string, unknown> = {};
41
73
  for (const [k, v] of Object.entries(data)) {
42
- if (!POLLUTED_KEYS.has(k)) clean[k] = v;
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
+ }
43
86
  }
44
87
  return clean;
45
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
+ }
46
107
  const start = Date.now();
47
108
  const diagnostics: string[] = [];
48
109
  let capturedModifications: Record<string, unknown> | undefined;
49
110
  for (const hook of scopedHooks) {
50
111
  try {
51
- 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);
52
116
  if (hook.mode === "blocking" && result.outcome === "block") {
53
117
  return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: result.reason };
54
118
  }
@@ -57,7 +121,7 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
57
121
  capturedModifications = { ...result.data };
58
122
  }
59
123
  } catch (error) {
60
- const message = error instanceof Error ? error.message : String(error);
124
+ const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
61
125
  if (hook.mode === "blocking") {
62
126
  return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: `Hook error: ${message}` };
63
127
  }
@@ -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 {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Simplifies plugin struct definition.
3
+ */
4
+ export function definePlugin<T extends { name: string; enablers: readonly string[] }>(spec: T): T {
5
+ return spec;
6
+ }
@@ -0,0 +1,32 @@
1
+ export interface Plugin {
2
+ readonly name: string;
3
+ readonly enablers: readonly string[];
4
+ readonly entryPatterns?: readonly string[];
5
+ readonly configPatterns?: readonly string[];
6
+ readonly toolingDependencies?: readonly string[];
7
+ readonly pathAliases?: readonly string[][];
8
+ readonly virtualModulePrefixes?: readonly string[];
9
+ }
10
+
11
+ export class PluginRegistry {
12
+ private plugins: Plugin[] = [];
13
+
14
+ register(plugin: Plugin): void {
15
+ this.plugins.push(plugin);
16
+ }
17
+
18
+ activePlugins(allDeps: string[]): Plugin[] {
19
+ return this.plugins.filter((p) =>
20
+ p.enablers.some((enabler) => {
21
+ if (enabler.endsWith("/")) {
22
+ return allDeps.some((d) => d.startsWith(enabler));
23
+ }
24
+ return allDeps.includes(enabler);
25
+ }),
26
+ );
27
+ }
28
+
29
+ allPlugins(): Plugin[] {
30
+ return [...this.plugins];
31
+ }
32
+ }
@@ -0,0 +1,3 @@
1
+ export { NextJsPlugin } from "./nextjs.ts";
2
+ export { VitestPlugin } from "./vitest.ts";
3
+ export { VitePlugin } from "./vite.ts";
@@ -0,0 +1,19 @@
1
+ import { definePlugin } from "../plugin-define.ts";
2
+
3
+ export const NextJsPlugin = definePlugin({
4
+ name: "nextjs",
5
+ enablers: ["next"],
6
+ entryPatterns: [
7
+ "src/app/**/*.{ts,tsx}",
8
+ "src/pages/**/*.{ts,tsx}",
9
+ "src/app/**/page.{ts,tsx}",
10
+ "src/app/**/layout.{ts,tsx}",
11
+ "src/app/**/route.{ts,tsx}",
12
+ "middleware.{ts,js}",
13
+ "next.config.{ts,js,mjs}",
14
+ ],
15
+ configPatterns: ["next.config.{ts,js,mjs}"],
16
+ toolingDependencies: ["next", "@next/font", "@next/mdx"],
17
+ pathAliases: [["~", "src"]],
18
+ virtualModulePrefixes: ["next:"],
19
+ });
@@ -0,0 +1,14 @@
1
+ import { definePlugin } from "../plugin-define.ts";
2
+
3
+ export const VitePlugin = definePlugin({
4
+ name: "vite",
5
+ enablers: ["vite", "rolldown-vite"],
6
+ entryPatterns: [
7
+ "src/main.{ts,tsx,js,jsx}",
8
+ "src/index.{ts,tsx,js,jsx}",
9
+ "index.html",
10
+ ],
11
+ configPatterns: ["vite.config.{ts,js,mts,mjs}"],
12
+ toolingDependencies: ["vite"],
13
+ virtualModulePrefixes: ["virtual:"],
14
+ });
@@ -0,0 +1,14 @@
1
+ import { definePlugin } from "../plugin-define.ts";
2
+
3
+ export const VitestPlugin = definePlugin({
4
+ name: "vitest",
5
+ enablers: ["vitest"],
6
+ entryPatterns: [
7
+ "**/*.test.{ts,tsx}",
8
+ "**/*.spec.{ts,tsx}",
9
+ "src/**/*.test.{ts,tsx}",
10
+ "src/**/*.spec.{ts,tsx}",
11
+ ],
12
+ configPatterns: ["vitest.config.{ts,js,mjs}", "vite.config.ts"],
13
+ toolingDependencies: ["vitest"],
14
+ });
@@ -6,6 +6,10 @@ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { logInternalError } from "../utils/internal-error.ts";
7
7
  import { appendEvent } from "../state/event-log.ts";
8
8
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
9
+ import {
10
+ registerWorker,
11
+ unregisterWorker,
12
+ } from "./orphan-worker-registry.ts";
9
13
  import type { TeamRunManifest } from "../state/types.ts";
10
14
 
11
15
 
@@ -179,15 +183,24 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
179
183
  "NPM_CONFIG_REGISTRY",
180
184
  "NPM_CONFIG_USERCONFIG",
181
185
  "NPM_CONFIG_GLOBALCONFIG",
182
- "PI_*",
183
- "PI_CREW_*",
184
- "PI_TEAMS_*",
186
+ // FIX: explicit list matches child-pi.ts to prevent regression.
187
+ // PI_CREW_PARENT_PID is needed for parent-guard (liveness check).
188
+ "PI_CREW_DEPTH",
189
+ "PI_CREW_MAX_DEPTH",
190
+ "PI_CREW_INHERIT_PROJECT_CONTEXT",
191
+ "PI_CREW_INHERIT_SKILLS",
192
+ "PI_CREW_PARENT_PID",
193
+ "PI_TEAMS_DEPTH",
194
+ "PI_TEAMS_MAX_DEPTH",
195
+ "PI_TEAMS_INHERIT_PROJECT_CONTEXT",
196
+ "PI_TEAMS_INHERIT_SKILLS",
197
+ "PI_TEAMS_PI_BIN",
198
+ "PI_TEAMS_MOCK_CHILD_PI",
199
+ "PI_CREW_ALLOW_MOCK",
185
200
  ],
186
201
  });
187
- // Block execution control vars from leaking
188
- delete filteredEnv.PI_CREW_PARENT_PID;
189
- delete filteredEnv.PI_CREW_EXECUTE_WORKERS;
190
- delete filteredEnv.PI_TEAMS_EXECUTE_WORKERS;
202
+ // FIX: removed delete workarounds with explicit allowlist, these vars
203
+ // are no longer auto-leaked. Matches child-pi.ts.
191
204
 
192
205
  const loader = resolveTypeScriptLoader();
193
206
  if (!loader) {
@@ -223,6 +236,21 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
223
236
  });
224
237
  child.unref();
225
238
 
239
+ // Track this worker in the orphan registry so it can be killed on
240
+ // session_start of a future session if the parent pi process is killed.
241
+ if (child.pid) {
242
+ registerWorker(
243
+ child.pid,
244
+ manifest.ownerSessionId ?? "unknown",
245
+ manifest.runId,
246
+ process.pid, // parentPid — used by cleanup to verify session is dead
247
+ );
248
+ // Best-effort: unregister when child exits. Background-runner writes
249
+ // the marker file before it dies, so we unregister on the next
250
+ // cleanup tick. But the child "exit" event won't fire because we
251
+ // unref'd and the stdio is piped + ignored.
252
+ }
253
+
226
254
  return { pid: child.pid, logPath };
227
255
  }
228
256