pi-crew 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.3] — Reliability hardening + UX quick wins (2026-06-15)
4
+
5
+ This release fixes 4 critical data-loss bugs found by the Round 12 reliability audit and adds three UX quick wins from the Round 13 UX research (+125 tests from the Round 14 coverage sprint).
6
+
7
+ ### Bug Fixes (Critical — data loss prevention)
8
+
9
+ - **`rotateEventLog` destroyed ALL events** — `atomicWriteFile("")` then `rename` replaced the file with empty content *before* the rename, so the archive received an empty file. Now copies content to archive first, then truncates in place. Also handles sub-millisecond timestamp collisions.
10
+ - **`compactEventLog` recovery loop replaced the file per-event** — each `atomicWriteFile` iteration overwrote the compacted log + previous recoveries, leaving only the last event. Now accumulates missing events into one `appendFileSync`.
11
+ - **Mailbox `delivery.json` lost-update race** — `appendMailboxMessage`, `acknowledgeMailboxMessage`, and `replayPendingMailboxMessages` all had unlocked read-modify-write cycles. Now wrapped in `withFileLockSync`.
12
+ - **`observation-store.save()` non-atomic write** — raw `writeFileSync` could leave a truncated file on crash. Now uses `atomicWriteJson`.
13
+ - **`background-runner` DEBUG log noise** — 10 trace-level `console.log` statements gated behind `PI_CREW_DEBUG` env var.
14
+
15
+ ### Features (UX)
16
+
17
+ - **Command argument autocomplete** — 13 run-scoped and team-scoped commands now implement `getArgumentCompletions` so Pi's built-in Tab-completion surfaces run IDs (with status icon + goal preview), team names, workflow names, and task IDs. No more memorizing long generated run IDs.
18
+ - **Custom message renderers** — `crew:run-started`, `crew:run-completed`, and `crew:resume-directive` entries now render with a clean crew-branded look (🚀/✅/❌ status icons, theme colors) instead of raw JSON blobs.
19
+ - **Natural-language crew routing** — type `crew status`, `team dashboard`, `crew help`, `teams`, etc. and pi-crew rewrites it to the equivalent slash command. Only transforms interactive input; never shadows explicit slash commands.
20
+
21
+ ### Tests
22
+
23
+ - +125 tests (4795 pass / 0 fail). New coverage: cascading replace engine (31), safe-paths traversal defense (21), atomic-write symlink prevention (15), command completions (20), message renderers (12), input router (18), event-log rotate regression (9).
24
+
25
+ ### Research
26
+
27
+ This release was driven by 4 deep research rounds (11–14), documented in `research-findings/`.
28
+
3
29
  ## [0.7.2] — Fix: Knowledge Injection into Workers + HITL for All Workflows (2026-06-15)
4
30
 
5
31
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Command argument autocomplete helpers (Round 13 UX quick-win).
3
+ *
4
+ * Pi's built-in slash-command autocomplete calls a command's
5
+ * `getArgumentCompletions(argumentPrefix)` when the user types
6
+ * `/command <prefix><TAB>`. Returning AutocompleteItem[] surfaces those
7
+ * suggestions; returning null falls back to file completion.
8
+ *
9
+ * These helpers provide run-id, team, and workflow completions without
10
+ * requiring the user to memorize long generated IDs.
11
+ */
12
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
13
+ import { listRecentRuns } from "./run-index.ts";
14
+ import { discoverTeams, allTeams } from "../teams/discover-teams.ts";
15
+ import { discoverWorkflows, allWorkflows } from "../workflows/discover-workflows.ts";
16
+ import { discoverAgents, allAgents } from "../agents/discover-agents.ts";
17
+ import type { TeamRunManifest } from "../state/types.ts";
18
+
19
+ const MAX_RUN_SUGGESTIONS = 15;
20
+
21
+ function filterByPrefix(items: AutocompleteItem[], prefix: string): AutocompleteItem[] | null {
22
+ const trimmed = prefix.trim();
23
+ const filtered = trimmed === ""
24
+ ? items
25
+ : items.filter((item) => item.value.startsWith(trimmed) || item.label.toLowerCase().includes(trimmed.toLowerCase()));
26
+ return filtered.length > 0 ? filtered.slice(0, MAX_RUN_SUGGESTIONS) : null;
27
+ }
28
+
29
+ function statusIcon(status: TeamRunManifest["status"]): string {
30
+ switch (status) {
31
+ case "running":
32
+ case "planning":
33
+ return "▶";
34
+ case "queued":
35
+ return "⏳";
36
+ case "completed":
37
+ return "✓";
38
+ case "failed":
39
+ case "blocked":
40
+ return "✗";
41
+ case "cancelled":
42
+ return "⊘";
43
+ default:
44
+ return "•";
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Suggest recent run IDs for run-scoped commands (/team-status, /team-cancel, …).
50
+ * Falls back to `process.cwd()` because Pi does not pass cwd into
51
+ * `getArgumentCompletions` — this is correct in the interactive TUI where the
52
+ * process cwd matches the session cwd.
53
+ */
54
+ export function suggestRunIds(_prefix: string, cwd?: string): AutocompleteItem[] | null {
55
+ const resolvedCwd = cwd ?? process.cwd();
56
+ const runs = listRecentRuns(resolvedCwd, MAX_RUN_SUGGESTIONS);
57
+ if (runs.length === 0) return null;
58
+ const items: AutocompleteItem[] = runs.map((run) => ({
59
+ value: run.runId,
60
+ label: run.runId,
61
+ description: `${statusIcon(run.status)} ${run.status} · ${run.team} · ${(run.goal ?? "").slice(0, 48)}`,
62
+ }));
63
+ return filterByPrefix(items, _prefix);
64
+ }
65
+
66
+ /** Suggest task IDs within a specific run (for /team-result <runId> <taskId>). */
67
+ export async function suggestTaskIds(runId: string, prefix: string, cwd?: string): Promise<AutocompleteItem[] | null> {
68
+ const resolvedCwd = cwd ?? process.cwd();
69
+ // Dynamic import to avoid pulling state-store into the hot command-registration path.
70
+ const { loadRunManifestById } = await import("../state/state-store.ts");
71
+ const loaded = loadRunManifestById(resolvedCwd, runId);
72
+ if (!loaded) return null;
73
+ const items: AutocompleteItem[] = loaded.tasks.map((task) => ({
74
+ value: task.id,
75
+ label: task.id,
76
+ description: `${task.status} · ${task.role} · ${task.title?.slice(0, 40) ?? ""}`,
77
+ }));
78
+ return filterByPrefix(items, prefix);
79
+ }
80
+
81
+ /** Suggest available teams for /team-run <team>. */
82
+ export function suggestTeams(prefix: string, cwd?: string): AutocompleteItem[] | null {
83
+ const resolvedCwd = cwd ?? process.cwd();
84
+ const teams = allTeams(discoverTeams(resolvedCwd));
85
+ if (teams.length === 0) return null;
86
+ const items: AutocompleteItem[] = teams.map((team) => ({
87
+ value: team.name,
88
+ label: team.name,
89
+ description: team.defaultWorkflow ? `workflow=${team.defaultWorkflow}` : undefined,
90
+ }));
91
+ return filterByPrefix(items, prefix);
92
+ }
93
+
94
+ /** Suggest available workflows. */
95
+ export function suggestWorkflows(prefix: string, cwd?: string): AutocompleteItem[] | null {
96
+ const resolvedCwd = cwd ?? process.cwd();
97
+ const workflows = allWorkflows(discoverWorkflows(resolvedCwd));
98
+ if (workflows.length === 0) return null;
99
+ const items: AutocompleteItem[] = workflows.map((wf) => ({
100
+ value: wf.name,
101
+ label: wf.name,
102
+ description: `${wf.steps?.length ?? 0} steps`,
103
+ }));
104
+ return filterByPrefix(items, prefix);
105
+ }
106
+
107
+ /** Suggest available agents. */
108
+ export function suggestAgents(prefix: string, cwd?: string): AutocompleteItem[] | null {
109
+ const resolvedCwd = cwd ?? process.cwd();
110
+ const agents = allAgents(discoverAgents(resolvedCwd));
111
+ if (agents.length === 0) return null;
112
+ const items: AutocompleteItem[] = agents.map((agent) => ({
113
+ value: agent.name,
114
+ label: agent.name,
115
+ description: agent.description?.slice(0, 60),
116
+ }));
117
+ return filterByPrefix(items, prefix);
118
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Natural-language crew input routing (Round 13 UX).
3
+ *
4
+ * Pi fires the `input` event before skill/template expansion and before
5
+ * before_agent_start. A handler can transform the text (e.g. rewrite
6
+ * "crew status" → "/team-status"), or fully handle it.
7
+ *
8
+ * This module matches a small set of natural-language crew phrases and
9
+ * rewrites them to the equivalent slash command, so users do not need to
10
+ * memorize command names. Slash-command input (text starting with "/") is
11
+ * always passed through unchanged — we never shadow explicit commands.
12
+ */
13
+ import type { InputEvent, InputEventResult } from "@earendil-works/pi-coding-agent";
14
+
15
+ /** Rules: phrase prefix (lowercased) → slash-command rewrite. */
16
+ const ROUTING_RULES: ReadonlyArray<{ match: RegExp; command: string; needsArg?: boolean }> = [
17
+ // Inspection — no runId needed (lists all runs).
18
+ { match: /^(crew|team)\s+status\b/i, command: "/team-status" },
19
+ { match: /^(crew|team)\s+list\b/i, command: "/team-status" },
20
+ { match: /^(crew|team)\s+(dashboard|board|panel)\b/i, command: "/team-dashboard" },
21
+ { match: /^(crew|team)\s+(help|commands)\b/i, command: "/team-help" },
22
+ { match: /^teams\b/i, command: "/teams" },
23
+ { match: /^(crew|team)\s+(doctor|diagnos\w*)/i, command: "/team-doctor" },
24
+ ];
25
+
26
+ /**
27
+ * Try to rewrite a natural-language crew phrase into a slash command.
28
+ * Returns the rewritten command string, or `null` if no rule matches.
29
+ *
30
+ * Rules intentionally only match at the START of the input and require a
31
+ * word boundary, so ordinary sentences mentioning "crew" are untouched.
32
+ */
33
+ export function rewriteCrewInput(text: string): string | null {
34
+ const trimmed = text.trim();
35
+ // Never transform explicit slash commands or inputs that don't start with
36
+ // a crew/team keyword phrase.
37
+ if (trimmed.startsWith("/")) return null;
38
+ for (const rule of ROUTING_RULES) {
39
+ const match = trimmed.match(rule.match);
40
+ if (!match) continue;
41
+ // Carry any remaining args after the matched phrase forward.
42
+ const rest = trimmed.slice(match[0].length).trim();
43
+ return rest ? `${rule.command} ${rest}` : rule.command;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Pi `input` event handler. Transforms matching crew phrases; passes
50
+ * everything else through unchanged.
51
+ */
52
+ export function handleCrewInput(event: InputEvent): InputEventResult {
53
+ // Only transform interactive user input — never programmatic/scripted input.
54
+ if (event.source !== "interactive") return { action: "continue" };
55
+ const rewritten = rewriteCrewInput(event.text);
56
+ if (!rewritten) return { action: "continue" };
57
+ return { action: "transform", text: rewritten, images: event.images };
58
+ }
59
+
60
+ /** Register the crew input router on a Pi instance. Safe to call once. */
61
+ export function registerCrewInputRouter(pi: { on?: (event: "input", handler: (e: InputEvent) => InputEventResult) => void }): void {
62
+ pi.on?.("input", handleCrewInput);
63
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Custom message renderers for pi-crew session entries (Round 13 UX).
3
+ *
4
+ * pi-crew emits CustomMessageEntry rows via `pi.appendEntry()` for run
5
+ * lifecycle events (crew:run-started, crew:run-completed,
6
+ * crew:resume-directive). Without a registered renderer these display as
7
+ * raw JSON in the conversation. These renderers give them a clean,
8
+ * crew-branded look using the active theme.
9
+ */
10
+ import { Text } from "@earendil-works/pi-tui";
11
+ import type { ExtensionAPI, MessageRenderOptions, Theme } from "@earendil-works/pi-coding-agent";
12
+
13
+ interface CrewMessageDetails {
14
+ runId?: string;
15
+ team?: string;
16
+ workflow?: string;
17
+ agent?: string;
18
+ goal?: string;
19
+ status?: string;
20
+ taskCount?: number;
21
+ timestamp?: number;
22
+ }
23
+
24
+ type MessageLike = {
25
+ content: string | Array<{ type: string; text?: string }>;
26
+ details?: CrewMessageDetails;
27
+ };
28
+
29
+ function extractText(message: MessageLike): string {
30
+ if (typeof message.content === "string") return message.content;
31
+ return (message.content ?? []).map((c) => c.text ?? "").join("");
32
+ }
33
+
34
+ function statusLevel(status: string | undefined): "success" | "error" | "warning" | "muted" {
35
+ switch (status) {
36
+ case "completed":
37
+ return "success";
38
+ case "failed":
39
+ case "blocked":
40
+ return "error";
41
+ case "cancelled":
42
+ return "warning";
43
+ default:
44
+ return "muted";
45
+ }
46
+ }
47
+
48
+ function statusIcon(status: string | undefined): string {
49
+ switch (status) {
50
+ case "completed":
51
+ return "✅";
52
+ case "failed":
53
+ case "blocked":
54
+ return "❌";
55
+ case "cancelled":
56
+ return "🚫";
57
+ case "running":
58
+ case "planning":
59
+ return "🚀";
60
+ default:
61
+ return "•";
62
+ }
63
+ }
64
+
65
+ /** Truncate a string to maxLen chars with an ellipsis. */
66
+ function truncate(text: string, maxLen: number): string {
67
+ return text.length > maxLen ? `${text.slice(0, maxLen - 1)}…` : text;
68
+ }
69
+
70
+ /** Render crew:run-started entries as a branded launch line. */
71
+ export function renderRunStarted(message: MessageLike, _options: MessageRenderOptions, theme: Theme): Text {
72
+ const details = message.details ?? {};
73
+ const goal = details.goal ? truncate(details.goal, 70) : "";
74
+ const team = details.team ?? details.agent ?? "direct";
75
+ const workflow = details.workflow ?? "default";
76
+ const text = `🚀 crew run ${details.runId ?? ""} started — ${team}/${workflow}${goal ? ` — ${goal}` : ""}`;
77
+ return new Text(theme.fg("accent", theme.bold("crew ")) + theme.fg("text", text), 0, 0);
78
+ }
79
+
80
+ /** Render crew:run-completed entries with a status-colored summary. */
81
+ export function renderRunCompleted(message: MessageLike, _options: MessageRenderOptions, theme: Theme): Text {
82
+ const details = message.details ?? {};
83
+ const status = details.status;
84
+ const level = statusLevel(status);
85
+ const icon = statusIcon(status);
86
+ const goal = details.goal ? truncate(details.goal, 60) : "";
87
+ const tasks = details.taskCount !== undefined ? ` · ${details.taskCount} tasks` : "";
88
+ const text = `${icon} crew run ${details.runId ?? ""} ${status ?? "finished"}${tasks}${goal ? ` — ${goal}` : ""}`;
89
+ return new Text(theme.fg(level, theme.bold("crew ")) + theme.fg(level, text), 0, 0);
90
+ }
91
+
92
+ /** Render crew:resume-directive entries as an informational system note. */
93
+ export function renderResumeDirective(message: MessageLike, _options: MessageRenderOptions, theme: Theme): Text {
94
+ const text = extractText(message) || "Context compacted — resuming in-flight crew work.";
95
+ return new Text(theme.fg("muted", theme.bold("crew ") + text), 0, 0);
96
+ }
97
+
98
+ /** Register all crew message renderers. Safe to call once at extension load. */
99
+ export function registerCrewMessageRenderers(
100
+ pi: { registerMessageRenderer?: ExtensionAPI["registerMessageRenderer"] },
101
+ ): void {
102
+ // Optional chaining guards against older Pi versions (and test stubs)
103
+ // without registerMessageRenderer.
104
+ // The renderers return Text (a Component) — cast through never to match
105
+ // the MessageRenderer<T> signature which expects Component | undefined.
106
+ pi.registerMessageRenderer?.("crew:run-started", renderRunStarted as never);
107
+ pi.registerMessageRenderer?.("crew:run-completed", renderRunCompleted as never);
108
+ pi.registerMessageRenderer?.("crew:resume-directive", renderResumeDirective as never);
109
+ }
@@ -109,6 +109,8 @@ import {
109
109
  sendFollowUp,
110
110
  } from "./registration/subagent-helpers.ts";
111
111
  import { registerSubagentTools } from "./registration/subagent-tools.ts";
112
+ import { registerCrewMessageRenderers } from "./message-renderers.ts";
113
+ import { registerCrewInputRouter } from "./crew-input-router.ts";
112
114
  import { registerTeamTool } from "./registration/team-tool.ts";
113
115
  import { handleTeamTool } from "./team-tool.ts";
114
116
  import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
@@ -2035,4 +2037,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
2035
2037
  }
2036
2038
  },
2037
2039
  });
2040
+
2041
+ // Round 13 UX: render pi-crew lifecycle entries (crew:run-started,
2042
+ // crew:run-completed, crew:resume-directive) with a branded look instead
2043
+ // of raw JSON. No-op on Pi versions without registerMessageRenderer.
2044
+ registerCrewMessageRenderers(pi);
2045
+
2046
+ // Round 13 UX: natural-language crew input routing. Lets users type
2047
+ // "crew status" instead of remembering /team-status. Only transforms
2048
+ // interactive input that starts with a crew/team keyword phrase; never
2049
+ // shadows explicit slash commands.
2050
+ registerCrewInputRouter(pi);
2038
2051
  }
@@ -23,6 +23,7 @@ import { handleTeamManagerCommand } from "../team-manager-command.ts";
23
23
  import { loadRunManifestById } from "../../state/state-store.ts";
24
24
  import type { TeamRunManifest } from "../../state/types.ts";
25
25
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
26
+ import { suggestRunIds, suggestTaskIds, suggestTeams } from "../command-completions.ts";
26
27
  import * as path from "node:path";
27
28
  // Heavy UI modules — lazy-loaded because they're only used in /crew commands.
28
29
  // RunDashboard (288ms), DurableTextViewer (658ms), Overlays are unnecessary at Pi startup.
@@ -203,6 +204,8 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
203
204
 
204
205
  pi.registerCommand("team-run", {
205
206
  description: "Manually start a pi-crew run (agent may also use the team tool autonomously)",
207
+ // Round 13 UX: suggest team names for Tab-completion of the first positional arg.
208
+ getArgumentCompletions: (argumentPrefix: string) => suggestTeams(argumentPrefix),
206
209
  handler: async (args: string, ctx: ExtensionCommandContext) => {
207
210
  const result = await handleTeamTool(parseRunArgs(args), { ...teamCommandContext(ctx), metricRegistry: deps.getMetricRegistry?.(), startForegroundRun: (runner, runId) => deps.startForegroundRun(ctx as ExtensionContext, runner, runId), abortForegroundRun: deps.abortForegroundRun, onRunStarted: undefined });
208
211
  await notifyCommandResult(ctx, commandText(result));
@@ -219,15 +222,21 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
219
222
  ["team-export", "export", "Export a pi-crew run bundle to artifacts/export"],
220
223
  ["team-cancel", "cancel", "Cancel a pi-crew run"],
221
224
  ] as const) {
222
- pi.registerCommand(name, { description, handler: async (args: string, ctx: ExtensionCommandContext) => {
223
- const runId = args.trim() || undefined;
224
- const result = await handleTeamTool({ action, runId }, { ...teamCommandContext(ctx), getRunSnapshotCache: deps.getRunSnapshotCache });
225
- await notifyCommandResult(ctx, commandText(result));
226
- } });
225
+ pi.registerCommand(name, {
226
+ description,
227
+ // Round 13 UX: suggest recent run IDs for Tab-completion.
228
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
229
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
230
+ const runId = args.trim() || undefined;
231
+ const result = await handleTeamTool({ action, runId }, { ...teamCommandContext(ctx), getRunSnapshotCache: deps.getRunSnapshotCache });
232
+ await notifyCommandResult(ctx, commandText(result));
233
+ },
234
+ });
227
235
  }
228
236
 
229
237
  pi.registerCommand("team-invalidate", {
230
238
  description: "Invalidate the snapshot cache for a run so the UI refreshes immediately: <runId>",
239
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
231
240
  handler: async (args: string, ctx: ExtensionCommandContext) => {
232
241
  const runId = args.trim() || undefined;
233
242
  if (!runId) {
@@ -241,6 +250,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
241
250
 
242
251
  pi.registerCommand("team-retry", {
243
252
  description: "Retry failed/cancelled pi-crew tasks: <runId> [taskId]",
253
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
244
254
  handler: async (args: string, ctx: ExtensionCommandContext) => {
245
255
  const tokens = args.trim().split(/\s+/).filter(Boolean);
246
256
  const runId = tokens.shift();
@@ -256,6 +266,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
256
266
 
257
267
  pi.registerCommand("team-respond", {
258
268
  description: "Respond to a waiting pi-crew task: <runId> <taskId|--all> <message>",
269
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
259
270
  handler: async (args: string, ctx: ExtensionCommandContext) => {
260
271
  const tokens = args.trim().split(/\s+/).filter(Boolean);
261
272
  const runId = tokens.shift();
@@ -269,6 +280,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
269
280
 
270
281
  pi.registerCommand("team-follow-up", {
271
282
  description: "Send a follow-up prompt to a pi-crew task: <runId> <taskId> <prompt>",
283
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
272
284
  handler: async (args: string, ctx: ExtensionCommandContext) => {
273
285
  const tokens = args.trim().split(/\s+/).filter(Boolean);
274
286
  const runId = tokens.shift();
@@ -285,6 +297,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
285
297
 
286
298
  pi.registerCommand("team-api", {
287
299
  description: "Run safe pi-crew API interop operations: <runId> <operation> [key=value]",
300
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
288
301
  handler: async (args: string, ctx: ExtensionCommandContext) => {
289
302
  const tokens = args.trim().split(/\s+/).filter(Boolean);
290
303
  const positional = tokens.filter((token) => !token.includes("=") && !token.startsWith("--"));
@@ -329,7 +342,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
329
342
  await notifyCommandResult(ctx, commandText(result));
330
343
  } });
331
344
 
332
- pi.registerCommand("team-forget", { description: "Forget a pi-crew run by deleting its state and artifacts", handler: async (args: string, ctx: ExtensionCommandContext) => {
345
+ pi.registerCommand("team-forget", { description: "Forget a pi-crew run by deleting its state and artifacts", getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix), handler: async (args: string, ctx: ExtensionCommandContext) => {
333
346
  const tokens = args.trim().split(/\s+/).filter(Boolean);
334
347
  const runId = tokens.find((token) => !token.startsWith("--"));
335
348
  const result = await handleTeamTool({ action: "forget", runId, force: tokens.includes("--force"), confirm: tokens.includes("--confirm") }, teamCommandContext(ctx));
@@ -415,7 +428,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
415
428
 
416
429
  pi.registerCommand("team-cleanup", { description: "Open a simple pi-crew interactive manager", handler: handleTeamManagerCommand });
417
430
 
418
- pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
431
+ pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", getArgumentCompletions: async (argumentPrefix: string) => { const parts = argumentPrefix.trim().split(/\s+/); return parts.length <= 1 ? suggestRunIds(parts[0] ?? "") : suggestTaskIds(parts[0] ?? "", parts[1] ?? ""); }, handler: async (args: string, ctx: ExtensionCommandContext) => {
419
432
  const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
420
433
  const selected = await selectAgentTask(ctx, runId, rawTaskId);
421
434
  const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined; // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
@@ -430,7 +443,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
430
443
  await notifyCommandResult(ctx, commandText(result));
431
444
  } });
432
445
 
433
- pi.registerCommand("team-transcript", { description: "Open a pi-crew transcript viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
446
+ pi.registerCommand("team-transcript", { description: "Open a pi-crew transcript viewer: <runId> [taskId]", getArgumentCompletions: async (argumentPrefix: string) => { const parts = argumentPrefix.trim().split(/\s+/); return parts.length <= 1 ? suggestRunIds(parts[0] ?? "") : suggestTaskIds(parts[0] ?? "", parts[1] ?? ""); }, handler: async (args: string, ctx: ExtensionCommandContext) => {
434
447
  const [runId, taskId] = args.trim().split(/\s+/).filter(Boolean);
435
448
  if (await openTranscriptViewer(ctx, runId, taskId)) return;
436
449
  const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-transcript", agentId: taskId } }, teamCommandContext(ctx));
@@ -46,6 +46,15 @@ import {
46
46
  runtimeResolutionState,
47
47
  } from "./runtime-resolver.ts";
48
48
 
49
+ /**
50
+ * Debug logger gated behind PI_CREW_DEBUG env var. Writes to background.log
51
+ * (console is redirected there). Eliminates log noise in normal operation
52
+ * while keeping diagnostics available when explicitly enabled.
53
+ */
54
+ function debugLog(message: string): void {
55
+ if (process.env.PI_CREW_DEBUG) console.log(message);
56
+ }
57
+
49
58
  /**
50
59
  * Heartbeat mechanism: periodically write a heartbeat file so the stale reconciler
51
60
  * can distinguish "process died" from "process still alive but quiet".
@@ -222,8 +231,7 @@ function runCleanup(
222
231
  exitDueToRejection: boolean,
223
232
  eventsPath?: string,
224
233
  ): void {
225
- console.log(
226
- `[background-runner] DEBUG: runCleanup, exitDueToRejection=${exitDueToRejection}`,
234
+ console.log(`[background-runner] runCleanup, exitDueToRejection=${exitDueToRejection}`,
227
235
  );
228
236
  stopInterruptGuard();
229
237
  stopParentGuard();
@@ -492,8 +500,7 @@ async function main(): Promise<void> {
492
500
  runId: manifest.runId,
493
501
  data: { pid: process.pid },
494
502
  });
495
- console.log(
496
- `[background-runner] DEBUG: async.started written, pid=${process.pid}`,
503
+ debugLog(`[background-runner] async.started written, pid=${process.pid}`,
497
504
  );
498
505
  writeAsyncStartMarker(manifest, {
499
506
  pid: process.pid,
@@ -505,7 +512,7 @@ async function main(): Promise<void> {
505
512
  manifest.runId,
506
513
  );
507
514
  const stopInterruptGuard = startInterruptGuard(manifest, abortController, stopParentGuard);
508
- console.log(`[background-runner] DEBUG: heartbeat+interrupt guard started`);
515
+ debugLog(`[background-runner] heartbeat+interrupt guard started`);
509
516
  // NOTE: Keep-alive interval is NOT unref'd (unlike heartbeat and interrupt
510
517
  // guard intervals which ARE unref'd). This is intentional — during jiti
511
518
  // compilation of team-runner.ts, the event loop must not drain prematurely.
@@ -514,25 +521,22 @@ async function main(): Promise<void> {
514
521
  const keepAlive = setInterval(() => {}, 5000);
515
522
 
516
523
  try {
517
- console.log(`[background-runner] DEBUG: about to call discoverAgents`);
524
+ debugLog(`[background-runner] about to call discoverAgents`);
518
525
  const agents = allAgents(discoverAgents(cwd));
519
- console.log(
520
- `[background-runner] DEBUG: discoverAgents done, ${agents.length} agents`,
526
+ debugLog(`[background-runner] discoverAgents done, ${agents.length} agents`,
521
527
  );
522
528
  try { fs.fsyncSync(fs.openSync(manifest.eventsPath, "a")); } catch { /* best-effort */ } // FORCE flush so we see this before death
523
- console.log(
524
- `[background-runner] DEBUG: calling directTeamAndWorkflowFromRun`,
529
+ debugLog(`[background-runner] calling directTeamAndWorkflowFromRun`,
525
530
  );
526
531
  const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
527
- console.log(`[background-runner] DEBUG: direct done, finding team`);
532
+ debugLog(`[background-runner] direct done, finding team`);
528
533
  const team =
529
534
  direct?.team ??
530
535
  allTeams(discoverTeams(cwd)).find(
531
536
  (candidate) => candidate.name === manifest.team,
532
537
  );
533
538
  if (!team) throw new Error(`Team '${manifest.team}' not found.`);
534
- console.log(
535
- `[background-runner] DEBUG: team=${team.name}, finding workflow`,
539
+ debugLog(`[background-runner] team=${team.name}, finding workflow`,
536
540
  );
537
541
  const baseWorkflow =
538
542
  direct?.workflow ??
@@ -541,9 +545,9 @@ async function main(): Promise<void> {
541
545
  );
542
546
  if (!baseWorkflow)
543
547
  throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
544
- console.log(`[background-runner] DEBUG: workflow=${baseWorkflow.name}`);
548
+ debugLog(`[background-runner] workflow=${baseWorkflow.name}`);
545
549
  const workflow = expandParallelResearchWorkflow(baseWorkflow, cwd);
546
- console.log(`[background-runner] DEBUG: loading config`);
550
+ debugLog(`[background-runner] loading config`);
547
551
  const loadedConfig = loadConfig(cwd);
548
552
  const runConfig =
549
553
  manifest.runConfig &&
@@ -597,7 +601,7 @@ async function main(): Promise<void> {
597
601
  // NOTE: abortController is already created above (before heartbeat/interrupt guard start)
598
602
  // so it is available here and its signal is passed through to executeTeamRun → child-pi.
599
603
 
600
- console.log(`[background-runner] DEBUG: calling executeTeamRun`);
604
+ debugLog(`[background-runner] calling executeTeamRun`);
601
605
  let result;
602
606
  try {
603
607
  result = await executeTeamRun({
@@ -615,15 +619,12 @@ async function main(): Promise<void> {
615
619
  workspaceId: manifest.ownerSessionId ?? manifest.cwd,
616
620
  signal: abortController.signal,
617
621
  });
618
- console.log(
619
- `[background-runner] DEBUG: executeTeamRun returned, status=${result.manifest.status}`,
622
+ console.log(`[background-runner] executeTeamRun returned, status=${result.manifest.status}`,
620
623
  );
621
624
  } catch (execError) {
622
- console.log(
623
- `[background-runner] DEBUG: executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`,
625
+ console.log(`[background-runner] executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`,
624
626
  );
625
- console.log(
626
- `[background-runner] DEBUG: stack: ${execError instanceof Error ? execError.stack : "N/A"}`,
627
+ console.log(`[background-runner] stack: ${execError instanceof Error ? execError.stack : "N/A"}`,
627
628
  );
628
629
  throw execError;
629
630
  }
@@ -634,8 +635,7 @@ async function main(): Promise<void> {
634
635
  runId: manifest.runId,
635
636
  data: { status: manifest.status, tasks: tasks.length },
636
637
  });
637
- console.log(
638
- `[background-runner] DEBUG: async.completed written, status=${manifest.status}`,
638
+ console.log(`[background-runner] async.completed written, status=${manifest.status}`,
639
639
  );
640
640
  if (
641
641
  manifest.status === "failed" ||
@@ -682,8 +682,7 @@ async function main(): Promise<void> {
682
682
  message,
683
683
  });
684
684
  process.exitCode = 1;
685
- console.log(
686
- `[background-runner] DEBUG: catch block, error=${error instanceof Error ? error.message : String(error)}`,
685
+ console.log(`[background-runner] catch block, error=${error instanceof Error ? error.message : String(error)}`,
687
686
  );
688
687
  } finally {
689
688
  // FIX Issue #4: Use shared runCleanup() function for consistent cleanup
@@ -113,15 +113,20 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
113
113
  const missingEvents = kept.filter((e) => e.metadata?.seq === undefined || !afterSeqs.has(e.metadata.seq));
114
114
  let recoveredCount = 0;
115
115
  let recoveryFailed = false;
116
- for (const event of missingEvents) {
116
+ if (missingEvents.length > 0) {
117
+ // BUGFIX (Round 12 C2): the previous loop called atomicWriteFile PER event,
118
+ // which REPLACES the entire file each iteration — destroying the
119
+ // compacted log and all previously-recovered events, leaving only the
120
+ // LAST missing event. FIX: accumulate all missing events into one
121
+ // string and append in a single write (appendFileSync appends without
122
+ // destroying existing content).
123
+ const recoveryLines = missingEvents.map((e) => JSON.stringify(e) + "\n").join("");
117
124
  try {
118
- // Use atomicWriteFile for recovery append too — safer than plain appendFileSync
119
- atomicWriteFile(eventsPath, JSON.stringify(event) + "\n");
120
- recoveredCount++;
125
+ fs.appendFileSync(eventsPath, recoveryLines);
126
+ recoveredCount = missingEvents.length;
121
127
  } catch (err) {
122
128
  recoveryFailed = true;
123
- // FIX: Log when recovery append fails to avoid silent event loss
124
- logInternalError("event-log-rotation.recovery", err, `eventsPath=${eventsPath} lostEvent=${JSON.stringify(event).slice(0, 100)}`);
129
+ logInternalError("event-log-rotation.recovery", err, `eventsPath=${eventsPath} lostEvents=${missingEvents.length}`);
125
130
  }
126
131
  }
127
132
  return {
@@ -159,12 +164,24 @@ export function rotateEventLog(eventsPath: string): boolean {
159
164
  return withEventLogLockSync(eventsPath, () => {
160
165
  try {
161
166
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
162
- const archivePath = `${eventsPath}.${ts}.archive.jsonl`;
163
- // Step 1: create new empty file at eventsPath FIRST
164
- // This ensures eventsPath always exists for readers
165
- atomicWriteFile(eventsPath, "");
166
- // Step 2: rename old content to archive (after new file is in place)
167
- fs.renameSync(eventsPath, archivePath);
167
+ let archivePath = `${eventsPath}.${ts}.archive.jsonl`;
168
+ // Round 12: avoid timestamp collisions when two rotations happen within
169
+ // the same millisecond (copyFileSync would silently overwrite the
170
+ // first archive). Append a counter until the path is free.
171
+ let collision = 1;
172
+ while (fs.existsSync(archivePath)) {
173
+ archivePath = `${eventsPath}.${ts}.${collision}.archive.jsonl`;
174
+ collision++;
175
+ }
176
+ // BUGFIX (Round 12 C1): the previous order (atomicWriteFile empty THEN
177
+ // rename) destroyed ALL events — atomicWriteFile replaces the file
178
+ // in place, so the rename then moved an EMPTY file to the archive.
179
+ // FIX: copy current content to the archive first (archive is populated,
180
+ // original still intact), then truncate the original to empty in place.
181
+ // copyFileSync + writeFileSync("") ensures eventsPath ALWAYS exists
182
+ // (no missing-file window for concurrent readers).
183
+ fs.copyFileSync(eventsPath, archivePath);
184
+ fs.writeFileSync(eventsPath, "", "utf-8");
168
185
  return true;
169
186
  } catch (error) {
170
187
  logInternalError("event-log.rotate", error, `eventsPath=${eventsPath}`);
@@ -408,10 +408,16 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
408
408
  // 3.3 — rotate mailbox file if it has grown past 10 MB. Cheap stat
409
409
  // check; rotates at most once per append.
410
410
  rotateMailboxFileIfNeeded(mailboxFile(manifest, complete.direction, complete.taskId));
411
- const delivery = readDeliveryState(manifest);
412
- delivery.messages[complete.id] = complete.status;
413
- delivery.updatedAt = createdAt;
414
- writeDeliveryState(manifest, delivery);
411
+ // BUGFIX (Round 12 C3): the delivery.json read-modify-write below was
412
+ // UNLOCKED, so concurrent appendMailboxMessage calls could interleave and
413
+ // clobber each other's delivery entries (lost-update race). FIX: wrap the
414
+ // entire read-modify-write in a file lock on the delivery file.
415
+ withFileLockSync(deliveryFile(manifest, true), () => {
416
+ const delivery = readDeliveryState(manifest);
417
+ delivery.messages[complete.id] = complete.status;
418
+ delivery.updatedAt = createdAt;
419
+ writeDeliveryState(manifest, delivery);
420
+ });
415
421
  return complete;
416
422
  }
417
423
 
@@ -437,12 +443,16 @@ export function readMailboxMessage(manifest: TeamRunManifest, messageId: string)
437
443
  }
438
444
 
439
445
  export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxDeliveryState {
440
- const delivery = readDeliveryState(manifest);
441
- if (!delivery.messages[messageId]) throw new Error(`Mailbox message '${messageId}' not found.`);
442
- delivery.messages[messageId] = "acknowledged";
443
- delivery.updatedAt = new Date().toISOString();
444
- writeDeliveryState(manifest, delivery);
445
- return delivery;
446
+ // BUGFIX (Round 12 I6): unlocked read-modify-write on delivery.json could
447
+ // clobber concurrent appends. FIX: wrap in a file lock.
448
+ return withFileLockSync(deliveryFile(manifest, true), () => {
449
+ const delivery = readDeliveryState(manifest);
450
+ if (!delivery.messages[messageId]) throw new Error(`Mailbox message '${messageId}' not found.`);
451
+ delivery.messages[messageId] = "acknowledged";
452
+ delivery.updatedAt = new Date().toISOString();
453
+ writeDeliveryState(manifest, delivery);
454
+ return delivery;
455
+ });
446
456
  }
447
457
 
448
458
  /**
@@ -503,14 +513,18 @@ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMes
503
513
  }
504
514
 
505
515
  export function replayPendingMailboxMessages(manifest: TeamRunManifest): MailboxReplayResult {
506
- const delivery = readDeliveryState(manifest);
507
- const pending = readAllInboxMessages(manifest).filter((message) => message.status !== "acknowledged" && delivery.messages[message.id] !== "acknowledged");
508
- if (!pending.length) return { messages: [], updatedAt: delivery.updatedAt };
509
- const updatedAt = new Date().toISOString();
510
- for (const message of pending) delivery.messages[message.id] = "delivered";
511
- delivery.updatedAt = updatedAt;
512
- writeDeliveryState(manifest, delivery);
513
- return { messages: pending, updatedAt };
516
+ // BUGFIX (Round 12 I6): unlocked read-modify-write on delivery.json could
517
+ // clobber concurrent appends/acknowledgments. FIX: wrap in a file lock.
518
+ return withFileLockSync(deliveryFile(manifest, true), () => {
519
+ const delivery = readDeliveryState(manifest);
520
+ const pending = readAllInboxMessages(manifest).filter((message) => message.status !== "acknowledged" && delivery.messages[message.id] !== "acknowledged");
521
+ if (!pending.length) return { messages: [], updatedAt: delivery.updatedAt };
522
+ const updatedAt = new Date().toISOString();
523
+ for (const message of pending) delivery.messages[message.id] = "delivered";
524
+ delivery.updatedAt = updatedAt;
525
+ writeDeliveryState(manifest, delivery);
526
+ return { messages: pending, updatedAt };
527
+ });
514
528
  }
515
529
 
516
530
  export function validateMailbox(manifest: TeamRunManifest, options: { repair?: boolean; signal?: AbortSignal } = {}): MailboxValidationReport {
@@ -8,10 +8,11 @@
8
8
  * Actual capture hooks into the lifecycle events (Pattern 12).
9
9
  */
10
10
 
11
- import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
11
+ import { readFileSync, existsSync, appendFileSync } from "node:fs";
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import { logInternalError } from "../utils/internal-error.ts";
15
+ import { atomicWriteJson } from "./atomic-write.ts";
15
16
 
16
17
  // ── Types ────────────────────────────────────────────────────────────────
17
18
 
@@ -153,12 +154,13 @@ export class ObservationStore {
153
154
  */
154
155
  save(): void {
155
156
  try {
156
- // Use path.dirname for cross-platform support (handles both \ and /)
157
- mkdirSync(path.dirname(this.storePath), { recursive: true });
158
- writeFileSync(this.storePath, JSON.stringify({
157
+ // BUGFIX (Round 12 I4): use atomicWriteJson (temp-file + rename) instead
158
+ // of raw writeFileSync, so a crash mid-write cannot leave a truncated /
159
+ // empty file that breaks load() on restart.
160
+ atomicWriteJson(this.storePath, {
159
161
  observations: this.observations,
160
162
  compressed: this.compressed,
161
- }, null, 2), "utf-8");
163
+ });
162
164
  } catch (error) {
163
165
  logInternalError("observation-store.save", error, `path=${this.storePath}`);
164
166
  }