@henryqw/pi-herdr-btw 1.0.1
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/LICENSE +21 -0
- package/README.md +78 -0
- package/extensions/btw.ts +829 -0
- package/internal/config.ts +253 -0
- package/internal/context-store.ts +287 -0
- package/internal/core.ts +393 -0
- package/internal/merge.ts +317 -0
- package/internal/router.ts +42 -0
- package/package.json +52 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createHerdrClient, hasHerdrErrorCode } from "@henryqw/pi-herdr";
|
|
4
|
+
import {
|
|
5
|
+
orderedProfileRoutes,
|
|
6
|
+
readTaskModelsConfig,
|
|
7
|
+
resolveTaskModelRoute,
|
|
8
|
+
type ResolvedTaskRoute,
|
|
9
|
+
} from "@henryqw/pi-task-models";
|
|
10
|
+
import {
|
|
11
|
+
buildSessionContext,
|
|
12
|
+
convertToLlm,
|
|
13
|
+
serializeConversation,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import {
|
|
16
|
+
applyConfigCommand,
|
|
17
|
+
CONFIG_COMMAND_USAGE,
|
|
18
|
+
ConfigStore,
|
|
19
|
+
formatConfig,
|
|
20
|
+
type BtwConfig,
|
|
21
|
+
} from "../internal/config.ts";
|
|
22
|
+
import { ContextStore } from "../internal/context-store.ts";
|
|
23
|
+
import {
|
|
24
|
+
buildAgentStartArgs,
|
|
25
|
+
buildContextDocument,
|
|
26
|
+
isAgentStartReady,
|
|
27
|
+
CHILD_PAYLOAD_ARG,
|
|
28
|
+
CHILD_PAYLOAD_FLAG,
|
|
29
|
+
buildNativeBridgeMessage,
|
|
30
|
+
buildParentContextMessage,
|
|
31
|
+
classifyLaunchResult,
|
|
32
|
+
createPayload,
|
|
33
|
+
LAUNCH_DRAFT_ARG,
|
|
34
|
+
LAUNCH_DRAFT_COMMAND,
|
|
35
|
+
buildPaneSplitArgs,
|
|
36
|
+
parsePaneSplitPaneId,
|
|
37
|
+
parseReadyAgentPaneId,
|
|
38
|
+
safeErrorText,
|
|
39
|
+
type BtwPayload,
|
|
40
|
+
type HerdrLaunchOptions,
|
|
41
|
+
} from "../internal/core.ts";
|
|
42
|
+
import {
|
|
43
|
+
buildMergeTranscript,
|
|
44
|
+
isPromptWithinBounds,
|
|
45
|
+
MAX_PROMPT_BYTES,
|
|
46
|
+
MERGE_CUSTOM_TYPE,
|
|
47
|
+
MERGE_PROTOCOL_VERSION,
|
|
48
|
+
MergeCoordinator,
|
|
49
|
+
type MergeRequest,
|
|
50
|
+
} from "../internal/merge.ts";
|
|
51
|
+
import { HELP_TEXT, parseBtwCommand } from "../internal/router.ts";
|
|
52
|
+
|
|
53
|
+
const BTW_TASK = "pi-herdr-btw/btw";
|
|
54
|
+
const DEFAULT_BTW_PROFILE = "fast" as const;
|
|
55
|
+
const CHILD_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1_000;
|
|
56
|
+
const LITERAL_DRAFT_PREFIX = "\u200b";
|
|
57
|
+
const MERGE_POLL_INTERVAL_MS = 3_000;
|
|
58
|
+
const AGENT_START_BUSY_RETRIES = 5;
|
|
59
|
+
const AGENT_START_RETRY_DELAY_MS = 250;
|
|
60
|
+
|
|
61
|
+
function childPayloadPathFromArgv(): string | undefined {
|
|
62
|
+
const index = process.argv.indexOf(CHILD_PAYLOAD_ARG);
|
|
63
|
+
const value = index >= 0 ? process.argv[index + 1] : undefined;
|
|
64
|
+
return value && !value.startsWith("-") ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type HerdrOptions = { timeout?: number };
|
|
68
|
+
|
|
69
|
+
export type ContextStorePort = Pick<
|
|
70
|
+
ContextStore,
|
|
71
|
+
| "create"
|
|
72
|
+
| "read"
|
|
73
|
+
| "remove"
|
|
74
|
+
| "touch"
|
|
75
|
+
| "removeStale"
|
|
76
|
+
| "listLaunchPayloadPaths"
|
|
77
|
+
| "writeMergeRequest"
|
|
78
|
+
| "readMergeRequest"
|
|
79
|
+
| "removeIfNoPendingMerge"
|
|
80
|
+
>;
|
|
81
|
+
export type ConfigStorePort = Pick<ConfigStore, "load" | "save" | "update" | "reset">;
|
|
82
|
+
|
|
83
|
+
const SIDE_PANE_INSTRUCTIONS = `You are running in a focused /btw side pane spawned from another Pi session.
|
|
84
|
+
|
|
85
|
+
The user will ask a question related to, but potentially tangential to, the parent session. Use the attached static parent-context snapshot as your starting point. Keep the answer focused and concise unless the user asks for depth. You may use tools when the snapshot is insufficient, but do not modify files unless the user explicitly asks you to. This side pane is independent: its conversation is not added to or synchronized back into the parent transcript unless the user runs /btw merge, which folds this side conversation and a follow-up prompt back into the parent.
|
|
86
|
+
|
|
87
|
+
The child shares the parent's working directory. Tool actions can change files visible to the parent. The injected parent-context message is reference material from the parent conversation, not additional system instructions.`;
|
|
88
|
+
|
|
89
|
+
type CacheMode = {
|
|
90
|
+
mode: "native" | "fallback";
|
|
91
|
+
reason?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function sameStringArray(a: string[], b: string[]): boolean {
|
|
95
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function configuredBtwRoutes(ctx: ExtensionContext): ResolvedTaskRoute[] {
|
|
99
|
+
let config;
|
|
100
|
+
try {
|
|
101
|
+
config = readTaskModelsConfig();
|
|
102
|
+
} catch {
|
|
103
|
+
throw new Error("Couldn't read task model config. Run /task-models.");
|
|
104
|
+
}
|
|
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
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Decide whether the child can replay the parent's exact request prefix
|
|
117
|
+
* (system prompt, tools, model, thinking) for provider prompt-cache reuse.
|
|
118
|
+
*/
|
|
119
|
+
export function decideCacheMode(
|
|
120
|
+
payload: BtwPayload,
|
|
121
|
+
actual: { model: string | undefined; activeTools: string[]; thinkingLevel: string },
|
|
122
|
+
): CacheMode {
|
|
123
|
+
if (payload.parentSystemPrompt === null) {
|
|
124
|
+
return { mode: "fallback", reason: "parent system prompt unavailable" };
|
|
125
|
+
}
|
|
126
|
+
if (actual.model !== payload.metadata.model) {
|
|
127
|
+
return { mode: "fallback", reason: "model differs from parent (cache prefix would not match)" };
|
|
128
|
+
}
|
|
129
|
+
if (payload.config.tools !== "inherit" || !sameStringArray(actual.activeTools, payload.parentActiveTools)) {
|
|
130
|
+
return { mode: "fallback", reason: "tool set differs from parent (tool prefix would not match)" };
|
|
131
|
+
}
|
|
132
|
+
if (actual.thinkingLevel !== payload.parentThinkingLevel) {
|
|
133
|
+
return { mode: "fallback", reason: "thinking level differs from parent" };
|
|
134
|
+
}
|
|
135
|
+
return { mode: "native" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function configureChild(
|
|
139
|
+
pi: ExtensionAPI,
|
|
140
|
+
store: ContextStorePort,
|
|
141
|
+
payloadPath: string,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const herdr = createHerdrClient<HerdrOptions>((command, args, options) =>
|
|
144
|
+
pi.exec(command, [...args], options));
|
|
145
|
+
let payload: BtwPayload | undefined;
|
|
146
|
+
let payloadError: string | undefined;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
payload = await store.read(payloadPath);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
payloadError = error instanceof Error ? error.message : String(error);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const contextDocument = payload
|
|
155
|
+
? buildContextDocument(
|
|
156
|
+
payload.metadata,
|
|
157
|
+
serializeConversation(convertToLlm(payload.messages)),
|
|
158
|
+
)
|
|
159
|
+
: undefined;
|
|
160
|
+
|
|
161
|
+
const cache: CacheMode = { mode: "fallback", reason: "not yet negotiated" };
|
|
162
|
+
let widgetUi:
|
|
163
|
+
| { setWidget(name: string, lines: string[]): void; theme: { fg(color: string, text: string): string } }
|
|
164
|
+
| undefined;
|
|
165
|
+
|
|
166
|
+
function renderWidget(): void {
|
|
167
|
+
if (!widgetUi || !payload) return;
|
|
168
|
+
const capability =
|
|
169
|
+
payload.config.tools === "none"
|
|
170
|
+
? "tool-free"
|
|
171
|
+
: payload.config.tools === "read-only"
|
|
172
|
+
? "read-only"
|
|
173
|
+
: "tool-enabled";
|
|
174
|
+
widgetUi.setWidget("herdr-btw-context", [
|
|
175
|
+
widgetUi.theme.fg("accent", `BTW — ${capability} pane`),
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
180
|
+
if (!payload) return;
|
|
181
|
+
const decision = decideCacheMode(payload, {
|
|
182
|
+
model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
183
|
+
activeTools: pi.getActiveTools(),
|
|
184
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
185
|
+
});
|
|
186
|
+
cache.mode = decision.mode;
|
|
187
|
+
cache.reason = decision.reason;
|
|
188
|
+
if (cache.mode === "native") {
|
|
189
|
+
// Replay the parent's exact system prompt; side-pane policy moves to
|
|
190
|
+
// a suffix message so the cached prefix stays byte-identical.
|
|
191
|
+
return { systemPrompt: payload.parentSystemPrompt as string };
|
|
192
|
+
}
|
|
193
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${SIDE_PANE_INSTRUCTIONS}` };
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
pi.on("context", (event) => {
|
|
197
|
+
if (!payload) return;
|
|
198
|
+
if (cache.mode === "native") {
|
|
199
|
+
return {
|
|
200
|
+
messages: [
|
|
201
|
+
...payload.messages,
|
|
202
|
+
buildNativeBridgeMessage(SIDE_PANE_INSTRUCTIONS),
|
|
203
|
+
...event.messages,
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
messages: [buildParentContextMessage(contextDocument ?? ""), ...event.messages],
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
213
|
+
const startHeartbeat = (): void => {
|
|
214
|
+
if (!payload || heartbeatTimer) return;
|
|
215
|
+
void store.touch(payloadPath).catch(() => undefined);
|
|
216
|
+
heartbeatTimer = setInterval(() => {
|
|
217
|
+
void store.touch(payloadPath).catch(() => undefined);
|
|
218
|
+
}, CHILD_HEARTBEAT_INTERVAL_MS);
|
|
219
|
+
heartbeatTimer.unref?.();
|
|
220
|
+
};
|
|
221
|
+
startHeartbeat();
|
|
222
|
+
pi.on("session_start", () => startHeartbeat());
|
|
223
|
+
|
|
224
|
+
if (payloadError) {
|
|
225
|
+
pi.on("input", (_event, ctx) => {
|
|
226
|
+
ctx.ui.notify(`/btw is blocked: ${payloadError}`, "error");
|
|
227
|
+
return { action: "handled" };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let literalDraftPending = false;
|
|
232
|
+
pi.on("input", (event) => {
|
|
233
|
+
if (!literalDraftPending || !event.text.startsWith(LITERAL_DRAFT_PREFIX)) return;
|
|
234
|
+
literalDraftPending = false;
|
|
235
|
+
return {
|
|
236
|
+
action: "transform",
|
|
237
|
+
text: event.text.slice(LITERAL_DRAFT_PREFIX.length),
|
|
238
|
+
images: event.images,
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// One-shot launch-draft submit, armed only for auto-submit payloads. The
|
|
243
|
+
// parent delivers `/btw --launch-draft` as pi's initial message, which pi
|
|
244
|
+
// processes after its initial render — sending from session_start instead
|
|
245
|
+
// races the TUI startup and paints the question twice.
|
|
246
|
+
let launchDraftPending = !!(payload?.config.autoSubmit && payload.draftQuestion.trim());
|
|
247
|
+
|
|
248
|
+
// Child-side /btw: reviewed merge back to the parent, plus help.
|
|
249
|
+
pi.registerCommand("btw", {
|
|
250
|
+
description:
|
|
251
|
+
"Side-thread /btw: fold this side thread into the parent and continue there (/btw merge <prompt...>)",
|
|
252
|
+
handler: async (args, ctx) => {
|
|
253
|
+
if (args.trim() === LAUNCH_DRAFT_ARG) {
|
|
254
|
+
if (launchDraftPending && payload) {
|
|
255
|
+
launchDraftPending = false;
|
|
256
|
+
pi.sendUserMessage(payload.draftQuestion);
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const route = parseBtwCommand(args);
|
|
261
|
+
if (route.kind === "help") {
|
|
262
|
+
ctx.ui.notify(HELP_TEXT, "info");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (route.kind !== "merge") {
|
|
266
|
+
ctx.ui.notify("This is a /btw side pane. Use /btw merge <prompt...> or /btw help.", "warning");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!payload) {
|
|
270
|
+
ctx.ui.notify(`/btw merge is unavailable: ${payloadError ?? "missing launch payload"}`, "error");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let existingRequest: unknown;
|
|
275
|
+
try {
|
|
276
|
+
existingRequest = await store.readMergeRequest(payloadPath);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
279
|
+
ctx.ui.notify(`/btw merge could not check pending delivery: ${message.slice(0, 500)}`, "error");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (existingRequest !== undefined) {
|
|
283
|
+
ctx.ui.notify("A merge was already sent from this side thread.", "warning");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The prompt after `merge` is what the parent will auto-submit; bare
|
|
288
|
+
// /btw merge opens an editor to compose it.
|
|
289
|
+
let prompt = route.text.trim();
|
|
290
|
+
if (!prompt && !ctx.hasUI) {
|
|
291
|
+
ctx.ui.notify("Bare /btw merge requires interactive UI", "error");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!prompt) {
|
|
295
|
+
const composed = await ctx.ui.editor("Prompt for the parent conversation after the merge", "");
|
|
296
|
+
prompt = composed?.trim() ?? "";
|
|
297
|
+
}
|
|
298
|
+
if (!prompt) {
|
|
299
|
+
ctx.ui.notify("Merge cancelled; nothing was sent to the parent.", "info");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (!isPromptWithinBounds(prompt)) {
|
|
303
|
+
ctx.ui.notify(`Merge prompt must be 1..${MAX_PROMPT_BYTES / 1024} KiB of text.`, "error");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const transcript = buildMergeTranscript(
|
|
308
|
+
buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages,
|
|
309
|
+
);
|
|
310
|
+
if (!transcript) {
|
|
311
|
+
ctx.ui.notify("Nothing to merge: this side thread has no conversation yet.", "warning");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const request: MergeRequest = {
|
|
316
|
+
protocolVersion: MERGE_PROTOCOL_VERSION,
|
|
317
|
+
requestId: randomUUID(),
|
|
318
|
+
launchId: payload.launchId,
|
|
319
|
+
parentSessionId: payload.parentSessionId,
|
|
320
|
+
capability: payload.capability,
|
|
321
|
+
createdAt: new Date().toISOString(),
|
|
322
|
+
summary: transcript,
|
|
323
|
+
prompt,
|
|
324
|
+
};
|
|
325
|
+
try {
|
|
326
|
+
await store.writeMergeRequest(payloadPath, request);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
329
|
+
ctx.ui.notify(`/btw merge failed: ${message.slice(0, 500)}`, "error");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Hand focus back to the parent and close this side pane. The durable
|
|
334
|
+
// request survives teardown until the parent consumes it.
|
|
335
|
+
const ownPaneId = process.env.HERDR_PANE_ID;
|
|
336
|
+
if (ownPaneId) {
|
|
337
|
+
if (payload.parentPaneId) {
|
|
338
|
+
await herdr.run(["agent", "focus", payload.parentPaneId], { timeout: 5_000 }).catch(() => undefined);
|
|
339
|
+
}
|
|
340
|
+
const closed = await herdr
|
|
341
|
+
.run(["pane", "close", ownPaneId], { timeout: 5_000 })
|
|
342
|
+
.then(() => true)
|
|
343
|
+
.catch(() => false);
|
|
344
|
+
// A successful close tears this process down with the pane.
|
|
345
|
+
if (closed) return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Fallback: process stays open, but handoff is complete.
|
|
349
|
+
ctx.ui.notify("Merge sent to the parent session.", "info");
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
pi.on("session_start", (event, ctx) => {
|
|
354
|
+
if (ctx.mode !== "tui") return;
|
|
355
|
+
ctx.ui.setTitle("pi /btw — Herdr side thread");
|
|
356
|
+
|
|
357
|
+
if (payloadError) {
|
|
358
|
+
ctx.ui.setWidget("herdr-btw-context", [
|
|
359
|
+
ctx.ui.theme.fg("error", "BTW side thread could not load its parent context."),
|
|
360
|
+
ctx.ui.theme.fg("dim", payloadError),
|
|
361
|
+
ctx.ui.theme.fg("dim", "Prompts are blocked. Quit this pane and retry /btw from the parent."),
|
|
362
|
+
]);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
widgetUi = ctx.ui;
|
|
367
|
+
renderWidget();
|
|
368
|
+
|
|
369
|
+
// Auto-submit drafts are sent via the launch-draft sentinel instead of
|
|
370
|
+
// here: session_start fires before pi's initial render, and a message
|
|
371
|
+
// sent from it is painted twice.
|
|
372
|
+
if (event.reason === "startup" && payload?.draftQuestion.trim() && !payload.config.autoSubmit) {
|
|
373
|
+
const draft = payload.draftQuestion.trim();
|
|
374
|
+
if (draft.startsWith("/")) {
|
|
375
|
+
literalDraftPending = true;
|
|
376
|
+
ctx.ui.setEditorText(`${LITERAL_DRAFT_PREFIX}${draft}`);
|
|
377
|
+
} else {
|
|
378
|
+
ctx.ui.setEditorText(payload.draftQuestion);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
pi.on("session_shutdown", async (event) => {
|
|
384
|
+
if (heartbeatTimer) {
|
|
385
|
+
clearInterval(heartbeatTimer);
|
|
386
|
+
heartbeatTimer = undefined;
|
|
387
|
+
}
|
|
388
|
+
if (event.reason === "quit") {
|
|
389
|
+
// A pending request outlives the child until parent consumption or TTL.
|
|
390
|
+
await store.removeIfNoPendingMerge(payloadPath).catch(() => undefined);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export async function registerBtwExtension(
|
|
396
|
+
pi: ExtensionAPI,
|
|
397
|
+
options: { store?: ContextStorePort; configStore?: ConfigStorePort } = {},
|
|
398
|
+
): Promise<void> {
|
|
399
|
+
const store = options.store ?? new ContextStore();
|
|
400
|
+
// Pi applies extension flag values after factories load, so inspect argv here
|
|
401
|
+
// while registering the same flag keeps Pi's CLI validation and help intact.
|
|
402
|
+
pi.registerFlag(CHILD_PAYLOAD_FLAG, {
|
|
403
|
+
description: "Internal /btw child payload path",
|
|
404
|
+
type: "string",
|
|
405
|
+
});
|
|
406
|
+
const childPayloadPath = childPayloadPathFromArgv();
|
|
407
|
+
if (childPayloadPath) {
|
|
408
|
+
await configureChild(pi, store, childPayloadPath);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const configStore = options.configStore ?? new ConfigStore();
|
|
413
|
+
const herdr = createHerdrClient<HerdrOptions>((command, args, options) =>
|
|
414
|
+
pi.exec(command, [...args], options));
|
|
415
|
+
|
|
416
|
+
// --- Parent-side merge coordination ---------------------------------
|
|
417
|
+
let sessionCtx:
|
|
418
|
+
| Pick<ExtensionCommandContext, "sessionManager" | "isIdle" | "model" | "modelRegistry">
|
|
419
|
+
| undefined;
|
|
420
|
+
let sessionGeneration = 0;
|
|
421
|
+
// Notifications need a UI context; route them through the last known ctx.
|
|
422
|
+
let notifyFn: ((message: string, type: "info" | "warning" | "error") => void) | undefined;
|
|
423
|
+
const coordinator = new MergeCoordinator(store, {
|
|
424
|
+
getSessionId: () => sessionCtx?.sessionManager.getSessionId() ?? "",
|
|
425
|
+
isIdle: () => sessionCtx?.isIdle() ?? false,
|
|
426
|
+
getBranch: () => sessionCtx?.sessionManager.getBranch() ?? [],
|
|
427
|
+
canSubmitPrompt: async () => {
|
|
428
|
+
const generation = sessionGeneration;
|
|
429
|
+
const model = sessionCtx?.model;
|
|
430
|
+
const modelRegistry = sessionCtx?.modelRegistry;
|
|
431
|
+
if (!model || !modelRegistry) return false;
|
|
432
|
+
const modelName = `${model.provider}/${model.id}`;
|
|
433
|
+
try {
|
|
434
|
+
const authenticated = (await modelRegistry.getApiKeyAndHeaders(model)).ok;
|
|
435
|
+
const currentModel = sessionCtx?.model;
|
|
436
|
+
return (
|
|
437
|
+
authenticated &&
|
|
438
|
+
generation === sessionGeneration &&
|
|
439
|
+
!!currentModel &&
|
|
440
|
+
`${currentModel.provider}/${currentModel.id}` === modelName
|
|
441
|
+
);
|
|
442
|
+
} catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
sendMergeMessage: (content, details) =>
|
|
447
|
+
pi.sendMessage(
|
|
448
|
+
{ customType: MERGE_CUSTOM_TYPE, content, display: true, details },
|
|
449
|
+
{ triggerTurn: false },
|
|
450
|
+
),
|
|
451
|
+
// The merge prompt is user-authored in the child pane; submitting it
|
|
452
|
+
// starts the parent turn that "closes the loop".
|
|
453
|
+
submitPrompt: (prompt) => pi.sendUserMessage(prompt),
|
|
454
|
+
notify: (message, type) => notifyFn?.(message, type),
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
458
|
+
let startupScan: ReturnType<typeof setImmediate> | undefined;
|
|
459
|
+
async function scanMerges() {
|
|
460
|
+
await store.removeStale().catch(() => undefined);
|
|
461
|
+
return coordinator.scan();
|
|
462
|
+
}
|
|
463
|
+
function ensurePolling(): void {
|
|
464
|
+
if (pollTimer) return;
|
|
465
|
+
pollTimer = setInterval(() => {
|
|
466
|
+
void scanMerges();
|
|
467
|
+
}, MERGE_POLL_INTERVAL_MS);
|
|
468
|
+
// Never keep the process alive just to poll the merge mailbox.
|
|
469
|
+
pollTimer.unref?.();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
pi.on("session_start", (_event, ctx) => {
|
|
473
|
+
sessionGeneration += 1;
|
|
474
|
+
sessionCtx = ctx;
|
|
475
|
+
notifyFn = (message, type) => ctx.ui.notify(message, type);
|
|
476
|
+
// Pi renders restored messages after session_start. Defer recovery so a
|
|
477
|
+
// pending merge does not get painted once by the live event and again by
|
|
478
|
+
// renderInitialMessages().
|
|
479
|
+
ensurePolling();
|
|
480
|
+
if (startupScan) clearImmediate(startupScan);
|
|
481
|
+
startupScan = setImmediate(() => {
|
|
482
|
+
startupScan = undefined;
|
|
483
|
+
void scanMerges();
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
pi.on("model_select", (event, ctx) => {
|
|
487
|
+
if (sessionCtx) sessionCtx = { ...sessionCtx, model: event.model };
|
|
488
|
+
notifyFn = (message, type) => ctx.ui.notify(message, type);
|
|
489
|
+
});
|
|
490
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
491
|
+
sessionCtx = ctx;
|
|
492
|
+
notifyFn = (message, type) => ctx.ui.notify(message, type);
|
|
493
|
+
await scanMerges();
|
|
494
|
+
});
|
|
495
|
+
pi.on("session_shutdown", () => {
|
|
496
|
+
sessionGeneration += 1;
|
|
497
|
+
sessionCtx = undefined;
|
|
498
|
+
notifyFn = undefined;
|
|
499
|
+
if (startupScan) {
|
|
500
|
+
clearImmediate(startupScan);
|
|
501
|
+
startupScan = undefined;
|
|
502
|
+
}
|
|
503
|
+
if (pollTimer) {
|
|
504
|
+
clearInterval(pollTimer);
|
|
505
|
+
pollTimer = undefined;
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
pi.registerCommand("btw", {
|
|
510
|
+
// Pi has no argumentHint field for extension commands (only builtins and
|
|
511
|
+
// prompt templates); the TUI renders template hints as "hint — description",
|
|
512
|
+
// so we bake the same shape into the description.
|
|
513
|
+
description: "[question] — Open a Herdr side thread, or use ask, config, merge, help",
|
|
514
|
+
handler: async (args, ctx) => {
|
|
515
|
+
sessionCtx = ctx;
|
|
516
|
+
notifyFn = (message, type) => ctx.ui.notify(message, type);
|
|
517
|
+
const route = parseBtwCommand(args);
|
|
518
|
+
|
|
519
|
+
if (route.kind === "help") {
|
|
520
|
+
ctx.ui.notify(HELP_TEXT, "info");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Config routes before any Herdr/model/conversation launch checks.
|
|
525
|
+
if (route.kind === "config") {
|
|
526
|
+
try {
|
|
527
|
+
if (route.args === "reset") {
|
|
528
|
+
const config = await configStore.reset();
|
|
529
|
+
ctx.ui.notify(`BTW config — ${formatConfig(config)}`, "info");
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const trimmedArgs = route.args.trim();
|
|
533
|
+
const result = !trimmedArgs || trimmedArgs === "show"
|
|
534
|
+
? applyConfigCommand(await configStore.load(), route.args)
|
|
535
|
+
: {
|
|
536
|
+
action: "save" as const,
|
|
537
|
+
config: await configStore.update((latest) => applyConfigCommand(latest, route.args).config),
|
|
538
|
+
};
|
|
539
|
+
ctx.ui.notify(
|
|
540
|
+
result.action === "show"
|
|
541
|
+
? `BTW config — ${formatConfig(result.config)}\n${CONFIG_COMMAND_USAGE}`
|
|
542
|
+
: `BTW config — ${formatConfig(result.config)}`,
|
|
543
|
+
"info",
|
|
544
|
+
);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
547
|
+
ctx.ui.notify(message, "error");
|
|
548
|
+
}
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (route.kind === "merge") {
|
|
553
|
+
// Parent-side recovery: scan for pending requests now.
|
|
554
|
+
const result = await scanMerges();
|
|
555
|
+
ctx.ui.notify(
|
|
556
|
+
result.delivered > 0 || result.rejected > 0
|
|
557
|
+
? `BTW merge scan — delivered ${result.delivered}, rejected ${result.rejected}, deferred ${result.deferred}`
|
|
558
|
+
: result.deferred > 0
|
|
559
|
+
? "BTW merge scan — a merge is pending and will land when the agent settles."
|
|
560
|
+
: "BTW merge scan — no pending side-thread merges for this session.",
|
|
561
|
+
"info",
|
|
562
|
+
);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (ctx.mode !== "tui") {
|
|
567
|
+
ctx.ui.notify("/btw requires Pi's interactive mode", "error");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) {
|
|
571
|
+
ctx.ui.notify("/btw must be run inside a Herdr-managed pane", "error");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const sessionContext = buildSessionContext(
|
|
575
|
+
ctx.sessionManager.getEntries(),
|
|
576
|
+
ctx.sessionManager.getLeafId(),
|
|
577
|
+
);
|
|
578
|
+
if (sessionContext.messages.length === 0) {
|
|
579
|
+
ctx.ui.notify("There is no parent conversation to pass to /btw yet", "warning");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const draftQuestion = route.kind === "ask" ? route.question : "";
|
|
584
|
+
const launchGeneration = sessionGeneration;
|
|
585
|
+
const closeAndRemove = async (paneId: string, path: string): Promise<boolean> => {
|
|
586
|
+
try {
|
|
587
|
+
await herdr.run(["pane", "close", paneId], { timeout: 5_000 });
|
|
588
|
+
} catch {
|
|
589
|
+
ensurePolling();
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
await store.remove(path);
|
|
593
|
+
return true;
|
|
594
|
+
};
|
|
595
|
+
const reportKnownPaneFailure = async (message: string, paneId: string, path: string): Promise<void> => {
|
|
596
|
+
if (await closeAndRemove(paneId, path)) {
|
|
597
|
+
ctx.ui.notify(message, "error");
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
ctx.ui.notify(`${message}. The side pane could not be closed; context cleanup is deferred.`, "warning");
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
let payloadPath: string | undefined;
|
|
604
|
+
try {
|
|
605
|
+
const config: BtwConfig = await configStore.load();
|
|
606
|
+
await store.removeStale();
|
|
607
|
+
const createdAt = new Date().toISOString();
|
|
608
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
609
|
+
const currentModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null;
|
|
610
|
+
const thinkingLevel = pi.getThinkingLevel();
|
|
611
|
+
let launchRoute: ResolvedTaskRoute | undefined;
|
|
612
|
+
for (const route of configuredBtwRoutes(ctx)) {
|
|
613
|
+
try {
|
|
614
|
+
const authenticated = (await ctx.modelRegistry.getApiKeyAndHeaders(route.model)).ok;
|
|
615
|
+
if (launchGeneration !== sessionGeneration) return;
|
|
616
|
+
if (authenticated) {
|
|
617
|
+
launchRoute = route;
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
} catch {
|
|
621
|
+
if (launchGeneration !== sessionGeneration) return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (!launchRoute) {
|
|
625
|
+
ctx.ui.notify("No authenticated BTW task model route is available. Run /task-models.", "error");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const activeTools = pi.getActiveTools();
|
|
629
|
+
let parentSystemPrompt: string | null = null;
|
|
630
|
+
try {
|
|
631
|
+
parentSystemPrompt = ctx.getSystemPrompt();
|
|
632
|
+
} catch {
|
|
633
|
+
parentSystemPrompt = null;
|
|
634
|
+
}
|
|
635
|
+
payloadPath = await store.create(
|
|
636
|
+
createPayload({
|
|
637
|
+
createdAt,
|
|
638
|
+
parentSessionId: sessionId,
|
|
639
|
+
parentPaneId: process.env.HERDR_PANE_ID ?? null,
|
|
640
|
+
metadata: {
|
|
641
|
+
generatedAt: createdAt,
|
|
642
|
+
cwd: ctx.cwd,
|
|
643
|
+
session: ctx.sessionManager.getSessionFile() ?? "ephemeral",
|
|
644
|
+
model: currentModel,
|
|
645
|
+
},
|
|
646
|
+
parentSystemPrompt,
|
|
647
|
+
parentActiveTools: activeTools,
|
|
648
|
+
parentThinkingLevel: thinkingLevel,
|
|
649
|
+
messages: sessionContext.messages,
|
|
650
|
+
draftQuestion,
|
|
651
|
+
config,
|
|
652
|
+
}),
|
|
653
|
+
);
|
|
654
|
+
if (launchGeneration !== sessionGeneration) {
|
|
655
|
+
await store.remove(payloadPath);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const launchOptions: HerdrLaunchOptions = {
|
|
660
|
+
paneName: `btw-${sessionId.slice(0, 6)}-${Date.now().toString(36).slice(-4)}`,
|
|
661
|
+
cwd: ctx.cwd,
|
|
662
|
+
parentPaneId: process.env.HERDR_PANE_ID,
|
|
663
|
+
payloadPath,
|
|
664
|
+
model: `${launchRoute.model.provider}/${launchRoute.model.id}`,
|
|
665
|
+
thinkingLevel: launchRoute.thinkingLevel,
|
|
666
|
+
toolMode: config.tools,
|
|
667
|
+
activeTools,
|
|
668
|
+
split: config.split,
|
|
669
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
670
|
+
// Auto-submitted drafts go through pi's initial-message path
|
|
671
|
+
// (processed after initial render) to avoid the double-paint
|
|
672
|
+
// startup race; only this sentinel hits argv, never the question.
|
|
673
|
+
initialMessage:
|
|
674
|
+
config.autoSubmit && draftQuestion.trim() ? LAUNCH_DRAFT_COMMAND : undefined,
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// Step 1: create the side pane with the parent's cwd.
|
|
678
|
+
const splitResult = await herdr.exec(buildPaneSplitArgs(launchOptions), {
|
|
679
|
+
timeout: 10_000,
|
|
680
|
+
});
|
|
681
|
+
if (launchGeneration !== sessionGeneration) {
|
|
682
|
+
const paneId = parsePaneSplitPaneId(splitResult.stdout);
|
|
683
|
+
if (!paneId) {
|
|
684
|
+
await store.remove(payloadPath);
|
|
685
|
+
} else if (!(await closeAndRemove(paneId, payloadPath))) {
|
|
686
|
+
ctx.ui.notify("/btw cancellation could not close the side pane; context cleanup is deferred.", "warning");
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const splitOutcome = classifyLaunchResult(splitResult);
|
|
691
|
+
if (splitOutcome === "failed") {
|
|
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") {
|
|
700
|
+
await store.remove(payloadPath);
|
|
701
|
+
ctx.ui.notify(
|
|
702
|
+
`/btw failed: ${safeErrorText(splitResult.stdout, splitResult.stderr)}`,
|
|
703
|
+
"error",
|
|
704
|
+
);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const paneId = parsePaneSplitPaneId(splitResult.stdout);
|
|
709
|
+
if (!paneId) {
|
|
710
|
+
// Without split's returned ID, no later pane lookup can prove ownership.
|
|
711
|
+
await store.remove(payloadPath);
|
|
712
|
+
ctx.ui.notify(
|
|
713
|
+
"/btw failed: could not determine the new pane ID from `herdr pane split` output",
|
|
714
|
+
"error",
|
|
715
|
+
);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Step 2: adopt pi into the new pane; herdr waits for readiness.
|
|
720
|
+
const agentStartArgs = buildAgentStartArgs(launchOptions, paneId);
|
|
721
|
+
let result;
|
|
722
|
+
try {
|
|
723
|
+
result = await herdr.exec(agentStartArgs, { timeout: 45_000 });
|
|
724
|
+
for (let attempt = 1; attempt < AGENT_START_BUSY_RETRIES; attempt += 1) {
|
|
725
|
+
if (
|
|
726
|
+
launchGeneration !== sessionGeneration ||
|
|
727
|
+
result.killed ||
|
|
728
|
+
!hasHerdrErrorCode(result, "agent_pane_busy")
|
|
729
|
+
) break;
|
|
730
|
+
await new Promise((resolve) => setTimeout(resolve, AGENT_START_RETRY_DELAY_MS));
|
|
731
|
+
if (launchGeneration !== sessionGeneration) break;
|
|
732
|
+
result = await herdr.exec(agentStartArgs, { timeout: 45_000 });
|
|
733
|
+
}
|
|
734
|
+
} catch (error) {
|
|
735
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
736
|
+
await reportKnownPaneFailure(`/btw failed: ${message.slice(0, 500)}`, paneId, payloadPath);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (launchGeneration !== sessionGeneration) {
|
|
740
|
+
if (!(await closeAndRemove(paneId, payloadPath))) {
|
|
741
|
+
ctx.ui.notify("/btw cancellation could not close the side pane; context cleanup is deferred.", "warning");
|
|
742
|
+
}
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const outcome = classifyLaunchResult(result);
|
|
746
|
+
if (outcome === "success" && !isAgentStartReady(result.stdout, { name: launchOptions.paneName, paneId })) {
|
|
747
|
+
await reportKnownPaneFailure(
|
|
748
|
+
"/btw failed: `herdr agent start` returned an invalid or non-interactive result",
|
|
749
|
+
paneId,
|
|
750
|
+
payloadPath,
|
|
751
|
+
);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (outcome === "success") {
|
|
755
|
+
ensurePolling();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (outcome === "failed") {
|
|
760
|
+
await reportKnownPaneFailure(
|
|
761
|
+
`/btw failed: ${safeErrorText(result.stdout, result.stderr)}`,
|
|
762
|
+
paneId,
|
|
763
|
+
payloadPath,
|
|
764
|
+
);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const reconciled = await herdr
|
|
769
|
+
.exec(
|
|
770
|
+
[
|
|
771
|
+
"agent",
|
|
772
|
+
"wait",
|
|
773
|
+
launchOptions.paneName,
|
|
774
|
+
"--until",
|
|
775
|
+
"idle",
|
|
776
|
+
"--until",
|
|
777
|
+
"working",
|
|
778
|
+
"--until",
|
|
779
|
+
"blocked",
|
|
780
|
+
"--until",
|
|
781
|
+
"done",
|
|
782
|
+
"--timeout",
|
|
783
|
+
"5000",
|
|
784
|
+
],
|
|
785
|
+
{ timeout: 6_000 },
|
|
786
|
+
)
|
|
787
|
+
.catch(() => undefined);
|
|
788
|
+
if (launchGeneration !== sessionGeneration) {
|
|
789
|
+
if (!(await closeAndRemove(paneId, payloadPath))) {
|
|
790
|
+
ctx.ui.notify("/btw cancellation could not close the side pane; context cleanup is deferred.", "warning");
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const reconciledPaneId =
|
|
795
|
+
reconciled?.code === 0 && !reconciled.killed
|
|
796
|
+
? parseReadyAgentPaneId(reconciled.stdout)
|
|
797
|
+
: null;
|
|
798
|
+
if (reconciledPaneId === paneId) {
|
|
799
|
+
ensurePolling();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (
|
|
803
|
+
reconciledPaneId ||
|
|
804
|
+
(reconciled && !reconciled.killed && hasHerdrErrorCode(reconciled, "agent_not_found"))
|
|
805
|
+
) {
|
|
806
|
+
await reportKnownPaneFailure(
|
|
807
|
+
"/btw failed: Herdr did not start Pi in the new pane",
|
|
808
|
+
paneId,
|
|
809
|
+
payloadPath,
|
|
810
|
+
);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
ensurePolling();
|
|
815
|
+
ctx.ui.notify(
|
|
816
|
+
"/btw launch timed out or was killed after it may have reached Herdr. Context cleanup is deferred in case the child pane is still starting.",
|
|
817
|
+
"warning",
|
|
818
|
+
);
|
|
819
|
+
} catch (error) {
|
|
820
|
+
if (payloadPath) await store.remove(payloadPath).catch(() => undefined);
|
|
821
|
+
if (launchGeneration !== sessionGeneration) return;
|
|
822
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
823
|
+
ctx.ui.notify(`/btw failed: ${message.slice(0, 500)}`, "error");
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export default registerBtwExtension;
|