@henryqw/pi-herdr-btw 1.1.1 → 1.1.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/extensions/btw.ts +16 -30
- package/internal/config.ts +14 -85
- 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;
|
|
@@ -78,7 +76,7 @@ export type ContextStorePort = Pick<
|
|
|
78
76
|
| "readMergeRequest"
|
|
79
77
|
| "removeIfNoPendingMerge"
|
|
80
78
|
>;
|
|
81
|
-
export type ConfigStorePort = Pick<ConfigStore, "load" | "
|
|
79
|
+
export type ConfigStorePort = Pick<ConfigStore, "load" | "update" | "reset">;
|
|
82
80
|
|
|
83
81
|
const SIDE_PANE_INSTRUCTIONS = `You are running in a focused /btw side pane spawned from another Pi session.
|
|
84
82
|
|
|
@@ -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:
|
|
@@ -688,15 +682,7 @@ export async function registerBtwExtension(
|
|
|
688
682
|
return;
|
|
689
683
|
}
|
|
690
684
|
const splitOutcome = classifyLaunchResult(splitResult);
|
|
691
|
-
if (splitOutcome
|
|
692
|
-
await store.remove(payloadPath);
|
|
693
|
-
ctx.ui.notify(
|
|
694
|
-
`/btw failed: ${safeErrorText(splitResult.stdout, splitResult.stderr)}`,
|
|
695
|
-
"error",
|
|
696
|
-
);
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
if (splitOutcome === "ambiguous") {
|
|
685
|
+
if (splitOutcome !== "success") {
|
|
700
686
|
await store.remove(payloadPath);
|
|
701
687
|
ctx.ui.notify(
|
|
702
688
|
`/btw failed: ${safeErrorText(splitResult.stdout, splitResult.stderr)}`,
|
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,
|
|
@@ -121,11 +122,6 @@ export class ConfigStore {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
async save(config: BtwConfig): Promise<void> {
|
|
125
|
-
const validated = parseConfig(config);
|
|
126
|
-
await this.withLock(() => this.saveUnlocked(validated));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
125
|
async update(mutator: (config: BtwConfig) => BtwConfig): Promise<BtwConfig> {
|
|
130
126
|
return this.withLock(async () => {
|
|
131
127
|
const next = parseConfig(mutator(await this.load()));
|
|
@@ -160,94 +156,27 @@ export class ConfigStore {
|
|
|
160
156
|
}
|
|
161
157
|
|
|
162
158
|
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
159
|
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
174
|
-
const token = randomUUID();
|
|
175
|
-
const ownerPath = join(lockPath, `owner-${token}`);
|
|
176
160
|
const deadline = Date.now() + CONFIG_LOCK_WAIT_MS;
|
|
177
|
-
|
|
178
161
|
while (true) {
|
|
179
162
|
try {
|
|
180
|
-
await
|
|
163
|
+
const release = await lock(this.path, {
|
|
164
|
+
lockfilePath: `${this.path}.lock`,
|
|
165
|
+
realpath: false,
|
|
166
|
+
stale: CONFIG_LOCK_STALE_MS,
|
|
167
|
+
update: CONFIG_LOCK_UPDATE_MS,
|
|
168
|
+
});
|
|
181
169
|
try {
|
|
182
|
-
await
|
|
183
|
-
}
|
|
184
|
-
await
|
|
185
|
-
await rmdir(lockPath).catch(() => undefined);
|
|
186
|
-
throw error;
|
|
170
|
+
return await operation();
|
|
171
|
+
} finally {
|
|
172
|
+
await release();
|
|
187
173
|
}
|
|
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
174
|
} 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
|
-
}
|
|
175
|
+
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
176
|
+
if (code !== "ELOCKED") throw error;
|
|
242
177
|
if (Date.now() >= deadline) throw new Error(`Timed out waiting for config lock: ${this.path}`);
|
|
243
178
|
await new Promise((resolve) => setTimeout(resolve, CONFIG_LOCK_RETRY_MS));
|
|
244
179
|
}
|
|
245
180
|
}
|
|
246
181
|
}
|
|
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
182
|
}
|
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.3",
|
|
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
|
}
|