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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.6.0",
3
+ "version": "0.6.3",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -84,7 +84,7 @@
84
84
  "ajv": "^8.20.0",
85
85
  "cli-highlight": "^2.1.11",
86
86
  "diff": "^5.2.0",
87
- "jiti": "^2.6.1",
87
+ "jiti": "^2.7.0",
88
88
  "typebox": "^1.1.38"
89
89
  },
90
90
  "devDependencies": {
@@ -69,7 +69,8 @@ export function getAgentSessionOptions(role: string): {
69
69
  * @param agent - The agent configuration
70
70
  * @param role - The role name to use for tool restrictions (defaults to agent.name)
71
71
  */
72
- export function buildAgentSessionOptions(
72
+ /** @internal */
73
+ function buildAgentSessionOptions(
73
74
  agent: AgentConfig,
74
75
  role?: string,
75
76
  ): {
@@ -4,13 +4,15 @@
4
4
 
5
5
  import type { RunMetrics } from "../state/run-metrics.ts";
6
6
 
7
- export interface FeedbackLoopStats {
7
+ /** @internal */
8
+ interface FeedbackLoopStats {
8
9
  runsObserved: number;
9
10
  avgSuccessRate: number;
10
11
  recommendations: string[];
11
12
  }
12
13
 
13
- export class FeedbackLoop {
14
+ /** @internal */
15
+ class FeedbackLoop {
14
16
  private runs: RunMetrics[] = [];
15
17
  private static readonly MAX_RUNS = 1000;
16
18
 
@@ -8,6 +8,7 @@ import {
8
8
  PiTeamsConfigSchema,
9
9
  } from "../schema/config-schema.ts";
10
10
  import { withFileLockSync } from "../state/locks.ts";
11
+ import { atomicWriteFile } from "../state/atomic-write.ts";
11
12
  import { logInternalError } from "../utils/internal-error.ts";
12
13
  import { projectCrewRoot, projectPiRoot } from "../utils/paths.ts";
13
14
  import { suggestConfigKey } from "./suggestions.ts";
@@ -79,7 +80,17 @@ function resolveHomeDir(): string {
79
80
  // directory (e.g. withIsolatedHome) set PI_TEAMS_HOME to a tmp dir
80
81
  // under /tmp; we skip the check in test environments (NODE_ENV=test)
81
82
  // so existing tests don't break.
82
- if (process.env.NODE_ENV === "test" || process.env.PI_CREW_SKIP_HOME_CHECK === "1") {
83
+ if (process.env.PI_CREW_SKIP_HOME_CHECK === "1") {
84
+ return envValue;
85
+ }
86
+ // NOTE: NODE_ENV=test bypass is intentional for test isolation only.
87
+ // It allows tests to use isolated temporary directories (e.g. withIsolatedHome
88
+ // sets PI_TEAMS_HOME to /tmp). This is NOT a security boundary — tests that
89
+ // need the validation skipped should set PI_CREW_SKIP_HOME_CHECK=1 explicitly.
90
+ // WARNING: Tests must NOT rely on PI_TEAMS_HOME for security boundaries in
91
+ // test environments. Any test verifying path-restriction behavior should set
92
+ // PI_CREW_SKIP_HOME_CHECK=1 and validate the path directly.
93
+ if (process.env.NODE_ENV === "test") {
83
94
  return envValue;
84
95
  }
85
96
  try {
@@ -481,10 +492,22 @@ function mergeConfig(
481
492
  };
482
493
  if (Object.keys(merged.otlp.headers ?? {}).length === 0)
483
494
  delete merged.otlp.headers;
484
- // Validate OTLP headers for injection attacks (newlines, CR, null bytes)
495
+ // Validate OTLP headers for injection attacks:
496
+ // - Check top-level keys for dangerous prototype pollution patterns
497
+ // - Block all control characters (except tab=0x09, newline=0x0A) to prevent
498
+ // header injection via CR/LF/zero-byte/etc.
485
499
  const invalidHeaders: string[] = [];
486
500
  for (const [k, v] of Object.entries(merged.otlp.headers ?? {})) {
487
- if (/[\r\n\x00]/.test(String(v))) { invalidHeaders.push(k); }
501
+ // Check top-level key for dangerous names (only top-level keys are checked)
502
+ const checkKey = (key: string): boolean => {
503
+ const lowerKey = key.toLowerCase();
504
+ if (DANGEROUS_OBJECT_KEYS.has(lowerKey)) return true;
505
+ return false;
506
+ };
507
+ if (checkKey(k)) { invalidHeaders.push(k); continue; }
508
+ // Block any control characters except tab (0x09) in values
509
+ const valStr = String(v);
510
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(valStr)) { invalidHeaders.push(k); }
488
511
  }
489
512
  if (invalidHeaders.length > 0) {
490
513
  delete merged.otlp.headers;
@@ -511,10 +534,57 @@ const LIMIT_CEILINGS = {
511
534
  runtimeGraceTurns: 1_000,
512
535
  } as const;
513
536
 
514
- function asRecord(value: unknown): Record<string, unknown> | undefined {
537
+ /**
538
+ * Keys that could allow prototype pollution if merged into plain objects.
539
+ * NOTE: This set is comprehensive for ES2023 and earlier. When upgrading JavaScript
540
+ * versions, verify whether new dangerous Object.prototype properties have been added
541
+ * that could enable prototype pollution attacks.
542
+ */
543
+ const DANGEROUS_OBJECT_KEYS = new Set([
544
+ "__proto__",
545
+ "constructor",
546
+ "prototype",
547
+ "hasOwnProperty",
548
+ "toString",
549
+ "valueOf",
550
+ "isPrototypeOf",
551
+ "propertyIsEnumerable",
552
+ "toLocaleString",
553
+ "__defineGetter__",
554
+ "__defineSetter__",
555
+ "__lookupGetter__",
556
+ "__lookupSetter__",
557
+ ]);
558
+
559
+ /**
560
+ * Strips dangerous Object.prototype keys from an object.
561
+ * Returns a new object built with Object.create(null) to prevent
562
+ * prototype pollution attacks.
563
+ */
564
+ function sanitizeObject(obj: Record<string, unknown>): Record<string, unknown> {
565
+ const sanitized: Record<string, unknown> = Object.create(null);
566
+ for (const [key, value] of Object.entries(obj)) {
567
+ // Case-insensitive check to catch __Proto__, CONSTRUCTOR, etc.
568
+ const lowerKey = key.toLowerCase();
569
+ if (DANGEROUS_OBJECT_KEYS.has(lowerKey)) continue;
570
+ if (value && typeof value === "object" && !Array.isArray(value)) {
571
+ sanitized[key] = sanitizeObject(value as Record<string, unknown>);
572
+ } else {
573
+ sanitized[key] = value;
574
+ }
575
+ }
576
+ return sanitized;
577
+ }
578
+
579
+ export function asRecord(value: unknown): Record<string, unknown> | undefined {
515
580
  if (!value || typeof value !== "object" || Array.isArray(value))
516
581
  return undefined;
517
- return value as Record<string, unknown>;
582
+ // Defensive: create a sanitized copy to prevent prototype pollution.
583
+ // Uses Object.create(null) so the result has no prototype chain.
584
+ // WARNING: The returned object has no prototype methods (no hasOwnProperty,
585
+ // toString, etc.). Use Object.hasOwn(obj, key) or
586
+ // Object.prototype.hasOwnProperty.call(obj, key) for property checks.
587
+ return sanitizeObject(value as Record<string, unknown>);
518
588
  }
519
589
 
520
590
  function parseWithSchema<T extends TSchema>(
@@ -1190,7 +1260,29 @@ function unsetPath(record: Record<string, unknown>, dottedPath: string): void {
1190
1260
 
1191
1261
  function readConfigRecord(filePath: string): Record<string, unknown> {
1192
1262
  if (!fs.existsSync(filePath)) return {};
1193
- const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
1263
+ // Defense-in-depth: reject config files larger than 10 MB before parsing.
1264
+ // This prevents memory exhaustion and blocks deeply nested JSON that could
1265
+ // cause stack overflow during parsing.
1266
+ const MAX_CONFIG_SIZE = 10 * 1024 * 1024;
1267
+ const stat = fs.statSync(filePath);
1268
+ if (stat.size > MAX_CONFIG_SIZE) {
1269
+ logInternalError(
1270
+ "config.file-too-large",
1271
+ new Error(`config file exceeds ${MAX_CONFIG_SIZE} bytes`),
1272
+ `path=${filePath}; size=${stat.size}`,
1273
+ );
1274
+ return {};
1275
+ }
1276
+ // Parse with depth limit to prevent stack overflow from deeply nested JSON.
1277
+ // Nesting beyond 100 levels is almost certainly an attack or malformed file.
1278
+ const MAX_JSON_DEPTH = 100;
1279
+ let depth = 0;
1280
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"), (_key, value) => {
1281
+ if (++depth > MAX_JSON_DEPTH) {
1282
+ throw new Error(`config JSON exceeds max depth ${MAX_JSON_DEPTH}`);
1283
+ }
1284
+ return value;
1285
+ }) as unknown;
1194
1286
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
1195
1287
  return raw as Record<string, unknown>;
1196
1288
  }
@@ -1244,7 +1336,7 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
1244
1336
  const projectPath = projectConfigPath(cwd);
1245
1337
  const projectConfig = readOptionalConfig(projectPath);
1246
1338
  // SECURITY FIX: Merge project config FIRST, then user config on top.
1247
- // This ensures user preferences always take precedence over project settings.
1339
+ // Precedence formula: merge(projectConfig, userConfig) = userConfig wins.
1248
1340
  // Sensitive fields have already been sanitized by sanitizeProjectConfig.
1249
1341
  let effectiveConfig = {};
1250
1342
  if (projectConfig.exists) {
@@ -1257,6 +1349,7 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
1257
1349
  ...projectConfig.warnings,
1258
1350
  ...projectSafeConfig.warnings,
1259
1351
  );
1352
+ // merge(base=projectConfig, override=userConfig) → override wins
1260
1353
  effectiveConfig = mergeConfig(effectiveConfig, projectSafeConfig.config);
1261
1354
  }
1262
1355
  // User config always takes precedence over project config
@@ -1265,19 +1358,20 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
1265
1358
 
1266
1359
 
1267
1360
  // `.pi/pi-crew.json` is the project-owned config file.
1268
- // SECURITY FIX: User config takes precedence over project-level `.pi/pi-crew.json`.
1269
- // This prevents malicious project configs from overriding user preferences.
1361
+ // Merge project config FIRST (base), then user config on top (override).
1362
+ // This ensures user preferences always take precedence over project settings.
1363
+ // Sensitive fields have already been sanitized by sanitizeProjectConfig.
1270
1364
  const piCrewJsonPath = projectPiCrewJsonPath(cwd);
1271
1365
  const piCrewJsonConfig = readOptionalConfig(piCrewJsonPath);
1272
1366
  if (piCrewJsonConfig.exists) {
1273
1367
  warnings.push(...piCrewJsonConfig.warnings);
1274
- // Merge project config first, then user config on top
1275
1368
  const projectPart = sanitizeProjectConfig(
1276
1369
  piCrewJsonPath,
1277
1370
  config,
1278
1371
  piCrewJsonConfig.config,
1279
1372
  );
1280
1373
  warnings.push(...projectPart.warnings);
1374
+ // base=project config, override=user config → user wins
1281
1375
  const mergedProject = mergeConfig(projectPart.config, config);
1282
1376
  config = mergedProject;
1283
1377
  paths.push(piCrewJsonPath);
@@ -1319,10 +1413,9 @@ export function updateConfig(
1319
1413
  merged = parseConfig(raw);
1320
1414
  }
1321
1415
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1322
- fs.writeFileSync(
1416
+ atomicWriteFile(
1323
1417
  filePath,
1324
1418
  `${JSON.stringify(merged, null, 2)}\n`,
1325
- "utf-8",
1326
1419
  );
1327
1420
  return { path: filePath, config: merged };
1328
1421
  });
@@ -1349,11 +1442,9 @@ export function updateAutonomousConfig(
1349
1442
  ? (current.autonomous as Record<string, unknown>)
1350
1443
  : {};
1351
1444
  current.autonomous = { ...currentAutonomous, ...patch };
1352
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
1353
- fs.writeFileSync(
1445
+ atomicWriteFile(
1354
1446
  filePath,
1355
1447
  `${JSON.stringify(current, null, 2)}\n`,
1356
- "utf-8",
1357
1448
  );
1358
1449
  return { path: filePath, config: parseConfig(current) };
1359
1450
  });
package/src/errors.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Error code taxonomy for pi-crew.
3
+ * Maps to semantic categories matching fallow's E001-E004 pattern.
4
+ */
5
+ // Implemented as const object + type alias (not `enum`) so that Node's
6
+ // `--experimental-strip-types` can load this module. TypeScript `enum`
7
+ // syntax is not supported in strip-only mode.
8
+ export const ErrorCode = {
9
+ FileReadError: "E001", // Cannot read a file
10
+ FileWriteError: "E002", // Cannot write a file
11
+ TaskNotFound: "E003", // Referenced task ID does not exist
12
+ InvalidStatusTransition: "E004", // Run/task status cannot legally transition
13
+ ConfigError: "E005", // Malformed config or missing required field
14
+ ResourceNotFound: "E006", // Agent/team/workflow not found in discovery paths
15
+ } as const;
16
+
17
+ export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode];
18
+
19
+ const DEFAULT_HELP: Record<ErrorCode, string | undefined> = {
20
+ [ErrorCode.FileReadError]: "Check that the file exists and that the process has read permission.",
21
+ [ErrorCode.FileWriteError]: "Check that the disk is not full and that the process has write permission.",
22
+ [ErrorCode.TaskNotFound]: "The task may have been removed or the run may be in an inconsistent state. Use `team status` to verify.",
23
+ [ErrorCode.InvalidStatusTransition]: "Verify the run status using `team status` before retrying.",
24
+ [ErrorCode.ConfigError]: "Check the configuration file for syntax errors or missing required fields.",
25
+ [ErrorCode.ResourceNotFound]: "Use `team list` to see available agents, teams, and workflows.",
26
+ };
27
+
28
+ /**
29
+ * Structured error type for pi-crew.
30
+ * Display format:
31
+ * error[E001]: Failed to read manifest.json: not found
32
+ * context: while loading run state
33
+ * help: Check that the file exists and that the process has read permission.
34
+ */
35
+ export class CrewError extends Error {
36
+ readonly code: ErrorCode;
37
+ help?: string;
38
+ private _context?: string;
39
+
40
+ constructor(code: ErrorCode, message: string, help?: string) {
41
+ super(message);
42
+ this.name = "CrewError";
43
+ this.code = code;
44
+ this.help = help ?? DEFAULT_HELP[code];
45
+ Object.defineProperty(this, "message", { enumerable: true });
46
+ Object.defineProperty(this, "code", { enumerable: true });
47
+ }
48
+
49
+ withContext(context: string): this {
50
+ this._context = context;
51
+ return this;
52
+ }
53
+
54
+ withHelp(help: string): this {
55
+ this.help = help;
56
+ return this;
57
+ }
58
+
59
+ toString(): string {
60
+ let out = `error[${this.code}]: ${this.message}`;
61
+ if (this._context) out += `\n context: ${this._context}`;
62
+ if (this.help) out += `\n help: ${this.help}`;
63
+ return out;
64
+ }
65
+ }
66
+
67
+ export const errors = {
68
+ fileRead(path: string, source: NodeJS.ErrnoException): CrewError {
69
+ return new CrewError(
70
+ ErrorCode.FileReadError,
71
+ `Failed to read ${path}: ${source.code?.toLowerCase() ?? "unknown"}`,
72
+ ).withContext("file system read operation");
73
+ },
74
+
75
+ fileWrite(path: string, source: NodeJS.ErrnoException): CrewError {
76
+ return new CrewError(
77
+ ErrorCode.FileWriteError,
78
+ `Failed to write ${path}: ${source.code?.toLowerCase() ?? "unknown"}`,
79
+ ).withContext("file system write operation");
80
+ },
81
+
82
+ taskNotFound(taskId: string, runId?: string): CrewError {
83
+ const msg = runId
84
+ ? `Task '${taskId}' not found in run '${runId}'`
85
+ : `Task '${taskId}' not found`;
86
+ return new CrewError(ErrorCode.TaskNotFound, msg);
87
+ },
88
+
89
+ invalidStatusTransition(from: string, to: string): CrewError {
90
+ return new CrewError(
91
+ ErrorCode.InvalidStatusTransition,
92
+ `Invalid run status transition: ${from} -> ${to}`,
93
+ );
94
+ },
95
+
96
+ config(message: string): CrewError {
97
+ return new CrewError(ErrorCode.ConfigError, message)
98
+ .withContext("configuration loading");
99
+ },
100
+
101
+ resourceNotFound(type: string, name: string): CrewError {
102
+ return new CrewError(
103
+ ErrorCode.ResourceNotFound,
104
+ `${type} '${name}' not found in any discovery path`,
105
+ );
106
+ },
107
+ } as const;
@@ -47,7 +47,7 @@ function isTaskActive(task: TeamTaskState): boolean {
47
47
  }
48
48
 
49
49
  function markActiveTasksAndAgentsFailed(run: TeamRunManifest, message: string): void {
50
- const loaded = loadRunManifestById(run.cwd, run.runId);
50
+ const loaded = loadRunManifestById(run.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
51
51
  const tasks = loaded?.tasks ?? [];
52
52
  const failedAt = new Date().toISOString();
53
53
  if (tasks.some(isTaskActive)) {
@@ -73,7 +73,7 @@ export function markDeadAsyncRunIfNeeded(run: TeamRunManifest, now = Date.now(),
73
73
  const asyncPid = run.async.pid;
74
74
  const message = `Background runner died unexpectedly; check background.log (${liveness.detail}).`;
75
75
  return withRunLockSync(run, () => {
76
- const fresh = loadRunManifestById(run.cwd, run.runId);
76
+ const fresh = loadRunManifestById(run.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
77
77
  if (!fresh || !isActiveRunStatus(fresh.manifest.status)) return undefined;
78
78
  const failed = updateRunStatus(fresh.manifest, "failed", message);
79
79
  markActiveTasksAndAgentsFailed(failed, message);
@@ -125,6 +125,10 @@ export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifie
125
125
  logInternalError("async-notifier", error, `interval=${intervalMs}`);
126
126
  }
127
127
  }, intervalMs);
128
+ // Defense-in-depth: never let the notifier timer keep the event loop alive.
129
+ // If stopAsyncRunNotifier is missed (session switch race), the next run of
130
+ // this interval is harmless, but the timer must not block process exit.
131
+ if (typeof state.interval.unref === "function") state.interval.unref();
128
132
  }
129
133
 
130
134
  export function stopAsyncRunNotifier(state: AsyncNotifierState): void {
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { logInternalError } from "../utils/internal-error.ts";
3
+ import { cleanupAllTrackedTempDirs } from "../runtime/pi-args.ts";
3
4
  // NOTE: globalProgressTracker import kept for documentation but not directly used
4
5
  // since we don't have agent IDs to untrack. Actual progress clearing should be
5
6
  // handled by the progress tracker itself on shutdown.
@@ -112,12 +113,14 @@ async function cleanupChildProcesses(): Promise<void> {
112
113
  }
113
114
 
114
115
  async function cleanupTempDirectories(): Promise<void> {
115
- // NOTE: getTempDir is not available in paths.ts.
116
- // For now, just log that cleanup is pending.
117
- // Actual temp directory cleanup should be implemented by the run-graph
118
- // or the specific code that creates temporary workspaces.
116
+ // Clean up every temp dir created in this process. Previously this was
117
+ // a stub that just logged; it caused /tmp/pi-crew-* dirs to accumulate
118
+ // from killed test runs and child-pi invocations. See issue #<n>.
119
119
  try {
120
- console.log(`[pi-crew] Temp directory cleanup deferred to run-graph`);
120
+ const result = cleanupAllTrackedTempDirs();
121
+ if (result.cleaned > 0) {
122
+ console.log(`[pi-crew] Cleaned ${result.cleaned} tracked temp dirs (${result.failed} failed)`);
123
+ }
121
124
  } catch (error) {
122
125
  logInternalError("crew-cleanup.temp", error);
123
126
  }
@@ -32,6 +32,8 @@ function requestId(raw: unknown): string | undefined {
32
32
 
33
33
  function reply(events: EventBusLike, channel: string, id: string | undefined, payload: RpcReply): void {
34
34
  if (!id) return;
35
+ // SECURITY: Validate requestId format to prevent channel injection.
36
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) return;
35
37
  events.emit(`${channel}:reply:${id}`, payload);
36
38
  }
37
39
 
@@ -59,6 +61,36 @@ function isAllowedRpcOperation(operation: string): boolean {
59
61
  return RPC_ALLOWED_OPERATIONS.has(operation);
60
62
  }
61
63
 
64
+ // SECURITY (HIGH #4 fix): In-memory rate limiter for RPC run requests.
65
+ // Prevents any extension from spawning unlimited child processes.
66
+ const RPC_RATE_LIMIT_MAX = 5; // Max 5 RPC run requests...
67
+ const RPC_RATE_LIMIT_WINDOW_MS = 60_000; // ...per 60 seconds
68
+ const rpcRunTimestamps: number[] = [];
69
+
70
+ function checkRpcRateLimit(): { allowed: boolean; retryAfterMs?: number } {
71
+ const now = Date.now();
72
+ // Evict entries older than the window
73
+ const cutoff = now - RPC_RATE_LIMIT_WINDOW_MS;
74
+ while (rpcRunTimestamps.length > 0 && rpcRunTimestamps[0] < cutoff) {
75
+ rpcRunTimestamps.shift();
76
+ }
77
+ if (rpcRunTimestamps.length >= RPC_RATE_LIMIT_MAX) {
78
+ const oldestInWindow = rpcRunTimestamps[0];
79
+ const retryAfterMs = oldestInWindow + RPC_RATE_LIMIT_WINDOW_MS - now;
80
+ return { allowed: false, retryAfterMs: Math.max(retryAfterMs, 1000) };
81
+ }
82
+ return { allowed: true };
83
+ }
84
+
85
+ function recordRpcRun(): void {
86
+ rpcRunTimestamps.push(Date.now());
87
+ }
88
+
89
+ /** Reset the RPC rate limiter. Used primarily for testing. */
90
+ export function resetRpcRateLimit(): void {
91
+ rpcRunTimestamps.length = 0;
92
+ }
93
+
62
94
  function isAllowedRpcRunParams(params: TeamToolParamsValue): { ok: boolean; error?: string } {
63
95
  // SECURITY: Require explicit intent for any RPC-initiated run creation.
64
96
  // This prevents malicious extensions from spawning child Pi processes silently.
@@ -83,6 +115,12 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => vo
83
115
  return typeof unsub === "function" ? unsub : () => {};
84
116
  }
85
117
 
118
+ // SECURITY TRUST BOUNDARY: RPC channels (pi-crew:rpc:run, pi-crew:rpc:status,
119
+ // pi-crew:rpc:live-control) are accessible to any extension on the shared event
120
+ // bus. Mitigations applied: rate limiting (RPC_RATE_LIMIT_MAX), explicit intent
121
+ // requirement for runs, operation allowlist for live-control reads, and cwd
122
+ // containment validation. A full fix requires event-bus-level origin signing.
123
+
86
124
  export function registerPiCrewRpc(events: EventBusLike | undefined, getCtx: () => ExtensionContext | undefined): PiCrewRpcHandle | undefined {
87
125
  if (!events) return undefined;
88
126
  const unsubs = [
@@ -90,6 +128,16 @@ export function registerPiCrewRpc(events: EventBusLike | undefined, getCtx: () =
90
128
  on(events, "pi-crew:rpc:run", async (raw) => {
91
129
  const id = requestId(raw);
92
130
  try {
131
+ // SECURITY (HIGH #4 fix): Rate limit RPC run requests
132
+ const rateLimit = checkRpcRateLimit();
133
+ if (!rateLimit.allowed) {
134
+ reply(events, "pi-crew:rpc:run", id, {
135
+ success: false,
136
+ error: `RPC run rate limit exceeded. Max ${RPC_RATE_LIMIT_MAX} requests per ${RPC_RATE_LIMIT_WINDOW_MS / 1000}s. Retry after ${Math.ceil((rateLimit.retryAfterMs ?? 60000) / 1000)}s.`,
137
+ });
138
+ return;
139
+ }
140
+ recordRpcRun();
93
141
  const ctx = getCtx();
94
142
  if (!ctx) throw new Error("No active pi-crew session context.");
95
143
  // Validate payload: only allow known fields from TeamToolParamsValue