pi-crew 0.2.20 → 0.2.21

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 (93) hide show
  1. package/CHANGELOG.md +23 -10
  2. package/README.md +4 -2
  3. package/docs/PROJECT_REVIEW.md +271 -0
  4. package/docs/PROJECT_REVIEW_FIXES.md +343 -0
  5. package/docs/PROJECT_REVIEW_ROUND4.md +156 -0
  6. package/docs/PROJECT_REVIEW_ROUND5.md +86 -0
  7. package/docs/fixes/BATCH_A_H1_H2.md +86 -0
  8. package/docs/fixes/bug-006-foreground-cancel-concurrent.md +78 -0
  9. package/docs/fixes/bug-007-async-notifier-stale-ctx.md +112 -0
  10. package/docs/fixes/bug-008-child-process-silent-timeout.md +100 -0
  11. package/docs/fixes/bug-009-executor-yield-limit-needs-attention.md +75 -0
  12. package/docs/fixes/bug-010-child-process-api-key-filtered.md +109 -0
  13. package/docs/fixes/bug-011-spawn-pi-enoent.md +92 -0
  14. package/docs/fixes/bug-012-essential-env-stripped.md +89 -0
  15. package/docs/fixes/bug-013-background-runner-death.md +84 -0
  16. package/docs/fixes/bug-014-infinite-retry-loop-needs-attention.md +82 -0
  17. package/docs/fixes/bug-015-background-runner-sigterm.md +65 -0
  18. package/docs/fixes/bug-017-background-runner-session-shutdown.md +66 -0
  19. package/docs/fixes/bug-017-background-runner-sigkill-double-fork.md +28 -0
  20. package/docs/fixes/bug-018-child-pi-worker-stdin-hang.md +61 -0
  21. package/docs/fixes/bug-019-phantom-runs-temp-workspace.md +52 -0
  22. package/docs/pi-crew-bugs.md +954 -0
  23. package/docs/pi-crew-investigation-report.md +411 -0
  24. package/docs/pi-crew-test-final.md +120 -0
  25. package/docs/pi-crew-test-results.md +260 -0
  26. package/docs/pi-crew-test-round2.md +136 -0
  27. package/docs/pi-crew-test-round4.md +100 -0
  28. package/docs/pi-crew-test-round5.md +70 -0
  29. package/docs/pi-crew-test-round6.md +110 -0
  30. package/docs/usage.md +14 -0
  31. package/package.json +4 -2
  32. package/src/adapters/export-util.ts +12 -6
  33. package/src/agents/agent-config.ts +2 -0
  34. package/src/config/defaults.ts +1 -1
  35. package/src/config/markers.ts +22 -17
  36. package/src/config/resilient-parser.ts +1 -1
  37. package/src/extension/async-notifier.ts +4 -2
  38. package/src/extension/management.ts +52 -0
  39. package/src/extension/register.ts +47 -10
  40. package/src/extension/run-index.ts +20 -2
  41. package/src/extension/run-maintenance.ts +2 -2
  42. package/src/extension/team-tool/parallel-dispatch.ts +1 -1
  43. package/src/extension/team-tool/run.ts +3 -6
  44. package/src/extension/team-tool.ts +67 -11
  45. package/src/observability/event-to-metric.ts +2 -1
  46. package/src/runtime/async-runner.ts +42 -34
  47. package/src/runtime/background-runner.ts +165 -7
  48. package/src/runtime/child-pi.ts +111 -18
  49. package/src/runtime/code-summary.ts +1 -1
  50. package/src/runtime/crash-recovery.ts +1 -1
  51. package/src/runtime/crew-agent-runtime.ts +2 -1
  52. package/src/runtime/heartbeat-watcher.ts +4 -0
  53. package/src/runtime/live-agent-manager.ts +1 -1
  54. package/src/runtime/live-session-runtime.ts +2 -1
  55. package/src/runtime/manifest-cache.ts +2 -2
  56. package/src/runtime/model-fallback.ts +2 -1
  57. package/src/runtime/phase-progress.ts +1 -1
  58. package/src/runtime/pi-args.ts +3 -1
  59. package/src/runtime/pi-spawn.ts +6 -0
  60. package/src/runtime/prose-compressor.ts +1 -1
  61. package/src/runtime/result-extractor.ts +0 -1
  62. package/src/runtime/retry-executor.ts +1 -1
  63. package/src/runtime/runtime-resolver.ts +1 -1
  64. package/src/runtime/skill-instructions.ts +0 -1
  65. package/src/runtime/stale-reconciler.ts +30 -3
  66. package/src/runtime/subagent-manager.ts +2 -0
  67. package/src/runtime/task-display.ts +1 -1
  68. package/src/runtime/task-graph-scheduler.ts +1 -1
  69. package/src/runtime/task-runner/tail-read.ts +26 -0
  70. package/src/runtime/task-runner.ts +1007 -383
  71. package/src/runtime/team-runner.ts +9 -5
  72. package/src/runtime/worker-startup.ts +3 -1
  73. package/src/schema/team-tool-schema.ts +2 -1
  74. package/src/state/active-run-registry.ts +8 -2
  75. package/src/state/atomic-write.ts +17 -0
  76. package/src/state/contracts.ts +5 -2
  77. package/src/state/event-log-rotation.ts +118 -31
  78. package/src/state/event-log.ts +33 -5
  79. package/src/state/event-reconstructor.ts +4 -2
  80. package/src/state/mailbox.ts +5 -1
  81. package/src/state/schedule.ts +146 -0
  82. package/src/state/types.ts +40 -0
  83. package/src/state/usage.ts +20 -0
  84. package/src/ui/crew-widget.ts +2 -2
  85. package/src/ui/run-event-bus.ts +1 -1
  86. package/src/ui/run-snapshot-cache.ts +2 -1
  87. package/src/ui/snapshot-types.ts +1 -0
  88. package/src/utils/gh-protocol.ts +2 -2
  89. package/src/utils/names.ts +1 -1
  90. package/src/utils/sse-parser.ts +0 -2
  91. package/src/worktree/branch-freshness.ts +1 -1
  92. package/src/worktree/cleanup.ts +54 -14
  93. package/src/worktree/worktree-manager.ts +19 -9
package/docs/usage.md CHANGED
@@ -48,6 +48,20 @@ Supported fields:
48
48
  "showModel": true,
49
49
  "showTokens": true,
50
50
  "showTools": true
51
+ },
52
+ "limits": {
53
+ "maxConcurrentWorkers": 4
54
+ },
55
+ "reliability": {
56
+ "autoRetry": false,
57
+ "autoRecover": false,
58
+ "deadletterThreshold": 3,
59
+ "retryPolicy": {
60
+ "maxAttempts": 3,
61
+ "initialDelayMs": 1000,
62
+ "backoffMultiplier": 2,
63
+ "maxDelayMs": 30000
64
+ }
51
65
  }
52
66
  }
53
67
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -80,9 +80,11 @@
80
80
  "@sinclair/typebox": "^0.34.49",
81
81
  "cli-highlight": "^2.1.11",
82
82
  "diff": "^5.2.0",
83
- "jiti": "^2.6.1"
83
+ "jiti": "^2.6.1",
84
+ "typebox": "^1.1.38"
84
85
  },
85
86
  "devDependencies": {
87
+ "@biomejs/biome": "^2.4.15",
86
88
  "@mariozechner/pi-agent-core": "^0.65.0",
87
89
  "@mariozechner/pi-ai": "^0.65.0",
88
90
  "@mariozechner/pi-coding-agent": "^0.65.0",
@@ -80,18 +80,24 @@ export type ExportableResource =
80
80
  | { kind: "skill"; config: SkillDescriptor };
81
81
 
82
82
  export function resourcesToExportContent(resources: ExportableResource[]): ExportContent[] {
83
- return resources.map((resource): ExportContent => {
83
+ const results: ExportContent[] = [];
84
+ for (const resource of resources) {
84
85
  switch (resource.kind) {
85
86
  case "agent":
86
- return agentToExportContent(resource.config);
87
+ results.push(agentToExportContent(resource.config));
88
+ break;
87
89
  case "team":
88
- return teamToExportContent(resource.config);
90
+ results.push(teamToExportContent(resource.config));
91
+ break;
89
92
  case "workflow":
90
- return workflowToExportContent(resource.config);
93
+ results.push(workflowToExportContent(resource.config));
94
+ break;
91
95
  case "skill":
92
- return skillToExportContent(resource.config);
96
+ results.push(skillToExportContent(resource.config));
97
+ break;
93
98
  }
94
- });
99
+ }
100
+ return results;
95
101
  }
96
102
 
97
103
  export interface ToolExportResult {
@@ -33,6 +33,8 @@ export interface AgentConfig {
33
33
  contextMode?: "fresh" | "fork";
34
34
  /** Maximum turns for this agent. Overrides runtime config if set. */
35
35
  maxTurns?: number;
36
+ /** Tools to explicitly forbid for this agent. Takes precedence over allowedTools. */
37
+ disallowedTools?: string[];
36
38
  disabled?: boolean;
37
39
  override?: { source: "config"; path: string };
38
40
  }
@@ -40,7 +40,7 @@ export const DEFAULT_CONCURRENCY = {
40
40
  };
41
41
 
42
42
  export const DEFAULT_EVENT_LOG = {
43
- terminalEventTypes: ["run.blocked", "run.completed", "run.failed", "run.cancelled", "task.completed", "task.failed", "task.skipped", "task.cancelled"],
43
+ terminalEventTypes: ["run.blocked", "run.completed", "run.failed", "run.cancelled", "task.completed", "task.failed", "task.skipped", "task.cancelled", "task.needs_attention"],
44
44
  };
45
45
 
46
46
  export const DEFAULT_ARTIFACT_CLEANUP = {
@@ -85,29 +85,34 @@ function renderBlocks(blocks: GuidanceBlock[]): string {
85
85
  * Parse the inner marker content and return the set of block IDs present.
86
86
  */
87
87
  export function extractGuidanceIds(content: string): string[] {
88
- const ids: string[] = [];
89
- const regex = /<!-- PI-CREW:BLOCK:([^\s>]+) -->/g;
90
- let match: RegExpExecArray | null;
91
- while ((match = regex.exec(content)) !== null) {
92
- ids.push(match[1]!);
93
- }
94
- return ids;
88
+ const ids: string[] = [];
89
+ const regex = /<!-- PI-CREW:BLOCK:([^\s>]+) -->/g;
90
+ let match: RegExpExecArray | null = regex.exec(content);
91
+ while (match !== null) {
92
+ const captured = match[1];
93
+ if (captured !== undefined) ids.push(captured);
94
+ match = regex.exec(content);
95
+ }
96
+ return ids;
95
97
  }
96
98
 
97
99
  /**
98
100
  * Parse existing blocks from marker content into a map of id → GuidanceBlock.
99
101
  */
100
102
  function parseExistingBlocks(inner: string): Map<string, GuidanceBlock> {
101
- const map = new Map<string, GuidanceBlock>();
102
- const regex =
103
- /<!-- PI-CREW:BLOCK:([^\s>]+) -->\n([\s\S]*?)<!-- PI-CREW:\/BLOCK:\1 -->/g;
104
- let match: RegExpExecArray | null;
105
- while ((match = regex.exec(inner)) !== null) {
106
- const id = match[1]!;
107
- const content = match[2]!;
108
- map.set(id, { id, content: content.replace(/\n$/, "") });
109
- }
110
- return map;
103
+ const map = new Map<string, GuidanceBlock>();
104
+ const regex =
105
+ /<!-- PI-CREW:BLOCK:([^\s>]+) -->\n([\s\S]*?)<!-- PI-CREW:\/BLOCK:\1 -->/g;
106
+ let match: RegExpExecArray | null = regex.exec(inner);
107
+ while (match !== null) {
108
+ const id = match[1];
109
+ const blockContent = match[2];
110
+ if (id !== undefined && blockContent !== undefined) {
111
+ map.set(id, { id, content: blockContent.replace(/\n$/, "") });
112
+ }
113
+ match = regex.exec(inner);
114
+ }
115
+ return map;
111
116
  }
112
117
 
113
118
  /**
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { PiTeamsConfigSchema } from "../schema/config-schema.ts";
6
- import { type TSchema } from "@sinclair/typebox";
6
+ import type { TSchema } from "@sinclair/typebox";
7
7
  import { Value } from "@sinclair/typebox/value";
8
8
  import { suggestConfigKey } from "./suggestions.ts";
9
9
  import {
@@ -106,8 +106,10 @@ export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifie
106
106
  } catch (error) {
107
107
  const message = error instanceof Error ? error.message : String(error);
108
108
  if (message.includes("stale") || message.includes("session replacement") || message.includes("old ctx")) {
109
- console.error(`[pi-crew] async notifier stale ctx detected; stopping notifier.`);
110
- try { stopAsyncRunNotifier(state); } catch { /* ignore */ }
109
+ // Don't stop the interval session_start will create a new notifier
110
+ // with the refreshed ctx. The isCurrent guard will make this old
111
+ // notifier dormant once sessionGeneration increments.
112
+ // Stopping here creates a race: old notifier dies before new one starts.
111
113
  return;
112
114
  }
113
115
  console.error(`[pi-crew] async notifier error: ${message}`);
@@ -170,6 +170,24 @@ function findReferences(ctx: ManagementContext, resource: "agent" | "team" | "wo
170
170
  return refs;
171
171
  }
172
172
 
173
+ function escapeRegex(str: string): string {
174
+ return str.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
175
+ }
176
+
177
+ function walkTsFiles(dir: string): string[] {
178
+ const results: string[] = [];
179
+ if (!fs.existsSync(dir)) return results;
180
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
181
+ const fullPath = path.join(dir, entry.name);
182
+ if (entry.isDirectory()) {
183
+ results.push(...walkTsFiles(fullPath));
184
+ } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".md")) {
185
+ results.push(fullPath);
186
+ }
187
+ }
188
+ return results;
189
+ }
190
+
173
191
  function updateReferencesForRename(ctx: ManagementContext, resource: "agent" | "team" | "workflow", oldName: string, newName: string, scope: MutableSource, dryRun: boolean): string[] {
174
192
  if (oldName === newName) return [];
175
193
  if (resource !== "agent" && resource !== "workflow") return [];
@@ -193,6 +211,40 @@ function updateReferencesForRename(ctx: ManagementContext, resource: "agent" | "
193
211
  fs.writeFileSync(team.filePath, serializeTeam(nextTeam), "utf-8");
194
212
  }
195
213
  }
214
+ // L12 fix: also update workflow step role references when renaming agents.
215
+ // Workflow files use `role:` to reference agent roles, not agent names.
216
+ for (const workflow of allWorkflows(discoverWorkflows(ctx.cwd)).filter((w) => w.source === scope)) {
217
+ let updated = false;
218
+ const newSteps = workflow.steps.map((step) => {
219
+ if (step.role === oldName) {
220
+ updated = true;
221
+ return { ...step, role: newName };
222
+ }
223
+ return step;
224
+ });
225
+ if (!updated) continue;
226
+ changed.push(workflow.filePath);
227
+ if (!dryRun) {
228
+ backupFile(workflow.filePath);
229
+ fs.writeFileSync(workflow.filePath, serializeWorkflow({ ...workflow, steps: newSteps }), "utf-8");
230
+ }
231
+ }
232
+ // L12 fix: update agent references in test fixtures.
233
+ const testDir = scope === "user" ? path.join(ctx.cwd, ".crew", "test") : path.join(ctx.cwd, "test", "fixtures");
234
+ if (fs.existsSync(testDir)) {
235
+ for (const fixture of walkTsFiles(testDir)) {
236
+ const content = fs.readFileSync(fixture, "utf-8");
237
+ if (!content.includes(oldName)) continue;
238
+ const agentPattern = new RegExp('(["\'\\`]agent[="\':\\s]*)' + escapeRegex(oldName) + '(["\'\\`]|\\s)', 'g');
239
+ const newContent = content.replace(agentPattern, `$1${newName}$2`);
240
+ if (newContent !== content) {
241
+ changed.push(fixture);
242
+ if (!dryRun) {
243
+ fs.writeFileSync(fixture, newContent, "utf-8");
244
+ }
245
+ }
246
+ }
247
+ }
196
248
  return changed;
197
249
  }
198
250
 
@@ -441,6 +441,39 @@ export function registerPiTeams(pi: ExtensionAPI): void {
441
441
  return undefined;
442
442
  }
443
443
  rpcHandle = registerPiCrewRpc(getPiEvents(), () => currentCtx);
444
+ time("register.globalRegistry");
445
+ // Register global RPC registry for cross-extension access (mirrors pi-subagents3's Symbol.for pattern)
446
+ // Uses lazy import to avoid pulling team-tool.ts into module load.
447
+ // Other extensions access via: const reg = globalThis[Symbol.for("pi-crew:registry")];
448
+ void import("./team-tool.ts").then(({ registerCrewGlobalRegistry }) => {
449
+ const manifestCacheForRegistry = getManifestCache(currentCtx?.cwd ?? process.cwd());
450
+ registerCrewGlobalRegistry({
451
+ version: 1,
452
+ getRecord: (runId) => manifestCacheForRegistry.get(runId),
453
+ listRuns: () => manifestCacheForRegistry.list(100).map((m) => ({ runId: m.runId, status: m.status, goal: m.goal })),
454
+ appendEvent: (runId, event) => {
455
+ const manifest = manifestCacheForRegistry.get(runId);
456
+ if (manifest) void import("../state/event-log.ts").then(({ appendEventFireAndForget }) => appendEventFireAndForget(manifest.eventsPath, event as Parameters<typeof appendEventFireAndForget>[1]));
457
+ },
458
+ waitForAll: async (runId) => {
459
+ const { loadRunManifestById } = await import("../state/state-store.ts");
460
+ const check = (): boolean => {
461
+ const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
462
+ if (!loaded) return true;
463
+ return !loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
464
+ };
465
+ while (!check()) await new Promise((resolve) => setTimeout(resolve, 500));
466
+ },
467
+ hasRunning: (runId) => {
468
+ const manifest = manifestCacheForRegistry.get(runId);
469
+ if (!manifest) return false;
470
+ const { loadRunManifestById } = require("../state/state-store.ts");
471
+ const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
472
+ if (!loaded) return false;
473
+ return loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
474
+ },
475
+ });
476
+ });
444
477
 
445
478
  const cleanupRuntime = (): void => {
446
479
  if (cleanedUp) return;
@@ -456,16 +489,20 @@ export function registerPiTeams(pi: ExtensionAPI): void {
456
489
  stopAsyncRunNotifier(notifierState);
457
490
 
458
491
  // Best-effort: kill any async background runners that are still alive.
459
- // Foreground child processes (team run tasks) are handled above.
460
- try {
461
- for (const manifest of manifestCache.list(50)) {
462
- if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) {
463
- killProcessPid(manifest.async.pid);
464
- }
465
- }
466
- } catch (error) {
467
- logInternalError("register.cleanupRuntime.killAsync", error);
468
- }
492
+ // NOTE: Background runners are designed to outlive the Pi session.
493
+ // Do NOT kill them on session_shutdown — they manage their own lifecycle.
494
+ // Only kill foreground child processes (handled above via abort controllers).
495
+ // See Bug #17: killing async runners on session_shutdown was the root cause
496
+ // of the "background runner dies at ~35s" bug.
497
+ // try {
498
+ // for (const manifest of manifestCache.list(50)) {
499
+ // if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) {
500
+ // killProcessPid(manifest.async.pid);
501
+ // }
502
+ // }
503
+ // } catch (error) {
504
+ // logInternalError("register.cleanupRuntime.killAsync", error);
505
+ // }
469
506
 
470
507
  // P0: Purge all stale active-run-index entries on session cleanup.
471
508
  // This handles: normal exit, SIGTERM, Ctrl+C — any case where cleanupRuntime fires.
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import type { TeamRunManifest } from "../state/types.ts";
4
5
  import { DEFAULT_PATHS } from "../config/defaults.ts";
@@ -22,11 +23,16 @@ function collectRuns(root: string, maxEntries?: number, signal?: AbortSignal): T
22
23
  const runsRoot = path.join(root, DEFAULT_PATHS.state.runsSubdir);
23
24
  if (!fs.existsSync(runsRoot)) return [];
24
25
  if (signal?.aborted) return [];
26
+ // 2.19 — skip temp directories to avoid phantom runs from test/isolated workspaces.
27
+ // Runs in /tmp/ are typically from tests or isolated worktrees and should not
28
+ // appear in production dashboards unless the process is actually alive.
29
+ const tempDirs = [os.tmpdir(), "/var/tmp", "/tmp"];
30
+ const isTempRoot = tempDirs.some((t) => root.startsWith(t + path.sep));
25
31
  const token = createCancellationToken({ signal });
26
32
  const entries = fs.readdirSync(runsRoot, { withFileTypes: true })
27
33
  .filter((entry) => entry.isDirectory() && isSafePathId(entry.name))
28
34
  .map((entry) => entry.name)
29
- .sort((a, b) => b.localeCompare(a));
35
+ .sort((a, b) => (b ?? "").localeCompare(a ?? ""));
30
36
  const selected = maxEntries !== undefined ? entries.slice(0, Math.max(0, maxEntries)) : entries;
31
37
  const results: TeamRunManifest[] = [];
32
38
  for (let i = 0; i < selected.length; i++) {
@@ -37,6 +43,18 @@ function collectRuns(root: string, maxEntries?: number, signal?: AbortSignal): T
37
43
  // Filter out ghost runs: active status but CWD no longer exists.
38
44
  // These are deadletter/replay/temp runs whose temp dirs were cleaned up.
39
45
  if ((manifest.status === "queued" || manifest.status === "running" || manifest.status === "planning") && manifest.cwd && !fs.existsSync(manifest.cwd)) continue;
46
+ // 2.19 — for runs in temp directories, verify the background process is alive.
47
+ // Without this, runs that completed/crashed but left stale manifests will still show.
48
+ if (isTempRoot && (manifest.status === "running" || manifest.status === "queued" || manifest.status === "planning")) {
49
+ const asyncPidPath = path.join(path.dirname(manifest.stateRoot), "async.pid");
50
+ try {
51
+ const pidData = JSON.parse(fs.readFileSync(asyncPidPath, "utf-8"));
52
+ const pid = pidData?.pid;
53
+ if (pid && typeof pid === "number") {
54
+ try { process.kill(pid, 0); } catch { continue; } // PID dead, skip this run
55
+ }
56
+ } catch { /* no async.pid or parse error — keep the run */ }
57
+ }
40
58
  results.push(manifest);
41
59
  } catch { /* skip unreadable manifests */ }
42
60
  }
@@ -46,7 +64,7 @@ function collectRuns(root: string, maxEntries?: number, signal?: AbortSignal): T
46
64
  function mergeRuns(runSets: TeamRunManifest[][], max?: number): TeamRunManifest[] {
47
65
  const byId = new Map<string, TeamRunManifest>();
48
66
  for (const runs of runSets) for (const run of runs) byId.set(run.runId, run);
49
- const sorted = [...byId.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
67
+ const sorted = [...byId.values()].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
50
68
  return max !== undefined ? sorted.slice(0, Math.max(0, max)) : sorted;
51
69
  }
52
70
 
@@ -50,7 +50,7 @@ function appendPruneAudit(cwd: string, payload: Record<string, unknown>): string
50
50
 
51
51
  export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsOptions = {}): PruneRunsResult {
52
52
  const token = createCancellationToken({ signal: options.signal });
53
- const finished = listRuns(cwd, options.signal).filter((run) => run.cwd === cwd && isFinished(run)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
53
+ const finished = listRuns(cwd, options.signal).filter((run) => run.cwd === cwd && isFinished(run)).sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
54
54
  const kept = finished.slice(0, keep).map((run) => run.runId);
55
55
  const removed: string[] = [];
56
56
  const toRemove = finished.slice(keep);
@@ -129,7 +129,7 @@ export function pruneUserLevelRuns(keep: number): PruneRunsResult {
129
129
  }
130
130
 
131
131
  // Sort newest first, keep top N, remove the rest
132
- finished.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
132
+ finished.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
133
133
  const kept = finished.slice(0, keep).map((r) => r.runId);
134
134
  const removed: string[] = [];
135
135
  for (const run of finished.slice(keep)) {
@@ -145,7 +145,7 @@ async function spawnSingleTask(
145
145
  });
146
146
 
147
147
  if (runtime.available && runtime.kind === "child-process") {
148
- spawnBackgroundTeamRun(created.manifest);
148
+ await spawnBackgroundTeamRun(created.manifest);
149
149
  }
150
150
 
151
151
  return { ok: true, value: { runId: created.manifest.runId, goal, agent: agentName } };
@@ -132,13 +132,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
132
132
  const executionManifest = { ...updatedManifest, runtimeResolution, runConfig: executedConfig, updatedAt: new Date().toISOString() };
133
133
  atomicWriteJson(paths.manifestPath, executionManifest);
134
134
  appendEvent(executionManifest.eventsPath, { type: "runtime.resolved", runId: executionManifest.runId, message: `Runtime resolved: ${runtime.kind} safety=${runtime.safety}`, data: { runtimeResolution } });
135
- const runAsync = params.async ?? loadedConfig.config.asyncByDefault ?? false;
136
- // Background runners are standalone Node processes — live-session (in-process Pi SDK)
137
- // is only valid when tasks run inside the parent Pi agent session. Override to
138
- // child-process for async runs so the background runner spawns child Pi workers.
135
+ const runAsync = params.async ?? executedConfig.asyncByDefault ?? false;
139
136
  let effectiveRuntime = runtime;
140
137
  if (runAsync && runtime.kind === "live-session") {
141
- effectiveRuntime = { ...runtime, kind: "child-process", steer: false, resume: false, liveToolActivity: false, fallback: "child-process", reason: "Background runner cannot use live-session; falling back to child-process." };
138
+ effectiveRuntime = { ...runtime, kind: "child-process", steer: true, resume: false, liveToolActivity: false, fallback: "child-process", reason: "Background runner cannot use live-session; falling back to child-process." };
142
139
  }
143
140
  const effectiveRuntimeResolution = effectiveRuntime !== runtime ? runtimeResolutionState(effectiveRuntime) : runtimeResolution;
144
141
  const effectiveManifest = effectiveRuntime !== runtime ? { ...executionManifest, runtimeResolution: effectiveRuntimeResolution, updatedAt: new Date().toISOString() } : executionManifest;
@@ -160,7 +157,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
160
157
  `Env: PI_CREW_EXECUTE_WORKERS=${process.env.PI_CREW_EXECUTE_WORKERS ?? "<unset>"}, PI_TEAMS_EXECUTE_WORKERS=${process.env.PI_TEAMS_EXECUTE_WORKERS ?? "<unset>"}`,
161
158
  ].join("\n"), { action: "run", status: "error", runId: blocked.runId, artifactsRoot: blocked.artifactsRoot }, true);
162
159
  }
163
- const spawned = spawnBackgroundTeamRun(effectiveManifest);
160
+ const spawned = await spawnBackgroundTeamRun(effectiveManifest);
164
161
  const asyncManifest = { ...effectiveManifest, async: { pid: spawned.pid, logPath: spawned.logPath, spawnedAt: new Date().toISOString() } };
165
162
  atomicWriteJson(paths.manifestPath, asyncManifest);
166
163
  appendEvent(effectiveManifest.eventsPath, { type: "async.spawned", runId: effectiveManifest.runId, data: { pid: spawned.pid, logPath: spawned.logPath } });
@@ -28,10 +28,11 @@ import type { PiTeamsToolResult } from "./tool-result.ts";
28
28
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
29
29
  // Heavy runtime — lazy-loaded to avoid 1.4s import cost at extension registration.
30
30
  // executeTeamRun is only called when a team run actually executes.
31
- import type { executeTeamRun as ExecuteTeamRunFn } from "../runtime/team-runner.ts";
32
- let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
33
- async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
34
- if (!_cachedExecuteTeamRun) {
31
+ import type { executeTeamRun as _executeTeamRunFn } from "../runtime/team-runner.ts";
32
+ type ExecuteTeamRunFn = typeof _executeTeamRunFn;
33
+ let _cachedExecuteTeamRun: ExecuteTeamRunFn | undefined = undefined;
34
+ async function executeTeamRun(...args: Parameters<ExecuteTeamRunFn>): Promise<Awaited<ReturnType<ExecuteTeamRunFn>>> {
35
+ if (_cachedExecuteTeamRun === undefined) {
35
36
  // LAZY: heavy runtime — defer 1.4s import cost until team run actually executes.
36
37
  const mod = await import("../runtime/team-runner.ts");
37
38
  _cachedExecuteTeamRun = mod.executeTeamRun;
@@ -51,10 +52,11 @@ import { autonomousPatchFromConfig, configPatchFromConfig, effectiveRunConfig, f
51
52
  import { handleApi } from "./team-tool/api.ts";
52
53
  // Lazy-loaded: run.ts pulls in spawnBackgroundTeamRun, resolveCrewRuntime, etc.
53
54
  // Static import fails silently in some jiti contexts (child-process), leaving handleRun undefined.
54
- import type { handleRun as HandleRunFn } from "./team-tool/run.ts";
55
- let _cachedHandleRun: typeof HandleRunFn | undefined;
56
- async function handleRun(...args: Parameters<typeof HandleRunFn>): Promise<Awaited<ReturnType<typeof HandleRunFn>>> {
57
- if (!_cachedHandleRun) {
55
+ import type { handleRun as _handleRunFn } from "./team-tool/run.ts";
56
+ type HandleRunFn = typeof _handleRunFn;
57
+ let _cachedHandleRun: HandleRunFn | undefined = undefined;
58
+ async function handleRun(...args: Parameters<HandleRunFn>): Promise<Awaited<ReturnType<HandleRunFn>>> {
59
+ if (_cachedHandleRun === undefined) {
58
60
  // LAZY: run.ts pulls in spawnBackgroundTeamRun + resolveCrewRuntime; also avoids jiti import race in child-process contexts.
59
61
  const mod = await import("./team-tool/run.ts");
60
62
  _cachedHandleRun = mod.handleRun;
@@ -156,15 +158,29 @@ function artifactKey(artifact: ArtifactDescriptor): string {
156
158
  function recoverCheckpointedTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]): { manifest: TeamRunManifest; tasks: TeamTaskState[]; recovered: string[] } {
157
159
  const recovered: string[] = [];
158
160
  let nextManifest = manifest;
159
- let nextTasks = tasks.map((task) => {
161
+ const nextTasks = tasks.map((task) => {
160
162
  if (task.status !== "running" || !task.checkpoint) return task;
161
163
  if (task.checkpoint.phase === "artifact-written" && task.resultArtifact) {
162
164
  recovered.push(task.id);
163
165
  return { ...task, status: "completed" as const, finishedAt: task.finishedAt ?? task.checkpoint.updatedAt, error: undefined, claim: undefined };
164
166
  }
165
167
  if (task.checkpoint.phase === "child-stdout-final") {
166
- const transcriptPath = path.join(manifest.artifactsRoot, "transcripts", `${task.id}.jsonl`);
167
- if (!fs.existsSync(transcriptPath)) return task;
168
+ // transcripts are written with .attempt-${i}.jsonl suffix; find the most recent one
169
+ const transcriptsDir = path.join(manifest.artifactsRoot, "transcripts");
170
+ let transcriptPath: string | undefined;
171
+ if (fs.existsSync(transcriptsDir)) {
172
+ const files = fs.readdirSync(transcriptsDir).filter((f) => f.startsWith(`${task.id}.attempt-`) && f.endsWith(".jsonl"));
173
+ if (files.length > 0) {
174
+ // Sort by attempt index descending to get the most recent
175
+ files.sort((a, b) => {
176
+ const idxA = parseInt(a.match(/\.attempt-(\d+)\./)?.[1] ?? "0");
177
+ const idxB = parseInt(b.match(/\.attempt-(\d+)\./)?.[1] ?? "0");
178
+ return idxB - idxA;
179
+ });
180
+ transcriptPath = path.join(transcriptsDir, files[0]);
181
+ }
182
+ }
183
+ if (!transcriptPath) return task;
168
184
  const transcript = fs.readFileSync(transcriptPath, "utf-8");
169
185
  const parsed = parsePiJsonOutput(transcript);
170
186
  if (!parsed.finalText && !parsed.usage) return task;
@@ -242,6 +258,22 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
242
258
  });
243
259
  }
244
260
 
261
+ export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
262
+ const { runId, taskId, message } = params;
263
+ if (!runId || !taskId || !message) {
264
+ return result("steer requires runId, taskId, and message", { action: "steer", status: "error" }, true);
265
+ }
266
+ const loaded = loadRunManifestById(ctx.cwd, runId);
267
+ if (!loaded) return result(`Run '${runId}' not found`, { action: "steer", status: "error" }, true);
268
+ const task = loaded.tasks.find(t => t.id === taskId);
269
+ if (!task) return result(`Task '${taskId}' not found`, { action: "steer", status: "error" }, true);
270
+ if (!task.pendingSteers) task.pendingSteers = [];
271
+ task.pendingSteers.push(message);
272
+ saveRunTasks(loaded.manifest, loaded.tasks);
273
+ appendEvent(loaded.manifest.eventsPath, { type: "task.steer_queued", runId, taskId, data: { message } });
274
+ return result(`Steer queued for task '${taskId}'. It will be delivered when the task's session is ready.`, { action: "steer", status: "ok" });
275
+ }
276
+
245
277
  export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
246
278
  const action = params.action ?? "list";
247
279
  switch (action) {
@@ -339,6 +371,30 @@ export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamConte
339
371
  case "create": return handleCreate(params, ctx);
340
372
  case "update": return handleUpdate(params, ctx);
341
373
  case "delete": return handleDelete(params, ctx);
374
+ case "steer": return handleSteer(params, ctx);
342
375
  default: return result(`Unknown action: ${action}`, { action: "unknown", status: "error" }, true);
343
376
  }
344
377
  }
378
+
379
+ /**
380
+ * Global RPC registry for cross-extension access to pi-crew's team orchestrator.
381
+ * Uses Symbol.for() for cross-package singleton pattern (same as OpenTelemetry).
382
+ * Extensions can access via: const reg = globalThis[Symbol.for("pi-crew:registry")];
383
+ */
384
+ const CREW_REGISTRY_KEY = Symbol.for("pi-crew:registry");
385
+ interface CrewRegistry {
386
+ version: 1;
387
+ getRecord: (runId: string) => TeamRunManifest | undefined;
388
+ listRuns: () => Array<{ runId: string; status: string; goal: string }>;
389
+ appendEvent: (runId: string, event: Record<string, unknown>) => void;
390
+ waitForAll: (runId: string) => Promise<void>;
391
+ hasRunning: (runId: string) => boolean;
392
+ }
393
+
394
+ export function registerCrewGlobalRegistry(registry: CrewRegistry): void {
395
+ (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] = registry;
396
+ }
397
+
398
+ export function getCrewGlobalRegistry(): CrewRegistry | undefined {
399
+ return (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] as CrewRegistry | undefined;
400
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { MetricRegistry } from "./metric-registry.ts";
2
+ import type { MetricRegistry } from "./metric-registry.ts";
3
3
 
4
4
  function recordValue(value: unknown): Record<string, unknown> {
5
5
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
@@ -46,6 +46,7 @@ export function wireEventToMetrics(events: ExtensionAPI["events"] | undefined, r
46
46
  ["crew.run.cancelled", (data) => { const item = recordValue(data); runCount.inc({ status: "cancelled", reason: cancellationReasonLabel(item.reason) }); }],
47
47
  ["crew.task.completed", (data) => { const item = recordValue(data); taskCount.inc({ status: "completed" }); taskDuration.observe({ role: stringValue(item.role, "unknown") }, numberValue(item.durationMs)); tokenUsage.observe({ role: stringValue(item.role, "unknown") }, numberValue(item.tokens)); }],
48
48
  ["crew.task.failed", () => taskCount.inc({ status: "failed" })],
49
+ ["crew.task.needs_attention", () => taskCount.inc({ status: "needs_attention" })],
49
50
  ["crew.task.retry_attempt", (data) => { const item = recordValue(data); taskCount.inc({ status: "retry" }); retryAttemptCount.inc({ runId: stringValue(item.runId, "unknown"), taskId: stringValue(item.taskId, "unknown") }); }],
50
51
  ["crew.task.deadletter", (data) => { const item = recordValue(data); deadletterCount.inc({ reason: stringValue(item.reason, "unknown") }); }],
51
52
  ["crew.task.overflow", (data) => { const item = recordValue(data); overflowCount.inc({ phase: stringValue(item.phase, "unknown"), previous_phase: stringValue(item.previousPhase, "none") }); }],