@henryqw/pi-herdr-btw 1.1.0 → 1.1.2
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/extensions/btw.ts +14 -20
- package/internal/config.ts +14 -80
- package/package.json +7 -3
package/extensions/btw.ts
CHANGED
|
@@ -2,10 +2,9 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { createHerdrClient, hasHerdrErrorCode } from "@henryqw/pi-herdr";
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
readTaskModelsConfig,
|
|
7
|
-
resolveTaskModelRoute,
|
|
5
|
+
resolveConfiguredTaskRoutes,
|
|
8
6
|
type ResolvedTaskRoute,
|
|
7
|
+
type TaskRouteError,
|
|
9
8
|
} from "@henryqw/pi-task-models";
|
|
10
9
|
import {
|
|
11
10
|
buildSessionContext,
|
|
@@ -51,7 +50,6 @@ import {
|
|
|
51
50
|
import { HELP_TEXT, parseBtwCommand } from "../internal/router.ts";
|
|
52
51
|
|
|
53
52
|
const BTW_TASK = "pi-herdr-btw/btw";
|
|
54
|
-
const DEFAULT_BTW_PROFILE = "fast" as const;
|
|
55
53
|
const CHILD_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1_000;
|
|
56
54
|
const LITERAL_DRAFT_PREFIX = "\u200b";
|
|
57
55
|
const MERGE_POLL_INTERVAL_MS = 3_000;
|
|
@@ -96,20 +94,18 @@ function sameStringArray(a: string[], b: string[]): boolean {
|
|
|
96
94
|
}
|
|
97
95
|
|
|
98
96
|
function configuredBtwRoutes(ctx: ExtensionContext): ResolvedTaskRoute[] {
|
|
99
|
-
let config;
|
|
100
97
|
try {
|
|
101
|
-
|
|
102
|
-
} catch {
|
|
103
|
-
|
|
98
|
+
return resolveConfiguredTaskRoutes(ctx, BTW_TASK);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const { taskRouteCode, profileName } = error as TaskRouteError;
|
|
101
|
+
throw new Error(
|
|
102
|
+
taskRouteCode === "profile-missing"
|
|
103
|
+
? `BTW task profile ${profileName} is not configured. Run /task-models.`
|
|
104
|
+
: taskRouteCode === "no-route"
|
|
105
|
+
? `BTW task profile ${profileName} has no available route. Run /task-models.`
|
|
106
|
+
: "Couldn't read task model config. Run /task-models.",
|
|
107
|
+
);
|
|
104
108
|
}
|
|
105
|
-
const profileName = config.tasks[BTW_TASK] ?? DEFAULT_BTW_PROFILE;
|
|
106
|
-
const profile = config.profiles[profileName];
|
|
107
|
-
if (!profile) throw new Error(`BTW task profile ${profileName} is not configured. Run /task-models.`);
|
|
108
|
-
const routes = orderedProfileRoutes(profile)
|
|
109
|
-
.map((route) => resolveTaskModelRoute(ctx, route))
|
|
110
|
-
.filter((route): route is ResolvedTaskRoute => route !== undefined);
|
|
111
|
-
if (!routes.length) throw new Error(`BTW task profile ${profileName} has no available route. Run /task-models.`);
|
|
112
|
-
return routes;
|
|
113
109
|
}
|
|
114
110
|
|
|
115
111
|
/**
|
|
@@ -140,8 +136,7 @@ async function configureChild(
|
|
|
140
136
|
store: ContextStorePort,
|
|
141
137
|
payloadPath: string,
|
|
142
138
|
): Promise<void> {
|
|
143
|
-
const herdr = createHerdrClient<HerdrOptions>((
|
|
144
|
-
pi.exec(command, [...args], options));
|
|
139
|
+
const herdr = createHerdrClient<HerdrOptions>(pi.exec.bind(pi));
|
|
145
140
|
let payload: BtwPayload | undefined;
|
|
146
141
|
let payloadError: string | undefined;
|
|
147
142
|
|
|
@@ -410,8 +405,7 @@ export async function registerBtwExtension(
|
|
|
410
405
|
}
|
|
411
406
|
|
|
412
407
|
const configStore = options.configStore ?? new ConfigStore();
|
|
413
|
-
const herdr = createHerdrClient<HerdrOptions>((
|
|
414
|
-
pi.exec(command, [...args], options));
|
|
408
|
+
const herdr = createHerdrClient<HerdrOptions>(pi.exec.bind(pi));
|
|
415
409
|
|
|
416
410
|
// --- Parent-side merge coordination ---------------------------------
|
|
417
411
|
let sessionCtx:
|
package/internal/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { chmod, lstat, mkdir, readFile, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
2
|
import { dirname, join } from "node:path";
|
|
4
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { lock } from "proper-lockfile";
|
|
5
5
|
|
|
6
6
|
export const TOOL_MODES = ["inherit", "all", "read-only", "none"] as const;
|
|
7
7
|
export type BtwToolMode = (typeof TOOL_MODES)[number];
|
|
@@ -16,6 +16,7 @@ export type BtwConfig = {
|
|
|
16
16
|
const CONFIG_LOCK_STALE_MS = 30_000;
|
|
17
17
|
const CONFIG_LOCK_WAIT_MS = 10_000;
|
|
18
18
|
const CONFIG_LOCK_RETRY_MS = 25;
|
|
19
|
+
const CONFIG_LOCK_UPDATE_MS = 5_000;
|
|
19
20
|
|
|
20
21
|
export const DEFAULT_CONFIG: Readonly<BtwConfig> = Object.freeze({
|
|
21
22
|
autoSubmit: false,
|
|
@@ -160,94 +161,27 @@ export class ConfigStore {
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
private async withLock<T>(operation: () => Promise<T>): Promise<T> {
|
|
163
|
-
const release = await this.acquireLock();
|
|
164
|
-
try {
|
|
165
|
-
return await operation();
|
|
166
|
-
} finally {
|
|
167
|
-
await release();
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private async acquireLock(): Promise<() => Promise<void>> {
|
|
172
|
-
const lockPath = `${this.path}.lock`;
|
|
173
164
|
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
174
|
-
const token = randomUUID();
|
|
175
|
-
const ownerPath = join(lockPath, `owner-${token}`);
|
|
176
165
|
const deadline = Date.now() + CONFIG_LOCK_WAIT_MS;
|
|
177
|
-
|
|
178
166
|
while (true) {
|
|
179
167
|
try {
|
|
180
|
-
await
|
|
168
|
+
const release = await lock(this.path, {
|
|
169
|
+
lockfilePath: `${this.path}.lock`,
|
|
170
|
+
realpath: false,
|
|
171
|
+
stale: CONFIG_LOCK_STALE_MS,
|
|
172
|
+
update: CONFIG_LOCK_UPDATE_MS,
|
|
173
|
+
});
|
|
181
174
|
try {
|
|
182
|
-
await
|
|
183
|
-
}
|
|
184
|
-
await
|
|
185
|
-
await rmdir(lockPath).catch(() => undefined);
|
|
186
|
-
throw error;
|
|
175
|
+
return await operation();
|
|
176
|
+
} finally {
|
|
177
|
+
await release();
|
|
187
178
|
}
|
|
188
|
-
return async () => {
|
|
189
|
-
if ((await readFile(ownerPath, "utf8").catch(() => undefined)) !== token) return;
|
|
190
|
-
try {
|
|
191
|
-
await rm(ownerPath);
|
|
192
|
-
} catch (error) {
|
|
193
|
-
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
194
|
-
if (code === "ENOENT") return;
|
|
195
|
-
throw error;
|
|
196
|
-
}
|
|
197
|
-
await rmdir(lockPath).catch((error) => {
|
|
198
|
-
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
199
|
-
if (code !== "ENOENT" && code !== "ENOTEMPTY") throw error;
|
|
200
|
-
});
|
|
201
|
-
};
|
|
202
179
|
} catch (error) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
const info = await lstat(lockPath).catch(() => undefined);
|
|
207
|
-
if (!info || Date.now() - info.mtimeMs > CONFIG_LOCK_STALE_MS) {
|
|
208
|
-
if (!info) continue;
|
|
209
|
-
const ownerName = await this.readOwnerName(lockPath);
|
|
210
|
-
const currentInfo = await lstat(lockPath).catch(() => undefined);
|
|
211
|
-
const currentOwnerName = await this.readOwnerName(lockPath);
|
|
212
|
-
if (
|
|
213
|
-
!currentInfo ||
|
|
214
|
-
currentInfo.dev !== info.dev ||
|
|
215
|
-
currentInfo.ino !== info.ino ||
|
|
216
|
-
currentInfo.ctimeMs !== info.ctimeMs ||
|
|
217
|
-
currentOwnerName !== ownerName
|
|
218
|
-
) {
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
// Remove only observed owner's unique entry. Only the process that
|
|
222
|
-
// removes that entry may remove lockPath; this prevents a stale
|
|
223
|
-
// reclaimer from deleting a replacement created in between.
|
|
224
|
-
if (!ownerName) {
|
|
225
|
-
if (Date.now() >= deadline) throw new Error(`Timed out waiting for config lock: ${this.path}`);
|
|
226
|
-
await new Promise((resolve) => setTimeout(resolve, CONFIG_LOCK_RETRY_MS));
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
try {
|
|
230
|
-
await rm(join(lockPath, ownerName));
|
|
231
|
-
} catch (error) {
|
|
232
|
-
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
233
|
-
if (code === "ENOENT") continue;
|
|
234
|
-
throw error;
|
|
235
|
-
}
|
|
236
|
-
await rmdir(lockPath).catch((error) => {
|
|
237
|
-
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
238
|
-
if (code !== "ENOENT" && code !== "ENOTEMPTY") throw error;
|
|
239
|
-
});
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
180
|
+
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
181
|
+
if (code !== "ELOCKED") throw error;
|
|
242
182
|
if (Date.now() >= deadline) throw new Error(`Timed out waiting for config lock: ${this.path}`);
|
|
243
183
|
await new Promise((resolve) => setTimeout(resolve, CONFIG_LOCK_RETRY_MS));
|
|
244
184
|
}
|
|
245
185
|
}
|
|
246
186
|
}
|
|
247
|
-
|
|
248
|
-
private async readOwnerName(lockPath: string): Promise<string | undefined> {
|
|
249
|
-
const entries = await readdir(lockPath, { withFileTypes: true }).catch(() => []);
|
|
250
|
-
const owners = entries.filter((entry) => entry.isFile() && entry.name.startsWith("owner-"));
|
|
251
|
-
return owners.length === 1 ? owners[0]?.name : undefined;
|
|
252
|
-
}
|
|
253
187
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-herdr-btw",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Open and merge tool-enabled Pi side threads in Herdr panes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -46,7 +46,11 @@
|
|
|
46
46
|
]
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@henryqw/pi-herdr": "^0.
|
|
50
|
-
"@henryqw/pi-task-models": "^0.
|
|
49
|
+
"@henryqw/pi-herdr": "^0.3.0",
|
|
50
|
+
"@henryqw/pi-task-models": "^0.7.0",
|
|
51
|
+
"proper-lockfile": "^4.1.2"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/proper-lockfile": "^4.1.4"
|
|
51
55
|
}
|
|
52
56
|
}
|