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 +19 -0
- package/package.json +1 -1
- package/src/extension/crew-autocomplete.ts +139 -0
- package/src/extension/crew-input-router.ts +42 -12
- package/src/extension/crew-shortcuts.ts +56 -0
- package/src/extension/register.ts +17 -0
- package/src/extension/registration/commands.ts +67 -54
- package/src/state/atomic-write.ts +23 -18
- package/src/state/state-store.ts +8 -2
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
|
@@ -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
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
39
|
-
const match = trimmed.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 ? `${
|
|
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
|
-
|
|
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
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
295
|
-
|
|
302
|
+
const r = fs.realpathSync.native(p);
|
|
303
|
+
return r.startsWith("\\\\?\\") ? r.slice(4) : r;
|
|
296
304
|
} catch {
|
|
297
|
-
|
|
305
|
+
try { return fs.realpathSync(p); } catch { return p; }
|
|
298
306
|
}
|
|
299
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
}
|
package/src/state/state-store.ts
CHANGED
|
@@ -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
|
-
|
|
217
|
-
|
|
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",
|