pi-crew 0.3.0 → 0.3.2
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 +32 -0
- package/package.json +2 -1
- package/src/extension/register.ts +2 -0
- package/src/extension/run-import.ts +13 -4
- package/src/runtime/background-runner.ts +15 -10
- package/src/runtime/child-pi.ts +1 -1
- package/src/runtime/iteration-hooks.ts +35 -6
- package/src/runtime/live-agent-manager.ts +1 -1
- package/src/runtime/role-permission.ts +7 -1
- package/src/worktree/worktree-manager.ts +29 -2
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.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
|
|
5
5
|
"author": "baphuongna",
|
|
6
6
|
"license": "MIT",
|
|
@@ -88,6 +88,7 @@
|
|
|
88
88
|
"@mariozechner/pi-agent-core": "^0.65.0",
|
|
89
89
|
"@mariozechner/pi-ai": "^0.65.0",
|
|
90
90
|
"@mariozechner/pi-coding-agent": "^0.65.0",
|
|
91
|
+
"@mariozechner/pi-tui": "^0.65.0",
|
|
91
92
|
"esbuild": "^0.28.0",
|
|
92
93
|
"typescript": "^5.9.3"
|
|
93
94
|
},
|
|
@@ -459,6 +459,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
459
459
|
if (manifest) void import("../state/event-log.ts").then(({ appendEventFireAndForget }) => appendEventFireAndForget(manifest.eventsPath, event as Parameters<typeof appendEventFireAndForget>[1]));
|
|
460
460
|
};
|
|
461
461
|
registry.waitForAll = async (runId: string) => {
|
|
462
|
+
// LAZY: state-store only needed for post-completion polling (waitForAll) and sync hasRunning check; avoid at startup.
|
|
462
463
|
const { loadRunManifestById } = await import("../state/state-store.ts");
|
|
463
464
|
const check = (): boolean => {
|
|
464
465
|
const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
|
|
@@ -470,6 +471,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
470
471
|
registry.hasRunning = (runId: string) => {
|
|
471
472
|
const manifest = manifestCacheForRegistry.get(runId);
|
|
472
473
|
if (!manifest) return false;
|
|
474
|
+
// LAZY: state-store only needed in hasRunning; avoid at startup.
|
|
473
475
|
const { loadRunManifestById } = require("../state/state-store.ts");
|
|
474
476
|
const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
|
|
475
477
|
if (!loaded) return false;
|
|
@@ -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:
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -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
|
|
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,29 @@ 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
|
+
// Normalize to forward slashes for consistent cross-platform comparison.
|
|
76
|
+
// e.g., "C:\\Users\\runner\\.pi\\hooks\\hook.sh" matches
|
|
77
|
+
// "C:\\Users\\runner\\.pi\\hooks/hook.sh" from path.join.
|
|
78
|
+
const normalizedHookPath = hookPath.replace(/\\/g, "/");
|
|
79
|
+
const homeHooksNormalized = (process.env.HOME ?? "").replace(/\\/g, "/") + "/.pi/hooks";
|
|
80
|
+
return normalizedHookPath === homeHooksNormalized || normalizedHookPath.startsWith(homeHooksNormalized + "/");
|
|
81
|
+
}
|
|
82
|
+
|
|
59
83
|
/**
|
|
60
84
|
* Create a not-fired result for when the hook script is absent or not executable.
|
|
61
85
|
*/
|
|
@@ -113,9 +137,9 @@ function isScriptRunnable(scriptPath: string): boolean {
|
|
|
113
137
|
* Spawns `bash <script>` with the hook payload as JSON on stdin.
|
|
114
138
|
* Captures stdout (capped at 8KB) and stderr. Enforces a 30-second timeout.
|
|
115
139
|
*
|
|
116
|
-
* **Security note:**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
140
|
+
* **Security note:** Hook paths are restricted to `.hooks/` relative paths
|
|
141
|
+
* or `$HOME/.pi/hooks/` absolute paths. All other paths are rejected before
|
|
142
|
+
* execution.
|
|
119
143
|
*
|
|
120
144
|
* @param payload - Structured hook payload
|
|
121
145
|
* @param hookScriptPath - Absolute or relative path to the hook script
|
|
@@ -126,7 +150,12 @@ export async function runIterationHook(
|
|
|
126
150
|
hookScriptPath: string,
|
|
127
151
|
options?: { timeoutMs?: number },
|
|
128
152
|
): Promise<HookResult> {
|
|
129
|
-
if (!
|
|
153
|
+
if (!isAllowedHookPath(hookScriptPath)) {
|
|
154
|
+
return { fired: false, stdout: "", stderr: "hook path not allowed: " + hookScriptPath, exitCode: null, timedOut: false, durationMs: 0 };
|
|
155
|
+
}
|
|
156
|
+
// Resolve relative paths relative to cwd
|
|
157
|
+
const resolvedScript = path.isAbsolute(hookScriptPath) ? hookScriptPath : path.join(payload.cwd, hookScriptPath);
|
|
158
|
+
if (!isScriptRunnable(resolvedScript)) {
|
|
130
159
|
return notFiredResult();
|
|
131
160
|
}
|
|
132
161
|
|
|
@@ -136,7 +165,7 @@ export async function runIterationHook(
|
|
|
136
165
|
const stderrChunks: Buffer[] = [];
|
|
137
166
|
|
|
138
167
|
return new Promise<HookResult>((resolve) => {
|
|
139
|
-
const { command, args } = resolveShellForScript(
|
|
168
|
+
const { command, args } = resolveShellForScript(resolvedScript);
|
|
140
169
|
const child = spawn(command, args, {
|
|
141
170
|
cwd: payload.cwd,
|
|
142
171
|
env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_*"] }), PI_CREW_HOOK: "1" },
|
|
@@ -264,4 +293,4 @@ export function hookLogEntry(
|
|
|
264
293
|
}
|
|
265
294
|
|
|
266
295
|
return entry;
|
|
267
|
-
}
|
|
296
|
+
}
|
|
@@ -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
|
|
75
|
-
if (!
|
|
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,
|