pi-crew 0.5.5 → 0.5.7

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +17 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/migration-v0.4-v0.5.md +19 -2
  5. package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
  6. package/package.json +7 -5
  7. package/src/benchmark/benchmark-runner.ts +45 -0
  8. package/src/benchmark/feedback-loop.ts +5 -0
  9. package/src/config/config.ts +38 -4
  10. package/src/config/defaults.ts +5 -0
  11. package/src/config/suggestions.ts +8 -0
  12. package/src/extension/async-notifier.ts +10 -1
  13. package/src/extension/cross-extension-rpc.ts +1 -1
  14. package/src/extension/notification-router.ts +18 -0
  15. package/src/extension/register.ts +13 -17
  16. package/src/extension/registration/subagent-tools.ts +1 -1
  17. package/src/extension/team-tool/anchor.ts +201 -0
  18. package/src/extension/team-tool/api.ts +2 -1
  19. package/src/extension/team-tool/auto-summarize.ts +154 -0
  20. package/src/extension/team-tool/run.ts +37 -2
  21. package/src/extension/team-tool.ts +44 -2
  22. package/src/hooks/registry.ts +1 -3
  23. package/src/observability/event-bus.ts +13 -4
  24. package/src/observability/event-to-metric.ts +0 -2
  25. package/src/runtime/anchor-manager.ts +473 -0
  26. package/src/runtime/async-runner.ts +8 -4
  27. package/src/runtime/auto-summarize.ts +350 -0
  28. package/src/runtime/background-runner.ts +2 -1
  29. package/src/runtime/budget-tracker.ts +354 -0
  30. package/src/runtime/chain-runner.ts +507 -0
  31. package/src/runtime/child-pi.ts +24 -6
  32. package/src/runtime/crash-recovery.ts +5 -4
  33. package/src/runtime/crew-agent-records.ts +32 -1
  34. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  35. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  36. package/src/runtime/delivery-coordinator.ts +10 -3
  37. package/src/runtime/dynamic-script-runner.ts +482 -0
  38. package/src/runtime/handoff-manager.ts +589 -0
  39. package/src/runtime/hidden-handoff.ts +424 -0
  40. package/src/runtime/live-agent-manager.ts +20 -4
  41. package/src/runtime/live-session-runtime.ts +39 -4
  42. package/src/runtime/manifest-cache.ts +2 -1
  43. package/src/runtime/model-resolver.ts +16 -4
  44. package/src/runtime/phase-tracker.ts +373 -0
  45. package/src/runtime/pipeline-runner.ts +514 -0
  46. package/src/runtime/retry-runner.ts +354 -0
  47. package/src/runtime/sandbox.ts +252 -0
  48. package/src/runtime/scheduler.ts +7 -2
  49. package/src/runtime/subagent-manager.ts +1 -1
  50. package/src/runtime/task-graph.ts +11 -1
  51. package/src/runtime/task-runner.ts +15 -1
  52. package/src/runtime/team-runner.ts +4 -3
  53. package/src/schema/team-tool-schema.ts +31 -0
  54. package/src/skills/discover-skills.ts +5 -0
  55. package/src/state/active-run-registry.ts +19 -3
  56. package/src/state/contracts.ts +9 -0
  57. package/src/state/crew-init.ts +3 -3
  58. package/src/state/decision-ledger.ts +26 -32
  59. package/src/state/event-log-rotation.ts +2 -2
  60. package/src/state/event-log.ts +17 -4
  61. package/src/state/mailbox.ts +35 -1
  62. package/src/state/run-cache.ts +18 -8
  63. package/src/tools/safe-bash-extension.ts +1 -0
  64. package/src/tools/safe-bash.ts +153 -20
  65. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  66. package/src/ui/powerbar-publisher.ts +1 -0
  67. package/src/ui/transcript-cache.ts +13 -0
  68. package/src/utils/bm25-search.ts +16 -8
  69. package/src/utils/env-filter.ts +8 -5
  70. package/src/utils/redaction.ts +169 -15
  71. package/src/utils/sse-parser.ts +10 -1
  72. package/src/worktree/cleanup.ts +6 -1
  73. package/workflows/chain.workflow.md +252 -0
  74. package/workflows/pipeline.workflow.md +27 -0
@@ -0,0 +1,507 @@
1
+ /**
2
+ * ChainRunner - Execute sequential chains with `->` syntax support.
3
+ *
4
+ * Based on pi-boomerang's parseChain pattern:
5
+ * - Parses "teamA -> teamB -> teamC" syntax
6
+ * - Supports per-step overrides for model, skill, thinking
7
+ * - Accumulates handoffs between steps
8
+ * - Executes steps sequentially with context passing
9
+ *
10
+ * @see docs/pi-boomerang-integration-plan.md
11
+ */
12
+
13
+ import type { HandoffSummary, HandoffManager, TaskPacket, TaskResult } from "./handoff-manager.ts";
14
+
15
+ /**
16
+ * Single step in a chain.
17
+ */
18
+ export interface ChainStep {
19
+ /** Step name/identifier */
20
+ name: string;
21
+ /** Team to execute (if using team reference) */
22
+ team?: string;
23
+ /** Workflow to execute (if using workflow reference) */
24
+ workflow?: string;
25
+ /** Template to execute (if using template reference) */
26
+ template?: string;
27
+ /** Inline goal text (for literal goals) */
28
+ inlineGoal?: string;
29
+
30
+ /** Per-step model override */
31
+ model?: string;
32
+ /** Per-step skill override */
33
+ skill?: string;
34
+ /** Thinking mode */
35
+ thinking?: "fast" | "standard" | "deep";
36
+
37
+ /** Step-specific context */
38
+ context?: Record<string, unknown>;
39
+ /** Step timeout in milliseconds */
40
+ timeout?: number;
41
+
42
+ /** Whether to continue chain on failure */
43
+ continueOnError?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Parsed chain specification.
48
+ */
49
+ export interface ChainSpec {
50
+ /** Ordered steps in the chain */
51
+ steps: ChainStep[];
52
+ /** Global arguments applied to all steps */
53
+ globalArgs?: Record<string, unknown>;
54
+ /** Global model override */
55
+ globalModel?: string;
56
+ /** Global skill override */
57
+ globalSkill?: string;
58
+ /** Global thinking mode */
59
+ globalThinking?: "fast" | "standard" | "deep";
60
+ /** Continue chain on step failure */
61
+ continueOnError?: boolean;
62
+ }
63
+
64
+ /**
65
+ * Result of a single chain step execution.
66
+ */
67
+ export interface ChainStepResult {
68
+ step: number;
69
+ name: string;
70
+ outcome: "success" | "failure" | "skipped" | "partial";
71
+ result?: TaskResult;
72
+ handoff?: HandoffSummary;
73
+ duration: number;
74
+ error?: string;
75
+ }
76
+
77
+ /**
78
+ * Final chain execution result.
79
+ */
80
+ export interface ChainResult {
81
+ steps: ChainStepResult[];
82
+ totalDuration: number;
83
+ success: boolean;
84
+ /** Total tokens used across all steps */
85
+ totalTokens?: number;
86
+ /** All handoffs generated during chain */
87
+ totalHandoffs: HandoffSummary[];
88
+ }
89
+
90
+ /**
91
+ * Task runner interface for chain execution.
92
+ */
93
+ export interface ChainTaskRunner {
94
+ runTask(packet: TaskPacket): Promise<TaskResult>;
95
+ }
96
+
97
+ /**
98
+ * ChainRunner executes sequential chains with context passing.
99
+ */
100
+ export class ChainRunner {
101
+ /** Maximum number of chain history entries to prevent memory leaks */
102
+ private static readonly MAX_CHAIN_HISTORY_SIZE = 100;
103
+
104
+ /** Maximum size per handoff entry to prevent memory issues from large artifacts */
105
+ private static readonly MAX_HANDOFF_ENTRY_SIZE = 5000; // bytes per entry
106
+
107
+ constructor(
108
+ private taskRunner: ChainTaskRunner,
109
+ private handoffManager: HandoffManager,
110
+ ) {}
111
+
112
+ /**
113
+ * Parse chain syntax: step1 -> step2 -> step3
114
+ *
115
+ * Supports multiple syntaxes:
116
+ * - Team reference: @teamName
117
+ * - Workflow reference: workflow:name
118
+ * - Template reference: template:name
119
+ * - Inline goal: "goal description"
120
+ *
121
+ * @example
122
+ * parseChain("@research -> @implement -> @review")
123
+ * parseChain('"Research AI trends" -> "Analyze findings"')
124
+ * parseChain("@step1 --model claude-opus-3 -> @step2")
125
+ *
126
+ * @param chainString - The chain string to parse
127
+ * @returns Parsed chain specification
128
+ */
129
+ parseChain(chainString: string): ChainSpec {
130
+ const stepStrings = chainString.split("->").map(s => s.trim());
131
+
132
+ const steps: ChainStep[] = stepStrings.map((step, index) => {
133
+ return this.parseStep(step, index);
134
+ });
135
+
136
+ // Extract global overrides
137
+ const globalModel = this.extractGlobalFlag(chainString, "global-model");
138
+ const globalSkill = this.extractGlobalFlag(chainString, "global-skill");
139
+ const globalThinking = this.extractGlobalFlag(chainString, "global-thinking") as "fast" | "standard" | "deep" | undefined;
140
+ const continueOnError = this.extractGlobalFlag(chainString, "continue-on-error") === "true";
141
+
142
+ return {
143
+ steps,
144
+ globalModel,
145
+ globalSkill,
146
+ globalThinking,
147
+ continueOnError,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Execute chain sequentially.
153
+ * Each step receives handoff from previous step.
154
+ *
155
+ * @param spec - Parsed chain specification
156
+ * @param initialContext - Initial context for the chain
157
+ * @param eventsPath - Optional event log path for events
158
+ * @returns Final chain result
159
+ */
160
+ async runChain(
161
+ spec: ChainSpec,
162
+ initialContext: Record<string, unknown> = {},
163
+ eventsPath?: string
164
+ ): Promise<ChainResult> {
165
+ const stepResults: ChainStepResult[] = [];
166
+ let accumulatedContext = { ...initialContext };
167
+ const startTime = Date.now();
168
+ let totalTokens = 0;
169
+ const allHandoffs: HandoffSummary[] = [];
170
+
171
+ for (let i = 0; i < spec.steps.length; i++) {
172
+ const step = spec.steps[i];
173
+ const stepStart = Date.now();
174
+
175
+ try {
176
+ // Resolve effective config (step overrides global)
177
+ const effectiveConfig = this.getEffectiveConfig(step, spec);
178
+
179
+ // Enrich context with previous handoffs
180
+ const stepContext = this.enrichContextFromHandoffs(
181
+ accumulatedContext,
182
+ stepResults
183
+ );
184
+
185
+ // Execute step
186
+ const result = await this.executeStep(effectiveConfig, stepContext);
187
+
188
+ // Track tokens
189
+ if (result.usage?.totalTokens) {
190
+ totalTokens += result.usage.totalTokens;
191
+ }
192
+
193
+ // Generate handoff for next step
194
+ const handoff = await this.handoffManager.generateSummary(
195
+ this.createMinimalPacket(step, i),
196
+ result
197
+ );
198
+
199
+ stepResults.push({
200
+ step: i + 1,
201
+ name: step.name,
202
+ outcome: result.outcome,
203
+ result,
204
+ handoff,
205
+ duration: Date.now() - stepStart,
206
+ });
207
+
208
+ if (handoff !== null) { allHandoffs.push(handoff); }
209
+
210
+ // Update accumulated context on success
211
+ if (result.outcome === "success") {
212
+ accumulatedContext = {
213
+ ...accumulatedContext,
214
+ [`step_${i}_result`]: result,
215
+ [`step_${i}_handoff`]: handoff,
216
+ };
217
+ } else {
218
+ // Stop chain on step failure unless configured to continue
219
+ if (!spec.continueOnError && !step.continueOnError) {
220
+ break;
221
+ }
222
+ }
223
+
224
+ // Emit progress event if eventsPath provided
225
+ if (eventsPath) {
226
+ const { appendEventAsync } = await import("../state/event-log.ts");
227
+ await appendEventAsync(eventsPath, {
228
+ type: "chain.step_completed",
229
+ runId: "chain",
230
+ taskId: `step-${i + 1}`,
231
+ data: {
232
+ step: i + 1,
233
+ name: step.name,
234
+ outcome: result.outcome,
235
+ duration: Date.now() - stepStart,
236
+ },
237
+ });
238
+ }
239
+
240
+ } catch (error) {
241
+ const errorMessage = error instanceof Error ? error.message : String(error);
242
+
243
+ stepResults.push({
244
+ step: i + 1,
245
+ name: step.name,
246
+ outcome: "failure",
247
+ duration: Date.now() - stepStart,
248
+ error: errorMessage,
249
+ });
250
+
251
+ // Stop chain on failure unless configured to continue
252
+ if (!spec.continueOnError && !step.continueOnError) {
253
+ break;
254
+ }
255
+ }
256
+ }
257
+
258
+ return {
259
+ steps: stepResults,
260
+ totalDuration: Date.now() - startTime,
261
+ success: stepResults.every(s => s.outcome !== "failure"),
262
+ totalTokens: totalTokens > 0 ? totalTokens : undefined,
263
+ totalHandoffs: allHandoffs,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Parse a single step from the chain string.
269
+ * Includes type safety checks for ChainStep parsing (H3).
270
+ */
271
+ private parseStep(step: string, index: number): ChainStep {
272
+ // Parse team reference: @teamName
273
+ const teamMatch = step.match(/^@([a-zA-Z][a-zA-Z0-9_]*)/);
274
+
275
+ // Parse workflow reference: workflow:name
276
+ const workflowMatch = step.match(/^workflow:([a-zA-Z][a-zA-Z0-9_]*)/);
277
+
278
+ // Parse template reference: template:name
279
+ const templateMatch = step.match(/^template:([a-zA-Z][a-zA-Z0-9_]*)/);
280
+
281
+ // Parse inline goal: "goal description" (can follow other patterns)
282
+ const inlineMatch = step.match(/"([^"]{1,10000})"/);
283
+
284
+ const nameParts = step.split(/\s+/);
285
+ const name = (nameParts[0] && nameParts[0].length > 0 && nameParts[0].length <= 100)
286
+ ? nameParts[0]
287
+ : `step-${index}`;
288
+
289
+ const parsed: ChainStep = {
290
+ name,
291
+ };
292
+
293
+ // Set step type based on matching pattern with type safety
294
+ if (teamMatch && teamMatch[1]) {
295
+ parsed.team = this.sanitizeIdentifier(teamMatch[1]);
296
+ }
297
+ if (workflowMatch && workflowMatch[1]) {
298
+ parsed.workflow = this.sanitizeIdentifier(workflowMatch[1]);
299
+ }
300
+ if (templateMatch && templateMatch[1]) {
301
+ parsed.template = this.sanitizeIdentifier(templateMatch[1]);
302
+ }
303
+ if (inlineMatch && inlineMatch[1]) {
304
+ parsed.inlineGoal = this.sanitizeInlineGoal(inlineMatch[1]);
305
+ }
306
+
307
+ // Parse per-step overrides with type safety
308
+ const modelVal = this.extractFlag(step, "model");
309
+ if (modelVal && this.isValidModelName(modelVal)) {
310
+ parsed.model = modelVal;
311
+ }
312
+
313
+ const skillVal = this.extractFlag(step, "skill");
314
+ if (skillVal && this.isValidIdentifier(skillVal)) {
315
+ parsed.skill = skillVal;
316
+ }
317
+
318
+ const thinkingVal = this.extractFlag(step, "thinking");
319
+ if (thinkingVal && this.isValidThinkingMode(thinkingVal)) {
320
+ parsed.thinking = thinkingVal;
321
+ }
322
+
323
+ // Parse step timeout
324
+ const timeoutStr = this.extractFlag(step, "timeout");
325
+ if (timeoutStr) {
326
+ const timeoutMs = parseInt(timeoutStr, 10);
327
+ if (!isNaN(timeoutMs) && timeoutMs > 0 && timeoutMs <= 86400000) {
328
+ parsed.timeout = timeoutMs * 1000; // Convert seconds to ms
329
+ }
330
+ }
331
+
332
+ // Parse continueOnError for step
333
+ if (this.extractFlag(step, "continue-on-error") === "true") {
334
+ parsed.continueOnError = true;
335
+ }
336
+
337
+ return parsed;
338
+ }
339
+
340
+ /**
341
+ * Sanitize identifier to prevent injection.
342
+ */
343
+ private sanitizeIdentifier(value: string): string {
344
+ return value.replace(/[^a-zA-Z0-9_]/g, '_').substring(0, 100);
345
+ }
346
+
347
+ /**
348
+ * Sanitize inline goal to prevent injection.
349
+ */
350
+ private sanitizeInlineGoal(value: string): string {
351
+ // Remove control characters and limit length
352
+ return value.replace(/[\x00-\x1F\x7F]/g, '').substring(0, 10000);
353
+ }
354
+
355
+ /**
356
+ * Validate model name format.
357
+ */
358
+ private isValidModelName(value: string): boolean {
359
+ return /^[a-zA-Z][a-zA-Z0-9_-]{0,50}$/.test(value);
360
+ }
361
+
362
+ /**
363
+ * Validate identifier format.
364
+ */
365
+ private isValidIdentifier(value: string): boolean {
366
+ return /^[a-zA-Z][a-zA-Z0-9_]{0,50}$/.test(value);
367
+ }
368
+
369
+ /**
370
+ * Validate thinking mode value.
371
+ */
372
+ private isValidThinkingMode(value: string): value is "fast" | "standard" | "deep" {
373
+ return ["fast", "standard", "deep"].includes(value);
374
+ }
375
+
376
+ /**
377
+ * Extract a flag from step string.
378
+ * Uses escaped flag name to prevent regex injection.
379
+ */
380
+ private extractFlag(input: string, flag: string): string | undefined {
381
+ // Escape regex special characters in flag name to prevent injection
382
+ const escapedFlag = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
383
+ const match = input.match(new RegExp(`--${escapedFlag}\\s+(\\S+)`));
384
+ return match?.[1];
385
+ }
386
+
387
+ /**
388
+ * Extract a global flag from the chain string.
389
+ * Global flags can appear anywhere in the chain string.
390
+ * Uses escaped flag name to prevent regex injection.
391
+ */
392
+ private extractGlobalFlag(input: string, flag: string): string | undefined {
393
+ // Escape regex special characters in flag name to prevent injection
394
+ const escapedFlag = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
395
+ const patternEq = '--' + escapedFlag + '=\\s*(\\S+)';
396
+ const match = input.match(new RegExp(patternEq, 'i'));
397
+ if (match) return match[1];
398
+
399
+ const patternNoEq = '--' + escapedFlag + '\\s+(\\S+)';
400
+ const matchNoEq = input.match(new RegExp(patternNoEq, 'i'));
401
+ if (matchNoEq) return matchNoEq[1];
402
+
403
+ return undefined;
404
+ }
405
+
406
+ /**
407
+ * Get effective config with step overrides global.
408
+ */
409
+ private getEffectiveConfig(step: ChainStep, spec: ChainSpec): ChainStep {
410
+ return {
411
+ ...step,
412
+ model: step.model ?? spec.globalModel,
413
+ skill: step.skill ?? spec.globalSkill,
414
+ thinking: step.thinking ?? spec.globalThinking,
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Enrich context with previous handoffs.
420
+ * Limits history size to prevent memory leaks.
421
+ */
422
+ private enrichContextFromHandoffs(
423
+ context: Record<string, unknown>,
424
+ previousResults: ChainStepResult[]
425
+ ): Record<string, unknown> {
426
+ const handoffs = previousResults
427
+ .filter(r => r.handoff)
428
+ .map(r => r.handoff!);
429
+
430
+ if (handoffs.length === 0) {
431
+ return context;
432
+ }
433
+
434
+ // Limit history size to prevent memory leak (H2)
435
+ const limitedHandoffs = handoffs.slice(-ChainRunner.MAX_CHAIN_HISTORY_SIZE);
436
+
437
+ // Limit per-entry size to prevent memory issues from large artifacts
438
+ const filteredHandoffs = limitedHandoffs.filter(h => {
439
+ const size = JSON.stringify(h).length;
440
+ return size <= ChainRunner.MAX_HANDOFF_ENTRY_SIZE;
441
+ });
442
+
443
+ return {
444
+ ...context,
445
+ __chainHistory: filteredHandoffs.map(h => ({
446
+ step: h.taskId,
447
+ outcome: h.outcome,
448
+ filesCreated: h.filesCreated?.slice(0, 50), // Limit array size
449
+ filesModified: h.filesModified?.slice(0, 50), // Limit array size
450
+ decisions: h.decisions?.slice(0, 20), // Limit array size
451
+ nextSteps: h.nextSteps?.slice(0, 20), // Limit array size
452
+ })),
453
+ };
454
+ }
455
+
456
+ /**
457
+ * Execute a single step.
458
+ */
459
+ private async executeStep(
460
+ config: ChainStep,
461
+ context: Record<string, unknown>
462
+ ): Promise<TaskResult> {
463
+ const packet: TaskPacket = {
464
+ taskId: `chain-${Date.now()}-${config.name}`,
465
+ runId: "chain",
466
+ goal: config.inlineGoal ?? config.name,
467
+ summarizeThreshold: 3000,
468
+ collapseContext: true,
469
+ context,
470
+ };
471
+
472
+ return this.taskRunner.runTask(packet);
473
+ }
474
+
475
+ /**
476
+ * Create minimal packet for handoff generation.
477
+ */
478
+ private createMinimalPacket(step: ChainStep, index: number): TaskPacket {
479
+ return {
480
+ taskId: `chain-step-${index}`,
481
+ runId: "chain",
482
+ sessionId: "chain",
483
+ goal: step.inlineGoal ?? step.name,
484
+ };
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Create a ChainRunner with default dependencies.
490
+ */
491
+ export function createChainRunner(
492
+ taskRunner: ChainTaskRunner,
493
+ handoffManager: HandoffManager
494
+ ): ChainRunner {
495
+ return new ChainRunner(taskRunner, handoffManager);
496
+ }
497
+
498
+ /**
499
+ * Parse chain from string shorthand.
500
+ */
501
+ export function parseChainString(chainString: string): ChainSpec {
502
+ const runner = new ChainRunner(
503
+ { runTask: () => Promise.reject(new Error("Not initialized")) } as ChainTaskRunner,
504
+ {} as HandoffManager
505
+ );
506
+ return runner.parseChain(chainString);
507
+ }
@@ -8,7 +8,7 @@ import { getPiSpawnCommand } from "./pi-spawn.ts";
8
8
  import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
9
9
  import { logInternalError } from "../utils/internal-error.ts";
10
10
  import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
11
- import { redactJsonLine, SECRET_KEY_PATTERN } from "../utils/redaction.ts";
11
+ import { redactJsonLine, isSecretKey } from "../utils/redaction.ts";
12
12
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
13
13
  import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
14
14
 
@@ -206,11 +206,29 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
206
206
  "SHELL",
207
207
  "TERM",
208
208
  "LANG",
209
- "LC_*",
210
- "XDG_*",
211
- "NVM_*",
212
- "NODE_*",
213
- "npm_*",
209
+ // FIX: Replaced broad wildcards (LC_*, XDG_*, NVM_*, NODE_*, npm_*) with
210
+ // specific names. Previously NPM_TOKEN, NODE_ENV=production, NVM_RC_VERSION
211
+ // all leaked through wildcards.
212
+ "LC_ALL",
213
+ "LC_COLLATE",
214
+ "LC_CTYPE",
215
+ "LC_MESSAGES",
216
+ "LC_MONETARY",
217
+ "LC_NUMERIC",
218
+ "LC_TIME",
219
+ "XDG_CONFIG_HOME",
220
+ "XDG_DATA_HOME",
221
+ "XDG_CACHE_HOME",
222
+ "XDG_RUNTIME_DIR",
223
+ "NVM_BIN",
224
+ "NVM_DIR",
225
+ "NVM_INC",
226
+ "NODE_PATH",
227
+ "NODE_DISABLE_COLORS",
228
+ "NODE_EXTRA_CA_CERTS",
229
+ "NPM_CONFIG_REGISTRY",
230
+ "NPM_CONFIG_USERCONFIG",
231
+ "NPM_CONFIG_GLOBALCONFIG",
214
232
  "PI_*",
215
233
  "PI_CREW_*",
216
234
  "PI_TEAMS_*",
@@ -15,6 +15,7 @@ import { activeRunEntries, unregisterActiveRun, readActiveRunRegistry } from "..
15
15
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
16
16
  import { projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
17
17
  import { terminateLiveAgentsForRun } from "./live-agent-manager.ts";
18
+ import { logInternalError } from "../utils/internal-error.ts";
18
19
 
19
20
  export interface RecoveryPlan {
20
21
  runId: string;
@@ -159,7 +160,7 @@ export function cancelOrphanedRuns(
159
160
  cancelled.push(manifest.runId);
160
161
  cancelledRun = true;
161
162
  });
162
- if (cancelledRun) void terminateLiveAgentsForRun(manifest.runId, "cancelled", appendEvent, loaded.manifest.eventsPath).catch(() => {});
163
+ if (cancelledRun) void terminateLiveAgentsForRun(manifest.runId, "cancelled", appendEvent, loaded.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.orphan.terminate", error, `runId=${manifest.runId}`));
163
164
  }
164
165
 
165
166
  return { cancelled, skipped };
@@ -268,7 +269,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
268
269
  saveRunTasks(fullLoaded.manifest, repairedTasks);
269
270
  for (const task of repairedTasks) { try { upsertCrewAgent(fullLoaded.manifest, recordFromTask(fullLoaded.manifest, task, "scaffold")); } catch { /* non-critical */ } }
270
271
  updateRunStatus(fullLoaded.manifest, "cancelled", "Orphaned run: worker process dead and no recent activity");
271
- void terminateLiveAgentsForRun(fullLoaded.manifest.runId, "cancelled", appendEvent, fullLoaded.manifest.eventsPath).catch(() => {});
272
+ void terminateLiveAgentsForRun(fullLoaded.manifest.runId, "cancelled", appendEvent, fullLoaded.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`));
272
273
  }
273
274
  } catch {
274
275
  // Best-effort manifest cleanup
@@ -299,7 +300,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
299
300
  saveRunTasks(fullLoaded.manifest, repairedTasks);
300
301
  for (const task of repairedTasks) { try { upsertCrewAgent(fullLoaded.manifest, recordFromTask(fullLoaded.manifest, task, "scaffold")); } catch { /* non-critical */ } }
301
302
  updateRunStatus(fullLoaded.manifest, "cancelled", "Orphaned run: no async worker and no manifest update in over " + Math.round(staleThresholdMs / 60000) + " minutes");
302
- void terminateLiveAgentsForRun(fullLoaded.manifest.runId, "cancelled", appendEvent, fullLoaded.manifest.eventsPath).catch(() => {});
303
+ void terminateLiveAgentsForRun(fullLoaded.manifest.runId, "cancelled", appendEvent, fullLoaded.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`));
303
304
  }
304
305
  } catch {
305
306
  // Best-effort
@@ -335,7 +336,7 @@ export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache,
335
336
  for (const task of result.repairedTasks) { try { upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold")); } catch { /* non-critical */ } }
336
337
  }
337
338
  updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
338
- void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch(() => {});
339
+ void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`));
339
340
  appendEvent(fresh.manifest.eventsPath, { type: "crew.run.reconciled_stale", runId: manifest.runId, message: result.detail, data: { verdict: result.verdict } });
340
341
  }
341
342
  if (result.verdict !== "healthy") {
@@ -263,12 +263,41 @@ export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: st
263
263
  }
264
264
 
265
265
  const agentEventSeqCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
266
+ const AGENT_EVENT_SEQ_SIDECAR = ".seq";
267
+
268
+ function readSeqFromSidecar(filePath: string): number | undefined {
269
+ try {
270
+ const raw = fs.readFileSync(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, "utf-8");
271
+ const n = Number.parseInt(raw, 10);
272
+ return Number.isFinite(n) && n > 0 ? n : undefined;
273
+ } catch {
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ function writeSeqToSidecar(filePath: string, seq: number): void {
279
+ try {
280
+ fs.writeFileSync(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, String(seq));
281
+ } catch (error) {
282
+ logInternalError("crew-agent-records.seq-sidecar", error, `filePath=${filePath}`);
283
+ }
284
+ }
266
285
 
267
286
  function nextAgentEventSeq(filePath: string): number {
268
- if (!fs.existsSync(filePath)) return 1;
287
+ if (!fs.existsSync(filePath)) {
288
+ // Clean up stale sidecar when main file is gone.
289
+ try { fs.unlinkSync(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`); } catch {}
290
+ return 1;
291
+ }
269
292
  const stat = fs.statSync(filePath);
270
293
  const cached = agentEventSeqCache.get(filePath);
271
294
  if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.seq + 1;
295
+ // FIX: Try sidecar file for O(1) lookup before falling back to O(n) scan.
296
+ const sidecarSeq = readSeqFromSidecar(filePath);
297
+ if (sidecarSeq !== undefined) {
298
+ agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: sidecarSeq });
299
+ return sidecarSeq + 1;
300
+ }
272
301
  let max = 0;
273
302
  for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
274
303
  if (!line.trim()) continue;
@@ -281,6 +310,7 @@ function nextAgentEventSeq(filePath: string): number {
281
310
  }
282
311
  }
283
312
  agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: max });
313
+ writeSeqToSidecar(filePath, max);
284
314
  return max + 1;
285
315
  }
286
316
 
@@ -292,6 +322,7 @@ export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string,
292
322
  try {
293
323
  const stat = fs.statSync(filePath);
294
324
  agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
325
+ writeSeqToSidecar(filePath, seq);
295
326
  } catch (error) {
296
327
  logInternalError("crew-agent-records.stat", error, `filePath=${filePath}`);
297
328
  }
@@ -44,6 +44,19 @@ const IrcParams = Type.Object({
44
44
 
45
45
  type IrcParams = Static<typeof IrcParams>;
46
46
 
47
+ /**
48
+ * Output schema for the irc tool's `details` field.
49
+ * All fields are optional — only present when relevant to the operation.
50
+ *
51
+ * Schema:
52
+ * op — Always present. "send" | "list"
53
+ * from — Sender agent ID. Present on all responses.
54
+ * to — Recipient agent ID. Present on send responses.
55
+ * delivered — Array of agent IDs that received the message. Present on send.
56
+ * notFound — Array of agent IDs that were unknown or unavailable. Present on send.
57
+ * peers — Array of { id, status } for list operation.
58
+ * error — Human-readable error description. Present when the operation failed.
59
+ */
47
60
  interface IrcDetails {
48
61
  op: "send" | "list";
49
62
  from?: string;
@@ -12,6 +12,7 @@
12
12
  import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
13
13
  import { Type, type Static } from "@sinclair/typebox";
14
14
  import type { YieldResult } from "../yield-handler.ts";
15
+ import { logInternalError } from "../../utils/internal-error.ts";
15
16
 
16
17
  const SubmitResultParams = Type.Object({
17
18
  summary: Type.String({ description: "Summary of completed work." }),
@@ -81,8 +82,8 @@ export function createSubmitResultTool(
81
82
  };
82
83
  try {
83
84
  onYield(result);
84
- } catch {
85
- // Yield handler failure should not prevent tool response
85
+ } catch (error) {
86
+ logInternalError("submit-result-tool.yield", error, toolCallId);
86
87
  }
87
88
  return response;
88
89
  },