@zigai/pi-mode 0.1.3 → 0.1.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/README.md +1 -1
- package/package.json +1 -1
- package/src/mode-state.ts +43 -12
- package/src/settings.ts +142 -13
- package/src/storage.ts +2 -10
package/README.md
CHANGED
package/package.json
CHANGED
package/src/mode-state.ts
CHANGED
|
@@ -161,6 +161,22 @@ function normalizeThinkingLevel(level: ThinkingLevel | undefined): ThinkingLevel
|
|
|
161
161
|
return undefined;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
function getLoadErrorCode(error: unknown): string | undefined {
|
|
165
|
+
if (!(error instanceof Error)) return undefined;
|
|
166
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
167
|
+
if (typeof code === "string") return code;
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function errorMessage(error: unknown): string {
|
|
172
|
+
if (error instanceof Error) return error.message;
|
|
173
|
+
return String(error);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function throwLoadError(filePath: string, error: unknown): never {
|
|
177
|
+
throw new Error(`Failed to load ${filePath}: ${errorMessage(error)}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
164
180
|
function sanitizeModeSpec(spec: ModeSpecJson | undefined): ModeSpec {
|
|
165
181
|
if (spec === undefined) return {};
|
|
166
182
|
|
|
@@ -225,6 +241,7 @@ async function loadModesFile(
|
|
|
225
241
|
filePath: string,
|
|
226
242
|
ctx: ExtensionContext,
|
|
227
243
|
pi: ExtensionAPI,
|
|
244
|
+
options?: { throwOnInvalid?: boolean },
|
|
228
245
|
): Promise<ModesFile> {
|
|
229
246
|
try {
|
|
230
247
|
const raw = await fs.readFile(filePath, "utf8");
|
|
@@ -244,7 +261,9 @@ async function loadModesFile(
|
|
|
244
261
|
};
|
|
245
262
|
ensureDefaultModeEntries(file, ctx, pi);
|
|
246
263
|
return file;
|
|
247
|
-
} catch {
|
|
264
|
+
} catch (error: unknown) {
|
|
265
|
+
if (getLoadErrorCode(error) === "ENOENT") return createDefaultModes(ctx, pi);
|
|
266
|
+
if (options?.throwOnInvalid === true) throwLoadError(filePath, error);
|
|
248
267
|
return createDefaultModes(ctx, pi);
|
|
249
268
|
}
|
|
250
269
|
}
|
|
@@ -404,16 +423,23 @@ async function persistRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<
|
|
|
404
423
|
const patch = computeModesPatch(runtime.baseline, runtime.data, false);
|
|
405
424
|
if (patch === null) return;
|
|
406
425
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
426
|
+
try {
|
|
427
|
+
await withFileLock(runtime.filePath, async () => {
|
|
428
|
+
const latest = await loadModesFile(runtime.filePath, ctx, pi, { throwOnInvalid: true });
|
|
429
|
+
applyModesPatch(latest, patch);
|
|
430
|
+
ensureDefaultModeEntries(latest, ctx, pi);
|
|
431
|
+
await saveModesFile(runtime.filePath, latest);
|
|
432
|
+
|
|
433
|
+
runtime.data = latest;
|
|
434
|
+
runtime.baseline = cloneModesFile(latest);
|
|
435
|
+
runtime.fileMtimeMs = await getMtimeMs(runtime.filePath);
|
|
436
|
+
});
|
|
437
|
+
} catch (error: unknown) {
|
|
438
|
+
if (ctx.hasUI) {
|
|
439
|
+
ctx.ui.notify(`Mode settings were not saved: ${errorMessage(error)}`, "error");
|
|
440
|
+
}
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
417
443
|
}
|
|
418
444
|
|
|
419
445
|
function getCurrentSelectionSpec(pi: ExtensionAPI): ModeSpec {
|
|
@@ -810,7 +836,12 @@ async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promis
|
|
|
810
836
|
|
|
811
837
|
if (choice === MODE_UI_SHOW_NAME_ON || choice === MODE_UI_SHOW_NAME_OFF) {
|
|
812
838
|
const next = !shouldShowModeName();
|
|
813
|
-
|
|
839
|
+
try {
|
|
840
|
+
setShowModeName(next);
|
|
841
|
+
} catch (error: unknown) {
|
|
842
|
+
ctx.ui.notify(`Mode name display was not saved: ${errorMessage(error)}`, "error");
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
814
845
|
requestEditorRender?.();
|
|
815
846
|
let displayState = "disabled";
|
|
816
847
|
if (next) {
|
package/src/settings.ts
CHANGED
|
@@ -1,14 +1,131 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
statSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
3
13
|
import { dirname, join } from "node:path";
|
|
4
14
|
|
|
5
15
|
export const SHOW_MODE_NAME_SETTINGS_KEY = "modeShowName";
|
|
6
16
|
|
|
17
|
+
const SETTINGS_LOCK_TIMEOUT_MS = 5_000;
|
|
18
|
+
const STALE_SETTINGS_LOCK_MS = 30_000;
|
|
19
|
+
|
|
7
20
|
let cachedShowModeName: boolean | undefined;
|
|
8
21
|
let cachedSettingsMtimeMs: number | null | undefined;
|
|
9
22
|
|
|
10
23
|
function getSettingsPath(): string {
|
|
11
|
-
return join(
|
|
24
|
+
return join(getAgentDir(), "settings.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getErrorCode(error: unknown): string | undefined {
|
|
28
|
+
if (!(error instanceof Error)) return undefined;
|
|
29
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
30
|
+
if (typeof code === "string") return code;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function throwError(error: unknown): never {
|
|
35
|
+
if (error instanceof Error) throw error;
|
|
36
|
+
throw new Error(String(error));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sleepSync(ms: number): void {
|
|
40
|
+
const buffer = new SharedArrayBuffer(4);
|
|
41
|
+
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function withSettingsLock<T>(settingsPath: string, fn: () => T): T {
|
|
45
|
+
const lockPath = `${settingsPath}.lock`;
|
|
46
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
47
|
+
|
|
48
|
+
const start = Date.now();
|
|
49
|
+
while (true) {
|
|
50
|
+
try {
|
|
51
|
+
const fd = openSync(lockPath, "wx");
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(
|
|
54
|
+
fd,
|
|
55
|
+
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
|
|
56
|
+
"utf8",
|
|
57
|
+
);
|
|
58
|
+
} catch {
|
|
59
|
+
// Ignore best-effort lock metadata.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
return fn();
|
|
64
|
+
} finally {
|
|
65
|
+
try {
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
} catch {
|
|
68
|
+
// Ignore cleanup failures.
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
unlinkSync(lockPath);
|
|
72
|
+
} catch {
|
|
73
|
+
// Ignore cleanup failures.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (error: unknown) {
|
|
77
|
+
if (getErrorCode(error) !== "EEXIST") throwError(error);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const stat = statSync(lockPath);
|
|
81
|
+
if (Date.now() - stat.mtimeMs > STALE_SETTINGS_LOCK_MS) {
|
|
82
|
+
unlinkSync(lockPath);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Ignore stale-lock checks.
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Date.now() - start > SETTINGS_LOCK_TIMEOUT_MS) {
|
|
90
|
+
throw new Error(`Timed out waiting for lock: ${lockPath}`);
|
|
91
|
+
}
|
|
92
|
+
sleepSync(40 + Math.random() * 80);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function atomicWriteUtf8Sync(filePath: string, content: string): void {
|
|
98
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
99
|
+
|
|
100
|
+
const tempPath = join(
|
|
101
|
+
dirname(filePath),
|
|
102
|
+
`.${filePath.split(/[\\/]/).pop() ?? "settings.json"}.tmp.${process.pid}.${Math.random()
|
|
103
|
+
.toString(16)
|
|
104
|
+
.slice(2)}`,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
writeFileSync(tempPath, content, "utf8");
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
renameSync(tempPath, filePath);
|
|
111
|
+
} catch (error: unknown) {
|
|
112
|
+
const code = getErrorCode(error);
|
|
113
|
+
if (code === "EEXIST" || code === "EPERM") {
|
|
114
|
+
try {
|
|
115
|
+
unlinkSync(filePath);
|
|
116
|
+
} catch {
|
|
117
|
+
// Ignore missing target before retrying the rename.
|
|
118
|
+
}
|
|
119
|
+
renameSync(tempPath, filePath);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(tempPath);
|
|
124
|
+
} catch {
|
|
125
|
+
// Ignore cleanup failures.
|
|
126
|
+
}
|
|
127
|
+
throwError(error);
|
|
128
|
+
}
|
|
12
129
|
}
|
|
13
130
|
|
|
14
131
|
function getSettingsMtimeMs(): number | null {
|
|
@@ -20,20 +137,35 @@ function getSettingsMtimeMs(): number | null {
|
|
|
20
137
|
}
|
|
21
138
|
}
|
|
22
139
|
|
|
23
|
-
function readSettingsObject(): Record<string, unknown> {
|
|
140
|
+
function readSettingsObject(options?: { throwOnInvalid?: boolean }): Record<string, unknown> {
|
|
141
|
+
const settingsPath = getSettingsPath();
|
|
24
142
|
try {
|
|
25
|
-
const raw = readFileSync(
|
|
143
|
+
const raw = readFileSync(settingsPath, "utf8");
|
|
26
144
|
const parsed = JSON.parse(raw) as unknown;
|
|
27
145
|
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
28
146
|
return { ...parsed };
|
|
29
147
|
}
|
|
30
|
-
|
|
31
|
-
|
|
148
|
+
if (options?.throwOnInvalid === true) {
|
|
149
|
+
throw new Error(`${settingsPath} must contain a JSON object.`);
|
|
150
|
+
}
|
|
151
|
+
} catch (error: unknown) {
|
|
152
|
+
if (getErrorCode(error) === "ENOENT") return {};
|
|
153
|
+
if (options?.throwOnInvalid === true) throwError(error);
|
|
154
|
+
// Ignore malformed settings files while reading and fall back to defaults.
|
|
32
155
|
}
|
|
33
156
|
|
|
34
157
|
return {};
|
|
35
158
|
}
|
|
36
159
|
|
|
160
|
+
function updateSettingsObject(update: (settings: Record<string, unknown>) => void): void {
|
|
161
|
+
const settingsPath = getSettingsPath();
|
|
162
|
+
withSettingsLock(settingsPath, () => {
|
|
163
|
+
const settings = readSettingsObject({ throwOnInvalid: true });
|
|
164
|
+
update(settings);
|
|
165
|
+
atomicWriteUtf8Sync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
37
169
|
export function shouldShowModeName(): boolean {
|
|
38
170
|
const mtimeMs = getSettingsMtimeMs();
|
|
39
171
|
if (cachedShowModeName !== undefined && cachedSettingsMtimeMs === mtimeMs) {
|
|
@@ -47,12 +179,9 @@ export function shouldShowModeName(): boolean {
|
|
|
47
179
|
}
|
|
48
180
|
|
|
49
181
|
export function setShowModeName(show: boolean): void {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
55
|
-
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
182
|
+
updateSettingsObject((settings) => {
|
|
183
|
+
settings[SHOW_MODE_NAME_SETTINGS_KEY] = show;
|
|
184
|
+
});
|
|
56
185
|
|
|
57
186
|
cachedSettingsMtimeMs = getSettingsMtimeMs();
|
|
58
187
|
cachedShowModeName = show;
|
package/src/storage.ts
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
export function expandUserPath(value: string): string {
|
|
6
|
-
if (value === "~") return os.homedir();
|
|
7
|
-
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
|
|
8
|
-
return value;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
5
|
export function getGlobalAgentDir(): string {
|
|
12
|
-
|
|
13
|
-
if (env !== undefined && env.length > 0) return expandUserPath(env);
|
|
14
|
-
return path.join(os.homedir(), ".pi", "agent");
|
|
6
|
+
return getAgentDir();
|
|
15
7
|
}
|
|
16
8
|
|
|
17
9
|
export function getGlobalModesPath(): string {
|