pi-crew 0.7.3 → 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,24 @@
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
+
3
22
  ## [0.7.3] — Reliability hardening + UX quick wins (2026-06-15)
4
23
 
5
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.7.3",
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,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
+ }
@@ -12,17 +12,47 @@
12
12
  */
13
13
  import type { InputEvent, InputEventResult } from "@earendil-works/pi-coding-agent";
14
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" },
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" },
24
38
  ];
25
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
+
26
56
  /**
27
57
  * Try to rewrite a natural-language crew phrase into a slash command.
28
58
  * Returns the rewritten command string, or `null` if no rule matches.
@@ -35,12 +65,12 @@ export function rewriteCrewInput(text: string): string | null {
35
65
  // Never transform explicit slash commands or inputs that don't start with
36
66
  // a crew/team keyword phrase.
37
67
  if (trimmed.startsWith("/")) return null;
38
- for (const rule of ROUTING_RULES) {
39
- const match = trimmed.match(rule.match);
68
+ for (const entry of CREW_PHRASES) {
69
+ const match = trimmed.match(phraseToRegex(entry.phrase));
40
70
  if (!match) continue;
41
71
  // Carry any remaining args after the matched phrase forward.
42
72
  const rest = trimmed.slice(match[0].length).trim();
43
- return rest ? `${rule.command} ${rest}` : rule.command;
73
+ return rest ? `${entry.command} ${rest}` : entry.command;
44
74
  }
45
75
  return null;
46
76
  }
@@ -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);
@@ -111,6 +111,8 @@ import {
111
111
  import { registerSubagentTools } from "./registration/subagent-tools.ts";
112
112
  import { registerCrewMessageRenderers } from "./message-renderers.ts";
113
113
  import { registerCrewInputRouter } from "./crew-input-router.ts";
114
+ import { registerCrewAutocomplete } from "./crew-autocomplete.ts";
115
+ import { registerCrewShortcuts } from "./crew-shortcuts.ts";
114
116
  import { registerTeamTool } from "./registration/team-tool.ts";
115
117
  import { handleTeamTool } from "./team-tool.ts";
116
118
  import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
@@ -214,6 +216,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
214
216
  let sessionGeneration = 0;
215
217
  let rpcHandle: PiCrewRpcHandle | undefined;
216
218
  let cleanedUp = false;
219
+ let crewAutocompleteRegistered = false;
217
220
  let manifestCache = createManifestCache(process.cwd());
218
221
  let runSnapshotCache = createRunSnapshotCache(process.cwd());
219
222
  let cacheCwd = process.cwd();
@@ -1219,6 +1222,14 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1219
1222
  sessionGeneration++;
1220
1223
  const ownerGeneration = sessionGeneration;
1221
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
+ }
1222
1233
  if (widgetState.interval) clearInterval(widgetState.interval);
1223
1234
  widgetState.interval = undefined;
1224
1235
  notifyActiveRuns(ctx);
@@ -2048,4 +2059,10 @@ export function registerPiTeams(pi: ExtensionAPI): void {
2048
2059
  // interactive input that starts with a crew/team keyword phrase; never
2049
2060
  // shadows explicit slash commands.
2050
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);
2051
2068
  }
@@ -154,6 +154,72 @@ function teamCommandContext(ctx: ExtensionCommandContext): ExtensionCommandConte
154
154
  return withSessionId(ctx);
155
155
  }
156
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
+
157
223
  async function handleHealthDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise<void> {
158
224
  const loaded = loadRunManifestById(ctx.cwd, selection.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
159
225
  if (!loaded) {
@@ -353,60 +419,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
353
419
  description: "View or update pi-crew settings: interactive UI or [list|get <key>|set <key> <value>|unset <key>|path|scope]",
354
420
  handler: async (args: string, ctx: ExtensionCommandContext) => {
355
421
  if (ctx.hasUI && !args.trim()) {
356
- const [{ updateConfig, parseConfig }, { asCrewTheme }, { createSettingsOverlay }] = await Promise.all([
357
- import("../../config/config.ts"),
358
- import("../../ui/theme-adapter.ts"),
359
- import("../../ui/settings-overlay.ts"),
360
- ]);
361
- const loaded = loadConfig(ctx.cwd);
362
- const config = loaded.config as Record<string, unknown>;
363
- await ctx.ui.custom<undefined>((_tui, _theme, _keybindings, done) => {
364
- const theme = asCrewTheme(_theme);
365
- const { overlay } = createSettingsOverlay(config, theme, (id: string, value: unknown) => {
366
- try {
367
- const patch: Record<string, unknown> = {};
368
- const keys = id.split(".");
369
- let target: Record<string, unknown> = patch;
370
- for (let i = 0; i < keys.length - 1; i++) {
371
- if (!target[keys[i]!] || typeof target[keys[i]!] !== "object") target[keys[i]!] = {};
372
- target = target[keys[i]!] as Record<string, unknown>;
373
- }
374
- target[keys[keys.length - 1]!] = value;
375
- if (value === undefined) { updateConfig({}, { unsetPaths: [id] }); }
376
- else { updateConfig(parseConfig(patch)); }
377
- } catch (error) {
378
- ctx.ui.notify(`Failed to save: ${error instanceof Error ? error.message : String(error)}`, "error");
379
- }
380
- }, () => done(undefined), async (action: string, value: unknown) => {
381
- // Action callbacks (Pi theme switch) write to a different store
382
- // than pi-crew config (e.g. ~/.pi/agent/settings.json).
383
- try {
384
- if (action === "piTheme" && typeof value === "string") {
385
- // Live theme switch: ctx.ui.setTheme() swaps the global theme,
386
- // persists it to settings.json, and triggers a UI redraw — no
387
- // restart needed. Falls back to file-write + restart hint if
388
- // the live API is unavailable (e.g. non-TUI mode).
389
- if (typeof ctx.ui.setTheme === "function") {
390
- const res = ctx.ui.setTheme(value);
391
- if (res.success) {
392
- ctx.ui.notify(`Theme: ${value} (applied live)`, "info");
393
- } else {
394
- const { setPiTheme } = await import("../../ui/theme-discovery.ts");
395
- setPiTheme(value);
396
- ctx.ui.notify(`Theme saved as '${value}' but failed to apply: ${res.error ?? "unknown"}. Restart Pi.`, "warning");
397
- }
398
- } else {
399
- const { setPiTheme } = await import("../../ui/theme-discovery.ts");
400
- setPiTheme(value);
401
- ctx.ui.notify(`Pi theme set to '${value}'. Restart Pi to apply.`, "info");
402
- }
403
- }
404
- } catch (error) {
405
- ctx.ui.notify(`Failed: ${error instanceof Error ? error.message : String(error)}`, "error");
406
- }
407
- });
408
- return overlay;
409
- }, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
422
+ await openTeamSettingsOverlay(ctx);
410
423
  return;
411
424
  }
412
425
  const result = await handleTeamTool({ action: "settings", config: { args: args.trim() } }, teamCommandContext(ctx));
@@ -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
  }
@@ -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",