@henryqw/pi-herdr-btw 1.1.1 → 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 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
- orderedProfileRoutes,
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
- config = readTaskModelsConfig();
102
- } catch {
103
- throw new Error("Couldn't read task model config. Run /task-models.");
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>((command, args, options) =>
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>((command, args, options) =>
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:
@@ -1,7 +1,7 @@
1
- import { randomUUID } from "node:crypto";
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 mkdir(lockPath, { mode: 0o700 });
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 writeFile(ownerPath, token, { encoding: "utf8", flag: "wx", mode: 0o600 });
183
- } catch (error) {
184
- await rm(ownerPath, { force: true }).catch(() => undefined);
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
- if (!error || typeof error !== "object" || (error as NodeJS.ErrnoException).code !== "EEXIST") {
204
- throw error;
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.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.1.1",
50
- "@henryqw/pi-task-models": "^0.5.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
  }