@oh-my-pi/pi-coding-agent 15.7.1 → 15.7.3
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 +95 -6
- package/dist/types/auto-thinking/classifier.d.ts +35 -0
- package/dist/types/cli/args.d.ts +1 -1
- package/dist/types/cli/extension-flags.d.ts +36 -0
- package/dist/types/config/config-file.d.ts +4 -0
- package/dist/types/config/file-lock.d.ts +23 -0
- package/dist/types/config/keybindings.d.ts +2 -1
- package/dist/types/config/model-registry.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +112 -69
- package/dist/types/edit/hashline/diff.d.ts +9 -3
- package/dist/types/eval/__tests__/agent-bridge.test.d.ts +1 -0
- package/dist/types/eval/__tests__/budget-bridge.test.d.ts +1 -0
- package/dist/types/eval/__tests__/idle-timeout.test.d.ts +1 -0
- package/dist/types/eval/agent-bridge.d.ts +25 -0
- package/dist/types/eval/backend.d.ts +17 -2
- package/dist/types/eval/budget-bridge.d.ts +29 -0
- package/dist/types/eval/idle-timeout.d.ts +28 -0
- package/dist/types/eval/js/executor.d.ts +8 -0
- package/dist/types/eval/js/tool-bridge.d.ts +2 -1
- package/dist/types/eval/py/executor.d.ts +13 -0
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/extensions/runner.d.ts +7 -0
- package/dist/types/extensibility/plugins/git-url.d.ts +11 -1
- package/dist/types/extensibility/plugins/manager.d.ts +12 -1
- package/dist/types/extensibility/shared-events.d.ts +2 -2
- package/dist/types/memory-backend/index.d.ts +1 -1
- package/dist/types/memory-backend/resolve.d.ts +1 -1
- package/dist/types/memory-backend/types.d.ts +3 -3
- package/dist/types/mnemopi/backend.d.ts +4 -0
- package/dist/types/mnemopi/config.d.ts +29 -0
- package/dist/types/mnemopi/state.d.ts +72 -0
- package/dist/types/modes/components/custom-editor.d.ts +2 -2
- package/dist/types/modes/components/model-selector.d.ts +3 -2
- package/dist/types/modes/components/omfg-panel.d.ts +19 -0
- package/dist/types/modes/controllers/command-controller.d.ts +7 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -3
- package/dist/types/modes/controllers/omfg-controller.d.ts +10 -0
- package/dist/types/modes/controllers/omfg-rule.d.ts +26 -0
- package/dist/types/modes/gradient-highlight.d.ts +5 -1
- package/dist/types/modes/interactive-mode.d.ts +7 -3
- package/dist/types/modes/magic-keywords.d.ts +14 -0
- package/dist/types/modes/markdown-prose.d.ts +27 -0
- package/dist/types/modes/orchestrate.d.ts +7 -2
- package/dist/types/modes/shared.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/turn-budget.d.ts +18 -0
- package/dist/types/modes/types.d.ts +7 -3
- package/dist/types/modes/ultrathink.d.ts +7 -2
- package/dist/types/modes/workflow.d.ts +15 -0
- package/dist/types/sdk.d.ts +15 -4
- package/dist/types/session/agent-session.d.ts +59 -23
- package/dist/types/session/session-manager.d.ts +18 -0
- package/dist/types/session/session-storage.d.ts +6 -0
- package/dist/types/session/shake-types.d.ts +24 -0
- package/dist/types/task/executor.d.ts +2 -2
- package/dist/types/thinking.d.ts +39 -1
- package/dist/types/tiny/device.d.ts +3 -3
- package/dist/types/tiny/models.d.ts +34 -1
- package/dist/types/tiny/title-protocol.d.ts +4 -0
- package/dist/types/tools/index.d.ts +19 -3
- package/dist/types/tools/memory-edit.d.ts +1 -1
- package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
- package/package.json +10 -10
- package/src/auto-thinking/classifier.ts +180 -0
- package/src/autoresearch/tools/run-experiment.ts +45 -113
- package/src/cli/args.ts +39 -16
- package/src/cli/extension-flags.ts +48 -0
- package/src/cli/plugin-cli.ts +11 -2
- package/src/config/config-file.ts +98 -13
- package/src/config/file-lock.ts +60 -17
- package/src/config/keybindings.ts +78 -27
- package/src/config/model-registry.ts +7 -1
- package/src/config/settings-schema.ts +118 -71
- package/src/config/settings.ts +12 -0
- package/src/edit/hashline/diff.ts +87 -22
- package/src/edit/renderer.ts +16 -12
- package/src/edit/streaming.ts +17 -6
- package/src/eval/__tests__/agent-bridge.test.ts +433 -0
- package/src/eval/__tests__/budget-bridge.test.ts +69 -0
- package/src/eval/__tests__/idle-timeout.test.ts +66 -0
- package/src/eval/__tests__/shared-executors.test.ts +53 -0
- package/src/eval/agent-bridge.ts +295 -0
- package/src/eval/backend.ts +17 -2
- package/src/eval/budget-bridge.ts +48 -0
- package/src/eval/idle-timeout.ts +80 -0
- package/src/eval/js/executor.ts +35 -7
- package/src/eval/js/index.ts +2 -1
- package/src/eval/js/shared/local-module-loader.ts +75 -10
- package/src/eval/js/shared/prelude.txt +85 -1
- package/src/eval/js/tool-bridge.ts +9 -0
- package/src/eval/py/executor.ts +41 -14
- package/src/eval/py/index.ts +2 -1
- package/src/eval/py/prelude.py +132 -1
- package/src/exec/bash-executor.ts +2 -3
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/extensions/runner.ts +12 -2
- package/src/extensibility/plugins/git-url.ts +90 -4
- package/src/extensibility/plugins/manager.ts +103 -7
- package/src/extensibility/shared-events.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +88 -88
- package/src/main.ts +50 -56
- package/src/memory-backend/index.ts +1 -1
- package/src/memory-backend/resolve.ts +3 -3
- package/src/memory-backend/types.ts +3 -3
- package/src/{mnemosyne → mnemopi}/backend.ts +70 -70
- package/src/{mnemosyne → mnemopi}/config.ts +36 -36
- package/src/{mnemosyne → mnemopi}/state.ts +67 -67
- package/src/modes/acp/acp-agent.ts +13 -3
- package/src/modes/components/agent-dashboard.ts +6 -6
- package/src/modes/components/custom-editor.ts +4 -11
- package/src/modes/components/extensions/state-manager.ts +3 -4
- package/src/modes/components/footer.ts +18 -12
- package/src/modes/components/hook-selector.ts +86 -20
- package/src/modes/components/model-selector.ts +20 -11
- package/src/modes/components/oauth-selector.ts +93 -21
- package/src/modes/components/omfg-panel.ts +141 -0
- package/src/modes/components/settings-defs.ts +9 -2
- package/src/modes/components/settings-selector.ts +4 -1
- package/src/modes/components/status-line/segments.ts +13 -5
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/tool-execution.ts +38 -19
- package/src/modes/components/tree-selector.ts +4 -3
- package/src/modes/components/user-message-selector.ts +94 -19
- package/src/modes/components/user-message.ts +8 -1
- package/src/modes/controllers/command-controller.ts +57 -0
- package/src/modes/controllers/event-controller.ts +65 -3
- package/src/modes/controllers/input-controller.ts +14 -11
- package/src/modes/controllers/omfg-controller.ts +283 -0
- package/src/modes/controllers/omfg-rule.ts +647 -0
- package/src/modes/controllers/selector-controller.ts +21 -6
- package/src/modes/gradient-highlight.ts +23 -6
- package/src/modes/interactive-mode.ts +41 -7
- package/src/modes/magic-keywords.ts +20 -0
- package/src/modes/markdown-prose.ts +247 -0
- package/src/modes/orchestrate.ts +17 -11
- package/src/modes/shared.ts +3 -11
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/turn-budget.ts +31 -0
- package/src/modes/types.ts +7 -1
- package/src/modes/ultrathink.ts +16 -10
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/modes/workflow.ts +42 -0
- package/src/prompts/system/auto-thinking-difficulty-local.md +14 -0
- package/src/prompts/system/auto-thinking-difficulty.md +12 -0
- package/src/prompts/system/omfg-user.md +51 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/system/workflow-notice.md +70 -0
- package/src/prompts/tools/eval.md +13 -1
- package/src/prompts/tools/memory-edit.md +1 -1
- package/src/sdk.ts +86 -38
- package/src/session/agent-session.ts +558 -80
- package/src/session/session-manager.ts +32 -0
- package/src/session/session-storage.ts +68 -8
- package/src/session/shake-types.ts +44 -0
- package/src/slash-commands/builtin-registry.ts +41 -16
- package/src/task/executor.ts +3 -3
- package/src/task/index.ts +6 -6
- package/src/thinking.ts +73 -1
- package/src/tiny/device.ts +4 -10
- package/src/tiny/models.ts +54 -2
- package/src/tiny/title-protocol.ts +11 -1
- package/src/tiny/worker.ts +19 -7
- package/src/tools/eval.ts +202 -26
- package/src/tools/grouped-file-output.ts +9 -2
- package/src/tools/index.ts +17 -5
- package/src/tools/memory-edit.ts +4 -4
- package/src/tools/memory-recall.ts +5 -5
- package/src/tools/memory-reflect.ts +5 -5
- package/src/tools/memory-retain.ts +4 -4
- package/src/tools/render-utils.ts +2 -1
- package/src/tools/search.ts +480 -76
- package/dist/types/mnemosyne/backend.d.ts +0 -4
- package/dist/types/mnemosyne/config.d.ts +0 -29
- package/dist/types/mnemosyne/state.d.ts +0 -72
- /package/dist/types/{mnemosyne → mnemopi}/index.d.ts +0 -0
- /package/src/{mnemosyne → mnemopi}/index.ts +0 -0
|
@@ -10,18 +10,44 @@ interface ConfigSchemaError {
|
|
|
10
10
|
message: string | undefined;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Module-private cache of (jsonPath, ymlPath) pairs we already migrated this
|
|
15
|
+
* process. Prevents `ConfigFile.relocate()` / repeated `tryLoad()` calls from
|
|
16
|
+
* re-running the migration over and over on the boot path.
|
|
17
|
+
*/
|
|
18
|
+
const migratedPaths = new Set<string>();
|
|
19
|
+
|
|
20
|
+
function migrationKey(jsonPath: string, ymlPath: string): string {
|
|
21
|
+
return `${jsonPath}\u0000${ymlPath}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Synchronous JSON → YAML migration kept for callers that still want the
|
|
26
|
+
* eager path (settings init, tests that observe migration completion).
|
|
27
|
+
* Idempotent — re-running is a no-op.
|
|
28
|
+
*/
|
|
13
29
|
function migrateJsonToYml(jsonPath: string, ymlPath: string) {
|
|
30
|
+
const key = migrationKey(jsonPath, ymlPath);
|
|
31
|
+
if (migratedPaths.has(key)) return;
|
|
14
32
|
try {
|
|
15
|
-
if (fs.existsSync(ymlPath))
|
|
16
|
-
|
|
33
|
+
if (fs.existsSync(ymlPath)) {
|
|
34
|
+
migratedPaths.add(key);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!fs.existsSync(jsonPath)) {
|
|
38
|
+
migratedPaths.add(key);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
17
41
|
|
|
18
42
|
const content = fs.readFileSync(jsonPath, "utf-8");
|
|
19
43
|
const parsed = JSON.parse(content);
|
|
20
44
|
if (!parsed) {
|
|
21
45
|
logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath });
|
|
46
|
+
migratedPaths.add(key);
|
|
22
47
|
return;
|
|
23
48
|
}
|
|
24
49
|
fs.writeFileSync(ymlPath, YAML.stringify(parsed, null, 2));
|
|
50
|
+
migratedPaths.add(key);
|
|
25
51
|
} catch (error) {
|
|
26
52
|
logger.warn("migrateJsonToYml: migration failed", { error: String(error) });
|
|
27
53
|
}
|
|
@@ -97,6 +123,7 @@ export type LoadResult<T> =
|
|
|
97
123
|
|
|
98
124
|
export class ConfigFile<T> implements IConfigFile<T> {
|
|
99
125
|
readonly #basePath: string;
|
|
126
|
+
readonly #jsonMigrationPath: string | null;
|
|
100
127
|
#cache?: LoadResult<T>;
|
|
101
128
|
#auxValidate?: (value: T) => void;
|
|
102
129
|
|
|
@@ -107,22 +134,32 @@ export class ConfigFile<T> implements IConfigFile<T> {
|
|
|
107
134
|
) {
|
|
108
135
|
this.#basePath = configPath;
|
|
109
136
|
if (configPath.endsWith(".yml")) {
|
|
110
|
-
|
|
111
|
-
migrateJsonToYml(jsonPath, configPath);
|
|
137
|
+
this.#jsonMigrationPath = `${configPath.slice(0, -4)}.json`;
|
|
112
138
|
} else if (configPath.endsWith(".yaml")) {
|
|
113
|
-
|
|
114
|
-
migrateJsonToYml(jsonPath, configPath);
|
|
139
|
+
this.#jsonMigrationPath = `${configPath.slice(0, -5)}.json`;
|
|
115
140
|
} else if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
|
|
116
141
|
// JSON configs are still supported without migration.
|
|
142
|
+
this.#jsonMigrationPath = null;
|
|
117
143
|
} else {
|
|
118
144
|
throw new Error(`Invalid config file path: ${configPath}`);
|
|
119
145
|
}
|
|
120
146
|
}
|
|
121
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Run the JSON → YAML migration synchronously, if applicable. Idempotent.
|
|
150
|
+
* Sync callers (tests, settings init) hit this implicitly via {@link tryLoad}.
|
|
151
|
+
*/
|
|
152
|
+
#ensureMigrated(): void {
|
|
153
|
+
if (this.#jsonMigrationPath) {
|
|
154
|
+
migrateJsonToYml(this.#jsonMigrationPath, this.#basePath);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
122
158
|
relocate(configPath?: string): ConfigFile<T> {
|
|
123
159
|
if (!configPath || configPath === this.#basePath) return this;
|
|
124
160
|
const result = new ConfigFile<T>(this.id, this.schema, configPath);
|
|
125
161
|
result.#auxValidate = this.#auxValidate;
|
|
162
|
+
result.#ensureMigrated();
|
|
126
163
|
return result;
|
|
127
164
|
}
|
|
128
165
|
|
|
@@ -135,6 +172,13 @@ export class ConfigFile<T> implements IConfigFile<T> {
|
|
|
135
172
|
}
|
|
136
173
|
}
|
|
137
174
|
|
|
175
|
+
async getMtimeMsAsync(): Promise<number | null> {
|
|
176
|
+
const file = Bun.file(this.path());
|
|
177
|
+
if (!(await file.exists())) return null;
|
|
178
|
+
const lm = file.lastModified;
|
|
179
|
+
return typeof lm === "number" && Number.isFinite(lm) ? lm : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
138
182
|
withValidation(name: string, validate: (value: T) => void): this {
|
|
139
183
|
const prev = this.#auxValidate;
|
|
140
184
|
this.#auxValidate = (value: T) => {
|
|
@@ -164,12 +208,8 @@ export class ConfigFile<T> implements IConfigFile<T> {
|
|
|
164
208
|
return result;
|
|
165
209
|
}
|
|
166
210
|
|
|
167
|
-
|
|
168
|
-
if (this.#cache) return this.#cache;
|
|
169
|
-
|
|
211
|
+
#parseContent(content: string): LoadResult<T> {
|
|
170
212
|
try {
|
|
171
|
-
const content = fs.readFileSync(this.path(), "utf-8").trim();
|
|
172
|
-
|
|
173
213
|
let parsed: unknown;
|
|
174
214
|
if (this.#basePath.endsWith(".json") || this.#basePath.endsWith(".jsonc")) {
|
|
175
215
|
parsed = JSONC.parse(content);
|
|
@@ -202,26 +242,71 @@ export class ConfigFile<T> implements IConfigFile<T> {
|
|
|
202
242
|
return this.#storeCache({ error: wrapped, status: "error" });
|
|
203
243
|
}
|
|
204
244
|
return this.#storeCache({ value, status: "ok" });
|
|
245
|
+
} catch (error) {
|
|
246
|
+
logger.warn("Failed to parse config file", { path: this.path(), error });
|
|
247
|
+
return this.#storeCache({
|
|
248
|
+
error: new ConfigError(this.id, undefined, { err: error, stage: "Unexpected" }),
|
|
249
|
+
status: "error",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
tryLoad(): LoadResult<T> {
|
|
255
|
+
if (this.#cache) return this.#cache;
|
|
256
|
+
this.#ensureMigrated();
|
|
257
|
+
|
|
258
|
+
let content: string;
|
|
259
|
+
try {
|
|
260
|
+
content = fs.readFileSync(this.path(), "utf-8").trim();
|
|
205
261
|
} catch (error) {
|
|
206
262
|
if (isEnoent(error)) {
|
|
207
263
|
return this.#storeCache({ status: "not-found" });
|
|
208
264
|
}
|
|
209
|
-
logger.warn("Failed to
|
|
265
|
+
logger.warn("Failed to read config file", { path: this.path(), error });
|
|
210
266
|
return this.#storeCache({
|
|
211
|
-
error: new ConfigError(this.id, undefined, { err: error, stage: "
|
|
267
|
+
error: new ConfigError(this.id, undefined, { err: error, stage: "Read" }),
|
|
268
|
+
status: "error",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return this.#parseContent(content);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async tryLoadAsync(): Promise<LoadResult<T>> {
|
|
275
|
+
if (this.#cache) return this.#cache;
|
|
276
|
+
this.#ensureMigrated();
|
|
277
|
+
|
|
278
|
+
let content: string;
|
|
279
|
+
try {
|
|
280
|
+
content = (await Bun.file(this.path()).text()).trim();
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (isEnoent(error)) {
|
|
283
|
+
return this.#storeCache({ status: "not-found" });
|
|
284
|
+
}
|
|
285
|
+
logger.warn("Failed to read config file", { path: this.path(), error });
|
|
286
|
+
return this.#storeCache({
|
|
287
|
+
error: new ConfigError(this.id, undefined, { err: error, stage: "Read" }),
|
|
212
288
|
status: "error",
|
|
213
289
|
});
|
|
214
290
|
}
|
|
291
|
+
return this.#parseContent(content);
|
|
215
292
|
}
|
|
216
293
|
|
|
217
294
|
load(): T | null {
|
|
218
295
|
return this.tryLoad().value ?? null;
|
|
219
296
|
}
|
|
220
297
|
|
|
298
|
+
async loadAsync(): Promise<T | null> {
|
|
299
|
+
return (await this.tryLoadAsync()).value ?? null;
|
|
300
|
+
}
|
|
301
|
+
|
|
221
302
|
loadOrDefault(): T {
|
|
222
303
|
return this.tryLoad().value ?? this.createDefault();
|
|
223
304
|
}
|
|
224
305
|
|
|
306
|
+
async loadOrDefaultAsync(): Promise<T> {
|
|
307
|
+
return (await this.tryLoadAsync()).value ?? this.createDefault();
|
|
308
|
+
}
|
|
309
|
+
|
|
225
310
|
path(): string {
|
|
226
311
|
return this.#basePath;
|
|
227
312
|
}
|
package/src/config/file-lock.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs/promises";
|
|
2
|
-
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
3
4
|
|
|
4
5
|
export interface FileLockOptions {
|
|
5
6
|
staleMs?: number;
|
|
@@ -16,14 +17,15 @@ const DEFAULT_OPTIONS: Required<FileLockOptions> = {
|
|
|
16
17
|
interface LockInfo {
|
|
17
18
|
pid: number;
|
|
18
19
|
timestamp: number;
|
|
20
|
+
token: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function getLockPath(filePath: string): string {
|
|
22
24
|
return `${filePath}.lock`;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
async function writeLockInfo(lockPath: string): Promise<void> {
|
|
26
|
-
const info: LockInfo = { pid: process.pid, timestamp: Date.now() };
|
|
27
|
+
async function writeLockInfo(lockPath: string, token: string): Promise<void> {
|
|
28
|
+
const info: LockInfo = { pid: process.pid, timestamp: Date.now(), token };
|
|
27
29
|
await Bun.write(`${lockPath}/info`, JSON.stringify(info));
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -47,33 +49,58 @@ function isProcessAlive(pid: number): boolean {
|
|
|
47
49
|
|
|
48
50
|
async function isLockStale(lockPath: string, staleMs: number): Promise<boolean> {
|
|
49
51
|
const info = await readLockInfo(lockPath);
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
if (info) {
|
|
53
|
+
if (!isProcessAlive(info.pid)) return true;
|
|
54
|
+
if (Date.now() - info.timestamp > staleMs) return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
// No info file. Either the lock holder is between mkdir and writeLockInfo
|
|
59
|
+
// (fresh dir, do not reap) or the dir was already removed (also do not
|
|
60
|
+
// reap — there is nothing to clean up, and an unguarded fs.rm here would
|
|
61
|
+
// race with another contender's successful mkdir and wipe their dir).
|
|
62
|
+
try {
|
|
63
|
+
const stat = await fs.stat(lockPath);
|
|
64
|
+
return Date.now() - stat.mtimeMs > staleMs;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (isEnoent(err)) return false;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
57
69
|
}
|
|
58
70
|
|
|
59
|
-
async function tryAcquireLock(lockPath: string): Promise<
|
|
71
|
+
async function tryAcquireLock(lockPath: string): Promise<string | null> {
|
|
60
72
|
try {
|
|
61
73
|
await fs.mkdir(lockPath);
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
const token = randomUUID();
|
|
75
|
+
await writeLockInfo(lockPath, token);
|
|
76
|
+
return token;
|
|
64
77
|
} catch (error) {
|
|
65
78
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
66
|
-
return
|
|
79
|
+
return null;
|
|
67
80
|
}
|
|
68
81
|
throw error;
|
|
69
82
|
}
|
|
70
83
|
}
|
|
71
84
|
|
|
72
|
-
async function releaseLock(lockPath: string): Promise<void> {
|
|
85
|
+
async function releaseLock(lockPath: string, expectedToken?: string): Promise<void> {
|
|
73
86
|
try {
|
|
87
|
+
if (expectedToken !== undefined) {
|
|
88
|
+
const info = await readLockInfo(lockPath);
|
|
89
|
+
if (!info || info.token !== expectedToken) {
|
|
90
|
+
// We are not the owner. The lock either expired and was reaped
|
|
91
|
+
// or another process has reclaimed it. Do nothing — releasing
|
|
92
|
+
// here would wipe the rightful owner's lock.
|
|
93
|
+
logger.debug("file-lock: skipping release for non-owned lock", {
|
|
94
|
+
lockPath,
|
|
95
|
+
expectedToken,
|
|
96
|
+
actualToken: info?.token,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
74
101
|
await fs.rm(lockPath, { recursive: true });
|
|
75
102
|
} catch {
|
|
76
|
-
// Ignore errors on release
|
|
103
|
+
// Ignore errors on release.
|
|
77
104
|
}
|
|
78
105
|
}
|
|
79
106
|
|
|
@@ -92,11 +119,14 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
|
|
|
92
119
|
const lockPath = getLockPath(filePath);
|
|
93
120
|
|
|
94
121
|
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
95
|
-
|
|
96
|
-
|
|
122
|
+
const token = await tryAcquireLock(lockPath);
|
|
123
|
+
if (token !== null) {
|
|
124
|
+
return () => releaseLock(lockPath, token);
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
if ((await lockExists(lockPath)) && (await isLockStale(lockPath, opts.staleMs))) {
|
|
128
|
+
// Reaping a stale lock — no token because we didn't acquire it. The
|
|
129
|
+
// rightful owner is presumed dead; rm without ownership check.
|
|
100
130
|
await releaseLock(lockPath);
|
|
101
131
|
continue;
|
|
102
132
|
}
|
|
@@ -119,3 +149,16 @@ export async function withFileLock<T>(
|
|
|
119
149
|
await release();
|
|
120
150
|
}
|
|
121
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Test-only handles for the internal lock primitives. These are NOT part of
|
|
155
|
+
* the public API — they exist so the contract tests can validate token-keyed
|
|
156
|
+
* release semantics and the mkdir-race window without re-implementing them.
|
|
157
|
+
*/
|
|
158
|
+
export const __internalsForTesting = {
|
|
159
|
+
tryAcquireLock,
|
|
160
|
+
releaseLock,
|
|
161
|
+
readLockInfo,
|
|
162
|
+
isLockStale,
|
|
163
|
+
getLockPath,
|
|
164
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
type Keybinding,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
KeybindingsManager as TuiKeybindingsManager,
|
|
11
11
|
} from "@oh-my-pi/pi-tui";
|
|
12
12
|
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
13
|
+
import { YAML } from "bun";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Application-level keybindings (coding agent specific).
|
|
@@ -330,17 +331,29 @@ function orderKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig {
|
|
|
330
331
|
return ordered;
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
const KEYBINDINGS_YML = "keybindings.yml";
|
|
335
|
+
const KEYBINDINGS_YAML = "keybindings.yaml";
|
|
336
|
+
const LEGACY_KEYBINDINGS_JSON = "keybindings.json";
|
|
337
|
+
|
|
338
|
+
interface KeybindingsConfigPaths {
|
|
339
|
+
readPath: string;
|
|
340
|
+
writeBackPath: string;
|
|
341
|
+
}
|
|
342
|
+
|
|
333
343
|
/**
|
|
334
344
|
* Load raw config from a file synchronously.
|
|
335
|
-
* Returns parsed JSON or null if file doesn't exist or is invalid.
|
|
345
|
+
* Returns parsed JSON/YAML or null if file doesn't exist or is invalid.
|
|
336
346
|
*/
|
|
337
347
|
function loadRawConfig(filePath: string): unknown {
|
|
338
348
|
try {
|
|
339
|
-
|
|
340
|
-
|
|
349
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
350
|
+
if (filePath.endsWith(".json")) {
|
|
351
|
+
return JSON.parse(content);
|
|
352
|
+
}
|
|
353
|
+
if (filePath.endsWith(".yml") || filePath.endsWith(".yaml")) {
|
|
354
|
+
return YAML.parse(content);
|
|
341
355
|
}
|
|
342
|
-
|
|
343
|
-
return JSON.parse(content);
|
|
356
|
+
throw new Error(`Unsupported keybindings config extension: ${filePath}`);
|
|
344
357
|
} catch (error) {
|
|
345
358
|
if (isEnoent(error)) {
|
|
346
359
|
return null;
|
|
@@ -350,34 +363,67 @@ function loadRawConfig(filePath: string): unknown {
|
|
|
350
363
|
}
|
|
351
364
|
}
|
|
352
365
|
|
|
366
|
+
function writeKeybindingsConfig(filePath: string, config: KeybindingsConfig): boolean {
|
|
367
|
+
try {
|
|
368
|
+
fs.writeFileSync(filePath, YAML.stringify(config, null, 2), "utf-8");
|
|
369
|
+
logger.debug("Migrated keybindings config", { path: filePath });
|
|
370
|
+
return true;
|
|
371
|
+
} catch (error) {
|
|
372
|
+
logger.warn("Failed to write migrated keybindings config", { path: filePath, error: String(error) });
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function resolveKeybindingsConfigPaths(agentDir: string): KeybindingsConfigPaths {
|
|
378
|
+
const ymlPath = path.join(agentDir, KEYBINDINGS_YML);
|
|
379
|
+
if (fs.existsSync(ymlPath)) {
|
|
380
|
+
return { readPath: ymlPath, writeBackPath: ymlPath };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const yamlPath = path.join(agentDir, KEYBINDINGS_YAML);
|
|
384
|
+
if (fs.existsSync(yamlPath)) {
|
|
385
|
+
return { readPath: yamlPath, writeBackPath: yamlPath };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const jsonPath = path.join(agentDir, LEGACY_KEYBINDINGS_JSON);
|
|
389
|
+
if (fs.existsSync(jsonPath)) {
|
|
390
|
+
return { readPath: jsonPath, writeBackPath: ymlPath };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return { readPath: ymlPath, writeBackPath: ymlPath };
|
|
394
|
+
}
|
|
395
|
+
|
|
353
396
|
/**
|
|
354
|
-
*
|
|
355
|
-
*
|
|
397
|
+
* Load and migrate keybindings config.
|
|
398
|
+
* Legacy JSON is read for compatibility, but successful write-back goes to YAML.
|
|
356
399
|
*/
|
|
357
|
-
function loadKeybindingsConfig(
|
|
400
|
+
function loadKeybindingsConfig(
|
|
401
|
+
filePath: string,
|
|
402
|
+
writeBackPath: string | undefined,
|
|
403
|
+
): {
|
|
404
|
+
config: KeybindingsConfig;
|
|
405
|
+
persistedPath: string;
|
|
406
|
+
} {
|
|
358
407
|
const rawConfig = loadRawConfig(filePath);
|
|
359
408
|
|
|
360
409
|
if (rawConfig === null) {
|
|
361
|
-
return {};
|
|
410
|
+
return { config: {}, persistedPath: filePath };
|
|
362
411
|
}
|
|
363
412
|
|
|
364
413
|
const { config: migratedConfig, migrated } = migrateKeybindingNames(rawConfig);
|
|
365
|
-
|
|
414
|
+
const shouldWriteBack = writeBackPath !== undefined && (migrated || writeBackPath !== filePath);
|
|
415
|
+
if (shouldWriteBack) {
|
|
366
416
|
const ordered = orderKeybindingsConfig(migratedConfig);
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
logger.debug("Migrated keybindings config", { path: filePath });
|
|
370
|
-
} catch (error) {
|
|
371
|
-
logger.warn("Failed to write migrated keybindings config", { path: filePath, error: String(error) });
|
|
372
|
-
}
|
|
417
|
+
const persistedPath = writeKeybindingsConfig(writeBackPath, ordered) ? writeBackPath : filePath;
|
|
418
|
+
return { config: migratedConfig, persistedPath };
|
|
373
419
|
}
|
|
374
420
|
|
|
375
|
-
return migratedConfig;
|
|
421
|
+
return { config: migratedConfig, persistedPath: filePath };
|
|
376
422
|
}
|
|
377
423
|
|
|
378
424
|
function migrateKeybindingsConfigFile(agentDir: string): void {
|
|
379
|
-
const
|
|
380
|
-
loadKeybindingsConfig(
|
|
425
|
+
const { readPath, writeBackPath } = resolveKeybindingsConfigPaths(agentDir);
|
|
426
|
+
loadKeybindingsConfig(readPath, writeBackPath);
|
|
381
427
|
}
|
|
382
428
|
|
|
383
429
|
/**
|
|
@@ -393,12 +439,13 @@ export class KeybindingsManager extends TuiKeybindingsManager {
|
|
|
393
439
|
}
|
|
394
440
|
|
|
395
441
|
/**
|
|
396
|
-
* Create from config file at agentDir/keybindings.
|
|
442
|
+
* Create from config file at agentDir/keybindings.yml.
|
|
443
|
+
* Legacy keybindings.json is migrated to keybindings.yml on load.
|
|
397
444
|
*/
|
|
398
445
|
static create(agentDir: string = getAgentDir()): KeybindingsManager {
|
|
399
|
-
const
|
|
400
|
-
const userBindings = KeybindingsManager.#loadFromFile(
|
|
401
|
-
const manager = new KeybindingsManager(userBindings,
|
|
446
|
+
const { readPath, writeBackPath } = resolveKeybindingsConfigPaths(agentDir);
|
|
447
|
+
const { config: userBindings, persistedPath } = KeybindingsManager.#loadFromFile(readPath, writeBackPath);
|
|
448
|
+
const manager = new KeybindingsManager(userBindings, persistedPath);
|
|
402
449
|
// Set globally so getKeybindings() returns this manager
|
|
403
450
|
setKeybindings(manager);
|
|
404
451
|
return manager;
|
|
@@ -416,7 +463,8 @@ export class KeybindingsManager extends TuiKeybindingsManager {
|
|
|
416
463
|
*/
|
|
417
464
|
reload(): void {
|
|
418
465
|
if (!this.#configPath) return;
|
|
419
|
-
|
|
466
|
+
const { config } = KeybindingsManager.#loadFromFile(this.#configPath);
|
|
467
|
+
this.setUserBindings(config);
|
|
420
468
|
}
|
|
421
469
|
|
|
422
470
|
/**
|
|
@@ -437,8 +485,11 @@ export class KeybindingsManager extends TuiKeybindingsManager {
|
|
|
437
485
|
/**
|
|
438
486
|
* Load user bindings from a file, migrating old names if needed.
|
|
439
487
|
*/
|
|
440
|
-
static #loadFromFile(
|
|
441
|
-
|
|
488
|
+
static #loadFromFile(
|
|
489
|
+
filePath: string,
|
|
490
|
+
writeBackPath?: string,
|
|
491
|
+
): { config: KeybindingsConfig; persistedPath: string } {
|
|
492
|
+
return loadKeybindingsConfig(filePath, writeBackPath);
|
|
442
493
|
}
|
|
443
494
|
}
|
|
444
495
|
|
|
@@ -851,6 +851,12 @@ export class ModelRegistry {
|
|
|
851
851
|
|
|
852
852
|
/**
|
|
853
853
|
* @param authStorage - Auth storage for API key resolution
|
|
854
|
+
*
|
|
855
|
+
* Sync constructor — eagerly loads bundled + cached models so tests and
|
|
856
|
+
* synchronous callers see a fully-populated registry immediately. Production
|
|
857
|
+
* boot paths SHOULD prefer {@link ModelRegistry.create} so the YAML/JSONC
|
|
858
|
+
* migration step lands off the event loop's hot path before the first
|
|
859
|
+
* `tryLoad()` runs.
|
|
854
860
|
*/
|
|
855
861
|
constructor(
|
|
856
862
|
readonly authStorage: AuthStorage,
|
|
@@ -866,7 +872,7 @@ export class ModelRegistry {
|
|
|
866
872
|
}
|
|
867
873
|
return undefined;
|
|
868
874
|
});
|
|
869
|
-
// Load models synchronously in constructor
|
|
875
|
+
// Load models synchronously in constructor.
|
|
870
876
|
this.#loadModels();
|
|
871
877
|
}
|
|
872
878
|
|