pi-crew 0.7.2 → 0.7.4

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,50 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.4] — Editor autocomplete + settings shortcut (2026-06-15)
4
+
5
+ Round 13 UX quick wins round-out: the remaining two Pi extension API integrations plus a hard-won CI reliability fix after the state-store test flake re-emerged on Windows and macOS.
6
+
7
+ ### Features (UX)
8
+
9
+ - **Editor autocomplete provider** — registered via Pi's `addAutocompleteProvider`. As you type `crew <prefix>` or `team <prefix>` at the start of the input line, Pi's popup now suggests natural-language crew phrases and shows the slash command they map to (e.g. `crew status → /team-status`, `team dashboard → /team-dashboard`). `crew` and `team` are interchangeable keywords, driven by a single `CREW_PHRASES` source of truth shared with the input router.
10
+ - **Keyboard shortcut** — `alt+s` opens the pi-crew settings overlay (config + theme picker). `openTeamSettingsOverlay(ctx)` was extracted from the settings command handler so the shortcut reuses the exact same overlay (DRY). `alt+s` was chosen to avoid Pi's built-in keymap (Pi only binds `alt+v` and `alt+arrow`/`alt+enter` among alt+letter keys).
11
+
12
+ ### Bug Fixes
13
+
14
+ - **createRunManifest swallowed the real write error** — `saveManifestAndTasksAtomicSync` returns `error: String(err)`, but `createRunManifest` passed it to `errors.fileWrite` as a fake `ErrnoException`; `.code` was `undefined` → every write failure showed `": unknown"`, hiding the actual cause. Now surfaces the real error string in the thrown context, so CI logs and production callers see *why* the write failed.
15
+ - **`atomicWriteFile` Windows path-form correctness** — must NEVER rewrite the write target to a different realpath form. Callers build `filePath` via `canonicalizePath` (`realpathSync.native`) and later stat/read it at that exact path; rewriting it (even to a "canonical" form) made the file land on a divergent path that Windows treated as separate → `existsSync`/`readFileSync` failed after a "successful" write. `canonicalize()` is now used ONLY as an mkdir fallback on Windows `EPERM`, never to change the write target.
16
+
17
+ ### Tests / CI
18
+
19
+ - **Cap `--test-concurrency` at 2 on all CI platforms.** After the Round 13/14 test additions pushed every GitHub Actions runner past its filesystem-contention threshold, `state-store.test.ts` write-then-stat tests flaked on Windows (Windows Defender locks fresh temp files → rename `EPERM` exhausts atomic-write retries) and macOS (`/var/folders` tmp contention under load). `scripts/test-runner.mjs` now clamps the CI-requested concurrency (`4 → 2`) so the FS has room to flush; local dev is unaffected. Green on all 3 platforms (run 27556451997). 8× concurrent local runs reproduced nothing — pure CI infra contention, not a deterministic bug.
20
+ - +20 tests for the new features (crew-autocomplete: 16, crew-shortcuts: 4).
21
+
22
+ ## [0.7.3] — Reliability hardening + UX quick wins (2026-06-15)
23
+
24
+ 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).
25
+
26
+ ### Bug Fixes (Critical — data loss prevention)
27
+
28
+ - **`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.
29
+ - **`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`.
30
+ - **Mailbox `delivery.json` lost-update race** — `appendMailboxMessage`, `acknowledgeMailboxMessage`, and `replayPendingMailboxMessages` all had unlocked read-modify-write cycles. Now wrapped in `withFileLockSync`.
31
+ - **`observation-store.save()` non-atomic write** — raw `writeFileSync` could leave a truncated file on crash. Now uses `atomicWriteJson`.
32
+ - **`background-runner` DEBUG log noise** — 10 trace-level `console.log` statements gated behind `PI_CREW_DEBUG` env var.
33
+
34
+ ### Features (UX)
35
+
36
+ - **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.
37
+ - **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.
38
+ - **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.
39
+
40
+ ### Tests
41
+
42
+ - +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).
43
+
44
+ ### Research
45
+
46
+ This release was driven by 4 deep research rounds (11–14), documented in `research-findings/`.
47
+
3
48
  ## [0.7.2] — Fix: Knowledge Injection into Workers + HITL for All Workflows (2026-06-15)
4
49
 
5
50
  ### 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.4",
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,139 @@
1
+ /**
2
+ * Crew editor autocomplete provider (Round 13 UX).
3
+ *
4
+ * Wraps Pi's built-in autocomplete provider and adds natural-language crew
5
+ * phrase completion: when the user types `crew <prefix>` or `team <prefix>`
6
+ * at the start of the input line, we suggest the matching phrases (e.g.
7
+ * "crew status → /team-status"). This teaches users the natural-language
8
+ * phrases that the input router (crew-input-router.ts) will rewrite on
9
+ * submit — so they discover the feature without reading docs.
10
+ *
11
+ * For any non-crew input we delegate to the wrapped (`current`) provider, so
12
+ * slash-command, file (`@`), and command-argument completion all keep working
13
+ * unchanged.
14
+ */
15
+ import type {
16
+ AutocompleteItem,
17
+ AutocompleteProvider,
18
+ AutocompleteSuggestions,
19
+ } from "@earendil-works/pi-tui";
20
+ import { CREW_PHRASES } from "./crew-input-router.ts";
21
+
22
+ /** Max phrases to suggest. */
23
+ const MAX_PHRASES = 12;
24
+
25
+ /**
26
+ * If the text before the cursor is a crew-natural-language trigger, return the
27
+ * query word (the partial keyword after `crew `/`team `), or `undefined` when
28
+ * it is not a crew trigger.
29
+ *
30
+ * Triggers look like `crew ` or `team ` optionally followed by a partial word
31
+ * made of word characters, anchored at the start of the line.
32
+ */
33
+ function extractCrewQuery(textBeforeCursor: string): string | undefined {
34
+ // Anchor at start of line; require the `crew|team` keyword + whitespace,
35
+ // then an optional partial word. We do NOT trigger mid-word on the keyword
36
+ // itself (e.g. "cre" alone is not a trigger) — the keyword must be complete.
37
+ const match = textBeforeCursor.match(/^(?:crew|team)\s+([\w-]*)$/i);
38
+ return match?.[1];
39
+ }
40
+
41
+ /** Filter the shared phrase list by a partial keyword prefix. */
42
+ export function suggestCrewPhrases(query: string): AutocompleteItem[] {
43
+ const q = query.toLowerCase();
44
+ // Phrases are keyed by their keyword after "crew "/"team " (or the bare
45
+ // word for "teams"). Build a lookup keyword per phrase.
46
+ const seen = new Set<string>();
47
+ const items: AutocompleteItem[] = [];
48
+ for (const entry of CREW_PHRASES) {
49
+ // Derive the autocomplete keyword: for "crew status" → "status";
50
+ // for "teams" → "teams".
51
+ const parts = entry.phrase.split(/\s+/);
52
+ const keyword = parts.length > 1 ? parts.slice(1).join(" ") : entry.phrase;
53
+ if (seen.has(entry.phrase)) continue;
54
+ if (q && !keyword.toLowerCase().startsWith(q)) continue;
55
+ seen.add(entry.phrase);
56
+ items.push({
57
+ value: entry.phrase,
58
+ label: entry.phrase,
59
+ description: `→ ${entry.command}`,
60
+ });
61
+ if (items.length >= MAX_PHRASES) break;
62
+ }
63
+ return items;
64
+ }
65
+
66
+ /**
67
+ * Create a crew autocomplete provider that wraps `current`. When the input is
68
+ * a crew natural-language trigger, returns phrase suggestions; otherwise
69
+ * delegates to `current`.
70
+ */
71
+ export function createCrewAutocompleteProvider(
72
+ current: AutocompleteProvider,
73
+ ): AutocompleteProvider {
74
+ return {
75
+ async getSuggestions(
76
+ lines: string[],
77
+ cursorLine: number,
78
+ cursorCol: number,
79
+ options: { signal: AbortSignal; force?: boolean },
80
+ ): Promise<AutocompleteSuggestions | null> {
81
+ // Only trigger on the first line (Pi's main input is single-line;
82
+ // multiline editors are out of scope and would surprise the user).
83
+ if (cursorLine === 0) {
84
+ const currentLine = lines[cursorLine] ?? "";
85
+ const before = currentLine.slice(0, cursorCol);
86
+ const query = extractCrewQuery(before);
87
+ if (query !== undefined) {
88
+ const items = suggestCrewPhrases(query);
89
+ if (items.length > 0) {
90
+ // prefix = the full text to replace (e.g. "crew st").
91
+ // The default applyCompletion replaces the trailing
92
+ // `prefix`-length chars with item.value.
93
+ return { items, prefix: before };
94
+ }
95
+ // Triggered but no matches — return empty rather than
96
+ // falling through to file/command completion, so the user
97
+ // doesn't get a confusing file list while typing a phrase.
98
+ return { items: [], prefix: before };
99
+ }
100
+ }
101
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
102
+ },
103
+
104
+ applyCompletion(
105
+ lines: string[],
106
+ cursorLine: number,
107
+ cursorCol: number,
108
+ item: AutocompleteItem,
109
+ prefix: string,
110
+ ): { lines: string[]; cursorLine: number; cursorCol: number } {
111
+ // Delegate to the wrapped provider. For a non-slash, non-@ prefix
112
+ // the default applyCompletion replaces the trailing `prefix`-length
113
+ // chars with item.value — which is exactly the full phrase. This
114
+ // matches our prefix contract (prefix = full text to cursor).
115
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
116
+ },
117
+
118
+ shouldTriggerFileCompletion(
119
+ lines: string[],
120
+ cursorLine: number,
121
+ cursorCol: number,
122
+ ): boolean {
123
+ // Suppress file-completion trigger inside a crew phrase so the
124
+ // editor doesn't pop a file list over our phrase suggestions.
125
+ if (cursorLine === 0) {
126
+ const before = (lines[cursorLine] ?? "").slice(0, cursorCol);
127
+ if (extractCrewQuery(before) !== undefined) return false;
128
+ }
129
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
130
+ },
131
+ };
132
+ }
133
+
134
+ /** Register the crew autocomplete provider on a Pi UI context. Safe to call once. */
135
+ export function registerCrewAutocomplete(
136
+ ctx: { ui?: { addAutocompleteProvider?: (factory: (current: AutocompleteProvider) => AutocompleteProvider) => void } },
137
+ ): void {
138
+ ctx.ui?.addAutocompleteProvider?.((current) => createCrewAutocompleteProvider(current));
139
+ }
@@ -0,0 +1,93 @@
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
+ /**
16
+ * Natural-language crew phrases → slash-command mapping.
17
+ *
18
+ * Single source of truth shared by:
19
+ * - the `input`-event router (rewrites submitted text), and
20
+ * - the editor autocomplete provider (suggests phrases as you type).
21
+ *
22
+ * Each entry maps a phrase (what the user types) to a slash command.
23
+ * The router matches when submitted text STARTS WITH a phrase (word boundary);
24
+ * the autocomplete matches when the line starts with `crew `/`team ` and the
25
+ * partial word is a prefix of a phrase's keyword.
26
+ */
27
+ export const CREW_PHRASES: ReadonlyArray<{ phrase: string; command: string }> = [
28
+ { phrase: "crew status", command: "/team-status" },
29
+ { phrase: "crew list", command: "/team-status" },
30
+ { phrase: "crew dashboard", command: "/team-dashboard" },
31
+ { phrase: "crew board", command: "/team-dashboard" },
32
+ { phrase: "crew panel", command: "/team-dashboard" },
33
+ { phrase: "crew help", command: "/team-help" },
34
+ { phrase: "crew commands", command: "/team-help" },
35
+ { phrase: "crew doctor", command: "/team-doctor" },
36
+ { phrase: "crew diagnose", command: "/team-doctor" },
37
+ { phrase: "teams", command: "/teams" },
38
+ ];
39
+
40
+ /**
41
+ * Build a case-insensitive anchored regex from a phrase. The leading `crew `
42
+ * keyword is treated as interchangeable with `team ` (so "crew status" matches
43
+ * both "crew status" and "team status"). Bare phrases like "teams" match
44
+ * verbatim.
45
+ */
46
+ function phraseToRegex(phrase: string): RegExp {
47
+ const kw = phrase.match(/^(crew|team)\s+(.*)$/i);
48
+ if (kw) {
49
+ const rest = kw[2].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ return new RegExp(`^(?:crew|team)\\s+${rest}\\b`, "i");
51
+ }
52
+ const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ return new RegExp(`^${escaped}\\b`, "i");
54
+ }
55
+
56
+ /**
57
+ * Try to rewrite a natural-language crew phrase into a slash command.
58
+ * Returns the rewritten command string, or `null` if no rule matches.
59
+ *
60
+ * Rules intentionally only match at the START of the input and require a
61
+ * word boundary, so ordinary sentences mentioning "crew" are untouched.
62
+ */
63
+ export function rewriteCrewInput(text: string): string | null {
64
+ const trimmed = text.trim();
65
+ // Never transform explicit slash commands or inputs that don't start with
66
+ // a crew/team keyword phrase.
67
+ if (trimmed.startsWith("/")) return null;
68
+ for (const entry of CREW_PHRASES) {
69
+ const match = trimmed.match(phraseToRegex(entry.phrase));
70
+ if (!match) continue;
71
+ // Carry any remaining args after the matched phrase forward.
72
+ const rest = trimmed.slice(match[0].length).trim();
73
+ return rest ? `${entry.command} ${rest}` : entry.command;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Pi `input` event handler. Transforms matching crew phrases; passes
80
+ * everything else through unchanged.
81
+ */
82
+ export function handleCrewInput(event: InputEvent): InputEventResult {
83
+ // Only transform interactive user input — never programmatic/scripted input.
84
+ if (event.source !== "interactive") return { action: "continue" };
85
+ const rewritten = rewriteCrewInput(event.text);
86
+ if (!rewritten) return { action: "continue" };
87
+ return { action: "transform", text: rewritten, images: event.images };
88
+ }
89
+
90
+ /** Register the crew input router on a Pi instance. Safe to call once. */
91
+ export function registerCrewInputRouter(pi: { on?: (event: "input", handler: (e: InputEvent) => InputEventResult) => void }): void {
92
+ pi.on?.("input", handleCrewInput);
93
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Crew keyboard shortcuts (Round 13 UX).
3
+ *
4
+ * Registers a small set of keyboard shortcuts for fast access to the most
5
+ * useful pi-crew overlays. Keys are chosen to avoid collisions with Pi's
6
+ * built-in keymap (see analysis of pi-tui core/keybindings defaults):
7
+ *
8
+ * alt+s → open the pi-crew settings overlay (config + theme picker)
9
+ *
10
+ * `alt+<letter>` combos are safe: Pi only binds `alt+v`, `alt+enter`, and the
11
+ * alt+arrow navigation keys. `alt+s` is mnemonic (settings) and free.
12
+ *
13
+ * Shortcuts are guarded by `hasUI` so they never fire in print/RPC mode, and
14
+ * by the optional `registerShortcut` API so older Pi versions degrade
15
+ * gracefully (no-op).
16
+ */
17
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import type { KeyId } from "@earendil-works/pi-tui";
19
+
20
+ type ShortcutHandler = (ctx: ExtensionContext) => Promise<void> | void;
21
+
22
+ interface ShortcutRegistration {
23
+ /** Pi KeyId, e.g. "alt+s". */
24
+ key: KeyId;
25
+ description: string;
26
+ handler: ShortcutHandler;
27
+ }
28
+
29
+ const CREW_SHORTCUTS: ReadonlyArray<ShortcutRegistration> = [
30
+ {
31
+ key: "alt+s",
32
+ description: "pi-crew: open settings (config + theme picker)",
33
+ // Lazy-import the overlay so this module stays lightweight at load time
34
+ // (avoids pulling the full commands.ts dependency tree into every
35
+ // process that imports this module, e.g. the unit test).
36
+ handler: async (ctx) => {
37
+ const { openTeamSettingsOverlay } = await import("./registration/commands.ts");
38
+ await openTeamSettingsOverlay(ctx);
39
+ },
40
+ },
41
+ ];
42
+
43
+ /**
44
+ * Register all crew keyboard shortcuts on a Pi instance. Safe to call once at
45
+ * extension load. No-ops when `registerShortcut` is unavailable (older Pi).
46
+ */
47
+ export function registerCrewShortcuts(
48
+ pi: { registerShortcut?: (shortcut: KeyId, options: { description?: string; handler: ShortcutHandler }) => void },
49
+ ): void {
50
+ for (const sc of CREW_SHORTCUTS) {
51
+ pi.registerShortcut?.(sc.key, { description: sc.description, handler: sc.handler });
52
+ }
53
+ }
54
+
55
+ /** Exported for tests / introspection. */
56
+ export const CREW_SHORTCUT_KEYS: readonly KeyId[] = CREW_SHORTCUTS.map((s) => s.key);
@@ -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,10 @@ 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";
114
+ import { registerCrewAutocomplete } from "./crew-autocomplete.ts";
115
+ import { registerCrewShortcuts } from "./crew-shortcuts.ts";
112
116
  import { registerTeamTool } from "./registration/team-tool.ts";
113
117
  import { handleTeamTool } from "./team-tool.ts";
114
118
  import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
@@ -212,6 +216,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
212
216
  let sessionGeneration = 0;
213
217
  let rpcHandle: PiCrewRpcHandle | undefined;
214
218
  let cleanedUp = false;
219
+ let crewAutocompleteRegistered = false;
215
220
  let manifestCache = createManifestCache(process.cwd());
216
221
  let runSnapshotCache = createRunSnapshotCache(process.cwd());
217
222
  let cacheCwd = process.cwd();
@@ -1217,6 +1222,14 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1217
1222
  sessionGeneration++;
1218
1223
  const ownerGeneration = sessionGeneration;
1219
1224
  currentCtx = ctx;
1225
+ // Round 13 UX: register the crew natural-language autocomplete provider
1226
+ // once we have a UI context. Guarded so repeated session_start events
1227
+ // don't stack wrappers (each wrapper delegates, but stacking wastes
1228
+ // call depth).
1229
+ if (!crewAutocompleteRegistered) {
1230
+ crewAutocompleteRegistered = true;
1231
+ registerCrewAutocomplete(ctx);
1232
+ }
1220
1233
  if (widgetState.interval) clearInterval(widgetState.interval);
1221
1234
  widgetState.interval = undefined;
1222
1235
  notifyActiveRuns(ctx);
@@ -2035,4 +2048,21 @@ export function registerPiTeams(pi: ExtensionAPI): void {
2035
2048
  }
2036
2049
  },
2037
2050
  });
2051
+
2052
+ // Round 13 UX: render pi-crew lifecycle entries (crew:run-started,
2053
+ // crew:run-completed, crew:resume-directive) with a branded look instead
2054
+ // of raw JSON. No-op on Pi versions without registerMessageRenderer.
2055
+ registerCrewMessageRenderers(pi);
2056
+
2057
+ // Round 13 UX: natural-language crew input routing. Lets users type
2058
+ // "crew status" instead of remembering /team-status. Only transforms
2059
+ // interactive input that starts with a crew/team keyword phrase; never
2060
+ // shadows explicit slash commands.
2061
+ registerCrewInputRouter(pi);
2062
+
2063
+ // Round 13 UX: keyboard shortcuts. alt+s opens the settings overlay
2064
+ // (config + theme picker). Keys chosen to avoid Pi's built-in keymap.
2065
+ // (The crew autocomplete provider is registered from session_start once
2066
+ // a UI context is available — see the session_start handler below.)
2067
+ registerCrewShortcuts(pi);
2038
2068
  }
@@ -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.
@@ -153,6 +154,72 @@ function teamCommandContext(ctx: ExtensionCommandContext): ExtensionCommandConte
153
154
  return withSessionId(ctx);
154
155
  }
155
156
 
157
+ /**
158
+ * Open the pi-crew settings overlay (config editor + theme picker).
159
+ *
160
+ * Extracted from the `team-settings` command so it is reusable from a
161
+ * keyboard shortcut. Takes the base `ExtensionContext` (the shortcut
162
+ * handler's context) — uses only `hasUI`, `cwd`, and `ui` fields, so both
163
+ * `ExtensionContext` and `ExtensionCommandContext` satisfy it.
164
+ */
165
+ export async function openTeamSettingsOverlay(ctx: ExtensionContext): Promise<void> {
166
+ if (!ctx.hasUI) return;
167
+ const [{ updateConfig, parseConfig }, { asCrewTheme }, { createSettingsOverlay }] = await Promise.all([
168
+ import("../../config/config.ts"),
169
+ import("../../ui/theme-adapter.ts"),
170
+ import("../../ui/settings-overlay.ts"),
171
+ ]);
172
+ const loaded = loadConfig(ctx.cwd);
173
+ const config = loaded.config as Record<string, unknown>;
174
+ await ctx.ui.custom<undefined>((_tui, _theme, _keybindings, done) => {
175
+ const theme = asCrewTheme(_theme);
176
+ const { overlay } = createSettingsOverlay(config, theme, (id: string, value: unknown) => {
177
+ try {
178
+ const patch: Record<string, unknown> = {};
179
+ const keys = id.split(".");
180
+ let target: Record<string, unknown> = patch;
181
+ for (let i = 0; i < keys.length - 1; i++) {
182
+ if (!target[keys[i]!] || typeof target[keys[i]!] !== "object") target[keys[i]!] = {};
183
+ target = target[keys[i]!] as Record<string, unknown>;
184
+ }
185
+ target[keys[keys.length - 1]!] = value;
186
+ if (value === undefined) { updateConfig({}, { unsetPaths: [id] }); }
187
+ else { updateConfig(parseConfig(patch)); }
188
+ } catch (error) {
189
+ ctx.ui.notify(`Failed to save: ${error instanceof Error ? error.message : String(error)}`, "error");
190
+ }
191
+ }, () => done(undefined), async (action: string, value: unknown) => {
192
+ // Action callbacks (Pi theme switch) write to a different store
193
+ // than pi-crew config (e.g. ~/.pi/agent/settings.json).
194
+ try {
195
+ if (action === "piTheme" && typeof value === "string") {
196
+ // Live theme switch: ctx.ui.setTheme() swaps the global theme,
197
+ // persists it to settings.json, and triggers a UI redraw — no
198
+ // restart needed. Falls back to file-write + restart hint if
199
+ // the live API is unavailable (e.g. non-TUI mode).
200
+ if (typeof ctx.ui.setTheme === "function") {
201
+ const res = ctx.ui.setTheme(value);
202
+ if (res.success) {
203
+ ctx.ui.notify(`Theme: ${value} (applied live)`, "info");
204
+ } else {
205
+ const { setPiTheme } = await import("../../ui/theme-discovery.ts");
206
+ setPiTheme(value);
207
+ ctx.ui.notify(`Theme saved as '${value}' but failed to apply: ${res.error ?? "unknown"}. Restart Pi.`, "warning");
208
+ }
209
+ } else {
210
+ const { setPiTheme } = await import("../../ui/theme-discovery.ts");
211
+ setPiTheme(value);
212
+ ctx.ui.notify(`Pi theme set to '${value}'. Restart Pi to apply.`, "info");
213
+ }
214
+ }
215
+ } catch (error) {
216
+ ctx.ui.notify(`Failed: ${error instanceof Error ? error.message : String(error)}`, "error");
217
+ }
218
+ });
219
+ return overlay;
220
+ }, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
221
+ }
222
+
156
223
  async function handleHealthDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise<void> {
157
224
  const loaded = loadRunManifestById(ctx.cwd, selection.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
158
225
  if (!loaded) {
@@ -203,6 +270,8 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
203
270
 
204
271
  pi.registerCommand("team-run", {
205
272
  description: "Manually start a pi-crew run (agent may also use the team tool autonomously)",
273
+ // Round 13 UX: suggest team names for Tab-completion of the first positional arg.
274
+ getArgumentCompletions: (argumentPrefix: string) => suggestTeams(argumentPrefix),
206
275
  handler: async (args: string, ctx: ExtensionCommandContext) => {
207
276
  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
277
  await notifyCommandResult(ctx, commandText(result));
@@ -219,15 +288,21 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
219
288
  ["team-export", "export", "Export a pi-crew run bundle to artifacts/export"],
220
289
  ["team-cancel", "cancel", "Cancel a pi-crew run"],
221
290
  ] 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
- } });
291
+ pi.registerCommand(name, {
292
+ description,
293
+ // Round 13 UX: suggest recent run IDs for Tab-completion.
294
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
295
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
296
+ const runId = args.trim() || undefined;
297
+ const result = await handleTeamTool({ action, runId }, { ...teamCommandContext(ctx), getRunSnapshotCache: deps.getRunSnapshotCache });
298
+ await notifyCommandResult(ctx, commandText(result));
299
+ },
300
+ });
227
301
  }
228
302
 
229
303
  pi.registerCommand("team-invalidate", {
230
304
  description: "Invalidate the snapshot cache for a run so the UI refreshes immediately: <runId>",
305
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
231
306
  handler: async (args: string, ctx: ExtensionCommandContext) => {
232
307
  const runId = args.trim() || undefined;
233
308
  if (!runId) {
@@ -241,6 +316,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
241
316
 
242
317
  pi.registerCommand("team-retry", {
243
318
  description: "Retry failed/cancelled pi-crew tasks: <runId> [taskId]",
319
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
244
320
  handler: async (args: string, ctx: ExtensionCommandContext) => {
245
321
  const tokens = args.trim().split(/\s+/).filter(Boolean);
246
322
  const runId = tokens.shift();
@@ -256,6 +332,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
256
332
 
257
333
  pi.registerCommand("team-respond", {
258
334
  description: "Respond to a waiting pi-crew task: <runId> <taskId|--all> <message>",
335
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
259
336
  handler: async (args: string, ctx: ExtensionCommandContext) => {
260
337
  const tokens = args.trim().split(/\s+/).filter(Boolean);
261
338
  const runId = tokens.shift();
@@ -269,6 +346,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
269
346
 
270
347
  pi.registerCommand("team-follow-up", {
271
348
  description: "Send a follow-up prompt to a pi-crew task: <runId> <taskId> <prompt>",
349
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
272
350
  handler: async (args: string, ctx: ExtensionCommandContext) => {
273
351
  const tokens = args.trim().split(/\s+/).filter(Boolean);
274
352
  const runId = tokens.shift();
@@ -285,6 +363,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
285
363
 
286
364
  pi.registerCommand("team-api", {
287
365
  description: "Run safe pi-crew API interop operations: <runId> <operation> [key=value]",
366
+ getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
288
367
  handler: async (args: string, ctx: ExtensionCommandContext) => {
289
368
  const tokens = args.trim().split(/\s+/).filter(Boolean);
290
369
  const positional = tokens.filter((token) => !token.includes("=") && !token.startsWith("--"));
@@ -329,7 +408,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
329
408
  await notifyCommandResult(ctx, commandText(result));
330
409
  } });
331
410
 
332
- pi.registerCommand("team-forget", { description: "Forget a pi-crew run by deleting its state and artifacts", handler: async (args: string, ctx: ExtensionCommandContext) => {
411
+ 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
412
  const tokens = args.trim().split(/\s+/).filter(Boolean);
334
413
  const runId = tokens.find((token) => !token.startsWith("--"));
335
414
  const result = await handleTeamTool({ action: "forget", runId, force: tokens.includes("--force"), confirm: tokens.includes("--confirm") }, teamCommandContext(ctx));
@@ -340,60 +419,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
340
419
  description: "View or update pi-crew settings: interactive UI or [list|get <key>|set <key> <value>|unset <key>|path|scope]",
341
420
  handler: async (args: string, ctx: ExtensionCommandContext) => {
342
421
  if (ctx.hasUI && !args.trim()) {
343
- const [{ updateConfig, parseConfig }, { asCrewTheme }, { createSettingsOverlay }] = await Promise.all([
344
- import("../../config/config.ts"),
345
- import("../../ui/theme-adapter.ts"),
346
- import("../../ui/settings-overlay.ts"),
347
- ]);
348
- const loaded = loadConfig(ctx.cwd);
349
- const config = loaded.config as Record<string, unknown>;
350
- await ctx.ui.custom<undefined>((_tui, _theme, _keybindings, done) => {
351
- const theme = asCrewTheme(_theme);
352
- const { overlay } = createSettingsOverlay(config, theme, (id: string, value: unknown) => {
353
- try {
354
- const patch: Record<string, unknown> = {};
355
- const keys = id.split(".");
356
- let target: Record<string, unknown> = patch;
357
- for (let i = 0; i < keys.length - 1; i++) {
358
- if (!target[keys[i]!] || typeof target[keys[i]!] !== "object") target[keys[i]!] = {};
359
- target = target[keys[i]!] as Record<string, unknown>;
360
- }
361
- target[keys[keys.length - 1]!] = value;
362
- if (value === undefined) { updateConfig({}, { unsetPaths: [id] }); }
363
- else { updateConfig(parseConfig(patch)); }
364
- } catch (error) {
365
- ctx.ui.notify(`Failed to save: ${error instanceof Error ? error.message : String(error)}`, "error");
366
- }
367
- }, () => done(undefined), async (action: string, value: unknown) => {
368
- // Action callbacks (Pi theme switch) write to a different store
369
- // than pi-crew config (e.g. ~/.pi/agent/settings.json).
370
- try {
371
- if (action === "piTheme" && typeof value === "string") {
372
- // Live theme switch: ctx.ui.setTheme() swaps the global theme,
373
- // persists it to settings.json, and triggers a UI redraw — no
374
- // restart needed. Falls back to file-write + restart hint if
375
- // the live API is unavailable (e.g. non-TUI mode).
376
- if (typeof ctx.ui.setTheme === "function") {
377
- const res = ctx.ui.setTheme(value);
378
- if (res.success) {
379
- ctx.ui.notify(`Theme: ${value} (applied live)`, "info");
380
- } else {
381
- const { setPiTheme } = await import("../../ui/theme-discovery.ts");
382
- setPiTheme(value);
383
- ctx.ui.notify(`Theme saved as '${value}' but failed to apply: ${res.error ?? "unknown"}. Restart Pi.`, "warning");
384
- }
385
- } else {
386
- const { setPiTheme } = await import("../../ui/theme-discovery.ts");
387
- setPiTheme(value);
388
- ctx.ui.notify(`Pi theme set to '${value}'. Restart Pi to apply.`, "info");
389
- }
390
- }
391
- } catch (error) {
392
- ctx.ui.notify(`Failed: ${error instanceof Error ? error.message : String(error)}`, "error");
393
- }
394
- });
395
- return overlay;
396
- }, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
422
+ await openTeamSettingsOverlay(ctx);
397
423
  return;
398
424
  }
399
425
  const result = await handleTeamTool({ action: "settings", config: { args: args.trim() } }, teamCommandContext(ctx));
@@ -415,7 +441,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
415
441
 
416
442
  pi.registerCommand("team-cleanup", { description: "Open a simple pi-crew interactive manager", handler: handleTeamManagerCommand });
417
443
 
418
- pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
444
+ 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
445
  const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
420
446
  const selected = await selectAgentTask(ctx, runId, rawTaskId);
421
447
  const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined; // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
@@ -430,7 +456,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
430
456
  await notifyCommandResult(ctx, commandText(result));
431
457
  } });
432
458
 
433
- pi.registerCommand("team-transcript", { description: "Open a pi-crew transcript viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
459
+ 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
460
  const [runId, taskId] = args.trim().split(/\s+/).filter(Boolean);
435
461
  if (await openTranscriptViewer(ctx, runId, taskId)) return;
436
462
  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
@@ -284,31 +284,36 @@ export const __test__renameWithRetryAsync = renameWithRetryAsync;
284
284
 
285
285
  export function atomicWriteFile(filePath: string, content: string, expectedHash?: string): void {
286
286
  if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
287
- // On Windows, resolve parent dir through realpathSync to handle short-name
288
- // vs long-name path alias (e.g. RUNNER~1 vs runneradmin). Without this,
289
- // mkdirSync may succeed but openSync fails with ENOENT because the OS
290
- // sees the paths as different locations.
291
- let dirPath = path.dirname(filePath);
292
- if (process.platform === "win32") {
287
+ // On Windows the parent directory may be referenced via a short-name alias
288
+ // (e.g. RUNNER~1 vs runneradmin). mkdirSync on one form can succeed while
289
+ // openSync on another form fails with ENOENT. We therefore ensure the dir
290
+ // exists, trying the canonical (realpathSync.native) form as a fallback on
291
+ // EPERM.
292
+ //
293
+ // CRITICAL: we NEVER rewrite `filePath`. The caller (e.g. createRunManifest)
294
+ // builds filePath via canonicalizePath() (realpathSync.native) and will later
295
+ // stat/read it back at that exact path. If we rewrote filePath to a different
296
+ // realpath form here, the written file would land on a path that diverges
297
+ // from the caller's path — making existsSync/readFileSync(callerPath) fail
298
+ // with ENOENT even though the write "succeeded". Writing to the original
299
+ // filePath guarantees the caller can always find the file it just wrote.
300
+ const canonicalize = (p: string): string => {
293
301
  try {
294
- const realDir = fs.realpathSync(dirPath);
295
- if (realDir !== dirPath) dirPath = realDir;
302
+ const r = fs.realpathSync.native(p);
303
+ return r.startsWith("\\\\?\\") ? r.slice(4) : r;
296
304
  } catch {
297
- // dirPath may not exist yet mkdirSync will create it
305
+ try { return fs.realpathSync(p); } catch { return p; }
298
306
  }
299
- filePath = path.join(dirPath, path.basename(filePath));
300
- }
307
+ };
308
+ const dirPath = path.dirname(filePath);
301
309
  try {
302
310
  fs.mkdirSync(dirPath, { recursive: true });
303
311
  } catch (error) {
304
312
  if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") {
305
- try {
306
- const realDir = fs.realpathSync(dirPath);
307
- if (realDir !== dirPath) {
308
- fs.mkdirSync(realDir, { recursive: true });
309
- dirPath = realDir;
310
- }
311
- } catch { /* ignore – will fail at write time with better error */ }
313
+ // mkdir hit a short/long-name alias wall — retry with the canonical
314
+ // form. The write itself still targets the original filePath below.
315
+ const realDir = canonicalize(dirPath);
316
+ if (realDir !== dirPath) fs.mkdirSync(realDir, { recursive: true });
312
317
  } else {
313
318
  throw error;
314
319
  }
@@ -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
  }
@@ -213,8 +213,14 @@ export function createRunManifest(params: {
213
213
  // throw to ensure manifest and tasks are always consistent.
214
214
  const result = saveManifestAndTasksAtomicSync(manifest, tasks);
215
215
  if (!result.manifestWritten || !result.tasksWritten) {
216
- throw errors.fileWrite(paths.stateRoot, result.error as unknown as NodeJS.ErrnoException)
217
- .withContext(`saveManifestAndTasksAtomicSync: manifestWritten=${result.manifestWritten}, tasksWritten=${result.tasksWritten}`);
216
+ // Surface the underlying error message (result.error is String(err) from
217
+ // saveManifestAndTasksAtomicSync). Passing it through errors.fileWrite as a
218
+ // fake ErrnoException loses the message (reads .code → undefined →
219
+ // "unknown"). Include it explicitly in the thrown message so CI logs and
220
+ // production callers can see WHY the write failed instead of ": unknown".
221
+ const cause = result.error ? `: ${result.error}` : "";
222
+ throw errors.fileWrite(paths.stateRoot, { code: "EWRITEFAIL" } as NodeJS.ErrnoException)
223
+ .withContext(`saveManifestAndTasksAtomicSync: manifestWritten=${result.manifestWritten}, tasksWritten=${result.tasksWritten}${cause}`);
218
224
  }
219
225
  appendEvent(paths.eventsPath, {
220
226
  type: "run.created",