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.
- package/CHANGELOG.md +225 -0
- package/README.md +70 -31
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +4 -3
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +3 -1
- package/src/extension/registration/team-tool.ts +3 -1
- package/src/extension/registration/viewers.ts +3 -2
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +12 -7
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +175 -47
- package/src/hooks/registry.ts +84 -12
- package/src/hooks/types.ts +12 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +86 -3
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +67 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +165 -38
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +6 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +4 -5
- package/src/runtime/dynamic-script-runner.ts +9 -6
- package/src/runtime/foreground-watchdog.ts +3 -3
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +11 -4
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +42 -21
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +109 -64
- package/src/runtime/skill-instructions.ts +114 -33
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +265 -53
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +98 -67
- package/src/runtime/team-runner.ts +186 -20
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/contracts.ts +2 -1
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +270 -75
- package/src/state/health-store.ts +71 -0
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +109 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +333 -44
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +4 -3
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +287 -10
- package/src/worktree/cleanup.ts +117 -26
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/src/tools/safe-bash.ts
CHANGED
|
@@ -39,7 +39,8 @@ const DANGEROUS_PATTERNS = [
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Linear-time check if command contains a dangerous rm pattern like "rm -rf /" or "rm -rf ~"
|
|
42
|
-
* Replaces O(n²) regex backtracking with O(n) string scanning
|
|
42
|
+
* Replaces O(n²) regex backtracking with O(n) string scanning.
|
|
43
|
+
* Expanded to also block: rm -rf /etc/*, rm --recursive --force /, rm -rf ~/.ssh, etc.
|
|
43
44
|
*/
|
|
44
45
|
function matchesDangerousRm(command: string): boolean {
|
|
45
46
|
let pos = 0;
|
|
@@ -56,32 +57,75 @@ function matchesDangerousRm(command: string): boolean {
|
|
|
56
57
|
// Must be followed by whitespace
|
|
57
58
|
const afterRm = rmIdx + 2;
|
|
58
59
|
if (afterRm >= len || /\s/.test(command[afterRm])) {
|
|
59
|
-
// Found "rm " - now check for
|
|
60
|
+
// Found "rm " - now check for recursive/force flags
|
|
60
61
|
let p = afterRm + 1;
|
|
62
|
+
let hasR = false;
|
|
63
|
+
let hasF = false;
|
|
61
64
|
while (p < len) {
|
|
62
65
|
// Skip whitespace
|
|
63
66
|
while (p < len && /\s/.test(command[p])) p++;
|
|
64
67
|
if (p >= len) break;
|
|
65
|
-
// Check for
|
|
66
|
-
if (command[p] !== "-")
|
|
67
|
-
p++;
|
|
68
|
-
let hasR = false, hasF = false;
|
|
69
|
-
while (p < len && /[a-zA-Z]/.test(command[p])) {
|
|
70
|
-
if (command[p] === "r" || command[p] === "R") hasR = true;
|
|
71
|
-
if (command[p] === "f" || command[p] === "F") hasF = true;
|
|
68
|
+
// Check for short flags (-r, -f, -rf, -R, -F, etc.)
|
|
69
|
+
if (command[p] === "-" && p + 1 < len && /[a-zA-Z]/.test(command[p + 1]) && command[p + 1] !== "-") {
|
|
72
70
|
p++;
|
|
71
|
+
while (p < len && /[a-zA-Z]/.test(command[p])) {
|
|
72
|
+
if (command[p] === "r" || command[p] === "R") hasR = true;
|
|
73
|
+
if (command[p] === "f" || command[p] === "F") hasF = true;
|
|
74
|
+
p++;
|
|
75
|
+
}
|
|
76
|
+
// Skip whitespace after flag
|
|
77
|
+
while (p < len && /\s/.test(command[p])) p++;
|
|
78
|
+
continue;
|
|
73
79
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
// Check for long flags (--recursive, --force)
|
|
81
|
+
if (command[p] === "-" && p + 1 < len && command[p + 1] === "-") {
|
|
82
|
+
p += 2;
|
|
83
|
+
const flagStart = p;
|
|
84
|
+
while (p < len && /[a-zA-Z]/.test(command[p])) p++;
|
|
85
|
+
const flagName = command.slice(flagStart, p);
|
|
86
|
+
if (flagName === "recursive") hasR = true;
|
|
87
|
+
if (flagName === "force") hasF = true;
|
|
88
|
+
// Skip whitespace after flag
|
|
89
|
+
while (p < len && /\s/.test(command[p])) p++;
|
|
90
|
+
continue;
|
|
83
91
|
}
|
|
92
|
+
// Not a flag — stop parsing flags
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
// Must have both -r and -f (or equivalents) to be dangerous
|
|
96
|
+
if (!hasR || !hasF) {
|
|
97
|
+
pos = rmIdx + 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Now check if followed by dangerous targets
|
|
101
|
+
if (p >= len) {
|
|
102
|
+
pos = rmIdx + 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// Block: ~ (home directory references)
|
|
106
|
+
const charAtP = command[p];
|
|
107
|
+
if (charAtP === "~") return true; // Home directory reference
|
|
108
|
+
// Block: / (root or dangerous system paths)
|
|
109
|
+
if (charAtP === "/") {
|
|
110
|
+
// Exact root '/' with nothing after
|
|
111
|
+
if (p + 1 >= len || /\s/.test(command[p + 1]) || command[p + 1] === ";") return true;
|
|
112
|
+
// Block dangerous system paths
|
|
113
|
+
const rest = command.slice(p);
|
|
114
|
+
if (/^\/etc[\/\s;]/.test(rest) || rest === "/etc") return true;
|
|
115
|
+
if ((/^\/var\/(?!tmp)/.test(rest)) || rest === "/var") return true;
|
|
116
|
+
if (/^\/usr[\/\s;]/.test(rest) || rest === "/usr") return true;
|
|
117
|
+
if (/^\/boot[\/\s;]/.test(rest) || rest === "/boot") return true;
|
|
118
|
+
if (/^\/sys[\/\s;]/.test(rest) || rest === "/sys") return true;
|
|
119
|
+
if (/^\/proc[\/\s;]/.test(rest) || rest === "/proc") return true;
|
|
120
|
+
if (/^\/dev[\/\s;]/.test(rest) || rest === "/dev") return true;
|
|
121
|
+
if (/^\/root[\/\s;]/.test(rest) || rest === "/root") return true;
|
|
122
|
+
if (/^\/home[\/\s;]/.test(rest) || rest === "/home") return true;
|
|
123
|
+
// /tmp/ and other non-system absolute paths are allowed
|
|
84
124
|
}
|
|
125
|
+
// Check for sensitive relative paths: .ssh, .gnupg
|
|
126
|
+
const rest = command.slice(p);
|
|
127
|
+
if (/^\.ssh[\/\\\s;]/.test(rest)) return true;
|
|
128
|
+
if (/^\.gnupg[\/\\\s;]/.test(rest)) return true;
|
|
85
129
|
}
|
|
86
130
|
pos = rmIdx + 1;
|
|
87
131
|
}
|
|
@@ -172,8 +216,13 @@ export function isDangerous(command: string, options: SafeBashOptions = {}): str
|
|
|
172
216
|
}
|
|
173
217
|
}
|
|
174
218
|
|
|
175
|
-
// Normalize: remove line continuations, collapse whitespace
|
|
176
|
-
const normalized = command
|
|
219
|
+
// Normalize: strip ANSI escapes and control chars, remove line continuations, collapse whitespace
|
|
220
|
+
const normalized = command
|
|
221
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // strip ANSI escapes
|
|
222
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // strip control chars
|
|
223
|
+
.replace(/\\\n/g, " ")
|
|
224
|
+
.replace(/\s+/g, " ")
|
|
225
|
+
.trim();
|
|
177
226
|
|
|
178
227
|
// Check allow patterns first (overrides)
|
|
179
228
|
for (const pattern of allowPatterns) {
|
|
@@ -11,24 +11,29 @@ export type {
|
|
|
11
11
|
// Using AgentEndEvent and AgentStartEvent instead
|
|
12
12
|
|
|
13
13
|
// Type guards for pi-crew usage
|
|
14
|
-
|
|
14
|
+
/** @internal */
|
|
15
|
+
function isToolEvent(event: AgentSessionEvent): boolean {
|
|
15
16
|
return event.type === "tool_execution_start" ||
|
|
16
17
|
event.type === "tool_execution_update" ||
|
|
17
18
|
event.type === "tool_execution_end";
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
/** @internal */
|
|
22
|
+
function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
|
|
21
23
|
return event.type === "agent_start" || event.type === "agent_end";
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
/** @internal */
|
|
27
|
+
function isCompactionEvent(event: AgentSessionEvent): boolean {
|
|
25
28
|
return event.type === "compaction_start" || event.type === "compaction_end";
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
/** @internal */
|
|
32
|
+
function isRetryEvent(event: AgentSessionEvent): boolean {
|
|
29
33
|
return event.type === "auto_retry_start" || event.type === "auto_retry_end";
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
/** @internal */
|
|
37
|
+
function isQueueEvent(event: AgentSessionEvent): boolean {
|
|
33
38
|
return event.type === "queue_update";
|
|
34
39
|
}
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -21,7 +21,8 @@ export const DASHBOARD_KEYS = {
|
|
|
21
21
|
notification: { dismissAll: ["H"] },
|
|
22
22
|
} as const;
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** @internal */
|
|
25
|
+
const KEY_RESERVED = new Set<string>([
|
|
25
26
|
...DASHBOARD_KEYS.close,
|
|
26
27
|
...DASHBOARD_KEYS.select,
|
|
27
28
|
...Object.values(DASHBOARD_KEYS.root).flat(),
|
|
@@ -109,7 +109,7 @@ export class LiveRunSidebar {
|
|
|
109
109
|
|
|
110
110
|
render(width: number): string[] {
|
|
111
111
|
const w = Math.max(36, width);
|
|
112
|
-
const loaded = loadRunManifestById(this.cwd, this.runId);
|
|
112
|
+
const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
113
113
|
if (!loaded) {
|
|
114
114
|
return renderLines(
|
|
115
115
|
[
|
package/src/ui/loaders.ts
CHANGED
|
@@ -131,6 +131,10 @@ export class CountdownTimer {
|
|
|
131
131
|
this.emitExpire();
|
|
132
132
|
}
|
|
133
133
|
}, 1000);
|
|
134
|
+
// Defense-in-depth: never let the countdown timer keep the event loop
|
|
135
|
+
// alive. If dispose() is missed (e.g. UI unmount race), the timer must
|
|
136
|
+
// not block process exit.
|
|
137
|
+
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
134
138
|
}
|
|
135
139
|
|
|
136
140
|
private emitExpire(): void {
|
|
@@ -15,7 +15,7 @@ export class AgentPickerOverlay {
|
|
|
15
15
|
private selected = 0;
|
|
16
16
|
|
|
17
17
|
constructor(opts: { cwd: string; runId: string; done: (selection: AgentPickerSelection | undefined) => void; theme?: unknown }) {
|
|
18
|
-
const loaded = loadRunManifestById(opts.cwd, opts.runId);
|
|
18
|
+
const loaded = loadRunManifestById(opts.cwd, opts.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
19
19
|
this.agents = loaded ? readCrewAgents(loaded.manifest) : [];
|
|
20
20
|
this.done = opts.done;
|
|
21
21
|
this.theme = asCrewTheme(opts.theme ?? {});
|
|
@@ -32,7 +32,7 @@ export class MailboxDetailOverlay {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
private refresh(): void {
|
|
35
|
-
const loaded = loadRunManifestById(this.cwd, this.runId);
|
|
35
|
+
const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
36
36
|
if (!loaded) return;
|
|
37
37
|
// Track task count changes to trigger re-render
|
|
38
38
|
const taskCount = loaded.tasks.length;
|
|
@@ -81,7 +81,7 @@ export function dispatchHealthRecovery(ctx: ExtensionContext, runId: string): Pr
|
|
|
81
81
|
|
|
82
82
|
export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
|
|
83
83
|
try {
|
|
84
|
-
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
84
|
+
const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
85
85
|
if (!loaded) return { ok: false, message: `Run '${runId}' not found.` };
|
|
86
86
|
const currentMs = Date.now();
|
|
87
87
|
const staleMs = 60_000;
|
|
@@ -111,8 +111,9 @@ export async function dispatchDiagnosticExport(ctx: ExtensionContext, runId: str
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
/** @internal */
|
|
115
|
+
function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
|
|
116
|
+
const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
116
117
|
if (!loaded) return undefined;
|
|
117
118
|
return readCrewAgents(loaded.manifest).find((agent) => agent.status === "running" || agent.status === "queued")?.taskId;
|
|
118
119
|
}
|
|
@@ -652,7 +652,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
652
652
|
function build(runId: string, previous?: CacheEntry): CacheEntry {
|
|
653
653
|
let loaded: ReturnType<typeof loadRunManifestById>;
|
|
654
654
|
try {
|
|
655
|
-
loaded = loadRunManifestById(cwd, runId);
|
|
655
|
+
loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
656
656
|
} catch {
|
|
657
657
|
if (previous) return previous;
|
|
658
658
|
throw new Error(`Run '${runId}' could not be parsed.`);
|
package/src/ui/status-colors.ts
CHANGED
|
@@ -47,7 +47,8 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/** @internal */
|
|
51
|
+
function colorForActivity(activityState: string | undefined): CrewThemeColor {
|
|
51
52
|
if (activityState === "needs_attention") return "warning";
|
|
52
53
|
if (activityState === "stale") return "error";
|
|
53
54
|
return "dim";
|
|
@@ -22,7 +22,8 @@ function buildCliTheme(theme: CrewTheme): Record<string, (text: string) => strin
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/** @internal */
|
|
26
|
+
function detectLanguageFromPath(filePath: string): string | undefined {
|
|
26
27
|
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
27
28
|
if (!ext) return undefined;
|
|
28
29
|
return languageMap[ext];
|
package/src/ui/tool-render.ts
CHANGED
|
@@ -29,6 +29,15 @@ export interface AgentToolResultDetails {
|
|
|
29
29
|
results?: Array<{ agentId?: string; status?: string; output?: string; error?: string }>;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Combined type for renderAgentToolResult — handles both nested details and flat result shapes */
|
|
33
|
+
interface AgentResultData extends AgentToolResultDetails {
|
|
34
|
+
agentId?: string;
|
|
35
|
+
status?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
output?: string;
|
|
38
|
+
runId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
33
42
|
|
|
34
43
|
export function formatTokens(n: number): string {
|
|
@@ -45,7 +54,8 @@ export function formatDuration(ms: number): string {
|
|
|
45
54
|
return s > 0 ? `${m}m${s}s` : `${m}m`;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
/** @internal */
|
|
58
|
+
function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
|
|
49
59
|
if (!contextWindow) return `${formatTokens(tokens)} ctx`;
|
|
50
60
|
const pct = (tokens / contextWindow) * 100;
|
|
51
61
|
const maxStr = contextWindow >= 1_000_000 ? `${(contextWindow / 1_000_000).toFixed(1)}M` : `${Math.round(contextWindow / 1000)}k`;
|
|
@@ -294,7 +304,7 @@ export function renderAgentToolResult(
|
|
|
294
304
|
_options: unknown, theme: Theme, _context: unknown,
|
|
295
305
|
): Component {
|
|
296
306
|
// Handle both nested details and flattened result shape
|
|
297
|
-
const d = (result
|
|
307
|
+
const d = (result.details ?? result) as AgentResultData;
|
|
298
308
|
const c = new Container();
|
|
299
309
|
const w = 116;
|
|
300
310
|
|
|
@@ -351,7 +361,7 @@ export function renderAgentToolResult(
|
|
|
351
361
|
function extractText(content: unknown[] | undefined): string {
|
|
352
362
|
if (!content) return "(no output)";
|
|
353
363
|
if (!Array.isArray(content)) return String(content);
|
|
354
|
-
return content.filter((c:
|
|
364
|
+
return content.filter((c): c is Record<string, unknown> => typeof c === "object" && c !== null && (c as Record<string, unknown>).type === "text").map((c) => String((c as Record<string, unknown>).text ?? "")).join("\n") || "(no output)";
|
|
355
365
|
}
|
|
356
366
|
|
|
357
367
|
function parseArgs(argsStr: string | undefined): Record<string, unknown> {
|
package/src/utils/env-filter.ts
CHANGED
|
@@ -1,10 +1,56 @@
|
|
|
1
1
|
import { isSecretKey } from "./redaction.ts";
|
|
2
2
|
|
|
3
|
+
// Well-known LLM provider API keys that are intentionally allowlisted in
|
|
4
|
+
// child-pi.ts and async-runner.ts for pass-through to worker processes.
|
|
5
|
+
// These are "secret" by pattern (contain KEY/API) but are safe to allowlist
|
|
6
|
+
// because they are standard provider credentials, not arbitrary secrets.
|
|
7
|
+
const KNOWN_PROVIDER_KEYS = new Set([
|
|
8
|
+
"MINIMAX_API_KEY", "MINIMAX_GROUP_ID",
|
|
9
|
+
"OPENAI_API_KEY", "OPENAI_ORG_ID",
|
|
10
|
+
"ANTHROPIC_API_KEY",
|
|
11
|
+
"GOOGLE_API_KEY", "GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
|
|
12
|
+
"AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT",
|
|
13
|
+
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION",
|
|
14
|
+
"ZEU_API_KEY", "ZERODEV_API_KEY",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function isKnownProviderKey(key: string): boolean {
|
|
18
|
+
return KNOWN_PROVIDER_KEYS.has(key);
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
export interface SanitizeEnvOptions {
|
|
4
22
|
/** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
|
|
5
23
|
allowList?: string[];
|
|
6
24
|
}
|
|
7
25
|
|
|
26
|
+
// Keywords that indicate a secret env var
|
|
27
|
+
const SECRET_SUFFIXES = ["token", "api", "key", "password", "passwd", "secret", "credential", "authorization", "private"];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a glob pattern could match secret env vars.
|
|
31
|
+
* A pattern like "PI_*" is dangerous because it could match PI_TOKEN, PI_API_KEY, etc.
|
|
32
|
+
*
|
|
33
|
+
* Exception: `PI_CREW_*` is a controlled namespace — the pi-crew codebase owns
|
|
34
|
+
* every PI_CREW_* env var (PI_CREW_PARENT_PID, PI_CREW_ADAPTIVE_REPAIR, etc.)
|
|
35
|
+
* and none of them are secrets. Allowing the glob here lets child Pi processes
|
|
36
|
+
* inherit our config without needing a per-var allowlist.
|
|
37
|
+
*/
|
|
38
|
+
function isDangerousGlob(pattern: string): boolean {
|
|
39
|
+
if (!pattern.endsWith("*")) return false;
|
|
40
|
+
const prefix = pattern.slice(0, -1); // Remove trailing *
|
|
41
|
+
if (prefix === "") return true; // Single "*" matches everything
|
|
42
|
+
// PI_CREW_* is the pi-crew controlled namespace — no secrets live here.
|
|
43
|
+
// This covers PI_CREW_*, PI_CREW_TEAMS_*, PI_CREW_AGENT_*, etc.
|
|
44
|
+
if (prefix.startsWith("PI_CREW_") || prefix === "PI_CREW") return false;
|
|
45
|
+
// Check if combining the prefix with any secret suffix would create a secret key
|
|
46
|
+
for (const suffix of SECRET_SUFFIXES) {
|
|
47
|
+
if (isSecretKey(prefix + suffix)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
8
54
|
/**
|
|
9
55
|
* Strip env vars whose keys look like secrets before passing to child processes.
|
|
10
56
|
*
|
|
@@ -14,11 +60,31 @@ export interface SanitizeEnvOptions {
|
|
|
14
60
|
export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnvOptions): Record<string, string> {
|
|
15
61
|
const filtered: Record<string, string> = {};
|
|
16
62
|
if (options?.allowList && options.allowList.length > 0) {
|
|
63
|
+
// Validate allowlist patterns don't match secrets
|
|
64
|
+
for (const pattern of options.allowList) {
|
|
65
|
+
if (isDangerousGlob(pattern)) {
|
|
66
|
+
throw new Error(`Allowlist pattern "${pattern}" could match secret env vars. Use a more specific pattern.`);
|
|
67
|
+
}
|
|
68
|
+
// Validate non-glob entries don't look like secret keys.
|
|
69
|
+
// Exception 1: if the key exists in env, it was intentionally set and
|
|
70
|
+
// should be allowed through (user knows what they're doing).
|
|
71
|
+
// Exception 2: known provider keys (MINIMAX_API_KEY, etc.) are always
|
|
72
|
+
// allowed because they are standard provider credentials explicitly
|
|
73
|
+
// listed in async-runner.ts / child-pi.ts allowlists.
|
|
74
|
+
if (!pattern.endsWith("*") && isSecretKey(pattern) && !(pattern in env) && !isKnownProviderKey(pattern)) {
|
|
75
|
+
throw new Error(`Allowlist entry "${pattern}" looks like a secret key. Use a more specific pattern.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
17
78
|
const matchers = options.allowList.map((p) => {
|
|
18
79
|
if (p.endsWith("*")) {
|
|
80
|
+
// Glob pattern: matches keys that start with the prefix AND have
|
|
81
|
+
// at least one additional character (distinguishes "PI_CREW_*" from "PI_CREW_").
|
|
82
|
+
// For example, "PI_CREW_*" matches "PI_CREW_DEPTH" but not "PI_CREW_".
|
|
83
|
+
// This ensures trailing glob patterns require extra chars, not exact-prefix-only matches.
|
|
19
84
|
const prefix = p.slice(0, -1);
|
|
20
85
|
return (k: string) => k.startsWith(prefix) && k.length > prefix.length;
|
|
21
86
|
}
|
|
87
|
+
// Exact match is case-sensitive; Unix env vars are uppercase by convention.
|
|
22
88
|
return (k: string) => k === p;
|
|
23
89
|
});
|
|
24
90
|
for (const [key, value] of Object.entries(env)) {
|
|
@@ -6,7 +6,15 @@ interface TimerApi {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
const defaultTimerApi: TimerApi = {
|
|
9
|
-
setTimeout: (handler, delayMs) =>
|
|
9
|
+
setTimeout: (handler, delayMs) => {
|
|
10
|
+
const t = setTimeout(handler, delayMs);
|
|
11
|
+
// Defense in depth: never let a coalescer timer block process exit.
|
|
12
|
+
// The timer may be cleared before it fires; .unref() is idempotent.
|
|
13
|
+
if (typeof t === "object" && t && "unref" in t && typeof t.unref === "function") {
|
|
14
|
+
t.unref();
|
|
15
|
+
}
|
|
16
|
+
return t;
|
|
17
|
+
},
|
|
10
18
|
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
11
19
|
};
|
|
12
20
|
|
package/src/utils/fs-watch.ts
CHANGED
|
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { FSWatcher, WatchListener } from "node:fs";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** @internal */
|
|
6
|
+
const FS_WATCH_RETRY_DELAY_MS = 5000;
|
|
6
7
|
|
|
7
8
|
export function closeWatcher(watcher: FSWatcher | null | undefined): void {
|
|
8
9
|
if (!watcher) {
|
|
@@ -85,4 +86,5 @@ export function watchCrewState(
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
// Re-export path helper so callers don't pull node:path just for join.
|
|
88
|
-
|
|
89
|
+
/** @internal */
|
|
90
|
+
const joinPath = path.join;
|
package/src/utils/gh-protocol.ts
CHANGED
|
@@ -473,7 +473,8 @@ export function resolveGitHubUrl(parsed: Parsed, scheme: "issue" | "pr", cwd: st
|
|
|
473
473
|
* Resolve a raw `issue://` or `pr://` URL string.
|
|
474
474
|
* Convenience wrapper combining parse + resolve.
|
|
475
475
|
*/
|
|
476
|
-
|
|
476
|
+
/** @internal */
|
|
477
|
+
function resolveGitHubProtocol(raw: string, scheme: "issue" | "pr", cwd: string): GhResult<unknown> {
|
|
477
478
|
const parsed = parseGitHubUrl(raw, scheme);
|
|
478
479
|
return resolveGitHubUrl(parsed, scheme, cwd);
|
|
479
480
|
}
|
package/src/utils/paths.ts
CHANGED
|
@@ -9,7 +9,49 @@ export function packageRoot(): string {
|
|
|
9
9
|
|
|
10
10
|
export function userPiRoot(): string {
|
|
11
11
|
const home = process.env.PI_TEAMS_HOME?.trim() || os.homedir();
|
|
12
|
-
|
|
12
|
+
const resolved = path.join(home, ".pi", "agent");
|
|
13
|
+
|
|
14
|
+
// Reject symlinks to prevent confusion attacks where PI_TEAMS_HOME points to
|
|
15
|
+
// an attacker-controlled target via a user-owned symlink.
|
|
16
|
+
// We use lstatSync (does NOT follow) to detect symlinks before they are resolved.
|
|
17
|
+
// ENOENT is acceptable — the directory may not exist yet (caller will create it).
|
|
18
|
+
let isSymlink = false;
|
|
19
|
+
try {
|
|
20
|
+
const lstats = fs.lstatSync(resolved);
|
|
21
|
+
isSymlink = lstats.isSymbolicLink();
|
|
22
|
+
} catch (err: unknown) {
|
|
23
|
+
if (err instanceof Error && "code" in err && err.code !== "ENOENT") throw err;
|
|
24
|
+
// Path doesn't exist yet — caller will create it. Skip further validation.
|
|
25
|
+
return resolved;
|
|
26
|
+
}
|
|
27
|
+
if (isSymlink) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`userPiRoot: PI_TEAMS_HOME path "${resolved}" is a symlink. ` +
|
|
30
|
+
"Symlinks are not supported for PI_TEAMS_HOME to prevent confusion attacks. " +
|
|
31
|
+
"Set PI_TEAMS_HOME to a direct path owned by the current user.",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Validate that the resolved path is owned by the current user
|
|
36
|
+
// to ensure security assumptions about file permissions (0o600/0o700) hold.
|
|
37
|
+
// Skip check if the directory does not exist yet.
|
|
38
|
+
try {
|
|
39
|
+
const stats = fs.statSync(resolved);
|
|
40
|
+
if (stats.uid !== os.userInfo().uid) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`userPiRoot: PI_TEAMS_HOME path "${resolved}" is not owned by the current user (uid=${os.userInfo().uid}, found uid=${stats.uid}). ` +
|
|
43
|
+
"This violates security assumptions about file permissions. Set PI_TEAMS_HOME to a path owned by the current user, or unset it to use the default.",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
} catch (err: unknown) {
|
|
47
|
+
if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
// ENOENT from statSync means the directory was deleted between lstat and stat
|
|
51
|
+
// (race condition). This is acceptable — caller will handle.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return resolved;
|
|
13
55
|
}
|
|
14
56
|
|
|
15
57
|
const PROJECT_DIR_MARKERS = [".git", ".pi", ".crew", ".hg", ".svn", ".factory", ".omc"];
|
|
@@ -48,8 +90,34 @@ function hasProjectMarker(dir: string): boolean {
|
|
|
48
90
|
return false;
|
|
49
91
|
}
|
|
50
92
|
|
|
93
|
+
/** On Windows, resolve a path to its canonical long-name form.
|
|
94
|
+
* Uses realpathSync.native to get the \\?\ long-name path, then strips
|
|
95
|
+
* the prefix for path.relative compatibility. */
|
|
96
|
+
function canonicalizePath(p: string): string {
|
|
97
|
+
try {
|
|
98
|
+
const r = fs.realpathSync.native(p);
|
|
99
|
+
return r.startsWith("\\\\?\\") ? r.slice(4) : r;
|
|
100
|
+
} catch {
|
|
101
|
+
try { return fs.realpathSync(p); } catch { return p; }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
51
105
|
export function findRepoRoot(cwd: string): string | undefined {
|
|
52
|
-
|
|
106
|
+
// Resolve symlinks before walking to prevent malicious symlinks from bypassing
|
|
107
|
+
// home/temp boundary checks. If the path doesn't exist (e.g., caller passed
|
|
108
|
+
// a non-existent CWD like /tmp/no-such-dir), fall back to the lexical path
|
|
109
|
+
// and let computeRepoRoot handle the rest. ENOENT here is common for
|
|
110
|
+
// newly-created test directories and shouldn't propagate as a crash.
|
|
111
|
+
let startKey: string;
|
|
112
|
+
try {
|
|
113
|
+
startKey = fs.realpathSync(cwd);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
116
|
+
startKey = path.resolve(cwd);
|
|
117
|
+
} else {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
53
121
|
const cached = projectRootCache.get(startKey);
|
|
54
122
|
if (cached && Date.now() - cached.cachedAt < PROJECT_ROOT_CACHE_TTL_MS) {
|
|
55
123
|
// Re-insert to refresh LRU position.
|
|
@@ -82,18 +150,25 @@ function computeRepoRoot(start: string): string | undefined {
|
|
|
82
150
|
}
|
|
83
151
|
|
|
84
152
|
export function projectPiRoot(cwd: string): string {
|
|
85
|
-
|
|
153
|
+
const repoRoot = findRepoRoot(cwd) ?? cwd;
|
|
154
|
+
const piDir = path.join(repoRoot, ".pi");
|
|
155
|
+
// Use realpathSync to resolve any symlinks before returning to prevent
|
|
156
|
+
// config from being read from unexpected locations.
|
|
157
|
+
if (fs.existsSync(piDir)) return fs.realpathSync(piDir);
|
|
158
|
+
return piDir;
|
|
86
159
|
}
|
|
87
160
|
|
|
88
161
|
export function projectCrewRoot(cwd: string): string {
|
|
89
162
|
const repoRoot = findRepoRoot(cwd) ?? cwd;
|
|
90
163
|
const crewDir = path.join(repoRoot, ".crew");
|
|
91
164
|
// Keep an existing .crew/ stable even when .pi/ exists for project config.
|
|
92
|
-
|
|
165
|
+
// Use canonicalizePath to get long-name form on Windows, matching
|
|
166
|
+
// what worktree operations and resolveRealContainedPath expect.
|
|
167
|
+
if (fs.existsSync(crewDir)) return canonicalizePath(crewDir);
|
|
93
168
|
// Legacy reuse: if .pi/ already exists for the project, namespace under .pi/teams/
|
|
94
169
|
// to avoid creating a parallel .crew/ alongside an existing pi project layout.
|
|
95
170
|
const piDir = path.join(repoRoot, ".pi");
|
|
96
|
-
if (fs.existsSync(piDir)) return path.join(piDir, "teams");
|
|
171
|
+
if (fs.existsSync(piDir)) return path.join(canonicalizePath(piDir), "teams");
|
|
97
172
|
return crewDir;
|
|
98
173
|
}
|
|
99
174
|
|
package/src/utils/redaction.ts
CHANGED
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g;
|
|
8
8
|
|
|
9
9
|
// Linear-time secret key detection
|
|
10
|
+
// IMPORTANT: This function must maintain linear-time guarantees.
|
|
11
|
+
// The fast-path regex uses simple string alternatives with anchors only (no quantifiers),
|
|
12
|
+
// and the linear scan iterates through characters once. If either path is replaced with
|
|
13
|
+
// a more complex regex, catastrophic backtracking (ReDoS) could result.
|
|
14
|
+
// Any modifications must preserve O(n) complexity where n = keyName.length.
|
|
10
15
|
export function isSecretKey(keyName: string): boolean {
|
|
11
|
-
// Fast path: common secret key names
|
|
16
|
+
// Fast path: common secret key names (safe anchored regex, no backtracking)
|
|
12
17
|
const lower = keyName.toLowerCase();
|
|
13
18
|
if (/^(token|apikey|api_key|password|secret|credential|authorization|privatekey|private_key)$/.test(lower)) {
|
|
14
19
|
return true;
|