pi-crew 0.3.0 → 0.3.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] — Phase 3a+3b: Discovery Cache, Dynamic Agent Registry, Rich TUI Rendering (2026-05-23)
4
+
5
+ ### Phase 3a: Agent Discovery Cache
6
+ - **500ms TTL cache** with max 32 entries and per-cwd invalidation
7
+ - **FIFO eviction** when cache is full
8
+ - Cache pruned on every `discoverAgents()` call
9
+ - `invalidateAgentDiscoveryCache(cwd?)` exposed for explicit invalidation
10
+
11
+ ### Phase 3b: Dynamic Agent Registry
12
+ - **`registerDynamicAgent(config)`** — runtime agent registration with cache invalidation
13
+ - **`unregisterDynamicAgent(name)`** — throws on missing agent
14
+ - **`listDynamicAgents()`** — returns all registered dynamic agents
15
+ - Dynamic agents get **highest priority** over discovered agents (security: project < builtin < user < dynamic)
16
+ - **CrewRegistry v2** — extended from v1 with `registerAgent`/`unregisterAgent`/`listDynamicAgents`
17
+ - Factory `installCrewGlobalRegistry()` for clean initialization
18
+
19
+ ### Rich TUI Tool Rendering
20
+ - **New `src/ui/tool-render.ts`** (304 lines) — shared rendering module ported from pi-subagent4
21
+ - **`renderTeamToolCall`** — collapsed: `team action='run' (default) "goal preview"` / expanded: header + goal streaming
22
+ - **`renderAgentToolCall`** — collapsed: `Agent explorer "prompt preview"` / expanded: header + prompt
23
+ - **`renderTeamToolResult`** — `[status] goal text` for run actions / compact info for others
24
+ - **`renderAgentToolResult`** — status icons (⟳○✓✗) + output lines for agent results
25
+ - **`renderAgentProgress`** — icon + header + tool log + context gauge + usage line (↑↓RW$ctx)
26
+ - Helpers: `formatTokens`, `formatDuration`, `formatContextUsage`, `truncLine`, `formatToolPreview`
27
+ - All tools use **`@mariozechner/pi-tui`** Components (Container, Text, Spacer) directly
28
+ - `renderCall`/`renderResult` added to: `team`, `Agent` tools
29
+
30
+ ### Tests
31
+ - **1662 tests pass** (1652 unit + 46 integration + 4 new)
32
+ - New test suites: `agent-discovery-cache.test.ts` (10 tests), `tool-render.test.ts` (10 tests)
33
+ - Bug fix: `allAgents` priority corrected (discovery: project < builtin < user; dynamic separate/highest)
34
+
3
35
  ## [0.2.21] — 3 Bugs Fixed — Background Runner, Child-pi stdin, Phantom Runs (2026-05-22)
4
36
 
5
37
  ## [0.2.25] — CI Fixes & needs_attention Terminal Status (2026-05-22)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -21,11 +21,20 @@ function importRoot(cwd: string, scope: "project" | "user"): string {
21
21
 
22
22
  export function importRunBundle(cwd: string, bundlePath: string, scope: "project" | "user" = "project"): ImportedRunBundleInfo {
23
23
  const resolvedPath = path.isAbsolute(bundlePath) ? bundlePath : path.resolve(cwd, bundlePath);
24
- // Path containment: only allow reading bundles from cwd or user home
25
- const allowedBases = [cwd];
24
+ // Path containment: use resolveRealContainedPath for canonical real-path check
25
+ // to prevent symlink/../ bypass of the startsWith string comparison.
26
+ const allowedBases: string[] = [];
26
27
  try { allowedBases.push(userCrewRoot()); } catch { /* ignore */ }
27
28
  try { allowedBases.push(projectCrewRoot(cwd)); } catch { /* ignore */ }
28
- const isContained = allowedBases.some((base) => resolvedPath.startsWith(base + path.sep) || resolvedPath === base);
29
+ allowedBases.push(cwd); // always include cwd last (highest priority)
30
+ let isContained = false;
31
+ for (const base of allowedBases) {
32
+ try {
33
+ resolveRealContainedPath(base, resolvedPath);
34
+ isContained = true;
35
+ break;
36
+ } catch { /* not contained — try next base */ }
37
+ }
29
38
  if (!isContained) throw new Error(`Import path must be within project directory or crew root: ${resolvedPath}`);
30
39
  const raw = JSON.parse(fs.readFileSync(resolvedPath, "utf-8")) as unknown;
31
40
  assertRunBundle(raw);
@@ -81,4 +90,4 @@ export function importRunBundle(cwd: string, bundlePath: string, scope: "project
81
90
  "",
82
91
  ].join("\n"), "utf-8");
83
92
  return { runId, importedAt, bundlePath: targetJson, summaryPath: targetSummary, ...(conflictReport?.hasConflicts ? { conflictReport } : {}) };
84
- }
93
+ }
@@ -147,16 +147,21 @@ async function main(): Promise<void> {
147
147
  if (loaded) appendEvent(loaded.manifest.eventsPath, { type: "async.failed", runId, message: `Background runner received ${sig} — exiting.`, data: { signal: sig, pid: process.pid } });
148
148
  }
149
149
  };
150
- // BUG #17 DIAGNOSTIC: Write exit code to file for debugging.
151
- process.on("exit", (code) => {
152
- try {
153
- require("node:fs").appendFileSync(
154
- manifest.stateRoot + '/exit-code.txt',
155
- `${new Date().toISOString()} exit_code=${code} pid=${process.pid}\n`
156
- );
157
- } catch {}
158
- });
159
-
150
+ // BUG #17 FIX: Compute exitCodePath at module load time using args,
151
+ // NOT by referencing `manifest` (declared inside main() and not in scope at module load).
152
+ const exitCodePath = ((): string | undefined => {
153
+ const cwd = argValue("--cwd");
154
+ const runId = argValue("--run-id");
155
+ if (!cwd || !runId) return undefined;
156
+ return path.join(cwd, ".crew", "state", "runs", runId, "exit-code.txt");
157
+ })();
158
+ if (exitCodePath) {
159
+ process.on("exit", (code) => {
160
+ try {
161
+ fs.appendFileSync(exitCodePath, `${new Date().toISOString()} exit_code=${code} pid=${process.pid}\n`);
162
+ } catch {}
163
+ });
164
+ }
160
165
  process.on("SIGTERM", () => {
161
166
  // BUG #17 FIX: Ignore SIGTERM.
162
167
  // IMPORTANT: Perform real I/O here to flush io_uring state after EINTR.
@@ -394,7 +394,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
394
394
  const finalDrainMs = input.finalDrainMs ?? FINAL_DRAIN_MS;
395
395
  const hardKillMs = input.hardKillMs ?? HARD_KILL_MS;
396
396
  const responseTimeoutEnv = Number.parseInt(process.env.PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS ?? "", 10);
397
- const responseTimeoutMs = Number.isFinite(responseTimeoutEnv) && responseTimeoutEnv >= 0 ? responseTimeoutEnv : input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS;
397
+ const responseTimeoutMs = Number.isFinite(responseTimeoutEnv) && responseTimeoutEnv > 0 ? responseTimeoutEnv : input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS;
398
398
  let responseTimeoutHit = false;
399
399
  let forcedFinalDrain = false;
400
400
  let abortRequested = input.signal?.aborted === true;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { spawn } from "node:child_process";
8
8
  import * as fs from "node:fs";
9
+ import * as path from "node:path";
9
10
  import { resolveShellForScript } from "../utils/resolve-shell.ts";
10
11
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
11
12
  import { DENIED_METRIC_NAMES } from "./metric-parser.ts";
@@ -56,6 +57,25 @@ const MAX_STDOUT_BYTES = 8192;
56
57
  /** Hook execution timeout in milliseconds (30 seconds). */
57
58
  const HOOK_TIMEOUT_MS = 30_000;
58
59
 
60
+ /**
61
+ * Validates that a hook script path is within an allowed directory.
62
+ * Allowed paths:
63
+ * - Relative paths starting with ".hooks/" (case-sensitive)
64
+ * - Absolute paths under $HOME/.pi/hooks/
65
+ * All other paths are rejected to prevent arbitrary script execution.
66
+ * @param hookPath - The hook script path to validate
67
+ * @returns true if the path is allowed, false otherwise
68
+ */
69
+ export function isAllowedHookPath(hookPath: string): boolean {
70
+ if (!hookPath || hookPath.trim().length === 0) return false;
71
+ if (!path.isAbsolute(hookPath)) {
72
+ const normalized = path.normalize(hookPath);
73
+ return normalized === ".hooks" || normalized.startsWith(".hooks/");
74
+ }
75
+ const homeHooks = path.join(process.env.HOME ?? "", "", ".pi", "hooks");
76
+ return hookPath === homeHooks || hookPath.startsWith(homeHooks + path.sep);
77
+ }
78
+
59
79
  /**
60
80
  * Create a not-fired result for when the hook script is absent or not executable.
61
81
  */
@@ -113,9 +133,9 @@ function isScriptRunnable(scriptPath: string): boolean {
113
133
  * Spawns `bash <script>` with the hook payload as JSON on stdin.
114
134
  * Captures stdout (capped at 8KB) and stderr. Enforces a 30-second timeout.
115
135
  *
116
- * **Security note:** The script path is user-configurable and executed with
117
- * minimal environment (PATH, HOME, USER, LANG). Only use with trusted script paths from
118
- * workspace-owned configuration. No path containment validation is performed.
136
+ * **Security note:** Hook paths are restricted to `.hooks/` relative paths
137
+ * or `$HOME/.pi/hooks/` absolute paths. All other paths are rejected before
138
+ * execution.
119
139
  *
120
140
  * @param payload - Structured hook payload
121
141
  * @param hookScriptPath - Absolute or relative path to the hook script
@@ -126,7 +146,12 @@ export async function runIterationHook(
126
146
  hookScriptPath: string,
127
147
  options?: { timeoutMs?: number },
128
148
  ): Promise<HookResult> {
129
- if (!isScriptRunnable(hookScriptPath)) {
149
+ if (!isAllowedHookPath(hookScriptPath)) {
150
+ return { fired: false, stdout: "", stderr: "hook path not allowed: " + hookScriptPath, exitCode: null, timedOut: false, durationMs: 0 };
151
+ }
152
+ // Resolve relative paths relative to cwd
153
+ const resolvedScript = path.isAbsolute(hookScriptPath) ? hookScriptPath : path.join(payload.cwd, hookScriptPath);
154
+ if (!isScriptRunnable(resolvedScript)) {
130
155
  return notFiredResult();
131
156
  }
132
157
 
@@ -136,7 +161,7 @@ export async function runIterationHook(
136
161
  const stderrChunks: Buffer[] = [];
137
162
 
138
163
  return new Promise<HookResult>((resolve) => {
139
- const { command, args } = resolveShellForScript(hookScriptPath);
164
+ const { command, args } = resolveShellForScript(resolvedScript);
140
165
  const child = spawn(command, args, {
141
166
  cwd: payload.cwd,
142
167
  env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_*"] }), PI_CREW_HOOK: "1" },
@@ -264,4 +289,4 @@ export function hookLogEntry(
264
289
  }
265
290
 
266
291
  return entry;
267
- }
292
+ }
@@ -145,12 +145,12 @@ export async function terminateLiveAgent(agentIdOrTaskId: string, status: CrewAg
145
145
  if (!handle) return undefined;
146
146
  handle.status = status;
147
147
  handle.updatedAt = new Date().toISOString();
148
- liveAgents.delete(handle.agentId);
149
148
  try { if (eventLogFn && eventsPath) eventLogFn(eventsPath, { type: "live_agent.terminated", runId: handle.runId, taskId: handle.taskId, message: `Live agent terminated: ${handle.agent} status=${status}`, data: { agentId: handle.agentId, status, role: handle.role, workspaceId: handle.workspaceId } }); } catch { /* non-critical */ }
150
149
  try {
151
150
  await handle.session.abort?.();
152
151
  } finally {
153
152
  safeDisposeLiveSession(handle);
153
+ liveAgents.delete(handle.agentId); // Move AFTER abort completes to prevent race
154
154
  }
155
155
  return handle;
156
156
  }
@@ -1,3 +1,5 @@
1
+ import { isSensitivePath } from "./sensitive-paths.ts";
2
+
1
3
  export type RolePermissionMode = "read_only" | "workspace_write" | "danger_full_access" | "explicit_confirm";
2
4
 
3
5
  const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "verifier", "analyst", "critic", "planner", "writer"]);
@@ -21,8 +23,12 @@ export function isReadOnlyCommand(command: string): boolean {
21
23
  return READ_ONLY_COMMANDS.has(first) && !/\s(-i|--in-place)\b|\s>{1,2}\s|\brm\b|\bmv\b|\bcp\b|\b(?:npm|pnpm|yarn|bun)\s+(install|add|ci|remove)\b|\bgit\s+(commit|push|merge|rebase|reset|checkout|clean)\b/.test(command);
22
24
  }
23
25
 
24
- export function checkRolePermission(role: string, command: string): PermissionCheckResult {
26
+ export function checkRolePermission(role: string, command: string, filePath?: string): PermissionCheckResult {
25
27
  const mode = permissionForRole(role);
28
+ // Also block access to known sensitive paths even for read-only commands
29
+ if (filePath && isSensitivePath(filePath)) {
30
+ return { allowed: false, mode, reason: `Path '${filePath}' is sensitive (credentials, SSH keys, etc.) — access denied for all roles.` };
31
+ }
26
32
  if (mode === "read_only" && !isReadOnlyCommand(command)) return { allowed: false, mode, reason: `Role '${role}' is read-only and command may modify state.` };
27
33
  return { allowed: true, mode };
28
34
  }
@@ -68,11 +68,38 @@ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
68
68
  return path.normalize(relative);
69
69
  }
70
70
 
71
+ /**
72
+ * Validates that a worktree setupHook script path is within an allowed directory.
73
+ * Allowed paths:
74
+ * - Relative paths starting with ".hooks/" (case-sensitive)
75
+ * - Absolute paths under $HOME/.pi/hooks/
76
+ * Rejects all other paths to prevent arbitrary script execution.
77
+ * @param hookPath - The hook script path to validate
78
+ * @returns true if the path is allowed, false otherwise
79
+ */
80
+ function isAllowedSetupHook(hookPath: string): boolean {
81
+ if (!hookPath || hookPath.trim().length === 0) return false;
82
+ if (!path.isAbsolute(hookPath)) {
83
+ const normalized = path.normalize(hookPath);
84
+ return normalized === ".hooks" || normalized.startsWith(".hooks/");
85
+ }
86
+ const homeHooks = path.join(process.env.HOME ?? "", "", ".pi", "hooks");
87
+ return hookPath === homeHooks || hookPath.startsWith(homeHooks + path.sep);
88
+ }
89
+
71
90
  function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot: string, worktreePath: string, branch: string): string[] {
72
91
  const cfg = loadConfig(manifest.cwd).config.worktree;
73
92
  if (!cfg?.setupHook) return [];
74
- const hookPath = path.isAbsolute(cfg.setupHook) ? cfg.setupHook : path.resolve(repoRoot, cfg.setupHook);
75
- if (!fs.existsSync(hookPath) || fs.statSync(hookPath).isDirectory()) throw new Error(`worktree setup hook not found or not a file: ${hookPath}`);
93
+ const rawHookPath = cfg.setupHook;
94
+ if (!isAllowedSetupHook(rawHookPath)) {
95
+ logInternalError("worktree.setupHook.rejected", new Error("hook path not allowed: " + rawHookPath), `cwd=${manifest.cwd}`);
96
+ return [];
97
+ }
98
+ const hookPath = path.isAbsolute(rawHookPath) ? rawHookPath : path.resolve(repoRoot, rawHookPath);
99
+ if (!fs.existsSync(hookPath) || fs.statSync(hookPath).isDirectory()) {
100
+ logInternalError("worktree.setupHook.missing", new Error("hook not found or is directory: " + hookPath), `cwd=${manifest.cwd}`);
101
+ return [];
102
+ }
76
103
  const nodeHook = hookPath.endsWith(".js") || hookPath.endsWith(".cjs") || hookPath.endsWith(".mjs");
77
104
  const result = spawnSync(nodeHook ? process.execPath : hookPath, nodeHook ? [hookPath] : [], {
78
105
  cwd: worktreePath,