pi-crew 0.2.25 → 0.3.0

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.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Tool Progress Event System
3
+ *
4
+ * Provides real-time visibility into tool execution within child Pi workers.
5
+ *
6
+ * Event flow:
7
+ * 1. Child Pi emits JSON events on stdout (tool_execution_start, tool_execution_end, etc.)
8
+ * 2. child-pi.ts parses these events and passes them to onJsonEvent callback
9
+ * 3. task-runner.ts calls applyAgentProgressEvent() to update task state
10
+ * 4. This module provides structured types and utilities for the event system
11
+ */
12
+
13
+ import type { CrewAgentProgress } from "../state/types.ts";
14
+
15
+ // ── Event Types ─────────────────────────────────────────────────────────
16
+
17
+ export interface ToolExecutionStartEvent {
18
+ type: "tool_execution_start";
19
+ toolName: string;
20
+ toolCallId: string;
21
+ args?: Record<string, unknown>;
22
+ timestamp: number;
23
+ }
24
+
25
+ export interface ToolExecutionEndEvent {
26
+ type: "tool_execution_end";
27
+ toolName: string;
28
+ toolCallId: string;
29
+ result?: unknown;
30
+ timestamp: number;
31
+ }
32
+
33
+ export interface ToolExecutionUpdateEvent {
34
+ type: "tool_execution_update";
35
+ toolName: string;
36
+ toolCallId: string;
37
+ partialResult?: unknown;
38
+ timestamp: number;
39
+ }
40
+
41
+ export interface ToolExecutionErrorEvent {
42
+ type: "tool_execution_error" | "tool_execution_failed";
43
+ toolName: string;
44
+ toolCallId: string;
45
+ error?: string;
46
+ timestamp: number;
47
+ }
48
+
49
+ export interface MessageEndEvent {
50
+ type: "message_end";
51
+ message: {
52
+ role: "assistant" | "user" | "system";
53
+ content?: unknown[];
54
+ usage?: UsageStats;
55
+ model?: string;
56
+ stopReason?: string;
57
+ };
58
+ timestamp: number;
59
+ }
60
+
61
+ export interface UsageStats {
62
+ input: number;
63
+ output: number;
64
+ cacheRead?: number;
65
+ cacheWrite?: number;
66
+ cost?: { total: number };
67
+ turns?: number;
68
+ totalTokens?: number;
69
+ }
70
+
71
+ // Union type of all tool progress events
72
+ export type ToolProgressEvent =
73
+ | ToolExecutionStartEvent
74
+ | ToolExecutionEndEvent
75
+ | ToolExecutionUpdateEvent
76
+ | ToolExecutionErrorEvent
77
+ | MessageEndEvent;
78
+
79
+ // ── Event Utilities ───────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Extract tool name from any event type
83
+ */
84
+ export function getToolName(event: ToolProgressEvent): string | undefined {
85
+ if ("toolName" in event) return event.toolName;
86
+ return undefined;
87
+ }
88
+
89
+ /**
90
+ * Check if event indicates tool is running
91
+ */
92
+ export function isToolRunning(event: ToolProgressEvent): boolean {
93
+ return event.type === "tool_execution_start";
94
+ }
95
+
96
+ /**
97
+ * Check if event indicates tool completed
98
+ */
99
+ export function isToolComplete(event: ToolProgressEvent): boolean {
100
+ return event.type === "tool_execution_end";
101
+ }
102
+
103
+ /**
104
+ * Check if event indicates tool failed
105
+ */
106
+ export function isToolError(event: ToolProgressEvent): boolean {
107
+ return event.type === "tool_execution_error" || event.type === "tool_execution_failed";
108
+ }
109
+
110
+ /**
111
+ * Get usage stats from message_end event
112
+ */
113
+ export function getUsage(event: ToolProgressEvent): UsageStats | undefined {
114
+ if (event.type === "message_end" && event.message?.usage) {
115
+ return event.message.usage as UsageStats;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ // ── Progress Display ──────────────────────────────────────────────────────
121
+
122
+ export interface ToolProgressDisplay {
123
+ /** Current/last tool being executed */
124
+ currentTool?: string;
125
+ /** Preview of tool arguments (truncated) */
126
+ currentToolArgs?: string;
127
+ /** When tool started */
128
+ currentToolStartedAt?: string;
129
+ /** All recent tools with their args */
130
+ recentTools: Array<{
131
+ tool: string;
132
+ args?: string;
133
+ startedAt?: string;
134
+ endedAt?: string;
135
+ status: "running" | "done" | "error";
136
+ }>;
137
+ /** Token usage snapshot */
138
+ tokens?: number;
139
+ /** Context window usage percentage */
140
+ contextPercent?: number;
141
+ /** Total tool count */
142
+ toolCount: number;
143
+ /** Last activity timestamp */
144
+ lastActivityAt?: string;
145
+ /** Activity state */
146
+ activityState: "active" | "idle" | "done";
147
+ }
148
+
149
+ /**
150
+ * Format tool progress for display
151
+ */
152
+ export function formatToolProgress(progress: CrewAgentProgress, maxContextTokens = 128000): ToolProgressDisplay {
153
+ const recentTools = progress.recentTools.map((t) => ({
154
+ tool: t.tool,
155
+ args: t.args,
156
+ startedAt: t.startedAt,
157
+ endedAt: t.endedAt,
158
+ status: t.endedAt ? "done" : "running" as const,
159
+ }));
160
+
161
+ // If there's a currentTool but no endedAt, it's still running
162
+ const currentRunning = progress.recentTools.find(
163
+ (t) => !t.endedAt && t.tool === progress.currentTool,
164
+ );
165
+ if (currentRunning && progress.currentTool) {
166
+ recentTools.push({
167
+ tool: progress.currentTool,
168
+ args: progress.currentToolArgs,
169
+ startedAt: progress.currentToolStartedAt,
170
+ status: "running",
171
+ });
172
+ }
173
+
174
+ const tokens = progress.tokens ?? 0;
175
+ const contextPercent = maxContextTokens > 0 ? Math.round((tokens / maxContextTokens) * 100) : 0;
176
+
177
+ return {
178
+ currentTool: progress.currentTool,
179
+ currentToolArgs: progress.currentToolArgs,
180
+ currentToolStartedAt: progress.currentToolStartedAt,
181
+ recentTools,
182
+ tokens,
183
+ contextPercent,
184
+ toolCount: progress.toolCount,
185
+ lastActivityAt: progress.lastActivityAt,
186
+ activityState: progress.activityState as "active" | "idle" | "done",
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Format a single line summary of current tool
192
+ */
193
+ export function formatCurrentToolLine(progress: CrewAgentProgress): string {
194
+ if (!progress.currentTool) return "";
195
+
196
+ const args = progress.currentToolArgs
197
+ ? ` ${progress.currentToolArgs.slice(0, 50)}${progress.currentToolArgs.length > 50 ? "..." : ""}`
198
+ : "";
199
+
200
+ const toolCount = progress.toolCount > 0 ? ` (${progress.toolCount})` : "";
201
+
202
+ return `${progress.currentTool}${args}${toolCount}`;
203
+ }
204
+
205
+ /**
206
+ * Format token usage for display
207
+ */
208
+ export function formatTokenUsage(progress: CrewAgentProgress, maxTokens = 128000): string {
209
+ const tokens = progress.tokens ?? 0;
210
+ const percent = maxTokens > 0 ? Math.round((tokens / maxTokens) * 100) : 0;
211
+ return `${tokens.toLocaleString()} / ${maxTokens.toLocaleString()} (${percent}%)`;
212
+ }
213
+
214
+ // ── Progress Bar Rendering ────────────────────────────────────────────────
215
+
216
+ export interface ProgressBarOptions {
217
+ width?: number;
218
+ showPercent?: boolean;
219
+ showCount?: boolean;
220
+ }
221
+
222
+ /**
223
+ * Render a progress bar for tool execution
224
+ */
225
+ export function renderProgressBar(
226
+ progress: CrewAgentProgress,
227
+ options: ProgressBarOptions = {},
228
+ ): string {
229
+ const width = options.width ?? 20;
230
+ const showPercent = options.showPercent ?? true;
231
+ const showCount = options.showCount ?? true;
232
+
233
+ // Calculate based on recent tools (max 10)
234
+ const recentCount = Math.min(progress.recentTools.length, 10);
235
+ const filled = Math.round((recentCount / 10) * width);
236
+ const empty = width - filled;
237
+
238
+ const bar = "█".repeat(filled) + "░".repeat(empty);
239
+ const percent = showPercent ? ` ${progress.toolCount} tools` : "";
240
+ const tokens = progress.tokens
241
+ ? ` | ${(progress.tokens / 1000).toFixed(1)}k tokens`
242
+ : "";
243
+
244
+ return `[${bar}]${percent}${tokens}`;
245
+ }
246
+
247
+ // ── Event Filtering ──────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * Filter events to only tool execution events
251
+ */
252
+ export function filterToolEvents(events: ToolProgressEvent[]): ToolProgressEvent[] {
253
+ return events.filter(
254
+ (e) =>
255
+ e.type === "tool_execution_start" ||
256
+ e.type === "tool_execution_end" ||
257
+ e.type === "tool_execution_update" ||
258
+ e.type === "tool_execution_error" ||
259
+ e.type === "tool_execution_failed",
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Get events for a specific tool
265
+ */
266
+ export function getEventsForTool(
267
+ events: ToolProgressEvent[],
268
+ toolName: string,
269
+ ): ToolProgressEvent[] {
270
+ return events.filter((e) => {
271
+ if ("toolName" in e) return e.toolName === toolName;
272
+ return false;
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Check if any event indicates an error
278
+ */
279
+ export function hasError(events: ToolProgressEvent[]): boolean {
280
+ return events.some(isToolError);
281
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Safe Bash Extension for pi-crew
3
+ * Wraps the built-in bash tool with dangerous command blocking
4
+ *
5
+ * Usage:
6
+ * 1. Enable in config: { "tools": { "bash": { "safeMode": true } } }
7
+ * 2. Or use via agent config: { "extensions": ["path/to/safe-bash-extension.ts"] }
8
+ * 3. Or set env var: PI_CREW_SAFE_BASH=true
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import { createBashTool } from "@mariozechner/pi-coding-agent";
13
+ import { Type } from "@sinclair/typebox";
14
+ import * as path from "node:path";
15
+
16
+ // Dangerous command patterns to block
17
+ const DANGEROUS_PATTERNS = [
18
+ // rm -rf on root or home
19
+ /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
20
+ /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
21
+ // Privilege escalation
22
+ /\bsudo\b/,
23
+ /\bsu\s+root\b/,
24
+ // Filesystem destruction
25
+ /\bmkfs\b/,
26
+ /\bdd\s+if=/,
27
+ // Fork bomb
28
+ /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
29
+ // Device writing
30
+ />\s*\/dev\/[sh]d[a-z]/,
31
+ /\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
32
+ /\bchown\s+(-[a-zA-Z]+\s+)?root/,
33
+ // Pipe to shell (download and execute)
34
+ /\bcurl\s.*\|\s*(ba)?sh/i,
35
+ /\bwget\s.*\|\s*(ba)?sh/i,
36
+ // System shutdown/reboot
37
+ /\bshutdown\b/,
38
+ /\breboot\b/,
39
+ /\binit\s+0\b/,
40
+ // Kill critical processes
41
+ /\bkill\s+-9\s+1\b/,
42
+ /\bkillall\b/,
43
+ // Encoded commands
44
+ /\|\s*base64\s+-d/,
45
+ // Network to shell
46
+ /\bbash\s+-i\s+>\s*\&/,
47
+ // /etc/passwd manipulation
48
+ /\becho\s+.*>\s*\/etc\/passwd/,
49
+ ];
50
+
51
+ function isDangerous(command: string): string | null {
52
+ const normalized = command.replace(/\\\n/g, " ").replace(/\s+/g, " ").trim();
53
+ for (const pattern of DANGEROUS_PATTERNS) {
54
+ if (pattern.test(normalized)) {
55
+ return `Command blocked: matches dangerous pattern \`${pattern}\``;
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ export default function safeBashExtension(pi: ExtensionAPI): void {
62
+ const cwd = pi.context?.cwd || process.cwd();
63
+ const bashTool = createBashTool(cwd);
64
+
65
+ pi.registerTool({
66
+ name: "safe_bash",
67
+ label: "Safe Bash",
68
+ description:
69
+ "Execute a bash command safely. Blocks dangerous commands like `rm -rf /`, `sudo`, `curl | sh`, etc.",
70
+ parameters: Type.Object({
71
+ command: Type.String({ description: "Bash command to execute" }),
72
+ timeout: Type.Optional(
73
+ Type.Number({ description: "Timeout in seconds (optional)" }),
74
+ ),
75
+ description: Type.Optional(
76
+ Type.String({ description: "Description of what this command does (optional)" }),
77
+ ),
78
+ }),
79
+ async execute(toolCallId, params, signal, onUpdate) {
80
+ const danger = isDangerous(params.command);
81
+ if (danger) {
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text" as const,
86
+ text: `🚫 ${danger}\n\nIf you need to run this command, use the regular 'bash' tool instead, but be careful!`,
87
+ },
88
+ ],
89
+ };
90
+ }
91
+ // Safe - delegate to real bash tool
92
+ return bashTool.execute(toolCallId, params, signal, onUpdate);
93
+ },
94
+ });
95
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Safe Bash Tool for pi-crew
3
+ * Wraps bash with dangerous command blocking
4
+ */
5
+
6
+ import { Type } from "@sinclair/typebox";
7
+
8
+ // Dangerous command patterns to block
9
+ const DANGEROUS_PATTERNS = [
10
+ // rm -rf / or rm -rf ~ (catastrophic root/home deletion)
11
+ /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s*)+(\/|~)(\s*$)/,
12
+ /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s*)+(\/|~)($|\s)/,
13
+ // Privilege escalation
14
+ /\bsudo\b/,
15
+ /\bsu\s+root\b/,
16
+ // Filesystem destruction
17
+ /\bmkfs\b/,
18
+ /\bdd\s+if=/,
19
+ // Fork bomb
20
+ /^:\s*\(\s*\)\s*\{.*\|.*&.*\}\s*;.*$/,
21
+ // Device writing
22
+ />\s*\/dev\/[sh]d[a-z]/,
23
+ /\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
24
+ /\bchown\s+(-[a-zA-Z]+\s+)?root/,
25
+ // Pipe to shell (download and execute)
26
+ /\bcurl\s.*\|\s*(ba)?sh/i,
27
+ /\bwget\s.*\|\s*(ba)?sh/i,
28
+ // System shutdown/reboot
29
+ /\bshutdown\b/,
30
+ /\breboot\b/,
31
+ /\binit\s+0\b/,
32
+ // Kill critical processes
33
+ /\bkill\s+-9\s+1\b/,
34
+ /\bkillall\b/,
35
+ // Encoded commands
36
+ /\|\s*base64\s+-d/,
37
+ /\|\s*python.*-c/,
38
+ /\|\s*perl.*-e/,
39
+ /\|\s*ruby.*-e/,
40
+ // Network to shell
41
+ /\bbash\s+-i\s+>\s*\&/,
42
+ /\bexec\s+.*bash/,
43
+ // /etc/passwd manipulation
44
+ /\becho\s+.*>\s*\/etc\/passwd/,
45
+ /\bcat\s+.*>\s*\/etc\/passwd/,
46
+ ];
47
+
48
+ export interface SafeBashOptions {
49
+ /** Enable/disable safe mode. Default: true */
50
+ enabled?: boolean;
51
+ /** Additional patterns to block */
52
+ additionalPatterns?: RegExp[];
53
+ /** Patterns to allow (overrides blocked) */
54
+ allowPatterns?: RegExp[];
55
+ }
56
+
57
+ const DEFAULT_ENABLED = true;
58
+
59
+ /**
60
+ * Check if a command is dangerous
61
+ * @returns Error message if dangerous, null if safe
62
+ */
63
+ export function isDangerous(command: string, options: SafeBashOptions = {}): string | null {
64
+ const { enabled = DEFAULT_ENABLED, additionalPatterns = [], allowPatterns = [] } = options;
65
+
66
+ if (!enabled) return null;
67
+
68
+ // Normalize: remove line continuations, collapse whitespace
69
+ const normalized = command.replace(/\\\n/g, " ").replace(/\s+/g, " ").trim();
70
+
71
+ // Check allow patterns first (overrides)
72
+ for (const pattern of allowPatterns) {
73
+ if (pattern.test(normalized)) {
74
+ return null; // Explicitly allowed
75
+ }
76
+ }
77
+
78
+ // Check dangerous patterns
79
+ const allPatterns = [...DANGEROUS_PATTERNS, ...additionalPatterns];
80
+ for (const pattern of allPatterns) {
81
+ if (pattern.test(normalized)) {
82
+ return `Command blocked by safe_bash: matches dangerous pattern \`${pattern}\``;
83
+ }
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Validate a bash command before execution
91
+ * Throws if dangerous
92
+ */
93
+ export function validateCommand(command: string, options: SafeBashOptions = {}): void {
94
+ const danger = isDangerous(command, options);
95
+ if (danger) {
96
+ throw new Error(danger);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Create a safe bash tool wrapper
102
+ * Returns an object with validation function and patterns for integration
103
+ */
104
+ export function createSafeBash(options: SafeBashOptions = {}) {
105
+ return {
106
+ /**
107
+ * Validate a command. Throws if dangerous.
108
+ */
109
+ validate(command: string): void {
110
+ validateCommand(command, options);
111
+ },
112
+
113
+ /**
114
+ * Check if a command is dangerous without throwing
115
+ */
116
+ check(command: string): string | null {
117
+ return isDangerous(command, options);
118
+ },
119
+
120
+ /**
121
+ * Get all active patterns (for debugging/config display)
122
+ */
123
+ getPatterns(): { dangerous: RegExp[]; additional: RegExp[]; allow: RegExp[] } {
124
+ return {
125
+ dangerous: [...DANGEROUS_PATTERNS],
126
+ additional: options.additionalPatterns || [],
127
+ allow: options.allowPatterns || [],
128
+ };
129
+ },
130
+
131
+ /**
132
+ * Check if safe mode is enabled
133
+ */
134
+ isEnabled(): boolean {
135
+ return options.enabled !== false;
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Common safe commands that are often blocked but might be needed
142
+ * These can be used in allowPatterns for specific use cases
143
+ */
144
+ export const COMMON_SAFE_PATTERNS = {
145
+ // Safe rm with specific paths
146
+ safeRm: /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?((?![\/~])\/)?(tmp|cache|node_modules|dist|build)\//,
147
+ // Safe git operations
148
+ safeGit: /\bgit\s+(clone|pull|push|commit|add|status|diff|log|branch|checkout|merge|rebase)/,
149
+ // Safe npm/yarn/pnpm
150
+ safePackage: /\b(npm|yarn|pnpm|bun)\s+(install|run|test|build|start|dev)/,
151
+ // Safe file read
152
+ safeRead: /\b(cat|head|tail|less|more|grep|find|ls)\s/,
153
+ };
154
+
155
+ /**
156
+ * Preset configurations for different trust levels
157
+ */
158
+ export const SAFE_BASH_PRESETS = {
159
+ /** Maximum security - block everything suspicious */
160
+ strict: {
161
+ enabled: true,
162
+ additionalPatterns: [],
163
+ allowPatterns: [],
164
+ },
165
+ /** Moderate - allow common dev operations */
166
+ development: {
167
+ enabled: true,
168
+ additionalPatterns: [],
169
+ allowPatterns: [COMMON_SAFE_PATTERNS.safePackage],
170
+ },
171
+ /** Minimal - only block catastrophic commands */
172
+ permissive: {
173
+ enabled: true,
174
+ additionalPatterns: [],
175
+ allowPatterns: [
176
+ COMMON_SAFE_PATTERNS.safeRm,
177
+ COMMON_SAFE_PATTERNS.safeGit,
178
+ COMMON_SAFE_PATTERNS.safePackage,
179
+ COMMON_SAFE_PATTERNS.safeRead,
180
+ ],
181
+ },
182
+ /** No safety checks */
183
+ disabled: {
184
+ enabled: false,
185
+ additionalPatterns: [],
186
+ allowPatterns: [],
187
+ },
188
+ };