pi-crew 0.5.5 → 0.5.6

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 (72) hide show
  1. package/CHANGELOG.md +116 -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 +10 -0
  10. package/src/config/suggestions.ts +8 -0
  11. package/src/extension/async-notifier.ts +10 -1
  12. package/src/extension/cross-extension-rpc.ts +1 -1
  13. package/src/extension/notification-router.ts +18 -0
  14. package/src/extension/register.ts +13 -17
  15. package/src/extension/registration/subagent-tools.ts +1 -1
  16. package/src/extension/team-tool/anchor.ts +201 -0
  17. package/src/extension/team-tool/api.ts +2 -1
  18. package/src/extension/team-tool/auto-summarize.ts +154 -0
  19. package/src/extension/team-tool/run.ts +37 -2
  20. package/src/extension/team-tool.ts +44 -2
  21. package/src/hooks/registry.ts +1 -3
  22. package/src/observability/event-bus.ts +13 -4
  23. package/src/observability/event-to-metric.ts +0 -2
  24. package/src/runtime/anchor-manager.ts +473 -0
  25. package/src/runtime/async-runner.ts +8 -4
  26. package/src/runtime/auto-summarize.ts +350 -0
  27. package/src/runtime/background-runner.ts +2 -1
  28. package/src/runtime/budget-tracker.ts +354 -0
  29. package/src/runtime/chain-runner.ts +507 -0
  30. package/src/runtime/child-pi.ts +1 -1
  31. package/src/runtime/crash-recovery.ts +5 -4
  32. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  33. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  34. package/src/runtime/delivery-coordinator.ts +10 -3
  35. package/src/runtime/dynamic-script-runner.ts +482 -0
  36. package/src/runtime/handoff-manager.ts +589 -0
  37. package/src/runtime/hidden-handoff.ts +424 -0
  38. package/src/runtime/live-agent-manager.ts +20 -4
  39. package/src/runtime/live-session-runtime.ts +39 -4
  40. package/src/runtime/manifest-cache.ts +2 -1
  41. package/src/runtime/model-resolver.ts +16 -4
  42. package/src/runtime/phase-tracker.ts +373 -0
  43. package/src/runtime/pipeline-runner.ts +514 -0
  44. package/src/runtime/retry-runner.ts +354 -0
  45. package/src/runtime/sandbox.ts +252 -0
  46. package/src/runtime/scheduler.ts +7 -2
  47. package/src/runtime/subagent-manager.ts +1 -1
  48. package/src/runtime/task-graph.ts +11 -1
  49. package/src/runtime/task-runner.ts +1 -1
  50. package/src/runtime/team-runner.ts +4 -3
  51. package/src/schema/team-tool-schema.ts +30 -0
  52. package/src/skills/discover-skills.ts +5 -0
  53. package/src/state/active-run-registry.ts +9 -2
  54. package/src/state/contracts.ts +9 -0
  55. package/src/state/crew-init.ts +3 -3
  56. package/src/state/decision-ledger.ts +26 -32
  57. package/src/state/event-log-rotation.ts +2 -2
  58. package/src/state/event-log.ts +9 -1
  59. package/src/state/mailbox.ts +10 -0
  60. package/src/state/run-cache.ts +18 -8
  61. package/src/tools/safe-bash-extension.ts +1 -0
  62. package/src/tools/safe-bash.ts +152 -20
  63. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  64. package/src/ui/powerbar-publisher.ts +1 -0
  65. package/src/ui/transcript-cache.ts +13 -0
  66. package/src/utils/bm25-search.ts +16 -8
  67. package/src/utils/env-filter.ts +8 -5
  68. package/src/utils/redaction.ts +169 -15
  69. package/src/utils/sse-parser.ts +10 -1
  70. package/src/worktree/cleanup.ts +6 -1
  71. package/workflows/chain.workflow.md +252 -0
  72. 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
 
@@ -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") {
@@ -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
  },
@@ -28,11 +28,10 @@ export class DeliveryCoordinator {
28
28
  private flushing = false;
29
29
  private readonly deps: DeliveryCoordinatorDeps;
30
30
  private ttlTimer: ReturnType<typeof setInterval> | undefined;
31
+ private timerStarted = false;
31
32
 
32
33
  constructor(deps: DeliveryCoordinatorDeps) {
33
34
  this.deps = deps;
34
- this.ttlTimer = setInterval(() => this.evictExpired(), 60_000);
35
- this.ttlTimer.unref();
36
35
  }
37
36
 
38
37
  activate(sessionId: string): void {
@@ -102,9 +101,11 @@ export class DeliveryCoordinator {
102
101
 
103
102
  flushQueuedResults(): void {
104
103
  if (!this.active || this.pending.length === 0) return;
105
- // H7: Set flushing BEFORE splice to prevent re-entrancy
104
+ // HIGH-16/ MEDIUM-16: Set flushing BEFORE splice to prevent re-entrancy
106
105
  if (this.flushing) return;
107
106
  this.flushing = true;
107
+ // Note: this.flushing is now set, so concurrent calls will exit early due to the check above
108
+ // This serves as a simple lock to prevent race conditions
108
109
  const batch = this.pending.splice(0);
109
110
  try {
110
111
  const retryLater: PendingDelivery[] = [];
@@ -162,6 +163,12 @@ export class DeliveryCoordinator {
162
163
  }
163
164
 
164
165
  private enqueue(delivery: PendingDelivery): void {
166
+ // Lazily start the TTL timer on first enqueue (only if never started)
167
+ if (!this.timerStarted) {
168
+ this.timerStarted = true;
169
+ this.ttlTimer = setInterval(() => this.evictExpired(), 60_000);
170
+ this.ttlTimer.unref();
171
+ }
165
172
  this.pending.push({ ...delivery, generation: this.generation });
166
173
  }
167
174