@zigai/pi-mode 0.1.3 → 0.2.0
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 +10 -2
- package/package.json +4 -1
- package/src/index.ts +2 -0
- package/src/mode-state.ts +97 -30
- package/src/settings.ts +246 -27
- package/src/storage.ts +2 -10
package/README.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# Pi Mode
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@zigai/pi-mode)
|
|
4
|
+
[](https://www.npmjs.com/package/@zigai/pi-mode)
|
|
5
|
+
[](../../LICENSE)
|
|
6
|
+
|
|
3
7
|
This Pi extension adds prompt modes for model and thinking-level switching.
|
|
4
8
|
|
|
5
9
|
## Install
|
|
6
10
|
|
|
7
11
|
```sh
|
|
8
|
-
pi install
|
|
12
|
+
pi install npm:@zigai/pi-mode
|
|
9
13
|
```
|
|
10
14
|
|
|
11
15
|
## Features
|
|
@@ -18,4 +22,8 @@ pi install git:github.com/zigai/pi-tweaks
|
|
|
18
22
|
|
|
19
23
|
Modes can store a provider, model, thinking level, and optional color. Project-local modes live in `.pi/modes.json` when present; otherwise global modes live in `~/.pi/agent/modes.json`.
|
|
20
24
|
|
|
21
|
-
By default, Pi Mode does not print the mode name in the editor border. To opt in, toggle `Show mode name` from `/mode` → `Configure modes…`, or set `"modeShowName": true` in `~/.pi/agent/settings.json`.
|
|
25
|
+
By default, Pi Mode does not print the mode name in the editor border. To opt in, toggle `Show mode name` from `/mode` → `Configure modes…`, or set `"modeShowName": true` in global `~/.pi/agent/settings.json` or trusted project `.pi/settings.json`. The `/mode` toggle writes to global settings.
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zigai/pi-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi package that adds prompt modes for model and thinking-level switching.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mode",
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"typebox": "^1.1.38"
|
|
34
|
+
},
|
|
32
35
|
"peerDependencies": {
|
|
33
36
|
"@earendil-works/pi-agent-core": "*",
|
|
34
37
|
"@earendil-works/pi-ai": "*",
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
handleSessionActivated,
|
|
8
8
|
selectModeUI,
|
|
9
9
|
} from "./mode-state.ts";
|
|
10
|
+
import { setSettingsContext } from "./settings.ts";
|
|
10
11
|
|
|
11
12
|
export default function (pi: ExtensionAPI) {
|
|
12
13
|
pi.registerCommand("mode", {
|
|
@@ -31,6 +32,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
pi.on("session_start", async (_event, ctx) => {
|
|
35
|
+
setSettingsContext(ctx);
|
|
34
36
|
await handleSessionActivated(pi, ctx);
|
|
35
37
|
applyModeEditor(pi, ctx);
|
|
36
38
|
});
|
package/src/mode-state.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
8
8
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
9
9
|
import fs from "node:fs/promises";
|
|
10
|
+
import { Type, type Static, type TSchema } from "typebox";
|
|
11
|
+
import { Value } from "typebox/value";
|
|
10
12
|
import {
|
|
11
13
|
ALL_THINKING_LEVELS,
|
|
12
14
|
CUSTOM_MODE_NAME,
|
|
@@ -34,17 +36,51 @@ type ScopedModelItem = {
|
|
|
34
36
|
thinkingLevel?: string;
|
|
35
37
|
};
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
provider
|
|
39
|
-
modelId
|
|
40
|
-
thinkingLevel
|
|
41
|
-
color
|
|
42
|
-
};
|
|
39
|
+
const ModeSpecJsonSchema = Type.Object({
|
|
40
|
+
provider: Type.Optional(Type.String()),
|
|
41
|
+
modelId: Type.Optional(Type.String()),
|
|
42
|
+
thinkingLevel: Type.Optional(Type.Unknown()),
|
|
43
|
+
color: Type.Optional(Type.String()),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const ModesFileJsonSchema = Type.Object({
|
|
47
|
+
$schema: Type.Optional(Type.String()),
|
|
48
|
+
version: Type.Optional(Type.Number()),
|
|
49
|
+
currentMode: Type.Optional(Type.String()),
|
|
50
|
+
modes: Type.Optional(Type.Record(Type.String(), ModeSpecJsonSchema)),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
type ModeSpecJson = Static<typeof ModeSpecJsonSchema>;
|
|
54
|
+
type ModesFileJson = Static<typeof ModesFileJsonSchema>;
|
|
55
|
+
|
|
56
|
+
function formatSchemaPath(instancePath: string): string {
|
|
57
|
+
if (instancePath.length === 0) return "root";
|
|
58
|
+
return instancePath
|
|
59
|
+
.slice(1)
|
|
60
|
+
.split("/")
|
|
61
|
+
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
62
|
+
.join(".");
|
|
63
|
+
}
|
|
43
64
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
function parseSchema(schema: TSchema, value: unknown, label: string): unknown {
|
|
66
|
+
const errors = [...Value.Errors(schema, value)];
|
|
67
|
+
if (errors.length > 0) {
|
|
68
|
+
const messages = errors
|
|
69
|
+
.slice(0, 5)
|
|
70
|
+
.map((error) => `${formatSchemaPath(error.instancePath)} ${error.message}`);
|
|
71
|
+
let suffix = "";
|
|
72
|
+
if (errors.length > messages.length) {
|
|
73
|
+
suffix = `; and ${errors.length - messages.length} more`;
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`${label} is invalid: ${messages.join("; ")}${suffix}`);
|
|
76
|
+
}
|
|
77
|
+
const parsed: unknown = Value.Parse(schema, value);
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseModesFileJson(value: unknown): ModesFileJson {
|
|
82
|
+
return parseSchema(ModesFileJsonSchema, value, "modes.json") as ModesFileJson;
|
|
83
|
+
}
|
|
48
84
|
|
|
49
85
|
function cloneModesFile(file: ModesFile): ModesFile {
|
|
50
86
|
return JSON.parse(JSON.stringify(file)) as ModesFile;
|
|
@@ -55,7 +91,7 @@ function modeSpec(modes: Record<string, ModeSpec>, name: string): ModeSpec | und
|
|
|
55
91
|
return undefined;
|
|
56
92
|
}
|
|
57
93
|
|
|
58
|
-
function computeModesPatch(
|
|
94
|
+
export function computeModesPatch(
|
|
59
95
|
base: ModesFile,
|
|
60
96
|
next: ModesFile,
|
|
61
97
|
includeCurrentMode: boolean,
|
|
@@ -108,7 +144,7 @@ function computeModesPatch(
|
|
|
108
144
|
return patch;
|
|
109
145
|
}
|
|
110
146
|
|
|
111
|
-
function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
|
|
147
|
+
export function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
|
|
112
148
|
if (patch.currentMode !== undefined) {
|
|
113
149
|
target.currentMode = patch.currentMode;
|
|
114
150
|
}
|
|
@@ -153,14 +189,30 @@ function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
|
|
|
153
189
|
}
|
|
154
190
|
}
|
|
155
191
|
|
|
156
|
-
function normalizeThinkingLevel(level:
|
|
157
|
-
if (level
|
|
158
|
-
if (ALL_THINKING_LEVELS.includes(level)) {
|
|
159
|
-
return level;
|
|
192
|
+
function normalizeThinkingLevel(level: unknown): ThinkingLevel | undefined {
|
|
193
|
+
if (typeof level !== "string") return undefined;
|
|
194
|
+
if (ALL_THINKING_LEVELS.includes(level as ThinkingLevel)) {
|
|
195
|
+
return level as ThinkingLevel;
|
|
160
196
|
}
|
|
161
197
|
return undefined;
|
|
162
198
|
}
|
|
163
199
|
|
|
200
|
+
function getLoadErrorCode(error: unknown): string | undefined {
|
|
201
|
+
if (!(error instanceof Error)) return undefined;
|
|
202
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
203
|
+
if (typeof code === "string") return code;
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function errorMessage(error: unknown): string {
|
|
208
|
+
if (error instanceof Error) return error.message;
|
|
209
|
+
return String(error);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function throwLoadError(filePath: string, error: unknown): never {
|
|
213
|
+
throw new Error(`Failed to load ${filePath}: ${errorMessage(error)}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
164
216
|
function sanitizeModeSpec(spec: ModeSpecJson | undefined): ModeSpec {
|
|
165
217
|
if (spec === undefined) return {};
|
|
166
218
|
|
|
@@ -169,7 +221,7 @@ function sanitizeModeSpec(spec: ModeSpecJson | undefined): ModeSpec {
|
|
|
169
221
|
};
|
|
170
222
|
if (spec.provider !== undefined) sanitized.provider = spec.provider;
|
|
171
223
|
if (spec.modelId !== undefined) sanitized.modelId = spec.modelId;
|
|
172
|
-
if (spec.color !== undefined) sanitized.color = spec.color;
|
|
224
|
+
if (spec.color !== undefined) sanitized.color = spec.color as ModeSpec["color"];
|
|
173
225
|
return sanitized;
|
|
174
226
|
}
|
|
175
227
|
|
|
@@ -225,10 +277,11 @@ async function loadModesFile(
|
|
|
225
277
|
filePath: string,
|
|
226
278
|
ctx: ExtensionContext,
|
|
227
279
|
pi: ExtensionAPI,
|
|
280
|
+
options?: { throwOnInvalid?: boolean },
|
|
228
281
|
): Promise<ModesFile> {
|
|
229
282
|
try {
|
|
230
283
|
const raw = await fs.readFile(filePath, "utf8");
|
|
231
|
-
const parsed = JSON.parse(raw)
|
|
284
|
+
const parsed = parseModesFileJson(JSON.parse(raw));
|
|
232
285
|
const currentMode = parsed.currentMode ?? "default";
|
|
233
286
|
const modesRaw = parsed.modes ?? {};
|
|
234
287
|
|
|
@@ -244,7 +297,9 @@ async function loadModesFile(
|
|
|
244
297
|
};
|
|
245
298
|
ensureDefaultModeEntries(file, ctx, pi);
|
|
246
299
|
return file;
|
|
247
|
-
} catch {
|
|
300
|
+
} catch (error: unknown) {
|
|
301
|
+
if (getLoadErrorCode(error) === "ENOENT") return createDefaultModes(ctx, pi);
|
|
302
|
+
if (options?.throwOnInvalid === true) throwLoadError(filePath, error);
|
|
248
303
|
return createDefaultModes(ctx, pi);
|
|
249
304
|
}
|
|
250
305
|
}
|
|
@@ -404,16 +459,23 @@ async function persistRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<
|
|
|
404
459
|
const patch = computeModesPatch(runtime.baseline, runtime.data, false);
|
|
405
460
|
if (patch === null) return;
|
|
406
461
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
462
|
+
try {
|
|
463
|
+
await withFileLock(runtime.filePath, async () => {
|
|
464
|
+
const latest = await loadModesFile(runtime.filePath, ctx, pi, { throwOnInvalid: true });
|
|
465
|
+
applyModesPatch(latest, patch);
|
|
466
|
+
ensureDefaultModeEntries(latest, ctx, pi);
|
|
467
|
+
await saveModesFile(runtime.filePath, latest);
|
|
468
|
+
|
|
469
|
+
runtime.data = latest;
|
|
470
|
+
runtime.baseline = cloneModesFile(latest);
|
|
471
|
+
runtime.fileMtimeMs = await getMtimeMs(runtime.filePath);
|
|
472
|
+
});
|
|
473
|
+
} catch (error: unknown) {
|
|
474
|
+
if (ctx.hasUI) {
|
|
475
|
+
ctx.ui.notify(`Mode settings were not saved: ${errorMessage(error)}`, "error");
|
|
476
|
+
}
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
417
479
|
}
|
|
418
480
|
|
|
419
481
|
function getCurrentSelectionSpec(pi: ExtensionAPI): ModeSpec {
|
|
@@ -810,7 +872,12 @@ async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promis
|
|
|
810
872
|
|
|
811
873
|
if (choice === MODE_UI_SHOW_NAME_ON || choice === MODE_UI_SHOW_NAME_OFF) {
|
|
812
874
|
const next = !shouldShowModeName();
|
|
813
|
-
|
|
875
|
+
try {
|
|
876
|
+
setShowModeName(next);
|
|
877
|
+
} catch (error: unknown) {
|
|
878
|
+
ctx.ui.notify(`Mode name display was not saved: ${errorMessage(error)}`, "error");
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
814
881
|
requestEditorRender?.();
|
|
815
882
|
let displayState = "disabled";
|
|
816
883
|
if (next) {
|
package/src/settings.ts
CHANGED
|
@@ -1,59 +1,278 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
getAgentDir,
|
|
3
|
+
SettingsManager,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
closeSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
openSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
statSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
3
17
|
import { dirname, join } from "node:path";
|
|
18
|
+
import { Type, type TSchema } from "typebox";
|
|
19
|
+
import { Value } from "typebox/value";
|
|
4
20
|
|
|
5
21
|
export const SHOW_MODE_NAME_SETTINGS_KEY = "modeShowName";
|
|
6
22
|
|
|
23
|
+
const SETTINGS_LOCK_TIMEOUT_MS = 5_000;
|
|
24
|
+
const STALE_SETTINGS_LOCK_MS = 30_000;
|
|
25
|
+
const SettingsObjectSchema = Type.Object({});
|
|
26
|
+
const ShowModeNameSchema = Type.Boolean();
|
|
27
|
+
|
|
28
|
+
type SettingsReadContext = {
|
|
29
|
+
cwd: string;
|
|
30
|
+
projectTrusted: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let settingsReadContext: SettingsReadContext | undefined;
|
|
7
34
|
let cachedShowModeName: boolean | undefined;
|
|
8
|
-
let
|
|
35
|
+
let cachedSettingsMtimeKey: string | undefined;
|
|
36
|
+
|
|
37
|
+
type ProjectTrustContext = ExtensionContext & {
|
|
38
|
+
isProjectTrusted?: () => boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function isProjectTrusted(ctx: ExtensionContext): boolean {
|
|
42
|
+
return (ctx as ProjectTrustContext).isProjectTrusted?.() ?? true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setSettingsContext(ctx: ExtensionContext): void {
|
|
46
|
+
const next: SettingsReadContext = {
|
|
47
|
+
cwd: ctx.cwd,
|
|
48
|
+
projectTrusted: isProjectTrusted(ctx),
|
|
49
|
+
};
|
|
50
|
+
if (
|
|
51
|
+
settingsReadContext?.cwd !== next.cwd ||
|
|
52
|
+
settingsReadContext.projectTrusted !== next.projectTrusted
|
|
53
|
+
) {
|
|
54
|
+
settingsReadContext = next;
|
|
55
|
+
cachedShowModeName = undefined;
|
|
56
|
+
cachedSettingsMtimeKey = undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
9
59
|
|
|
10
60
|
function getSettingsPath(): string {
|
|
11
|
-
return join(
|
|
61
|
+
return join(getAgentDir(), "settings.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getProjectSettingsPath(): string | undefined {
|
|
65
|
+
if (settingsReadContext === undefined || !settingsReadContext.projectTrusted) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return join(settingsReadContext.cwd, ".pi", "settings.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getErrorCode(error: unknown): string | undefined {
|
|
72
|
+
if (!(error instanceof Error)) return undefined;
|
|
73
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
74
|
+
if (typeof code === "string") return code;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function throwError(error: unknown): never {
|
|
79
|
+
if (error instanceof Error) throw error;
|
|
80
|
+
throw new Error(String(error));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sleepSync(ms: number): void {
|
|
84
|
+
const buffer = new SharedArrayBuffer(4);
|
|
85
|
+
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseOptionalBoolean(schema: TSchema, value: unknown): boolean | undefined {
|
|
89
|
+
if (value === undefined) return undefined;
|
|
90
|
+
if (!Value.Check(schema, value)) return undefined;
|
|
91
|
+
const parsed: unknown = Value.Parse(schema, value);
|
|
92
|
+
if (typeof parsed === "boolean") return parsed;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function withSettingsLock<T>(settingsPath: string, fn: () => T): T {
|
|
97
|
+
const lockPath = `${settingsPath}.lock`;
|
|
98
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
99
|
+
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
while (true) {
|
|
102
|
+
try {
|
|
103
|
+
const fd = openSync(lockPath, "wx");
|
|
104
|
+
try {
|
|
105
|
+
writeFileSync(
|
|
106
|
+
fd,
|
|
107
|
+
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
|
|
108
|
+
"utf8",
|
|
109
|
+
);
|
|
110
|
+
} catch {
|
|
111
|
+
// Ignore best-effort lock metadata.
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
return fn();
|
|
116
|
+
} finally {
|
|
117
|
+
try {
|
|
118
|
+
closeSync(fd);
|
|
119
|
+
} catch {
|
|
120
|
+
// Ignore cleanup failures.
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(lockPath);
|
|
124
|
+
} catch {
|
|
125
|
+
// Ignore cleanup failures.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (error: unknown) {
|
|
129
|
+
if (getErrorCode(error) !== "EEXIST") throwError(error);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const stat = statSync(lockPath);
|
|
133
|
+
if (Date.now() - stat.mtimeMs > STALE_SETTINGS_LOCK_MS) {
|
|
134
|
+
unlinkSync(lockPath);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// Ignore stale-lock checks.
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (Date.now() - start > SETTINGS_LOCK_TIMEOUT_MS) {
|
|
142
|
+
throw new Error(`Timed out waiting for lock: ${lockPath}`);
|
|
143
|
+
}
|
|
144
|
+
sleepSync(40 + Math.random() * 80);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function atomicWriteUtf8Sync(filePath: string, content: string): void {
|
|
150
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
151
|
+
|
|
152
|
+
const tempPath = join(
|
|
153
|
+
dirname(filePath),
|
|
154
|
+
`.${filePath.split(/[\\/]/).pop() ?? "settings.json"}.tmp.${process.pid}.${Math.random()
|
|
155
|
+
.toString(16)
|
|
156
|
+
.slice(2)}`,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
writeFileSync(tempPath, content, "utf8");
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
renameSync(tempPath, filePath);
|
|
163
|
+
} catch (error: unknown) {
|
|
164
|
+
const code = getErrorCode(error);
|
|
165
|
+
if (code === "EEXIST" || code === "EPERM") {
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(filePath);
|
|
168
|
+
} catch {
|
|
169
|
+
// Ignore missing target before retrying the rename.
|
|
170
|
+
}
|
|
171
|
+
renameSync(tempPath, filePath);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
unlinkSync(tempPath);
|
|
176
|
+
} catch {
|
|
177
|
+
// Ignore cleanup failures.
|
|
178
|
+
}
|
|
179
|
+
throwError(error);
|
|
180
|
+
}
|
|
12
181
|
}
|
|
13
182
|
|
|
14
|
-
function
|
|
183
|
+
function getFileMtimeMs(filePath: string | undefined): number | null {
|
|
184
|
+
if (filePath === undefined) return null;
|
|
15
185
|
try {
|
|
16
|
-
if (!existsSync(
|
|
17
|
-
return statSync(
|
|
186
|
+
if (!existsSync(filePath)) return null;
|
|
187
|
+
return statSync(filePath).mtimeMs;
|
|
18
188
|
} catch {
|
|
19
189
|
return null;
|
|
20
190
|
}
|
|
21
191
|
}
|
|
22
192
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
193
|
+
function getSettingsMtimeKey(): string {
|
|
194
|
+
return `${getFileMtimeMs(getSettingsPath())}:${getFileMtimeMs(getProjectSettingsPath())}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatSchemaPath(instancePath: string): string {
|
|
198
|
+
if (instancePath.length === 0) return "root";
|
|
199
|
+
return instancePath
|
|
200
|
+
.slice(1)
|
|
201
|
+
.split("/")
|
|
202
|
+
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
203
|
+
.join(".");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseSettingsObject(value: unknown, settingsPath: string): Record<string, unknown> {
|
|
207
|
+
const errors = [...Value.Errors(SettingsObjectSchema, value)];
|
|
208
|
+
if (errors.length > 0) {
|
|
209
|
+
const messages = errors
|
|
210
|
+
.slice(0, 5)
|
|
211
|
+
.map((error) => `${formatSchemaPath(error.instancePath)} ${error.message}`);
|
|
212
|
+
let suffix = "";
|
|
213
|
+
if (errors.length > messages.length) {
|
|
214
|
+
suffix = `; and ${errors.length - messages.length} more`;
|
|
29
215
|
}
|
|
30
|
-
|
|
31
|
-
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${settingsPath} must contain a JSON object: ${messages.join("; ")}${suffix}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return { ...(Value.Parse(SettingsObjectSchema, value) as Record<string, unknown>) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readSettingsObject(options?: { throwOnInvalid?: boolean }): Record<string, unknown> {
|
|
224
|
+
const settingsPath = getSettingsPath();
|
|
225
|
+
try {
|
|
226
|
+
const raw = readFileSync(settingsPath, "utf8");
|
|
227
|
+
return parseSettingsObject(JSON.parse(raw), settingsPath);
|
|
228
|
+
} catch (error: unknown) {
|
|
229
|
+
if (getErrorCode(error) === "ENOENT") return {};
|
|
230
|
+
if (options?.throwOnInvalid === true) throwError(error);
|
|
231
|
+
// Ignore malformed settings files while reading and fall back to defaults.
|
|
32
232
|
}
|
|
33
233
|
|
|
34
234
|
return {};
|
|
35
235
|
}
|
|
36
236
|
|
|
237
|
+
function updateSettingsObject(update: (settings: Record<string, unknown>) => void): void {
|
|
238
|
+
const settingsPath = getSettingsPath();
|
|
239
|
+
withSettingsLock(settingsPath, () => {
|
|
240
|
+
const settings = readSettingsObject({ throwOnInvalid: true });
|
|
241
|
+
update(settings);
|
|
242
|
+
atomicWriteUtf8Sync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function applyShowModeNameSetting(settings: Record<string, unknown>, fallback: boolean): boolean {
|
|
247
|
+
return (
|
|
248
|
+
parseOptionalBoolean(ShowModeNameSchema, settings[SHOW_MODE_NAME_SETTINGS_KEY]) ?? fallback
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
37
252
|
export function shouldShowModeName(): boolean {
|
|
38
|
-
const
|
|
39
|
-
if (cachedShowModeName !== undefined &&
|
|
253
|
+
const mtimeKey = getSettingsMtimeKey();
|
|
254
|
+
if (cachedShowModeName !== undefined && cachedSettingsMtimeKey === mtimeKey) {
|
|
40
255
|
return cachedShowModeName;
|
|
41
256
|
}
|
|
42
257
|
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
258
|
+
const context = settingsReadContext ?? { cwd: process.cwd(), projectTrusted: false };
|
|
259
|
+
const manager = SettingsManager.create(context.cwd, getAgentDir(), {
|
|
260
|
+
projectTrusted: context.projectTrusted,
|
|
261
|
+
});
|
|
262
|
+
let show = false;
|
|
263
|
+
show = applyShowModeNameSetting(manager.getGlobalSettings() as Record<string, unknown>, show);
|
|
264
|
+
show = applyShowModeNameSetting(manager.getProjectSettings() as Record<string, unknown>, show);
|
|
265
|
+
|
|
266
|
+
cachedSettingsMtimeKey = mtimeKey;
|
|
267
|
+
cachedShowModeName = show;
|
|
46
268
|
return cachedShowModeName;
|
|
47
269
|
}
|
|
48
270
|
|
|
49
271
|
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");
|
|
272
|
+
updateSettingsObject((settings) => {
|
|
273
|
+
settings[SHOW_MODE_NAME_SETTINGS_KEY] = show;
|
|
274
|
+
});
|
|
56
275
|
|
|
57
|
-
|
|
276
|
+
cachedSettingsMtimeKey = getSettingsMtimeKey();
|
|
58
277
|
cachedShowModeName = show;
|
|
59
278
|
}
|
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 {
|