pi-crew 0.9.35 → 0.9.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.35",
3
+ "version": "0.9.37",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -130,6 +130,7 @@ function purgeStaleActiveRunIndexSyncIfLoaded(): void {
130
130
 
131
131
  import { pruneFinishedRuns, pruneUserLevelRuns } from "../extension/run-maintenance.ts";
132
132
  import { initI18n } from "../i18n.ts";
133
+ import { globalProgressTracker } from "../runtime/progress-tracker.ts";
133
134
  // H3-L2 split: DeliveryCoordinator + OverflowRecoveryTracker moved to registration/lifecycle.ts.
134
135
  import { tryRegisterSessionCleanup } from "../runtime/session-resources.ts";
135
136
  import { createSessionSnapshot } from "../runtime/session-snapshot.ts";
@@ -1474,6 +1475,11 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1474
1475
  const record = event as Record<string, unknown>;
1475
1476
  const eventType = typeof record.type === "string" ? record.type : undefined;
1476
1477
  if (eventType) lifecycleState.overflowTracker?.feedEvent(taskId, runId, eventType);
1478
+ // Phase 2: forward worker events to progress tracker for real-time
1479
+ // widget display (tool calls, assistant text) on child-process runtime.
1480
+ if (record && typeof record === "object") {
1481
+ globalProgressTracker.handleWorkerEvent(taskId, runId, record);
1482
+ }
1477
1483
  },
1478
1484
  });
1479
1485
  registerSubagentTools(pi, subagentManager, {
@@ -23,6 +23,7 @@ import { loadConfig } from "../../config/config.ts";
23
23
  import { t } from "../../i18n.ts";
24
24
  import type { BatchBarrier } from "../../runtime/batch-barrier.ts";
25
25
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
26
+ import { globalProgressTracker } from "../../runtime/progress-tracker.ts";
26
27
  import { checkSubagentSpawnPermission, currentCrewRole } from "../../runtime/role-permission.ts";
27
28
  import { loadRunManifestById } from "../../state/state-store.ts";
28
29
  import {
@@ -444,7 +445,23 @@ function startAgentToolProgress(cwd: string, agentRecordId: string, onUpdate: On
444
445
  agents,
445
446
  error: record.error,
446
447
  });
447
- onUpdate({ content: [{ type: "text", text }] });
448
+ // Phase 2C: enrich with real-time worker progress (tool calls, partial text)
449
+ // from globalProgressTracker. For foreground agents, find the task ID from
450
+ // the manifest and look up streaming progress.
451
+ let progressLine = text;
452
+ if (tasks && tasks.length > 0) {
453
+ const wp = globalProgressTracker.getWorkerProgress(tasks[0].id);
454
+ if (wp) {
455
+ const toolLine = wp.currentTool ? `\n ⚡ tool: ${wp.currentTool}` : "";
456
+ const tokenLine =
457
+ wp.tokens.input + wp.tokens.output > 0
458
+ ? `\n 📊 tokens: ${(wp.tokens.input / 1000).toFixed(1)}K in, ${(wp.tokens.output / 1000).toFixed(1)}K out`
459
+ : "";
460
+ const textLine = wp.partialText ? `\n 💬 ${wp.partialText.slice(-150).replace(/\n/g, " ")}` : "";
461
+ progressLine = `${text}${toolLine}${tokenLine}${textLine}`;
462
+ }
463
+ }
464
+ onUpdate({ content: [{ type: "text", text: progressLine }] });
448
465
  } catch (error) {
449
466
  logInternalError("subagent-tools.progress", error, `agentId=${agentRecordId}`);
450
467
  }
@@ -47,6 +47,40 @@ function extractTextContent(content: unknown): string {
47
47
  .join("\n");
48
48
  }
49
49
 
50
+ /**
51
+ * Maximum character budget for inherited parent context (~3K tokens).
52
+ * When exceeded, oldest messages are dropped first (most-recent-first retention).
53
+ */
54
+ export const MAX_PARENT_CONTEXT_CHARS = 12_000;
55
+
56
+ /** Maximum chars for a single assistant message before truncation. */
57
+ const MAX_ASSISTANT_MSG_CHARS = 500;
58
+
59
+ /** Truncated length for oversized assistant messages. */
60
+ const TRUNCATED_ASSISTANT_CHARS = 200;
61
+
62
+ /** Messages starting with these patterns and exceeding this size are likely
63
+ * file dumps / bash output — skip them to keep context relevant and compact. */
64
+ const NOISY_THRESHOLD = 1_000;
65
+ const NOISY_PREFIXES = ["```", "total ", "drwx", "-rw", "import ", "export "];
66
+
67
+ /**
68
+ * Check if a message looks like noisy file/tool output (code dumps, ls output,
69
+ * import lists) that bloats context without helping the subagent.
70
+ */
71
+ function isNoisyContent(text: string): boolean {
72
+ if (text.length < NOISY_THRESHOLD) return false;
73
+ return NOISY_PREFIXES.some((prefix) => text.startsWith(prefix));
74
+ }
75
+
76
+ /**
77
+ * Build a compact parent conversation context for subagent inheritance.
78
+ *
79
+ * Extracts recent user messages, assistant reasoning, and compaction summaries
80
+ * from the parent session. Applies a character budget (most-recent-first
81
+ * retention) and filters noisy content (file dumps, long bash output) to keep
82
+ * the inherited context relevant without bloating the subagent's token budget.
83
+ */
50
84
  export function buildParentContext(ctx: TeamContext): string | undefined {
51
85
  const branch = ctx.sessionManager?.getBranch?.();
52
86
  if (!Array.isArray(branch) || branch.length === 0) return undefined;
@@ -58,21 +92,43 @@ export function buildParentContext(ctx: TeamContext): string | undefined {
58
92
  message?: unknown;
59
93
  summary?: unknown;
60
94
  };
61
- if (record.type === "compaction" && typeof record.summary === "string") parts.push(`[Summary]: ${record.summary}`);
95
+ // Compaction summaries are always valuable — keep in full.
96
+ if (record.type === "compaction" && typeof record.summary === "string") {
97
+ parts.push(`[Summary]: ${record.summary}`);
98
+ continue;
99
+ }
62
100
  const message =
63
101
  record.message && typeof record.message === "object" && !Array.isArray(record.message)
64
102
  ? (record.message as { role?: unknown; content?: unknown })
65
103
  : undefined;
66
104
  if (!message || (message.role !== "user" && message.role !== "assistant")) continue;
67
- const text = extractTextContent(message.content).trim();
68
- if (text) parts.push(`[${message.role === "user" ? "User" : "Assistant"}]: ${text}`);
105
+ let text = extractTextContent(message.content).trim();
106
+ if (!text) continue;
107
+ // Filter: skip noisy content (file dumps, long bash output).
108
+ if (isNoisyContent(text)) continue;
109
+ // Truncate: long assistant messages get shortened to key points.
110
+ if (message.role === "assistant" && text.length > MAX_ASSISTANT_MSG_CHARS) {
111
+ text = `${text.slice(0, TRUNCATED_ASSISTANT_CHARS)}…`;
112
+ }
113
+ parts.push(`[${message.role === "user" ? "User" : "Assistant"}]: ${text}`);
69
114
  }
70
115
  if (!parts.length) return undefined;
116
+
117
+ // Budget: keep most-recent messages first, drop oldest when over budget.
118
+ let totalChars = 0;
119
+ const budgeted: string[] = [];
120
+ for (const part of [...parts].reverse()) {
121
+ if (totalChars + part.length > MAX_PARENT_CONTEXT_CHARS) break;
122
+ budgeted.unshift(part);
123
+ totalChars += part.length;
124
+ }
125
+ if (!budgeted.length) return undefined;
126
+
71
127
  return [
72
128
  `# Parent Conversation Context`,
73
129
  "The following context was inherited from the parent Pi session. Treat it as reference-only.",
74
130
  "",
75
- parts.join("\n\n"),
131
+ budgeted.join("\n\n"),
76
132
  ].join("\n");
77
133
  }
78
134
 
@@ -14,7 +14,7 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
14
14
  "runtime.mode": "auto",
15
15
  "runtime.maxTurns": 10000,
16
16
  "runtime.graceTurns": 5,
17
- "runtime.inheritContext": false,
17
+ "runtime.inheritContext": true,
18
18
  "runtime.promptMode": "replace",
19
19
  "runtime.completionMutationGuard": "warn",
20
20
  "runtime.isolationPolicy": undefined,
@@ -210,7 +210,7 @@ export const BACKGROUND_RUNNER_ENV_ALLOWLIST: string[] = [
210
210
  // Phase 1.5: worker-thread atomic writer opt-in (RFC 15).
211
211
  "PI_CREW_WORKER_ATOMIC_WRITER",
212
212
  "PI_TEAMS_WORKER_ATOMIC_WRITER",
213
- // Phase 1.5 #1: verification env sanitization opt-in (RFC 13 §6).
213
+ // Phase 1.5 #1: verification env sanitization opt-out (RFC 13 §6).
214
214
  "PI_CREW_VERIFICATION_SANITIZE_ENV",
215
215
  "PI_TEAMS_VERIFICATION_SANITIZE_ENV",
216
216
  "PI_CREW_VERIFICATION_PRESERVE_ENV",
@@ -9,6 +9,8 @@ export interface AgentProgress {
9
9
  turns: number;
10
10
  tokens: { input: number; output: number };
11
11
  status: "idle" | "running" | "completed" | "error";
12
+ /** Most recent partial assistant text (for live streaming display). */
13
+ partialText?: string;
12
14
  }
13
15
 
14
16
  export class ProgressTracker {
@@ -118,7 +120,145 @@ export class ProgressTracker {
118
120
  getProgress(agentId: string): AgentProgress | undefined {
119
121
  return this.sessions.get(agentId)?.progress;
120
122
  }
123
+
124
+ // ── Child-process worker event bridge ──────────────────────────────────
125
+ //
126
+ // For child-process runtime, events arrive as raw JSON from the child pi
127
+ // process's stdout (via onJsonEvent). These methods bridge those events into
128
+ // the same crewEventBus stream that live-session uses, so the widget shows
129
+ // real-time tool calls and assistant text for BOTH runtimes.
130
+
131
+ private workerProgress = new Map<string, AgentProgress>();
132
+
133
+ /** Throttle: don't emit more than 1 progress event per 500ms per worker. */
134
+ private lastEmitTs = new Map<string, number>();
135
+ private static readonly EMIT_THROTTLE_MS = 500;
136
+
137
+ /**
138
+ * Handle a raw child-process JSON event (from onJsonEvent callback).
139
+ * Processes tool_execution_start/end, agent_start/end, and assistant text.
140
+ */
141
+ handleWorkerEvent(taskId: string, runId: string, event: Record<string, unknown>): void {
142
+ let progress = this.workerProgress.get(taskId);
143
+ if (!progress) {
144
+ progress = {
145
+ toolCalls: 0,
146
+ currentTool: null,
147
+ toolStartTime: null,
148
+ errors: [],
149
+ turns: 0,
150
+ tokens: { input: 0, output: 0 },
151
+ status: "running",
152
+ };
153
+ this.workerProgress.set(taskId, progress);
154
+ }
155
+
156
+ const eventType = typeof event.type === "string" ? event.type : undefined;
157
+
158
+ switch (eventType) {
159
+ case "tool_execution_start": {
160
+ progress.toolCalls++;
161
+ progress.currentTool = typeof event.toolName === "string" ? event.toolName : "unknown";
162
+ progress.toolStartTime = Date.now();
163
+ this.emitThrottled(taskId, runId, progress);
164
+ break;
165
+ }
166
+ case "tool_execution_end": {
167
+ progress.currentTool = null;
168
+ progress.toolStartTime = null;
169
+ if (event.isError) {
170
+ progress.errors.push(String(event.result ?? "Unknown error"));
171
+ crewEventBus.emit({
172
+ type: "agent:error",
173
+ runId,
174
+ agentId: taskId,
175
+ payload: String(event.result ?? "Unknown error"),
176
+ timestamp: Date.now(),
177
+ });
178
+ }
179
+ this.emitThrottled(taskId, runId, progress);
180
+ break;
181
+ }
182
+ case "turn_start":
183
+ case "turn_end":
184
+ progress.turns++;
185
+ break;
186
+ case "agent_start":
187
+ progress.status = "running";
188
+ break;
189
+ case "agent_end":
190
+ case "agent_settled":
191
+ progress.status = "completed";
192
+ this.emitThrottled(taskId, runId, progress);
193
+ break;
194
+ case "message":
195
+ case "message_end": {
196
+ // Extract assistant text for streaming display.
197
+ const message = event.message as { role?: string; content?: unknown } | undefined;
198
+ if (message?.role === "assistant") {
199
+ const text = extractWorkerText(message.content);
200
+ if (text) {
201
+ progress.partialText = text.slice(-2000); // keep last 2K chars
202
+ this.emitThrottled(taskId, runId, progress);
203
+ }
204
+ }
205
+ // Track usage from message_end events.
206
+ if (eventType === "message_end" && event.usage && typeof event.usage === "object") {
207
+ const usage = event.usage as { input?: number; output?: number };
208
+ if (typeof usage.input === "number") progress.tokens.input += usage.input;
209
+ if (typeof usage.output === "number") progress.tokens.output += usage.output;
210
+ }
211
+ break;
212
+ }
213
+ }
214
+ }
215
+
216
+ /** Get the accumulated progress for a child-process worker task. */
217
+ getWorkerProgress(taskId: string): AgentProgress | undefined {
218
+ return this.workerProgress.get(taskId);
219
+ }
220
+
221
+ /** Remove a worker from tracking after completion. */
222
+ untrackWorker(taskId: string): void {
223
+ this.workerProgress.delete(taskId);
224
+ this.lastEmitTs.delete(taskId);
225
+ }
226
+
227
+ /**
228
+ * Emit a progress event to crewEventBus, throttled to avoid flooding
229
+ * the widget with re-renders (max 1 event per EMIT_THROTTLE_MS per worker).
230
+ */
231
+ private emitThrottled(taskId: string, runId: string, progress: AgentProgress): void {
232
+ const now = Date.now();
233
+ const last = this.lastEmitTs.get(taskId) ?? 0;
234
+ if (now - last < ProgressTracker.EMIT_THROTTLE_MS) return;
235
+ this.lastEmitTs.set(taskId, now);
236
+ crewEventBus.emit({
237
+ type: "agent:progress",
238
+ runId,
239
+ agentId: taskId,
240
+ payload: { ...progress },
241
+ timestamp: now,
242
+ });
243
+ }
121
244
  }
122
245
 
123
246
  // Export singleton instance
124
247
  export const globalProgressTracker = new ProgressTracker();
248
+
249
+ /**
250
+ * Extract text content from a worker message's content field.
251
+ * Handles string content and array-of-parts (like extractTextContent in context.ts).
252
+ */
253
+ function extractWorkerText(content: unknown): string {
254
+ if (typeof content === "string") return content;
255
+ if (!Array.isArray(content)) return "";
256
+ return content
257
+ .map((part) =>
258
+ part && typeof part === "object" && !Array.isArray(part) && typeof (part as { text?: unknown }).text === "string"
259
+ ? (part as { text: string }).text
260
+ : "",
261
+ )
262
+ .filter(Boolean)
263
+ .join("\n");
264
+ }
@@ -25,8 +25,9 @@ import { redactSecretString } from "../utils/redaction.ts";
25
25
  * against adversarial workers; this kills the leak at the source by never
26
26
  * giving the verification process the secret in the first place.
27
27
  *
28
- * Opt-in via `PI_CREW_VERIFICATION_SANITIZE_ENV=1` to avoid breaking existing
29
- * flows whose tests legitimately need API access. Escape hatch:
28
+ * Opt-out via `PI_CREW_VERIFICATION_SANITIZE_ENV=0` or
29
+ * `PI_TEAMS_VERIFICATION_SANITIZE_ENV=0` to disable sanitization (e.g. for
30
+ * tests that legitimately need API access). Escape hatch:
30
31
  * `PI_CREW_VERIFICATION_PRESERVE_ENV=KEY1,KEY2,...` lets users explicitly opt
31
32
  * specific secrets back in (audited via the allowlist validator).
32
33
  */
@@ -62,9 +63,13 @@ const VERIFICATION_ENV_ALLOWLIST: readonly string[] = [
62
63
  "NPM_CONFIG_GLOBALCONFIG",
63
64
  ];
64
65
 
65
- /** Whether env sanitization for verification is enabled (env var opt-in). */
66
+ /** Whether env sanitization for verification is enabled (opt-out: enabled by default). */
66
67
  export function isVerificationEnvSanitizeEnabled(): boolean {
67
- return process.env.PI_CREW_VERIFICATION_SANITIZE_ENV === "1" || process.env.PI_TEAMS_VERIFICATION_SANITIZE_ENV === "1";
68
+ // Opt-out: explicitly disabled via env var set to '0'.
69
+ if (process.env.PI_CREW_VERIFICATION_SANITIZE_ENV === "0" || process.env.PI_TEAMS_VERIFICATION_SANITIZE_ENV === "0") {
70
+ return false;
71
+ }
72
+ return true;
68
73
  }
69
74
 
70
75
  /**
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { assertSafePathId } from "../utils/safe-paths.ts";
4
5
 
5
6
  /**
6
7
  * Represents a learned instinct that guides agent behavior.
@@ -120,6 +121,7 @@ export class InstinctStore {
120
121
  if (!savedInstinct.projectId) {
121
122
  throw new Error("Project-scoped instinct requires a projectId");
122
123
  }
124
+ assertSafePathId("projectId", savedInstinct.projectId);
123
125
  this.appendInstinctToFile(this.getProjectInstinctPath(savedInstinct.projectId), savedInstinct);
124
126
  }
125
127
 
@@ -163,6 +165,7 @@ export class InstinctStore {
163
165
  * @returns Array of instincts for the project
164
166
  */
165
167
  getProjectInstincts(projectId: string): Instinct[] {
168
+ assertSafePathId("projectId", projectId);
166
169
  const projectInstincts = this.readInstinctsFromFile(this.getProjectInstinctPath(projectId));
167
170
  const globalInstincts = this.readInstinctsFromFile(this.getGlobalInstinctPath());
168
171
  return [...projectInstincts, ...globalInstincts];
@@ -183,6 +186,7 @@ export class InstinctStore {
183
186
  .readdirSync(projectsDir, { withFileTypes: true })
184
187
  .filter((e) => e.isDirectory())
185
188
  .map((e) => e.name)) {
189
+ assertSafePathId("projectId", projectId);
186
190
  const filePath = path.join(projectsDir, projectId, INSTINCT_FILE);
187
191
  const instincts = this.readInstinctsFromFile(filePath);
188
192
  const index = instincts.findIndex((i) => i.id === instinctId);
@@ -239,6 +243,7 @@ export class InstinctStore {
239
243
  .readdirSync(projectsDir, { withFileTypes: true })
240
244
  .filter((e) => e.isDirectory())
241
245
  .map((e) => e.name)) {
246
+ assertSafePathId("projectId", projectId);
242
247
  const filePath = path.join(projectsDir, projectId, INSTINCT_FILE);
243
248
  instincts = this.readInstinctsFromFile(filePath);
244
249
  index = instincts.findIndex((i) => i.id === instinctId);
@@ -327,7 +327,7 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
327
327
  "runtime.mode": "auto",
328
328
  "runtime.maxTurns": 10000,
329
329
  "runtime.graceTurns": 5,
330
- "runtime.inheritContext": false,
330
+ "runtime.inheritContext": true,
331
331
  "runtime.promptMode": "replace",
332
332
  "runtime.completionMutationGuard": "warn",
333
333
  "runtime.isolationPolicy": undefined,
@@ -56,6 +56,11 @@ export function ensureIntermediateDir(config: Partial<IntermediateStoreConfig> =
56
56
  * @returns Path to the written file
57
57
  */
58
58
  export function writeIntermediate(config: Partial<IntermediateStoreConfig>, phase: string, stepId: string, data: unknown): string {
59
+ // Security: validate phase/stepId before building the filename to prevent
60
+ // path traversal via poisoned phase or stepId.
61
+ if (!isSafePathId(phase) || !isSafePathId(stepId)) {
62
+ throw new Error("Invalid phase or stepId for intermediate store");
63
+ }
59
64
  const dir = ensureIntermediateDir(config);
60
65
  const filename = `${phase}-${stepId}.json`;
61
66
  const filePath = path.join(dir, filename);