@hypercarrier/rarebit 0.4.0 → 0.5.0

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-10-07
4
+
5
+ - Add `/rarebit btw [--rarebits] [--readonly] [question]`. It opens a
6
+ temporary side Pi in a Herdr split on a private snapshot of the active
7
+ branch, so you can ask about history while the main agent keeps working.
8
+ `--rarebits` snapshots only Rarebit messages. By default the side Pi
9
+ inherits the parent's active tools; `--readonly` limits it to read-only
10
+ built-in tools without extensions.
11
+ - Add the optional `rarebit.btw.model` setting and a BTW model field in
12
+ Rarebit settings. Unset, BTW uses the parent model and thinking level.
13
+ - Autocomplete `/rarebit btw` flags. Add BTW actions to the command palette.
14
+
3
15
  ## 0.4.0 — 2026-10-07
4
16
 
5
17
  - Replace Recall's hour-bucketed `rarebit_conversation/v1` with a flat
package/README.md CHANGED
@@ -173,6 +173,30 @@ npm exec -- piq entries --session /absolute/path/to/session.jsonl
173
173
  PiQ is read-only JSONL output for `jq`. It does not classify Rarebits, mutate a
174
174
  Session, invoke a model, or migrate legacy entries.
175
175
 
176
+ #### BTW: side questions while the agent works
177
+
178
+ `/rarebit btw` opens a temporary Pi in a Herdr split beside the current pane.
179
+ Ask it about the history while the main agent keeps working. The main Session
180
+ is unchanged.
181
+
182
+ ```text
183
+ /rarebit btw What did we decide about the release version?
184
+ /rarebit btw --rarebits --readonly Why did we drop the old schema?
185
+ ```
186
+
187
+ - By default, BTW copies the active branch. `--rarebits` copies only the
188
+ Rarebit messages, which is cheaper and reaches past compaction.
189
+ - By default, the side Pi loads extensions and enables the parent's active
190
+ tools. `--readonly` enables only `read`, `grep`, `find`, and `ls`, and loads
191
+ no extensions, skills, context files, or MCP servers.
192
+ - The side Pi uses `rarebit.btw.model` when set, for example
193
+ `"btw": { "model": "provider/model:low" }`. Otherwise it uses the parent
194
+ model and thinking level.
195
+ - A new BTW replaces the earlier BTW pane in the tab. Closing the pane deletes
196
+ the private snapshot in the OS temp directory.
197
+
198
+ BTW requires Pi inside Herdr.
199
+
176
200
  ### 4. Summary, Title, and Recap
177
201
 
178
202
  Summary and Title are optional derivations over the selected evidence. Recap is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypercarrier/rarebit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Sparse, provenance-bearing conversational evidence recovery for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "HyperCarrier contributors",
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Live Rarebit BTW canary in Herdr with the installed Pi and a real model.
4
+ *
5
+ * Uses only synthetic Session prose in a disposable directory. It opens an
6
+ * unfocused Herdr tab, starts Pi with this Rarebit checkout on the synthetic
7
+ * Session, types `/rarebit btw ...`, and checks that the side pane answers a
8
+ * question that needs the history but is not a copy of it. It then checks
9
+ * pane replacement and snapshot cleanup.
10
+ *
11
+ * Env: RAREBIT_BTW_E2E_MODEL (default magpie/claude/claude-haiku-4-5).
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { execFileSync } from "node:child_process";
15
+ import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { dirname, join, resolve } from "node:path";
18
+
19
+ const packageRoot = resolve(dirname(new URL(import.meta.url).pathname), "..");
20
+ const herdrBin = process.env.HERDR_BIN_PATH || "herdr";
21
+ const model = process.env.RAREBIT_BTW_E2E_MODEL ?? "magpie/claude/claude-haiku-4-5";
22
+ const LABEL = "rarebit-btw";
23
+
24
+ const herdr = (...args) => {
25
+ const out = execFileSync(herdrBin, args, { encoding: "utf8", timeout: 120_000 });
26
+ return out.trim() ? JSON.parse(out) : {};
27
+ };
28
+ const herdrText = (...args) => execFileSync(herdrBin, args, { encoding: "utf8", timeout: 120_000 });
29
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
30
+ const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
31
+
32
+ function assistantMessage(text) {
33
+ return {
34
+ role: "assistant", content: [{ type: "text", text }], api: "synthetic", provider: "synthetic",
35
+ model: "synthetic", stopReason: "stop", timestamp: Date.parse("2026-10-07T00:00:02Z"),
36
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
37
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
38
+ };
39
+ }
40
+
41
+ async function syntheticSession(directory) {
42
+ const file = join(directory, "source.jsonl");
43
+ const lines = [
44
+ { type: "session", version: 3, id: "btw-e2e-source", timestamp: "2026-10-07T00:00:00.000Z", cwd: directory },
45
+ { type: "message", id: "u1", parentId: null, timestamp: "2026-10-07T00:00:01.000Z",
46
+ message: { role: "user", content: "Plan: the synthetic deploy window opens at 14:30 UTC and lasts 95 minutes.", timestamp: Date.parse("2026-10-07T00:00:01Z") } },
47
+ { type: "message", id: "a1", parentId: "u1", timestamp: "2026-10-07T00:00:02.000Z",
48
+ message: assistantMessage("Noted the synthetic deploy window plan.") },
49
+ ];
50
+ await writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`);
51
+ return file;
52
+ }
53
+
54
+ const btwPanes = (tabId) => herdr("pane", "list").result.panes
55
+ .filter((pane) => pane.tab_id === tabId && pane.label === LABEL);
56
+
57
+ async function waitForBtwPane(tabId, exclude, timeoutMs = 20_000) {
58
+ const deadline = Date.now() + timeoutMs;
59
+ while (Date.now() < deadline) {
60
+ const pane = btwPanes(tabId).find((candidate) => candidate.pane_id !== exclude);
61
+ if (pane) return pane.pane_id;
62
+ await sleep(250);
63
+ }
64
+ throw new Error("no BTW pane appeared");
65
+ }
66
+
67
+ async function snapshotDirs() {
68
+ return new Set((await readdir(tmpdir())).filter((name) => name.startsWith("hc-rarebit-btw-")));
69
+ }
70
+
71
+ async function ask(mainPane, tabId, command, previousPane) {
72
+ const before = await snapshotDirs();
73
+ herdrText("pane", "send-text", mainPane, command);
74
+ herdrText("pane", "send-keys", mainPane, "Enter");
75
+ const pane = await waitForBtwPane(tabId, previousPane);
76
+ herdrText("pane", "wait-output", pane, "--regex", "ANSWER\\s*=\\s*16:05", "--timeout", "120000");
77
+ const created = [...await snapshotDirs()].filter((name) => !before.has(name));
78
+ assert.equal(created.length, 1, "one private snapshot per BTW");
79
+ herdrText("pane", "wait-output", mainPane, "--match", "Rarebit BTW opened", "--timeout", "10000");
80
+ const snapshot = join(tmpdir(), created[0]);
81
+ return { pane, snapshot, launcher: await readFile(join(snapshot, "launch.sh"), "utf8") };
82
+ }
83
+
84
+ const work = await mkdtemp(join(tmpdir(), "rarebit-btw-e2e-"));
85
+ let tabId;
86
+ try {
87
+ const source = await syntheticSession(work);
88
+ const tab = herdr("tab", "create", "--no-focus", "--cwd", work, "--label", "rarebit-btw-e2e");
89
+ tabId = tab.result.tab.tab_id;
90
+ const mainPane = tab.result.root_pane.pane_id;
91
+ herdrText("pane", "run", mainPane, [
92
+ "pi", "--session", source, "--no-extensions", "-e", join(packageRoot, "src/extension.mjs"),
93
+ "--model", model, "--no-skills", "--no-context-files", "--no-mcp",
94
+ ].map(quote).join(" "));
95
+ herdrText("pane", "wait-output", mainPane, "--match", "deploy window", "--timeout", "60000");
96
+ await sleep(1500);
97
+
98
+ const question = "When does the deploy window close? Reply exactly as ANSWER=HH:MM";
99
+ const rarebits = await ask(mainPane, tabId, `/rarebit btw --rarebits --readonly ${question}`);
100
+ assert.match(rarebits.launcher, /'--no-extensions'.*'--tools' 'read,grep,find,ls'|'--tools' 'read,grep,find,ls'.*'--no-extensions'/s);
101
+ console.log(`ok read-only Rarebits BTW answered in ${rarebits.pane}`);
102
+
103
+ const full = await ask(mainPane, tabId, `/rarebit btw ${question}`, rarebits.pane);
104
+ assert.deepEqual(btwPanes(tabId).map((pane) => pane.pane_id), [full.pane], "the new BTW replaced the old one");
105
+ assert.match(full.launcher, /'--tools' '[^']*read[^']*bash/, "full BTW inherits the parent's active tools");
106
+ assert.doesNotMatch(full.launcher, /--no-extensions/);
107
+ const replacedName = rarebits.snapshot.split("/").at(-1);
108
+ for (const deadline = Date.now() + 15_000; (await snapshotDirs()).has(replacedName) && Date.now() < deadline;) await sleep(500);
109
+ assert.ok(!(await snapshotDirs()).has(replacedName), "replaced BTW deleted its snapshot");
110
+ console.log(`ok full BTW with inherited tools answered in ${full.pane} and replaced the earlier pane`);
111
+
112
+ herdrText("pane", "close", full.pane);
113
+ const name = full.snapshot.split("/").at(-1);
114
+ const deadline = Date.now() + 15_000;
115
+ while ((await snapshotDirs()).has(name) && Date.now() < deadline) await sleep(500);
116
+ assert.ok(!(await snapshotDirs()).has(name), "closed BTW deleted its snapshot");
117
+ console.log("ok closing the BTW pane deleted its snapshot");
118
+ } catch (error) {
119
+ if (tabId) {
120
+ for (const pane of herdr("pane", "list").result.panes.filter((p) => p.tab_id === tabId))
121
+ console.error(`--- ${pane.pane_id} ${pane.label ?? ""}\n${herdrText("pane", "read", pane.pane_id, "--source", "recent-unwrapped").split("\n").slice(-25).join("\n")}`);
122
+ }
123
+ throw error;
124
+ } finally {
125
+ if (tabId) try { herdrText("tab", "close", tabId); } catch {}
126
+ await rm(work, { recursive: true, force: true });
127
+ }
package/src/extension.mjs CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  sessionFilenameFor,
33
33
  writeRarebitForkFile,
34
34
  } from "./rarebit-fork.mjs";
35
+ import { openRarebitBtwPane, prepareRarebitBtw, watchRarebitBtwPane } from "./rarebit-btw.mjs";
35
36
  import {
36
37
  RAREBIT_CONVERSATION_SCHEMA_VERSION,
37
38
  RAREBIT_RECALL_SCHEMA_VERSION,
@@ -118,6 +119,10 @@ export async function readConfiguredRarebitSettings({
118
119
  summaryPrompt: resolved.summaryPrompt,
119
120
  diagnostics: resolved.diagnostics,
120
121
  recap: resolved.recap,
122
+ ...(resolved.btwModel ? { btwModel: resolved.btwModel } : {}),
123
+ ...(resolved.btwModelConfigurationError
124
+ ? { btwModelConfigurationError: resolved.btwModelConfigurationError }
125
+ : {}),
121
126
  reserveTokens: resolveReserveTokens(global.value, project.value, model),
122
127
  };
123
128
  }
@@ -755,6 +760,30 @@ ${fence(requestText)}`;
755
760
  }
756
761
  return;
757
762
  }
763
+ if (subcommand === "btw") {
764
+ const flags = command.flags ?? [];
765
+ let btw;
766
+ try {
767
+ const effective = await loadEffective(ctx);
768
+ if (effective.btwModelConfigurationError) throw new Error(effective.btwModelConfigurationError);
769
+ const readonly = flags.includes("--readonly");
770
+ btw = await prepareRarebitBtw(ctx, {
771
+ mode: flags.includes("--rarebits") ? "rarebits" : "full",
772
+ question: rest[0] ?? "",
773
+ readonly,
774
+ activeTools: readonly ? undefined : pi.getActiveTools?.(),
775
+ model: effective.btwModel,
776
+ thinking: pi.getThinkingLevel?.(),
777
+ });
778
+ const opened = await openRarebitBtwPane({ launcherPath: btw.launcherPath, cwd: ctx.cwd ?? process.cwd() });
779
+ watchRarebitBtwPane({ paneId: opened.paneId, directory: btw.directory });
780
+ notify(ctx, `Rarebit BTW opened beside this pane (${btw.mode}${btw.readonly ? ", readonly" : ""}; ${btw.model ?? "default model"}; ${btw.summary})`, "info");
781
+ } catch (error) {
782
+ await btw?.discard();
783
+ notify(ctx, `Rarebit BTW failed: ${error?.message ?? error}`, "error");
784
+ }
785
+ return;
786
+ }
758
787
  if (subcommand === "recall") {
759
788
  const prompt = rest[0];
760
789
  if (typeof pi.sendUserMessage !== "function") {
@@ -0,0 +1,245 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ import {
9
+ buildRarebitForkPlan,
10
+ createRarebitForkEntries,
11
+ writeRarebitForkFile,
12
+ } from "./rarebit-fork.mjs";
13
+
14
+ /**
15
+ * Rarebit BTW: a temporary side Pi Session beside the main Session.
16
+ *
17
+ * The main Session stays the authority. BTW writes a private snapshot of the
18
+ * active branch to the OS temp directory, opens a Herdr split, and runs a
19
+ * second Pi on that snapshot. By default the side Pi loads extensions as
20
+ * usual and enables the parent's active tools. Read-only BTW enables only
21
+ * read-only built-in tools and loads no extensions, skills, context files,
22
+ * or MCP servers. Closing the side Pi deletes the snapshot.
23
+ */
24
+
25
+ export const RAREBIT_BTW_MODES = Object.freeze(["full", "rarebits"]);
26
+ export const RAREBIT_BTW_PANE_LABEL = "rarebit-btw";
27
+ export const RAREBIT_BTW_READONLY_TOOLS = "read,grep,find,ls";
28
+
29
+ const execFileAsync = promisify(execFile);
30
+
31
+ function messageOf(entry) {
32
+ return entry?.type === "message" ? entry.message : null;
33
+ }
34
+
35
+ /**
36
+ * Drop the unfinished tail of a mid-turn snapshot: the first assistant
37
+ * message whose tool calls have no results, and everything after it.
38
+ */
39
+ export function completedBranchPrefix(branch) {
40
+ const resultIds = new Set();
41
+ for (const entry of branch) {
42
+ const message = messageOf(entry);
43
+ if (message?.role === "toolResult" && message.toolCallId) resultIds.add(message.toolCallId);
44
+ }
45
+ const cut = branch.findIndex((entry) => {
46
+ const message = messageOf(entry);
47
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return false;
48
+ return message.content.some((part) => part?.type === "toolCall" && !resultIds.has(part.id));
49
+ });
50
+ return cut < 0 ? branch : branch.slice(0, cut);
51
+ }
52
+
53
+ /** Full mode: the active branch verbatim, under a new Session header. */
54
+ export function createFullBtwEntries({ header, sessionFile, cwd, branch, now = new Date() }) {
55
+ const entries = completedBranchPrefix(branch);
56
+ const sessionId = randomUUID();
57
+ return {
58
+ sessionId,
59
+ header: {
60
+ type: "session",
61
+ version: header?.version ?? 3,
62
+ id: sessionId,
63
+ timestamp: now.toISOString(),
64
+ cwd: resolve(cwd ?? header?.cwd ?? process.cwd()),
65
+ parentSession: resolve(sessionFile),
66
+ },
67
+ entries,
68
+ droppedTailEntries: branch.length - entries.length,
69
+ };
70
+ }
71
+
72
+ function shellQuote(value) {
73
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
74
+ }
75
+
76
+ export function btwSystemPrompt({ mode, sourceFile, readonly = false }) {
77
+ const history = mode === "rarebits"
78
+ ? "This Session holds only the Rarebit messages of another Pi Session: user requests and final agent replies."
79
+ : "This Session is a copy of another Pi Session's active branch.";
80
+ return [
81
+ "## BTW side chat",
82
+ history,
83
+ "The user asks quick side questions about that history while the original agent keeps working.",
84
+ "- Answer from the history. Be brief.",
85
+ readonly
86
+ ? "- Do not continue the original task. Do not modify files."
87
+ : "- Do not continue the original task unless the user asks.",
88
+ `- For detail the history omits, search the source Session JSONL with grep or read: ${sourceFile}`,
89
+ ].join("\n");
90
+ }
91
+
92
+ function toolArguments({ readonly, activeTools }) {
93
+ if (readonly) return [
94
+ "--tools", RAREBIT_BTW_READONLY_TOOLS,
95
+ "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-context-files", "--no-mcp",
96
+ ];
97
+ if (!Array.isArray(activeTools)) return [];
98
+ return activeTools.length > 0 ? ["--tools", activeTools.join(",")] : ["--no-tools"];
99
+ }
100
+
101
+ /** The argv for the side Pi. */
102
+ export function btwPiArguments({ sessionFile, model, thinking, readonly = false, activeTools, systemPrompt, question, name }) {
103
+ return [
104
+ "--session", sessionFile,
105
+ ...(model?.provider && model?.id ? ["--model", `${model.provider}/${model.id}`] : []),
106
+ ...(thinking ? ["--thinking", thinking] : []),
107
+ ...toolArguments({ readonly, activeTools }),
108
+ "--append-system-prompt", systemPrompt,
109
+ ...(name ? ["--name", name] : []),
110
+ ...(question ? ["--", question] : []),
111
+ ];
112
+ }
113
+
114
+ /** A launcher that runs the side Pi, then deletes the snapshot and exits the pane shell. */
115
+ export function btwLauncherScript({ piCommand = "pi", piArguments, directory }) {
116
+ return [
117
+ "#!/bin/sh",
118
+ `trap ${shellQuote(`rm -rf ${shellQuote(directory)}`)} EXIT`,
119
+ "trap 'exit 129' HUP INT TERM",
120
+ `${shellQuote(piCommand)} ${piArguments.map(shellQuote).join(" ")}`,
121
+ "",
122
+ ].join("\n");
123
+ }
124
+
125
+ /**
126
+ * Write the snapshot and launcher. Returns paths and counts; the caller owns
127
+ * `discard` until the launcher runs.
128
+ */
129
+ export async function prepareRarebitBtw(ctx, {
130
+ mode = "full",
131
+ question = "",
132
+ readonly = false,
133
+ activeTools,
134
+ model: configuredModel,
135
+ thinking,
136
+ tempRoot = tmpdir(),
137
+ now = () => new Date(),
138
+ piCommand = "pi",
139
+ } = {}) {
140
+ if (!RAREBIT_BTW_MODES.includes(mode)) throw new Error(`unknown BTW mode ${mode}`);
141
+ const sourceFile = ctx?.sessionManager?.getSessionFile?.();
142
+ const branch = ctx?.sessionManager?.getBranch?.();
143
+ const header = ctx?.sessionManager?.getHeader?.();
144
+ if (typeof sourceFile !== "string" || !Array.isArray(branch))
145
+ throw new Error("the current Pi Session must be persisted before BTW");
146
+ // A configured BTW model carries its own optional `:thinking` suffix; the
147
+ // parent model brings the parent thinking level.
148
+ const model = configuredModel ?? ctx?.model ?? null;
149
+ const sideThinking = configuredModel ? undefined : thinking;
150
+ const directory = await mkdtemp(join(resolve(tempRoot), "hc-rarebit-btw-"));
151
+ try {
152
+ await chmod(directory, 0o700);
153
+ let session;
154
+ let summary;
155
+ if (mode === "rarebits") {
156
+ const plan = buildRarebitForkPlan({
157
+ header, sessionFile: sourceFile, cwd: ctx.cwd, branch, targetCwd: ctx.cwd,
158
+ maxTokenLength: Math.max(1, Math.floor((model?.contextWindow ?? ctx?.model?.contextWindow ?? 200_000) / 2)),
159
+ targetModel: model, forkCreatedAt: now().toISOString(),
160
+ });
161
+ session = createRarebitForkEntries(plan);
162
+ summary = `${plan.selected.length} Rarebit messages, ~${plan.importedTokens} tokens` +
163
+ (plan.omittedOccurrences ? `, ${plan.omittedOccurrences} older omitted` : "");
164
+ } else {
165
+ session = createFullBtwEntries({ header, sessionFile: sourceFile, cwd: ctx.cwd, branch, now: now() });
166
+ summary = `${session.entries.length} entries` +
167
+ (session.droppedTailEntries ? `, ${session.droppedTailEntries} unfinished dropped` : "");
168
+ }
169
+ const sessionFile = join(directory, "session.jsonl");
170
+ await writeRarebitForkFile(sessionFile, session);
171
+ const piArguments = btwPiArguments({
172
+ sessionFile, model, thinking: sideThinking, readonly, activeTools,
173
+ systemPrompt: btwSystemPrompt({ mode, sourceFile: resolve(sourceFile), readonly }),
174
+ question: String(question ?? "").trim(),
175
+ name: `btw: ${mode}${readonly ? ", readonly" : ""}`,
176
+ });
177
+ const launcherPath = join(directory, "launch.sh");
178
+ await writeFile(launcherPath, btwLauncherScript({ piCommand, piArguments, directory }), { mode: 0o700 });
179
+ return {
180
+ mode, readonly, directory, sessionFile, launcherPath, summary,
181
+ model: model?.provider && model?.id ? `${model.provider}/${model.id}` : null,
182
+ discard: () => rm(directory, { recursive: true, force: true }),
183
+ };
184
+ } catch (error) {
185
+ await rm(directory, { recursive: true, force: true }).catch(() => {});
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ async function herdrJson(herdrBin, args, run) {
191
+ const { stdout } = await run(herdrBin, args, { timeout: 5_000 });
192
+ return stdout.trim() ? JSON.parse(stdout) : {};
193
+ }
194
+
195
+ /**
196
+ * Open the side Pi in a Herdr split beside `paneId`. Another BTW pane already
197
+ * open in the same tab is closed first, so each BTW starts from a fresh
198
+ * snapshot. A BTW started inside a BTW pane keeps its own pane.
199
+ */
200
+ export async function openRarebitBtwPane({
201
+ launcherPath,
202
+ cwd,
203
+ paneId = process.env.HERDR_PANE_ID,
204
+ herdrBin = process.env.HERDR_BIN_PATH || "herdr",
205
+ direction = "right",
206
+ ratio = 0.45,
207
+ run = execFileAsync,
208
+ } = {}) {
209
+ if (!paneId) throw new Error("BTW needs Herdr: HERDR_PANE_ID is not set");
210
+ const current = await herdrJson(herdrBin, ["pane", "get", paneId], run);
211
+ const tabId = current?.result?.pane?.tab_id;
212
+ const listed = await herdrJson(herdrBin, ["pane", "list"], run);
213
+ for (const pane of listed?.result?.panes ?? []) {
214
+ if (pane.tab_id === tabId && pane.label === RAREBIT_BTW_PANE_LABEL && pane.pane_id !== paneId)
215
+ await run(herdrBin, ["pane", "close", pane.pane_id], { timeout: 5_000 });
216
+ }
217
+ const split = await herdrJson(herdrBin, [
218
+ "pane", "split", paneId, "--direction", direction, "--ratio", String(ratio),
219
+ "--cwd", cwd, "--focus",
220
+ ], run);
221
+ const newPaneId = split?.result?.pane?.pane_id;
222
+ if (!newPaneId) throw new Error("Herdr did not report the new pane");
223
+ await run(herdrBin, ["pane", "rename", newPaneId, RAREBIT_BTW_PANE_LABEL], { timeout: 5_000 });
224
+ await run(herdrBin, ["pane", "run", newPaneId, `exec sh ${shellQuote(launcherPath)}`], { timeout: 5_000 });
225
+ return { paneId: newPaneId };
226
+ }
227
+
228
+ /**
229
+ * Delete the snapshot once Herdr no longer reports the BTW pane. Herdr may
230
+ * kill the pane before the launcher's EXIT trap runs, so cleanup follows
231
+ * the pane's lifetime in a detached process that outlives both Pis.
232
+ */
233
+ export function watchRarebitBtwPane({
234
+ paneId,
235
+ directory,
236
+ herdrBin = process.env.HERDR_BIN_PATH || "herdr",
237
+ intervalSeconds = 3,
238
+ spawnImpl = spawn,
239
+ }) {
240
+ const script = [
241
+ `while [ -d ${shellQuote(directory)} ] && ${shellQuote(herdrBin)} pane get ${shellQuote(paneId)} >/dev/null 2>&1; do sleep ${Number(intervalSeconds)}; done`,
242
+ `rm -rf ${shellQuote(directory)}`,
243
+ ].join("; ");
244
+ spawnImpl("sh", ["-c", script], { detached: true, stdio: "ignore" }).unref();
245
+ }
@@ -10,6 +10,8 @@ const number = (usage, validate) => ({
10
10
  validate,
11
11
  });
12
12
 
13
+ const flag = (value, description) => ({ value, description });
14
+
13
15
  const rest = (usage) => ({
14
16
  kind: "rest",
15
17
  usage,
@@ -49,6 +51,15 @@ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
49
51
  description: "Recall active-branch Rarebit messages with a prompt",
50
52
  forms: [[rest("<prompt...>")]],
51
53
  },
54
+ {
55
+ name: "btw",
56
+ description: "Ask side questions in a temporary Pi beside this one (Herdr)",
57
+ flags: [
58
+ flag("--rarebits", "Fork only Rarebit messages (default: full branch)"),
59
+ flag("--readonly", "Read-only tools, no extensions (default: inherit tools)"),
60
+ ],
61
+ forms: [[], [rest("<question...>")]],
62
+ },
52
63
  {
53
64
  name: "fork",
54
65
  description: "Open a bounded Rarebit fork without starting a model turn",
@@ -142,7 +153,8 @@ export function rarebitCommandUsage(
142
153
  );
143
154
  if (!definition) return rarebitCommandUsage(undefined, grammar);
144
155
  const note = definition.usageNote ? ` (${definition.usageNote})` : "";
145
- return `Usage: /${grammar.name} ${definition.name}${renderForms(definition.forms)}${note}`;
156
+ const flags = (definition.flags ?? []).map(({ value }) => ` [${value}]`).join("");
157
+ return `Usage: /${grammar.name} ${definition.name}${flags}${renderForms(definition.forms)}${note}`;
146
158
  }
147
159
 
148
160
  const matchToken = (definition, rawValue) => {
@@ -152,6 +164,28 @@ const matchToken = (definition, rawValue) => {
152
164
  return Number.isFinite(value) && definition.validate(value);
153
165
  };
154
166
 
167
+ const flagValues = (definition) => new Set((definition.flags ?? []).map(({ value }) => value));
168
+
169
+ // Leading flags, in any order, precede the free-form text.
170
+ function withFlags(definition, argumentsBefore) {
171
+ const known = flagValues(definition);
172
+ let text = String(argumentsBefore.at(-1) ?? "");
173
+ const flags = [];
174
+ for (;;) {
175
+ const word = text.match(/^\s*(\S+)/)?.[1];
176
+ if (!word || !known.has(word)) break;
177
+ if (!flags.includes(word)) flags.push(word);
178
+ text = text.replace(/^\s*\S+/, "");
179
+ }
180
+ const question = text.trim() ? text.replace(/^\s/, "") : "";
181
+ return {
182
+ ok: true,
183
+ subcommand: definition.name,
184
+ flags,
185
+ arguments: [...argumentsBefore.slice(0, -1), ...(question ? [question] : [])],
186
+ };
187
+ }
188
+
155
189
  export function parseRarebitCommand(input, grammar = RAREBIT_COMMAND_GRAMMAR) {
156
190
  const rawInput = String(input ?? "");
157
191
  const tokens = rawInput.trim().split(/\s+/).filter(Boolean);
@@ -170,6 +204,9 @@ export function parseRarebitCommand(input, grammar = RAREBIT_COMMAND_GRAMMAR) {
170
204
  const freeform = definition.forms.find(
171
205
  (form) => form.at(-1)?.kind === "rest",
172
206
  );
207
+ if (freeform && tokens.length === 1 && definition.forms.some((form) => form.length === 0)) {
208
+ return { ok: true, subcommand: definition.name, ...(definition.flags ? { flags: [] } : {}), arguments: [] };
209
+ }
173
210
  if (freeform) {
174
211
  const prefix = freeform.slice(0, -1);
175
212
  const leading = rawInput.trimStart();
@@ -191,6 +228,7 @@ export function parseRarebitCommand(input, grammar = RAREBIT_COMMAND_GRAMMAR) {
191
228
  }
192
229
  if (prefixMatches && /^\s/.test(remaining)) {
193
230
  const prompt = remaining.slice(1);
231
+ if (definition.flags) return withFlags(definition, [...parsed, prompt]);
194
232
  if (matchToken(freeform.at(-1), prompt)) {
195
233
  return {
196
234
  ok: true,
@@ -289,6 +327,21 @@ export function getRarebitArgumentCompletions(
289
327
  const matches = [];
290
328
  const seen = new Set();
291
329
 
330
+ if (definition.flags) {
331
+ const known = flagValues(definition);
332
+ if (!priorArguments.every((value) => known.has(value))) return null;
333
+ const flagMatches = definition.flags
334
+ .filter(({ value }) => !priorArguments.includes(value) && value.startsWith(partial))
335
+ .map(({ value, description }) =>
336
+ completionItem({
337
+ value: `${[definition.name, ...priorArguments, value].join(" ")} `,
338
+ label: value,
339
+ description,
340
+ }),
341
+ );
342
+ return flagMatches.length > 0 ? flagMatches : null;
343
+ }
344
+
292
345
  for (const form of definition.forms) {
293
346
  const priorMatches = priorArguments.every(
294
347
  (value, index) => form[index] && matchToken(form[index], value),
@@ -197,6 +197,7 @@ export const RAREBIT_SETTINGS_FIELDS = Object.freeze([
197
197
  field("recap_timezone", "Recap", "Recap timezone", ["recap", "timezone"], "timezone", `Timestamp zone. Blank uses the host timezone (${HOST_TIMEZONE}).`, { defaultValue: HOST_TIMEZONE }),
198
198
  field("auto_title", "Session", "Automatic Session title", ["auto_title"], "boolean", "Generate a Rarebit title from the first persisted owner message.", { defaultValue: true }),
199
199
  field("model", "Session", "Rarebit model", ["model"], "string", "Model in provider/model form. Blank removes the local override.", { defaultValue: "Not set" }),
200
+ field("btw_model", "Session", "BTW model", ["btw", "model"], "string", "Model for /rarebit btw in provider/model form; add :<thinking> to set thinking. Blank uses the parent Session model.", { defaultValue: "Parent model" }),
200
201
  ]);
201
202
 
202
203
  const paletteItems = [
@@ -207,6 +208,8 @@ const paletteItems = [
207
208
  { id: "summarize", tab: "Actions", label: "Summarize", description: "Force a Rarebit Summary materialization." },
208
209
  { id: "recall", tab: "Actions", label: "Recall", description: "Recall active-branch Rarebit messages with a prompt." },
209
210
  { id: "fork", tab: "Actions", label: "Fork", description: "Open a bounded Rarebit fork without a model turn." },
211
+ { id: "btw", tab: "Actions", label: "BTW", description: "Ask side questions in a temporary Pi beside this one." },
212
+ { id: "btw_rarebits", tab: "Actions", label: "BTW (Rarebits)", description: "BTW with Rarebit messages only." },
210
213
  { id: "summary_status", tab: "Summary", label: "Summary status", description: "Show effective Summary policy." },
211
214
  { id: "summary_settings", tab: "Summary", label: "Summary settings", description: "Edit Summary diagnostics and input policy." },
212
215
  { id: "recap", tab: "Recap", label: "Recap", description: "Show the current Recap in the TUI." },
@@ -475,6 +478,8 @@ export function rarebitPaletteCommand(selection) {
475
478
  case "recall": return "recall";
476
479
  case "title": return "title";
477
480
  case "fork": return "fork";
481
+ case "btw": return "btw";
482
+ case "btw_rarebits": return "btw --rarebits";
478
483
  case "help": return "help";
479
484
  default: return undefined;
480
485
  }
@@ -72,8 +72,8 @@ export function mergeRarebitSettings(
72
72
  return merged;
73
73
  }
74
74
 
75
- export function modelFromRarebitSettings(rarebit) {
76
- const candidate = rarebit?.model;
75
+ export function modelFromRarebitSettings(rarebit, { key = "model" } = {}) {
76
+ const candidate = key.split(".").reduce((value, part) => value?.[part], rarebit);
77
77
  if (isRecord(candidate)) {
78
78
  const provider = nonEmptyString(candidate.provider);
79
79
  const id = nonEmptyString(candidate.id);
@@ -91,10 +91,10 @@ export function modelFromRarebitSettings(rarebit) {
91
91
  };
92
92
  }
93
93
  if (candidate === undefined)
94
- return { error: "Rarebit setting rarebit.model is missing" };
94
+ return { error: `Rarebit setting rarebit.${key} is missing` };
95
95
  return {
96
96
  error:
97
- "Rarebit setting rarebit.model must be provider/model or {provider,id}",
97
+ `Rarebit setting rarebit.${key} must be provider/model or {provider,id}`,
98
98
  };
99
99
  }
100
100
 
@@ -110,6 +110,10 @@ export function resolveRarebitSettings(
110
110
  ) {
111
111
  const rarebit = mergeRarebitSettings(globalSettings, projectSettings);
112
112
  const resolved = modelFromRarebitSettings(rarebit);
113
+ // An omitted BTW model means "use the parent Session model".
114
+ const btw = rarebit?.btw?.model === undefined
115
+ ? {}
116
+ : modelFromRarebitSettings(rarebit, { key: "btw.model" });
113
117
  const recap =
114
118
  isRecord(rarebit?.recap)
115
119
  ? rarebit.recap
@@ -134,6 +138,8 @@ export function resolveRarebitSettings(
134
138
  return {
135
139
  ...(resolved.model ? { model: resolved.model } : {}),
136
140
  ...(resolved.error ? { modelConfigurationError: resolved.error } : {}),
141
+ ...(btw.model ? { btwModel: btw.model } : {}),
142
+ ...(btw.error ? { btwModelConfigurationError: btw.error } : {}),
137
143
  summaryPolicy: {
138
144
  ...DEFAULT_RAREBIT_SUMMARY_POLICY,
139
145
  ...(rarebit.min_total_length === undefined
@@ -0,0 +1,158 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ RAREBIT_BTW_PANE_LABEL,
9
+ completedBranchPrefix,
10
+ openRarebitBtwPane,
11
+ prepareRarebitBtw,
12
+ watchRarebitBtwPane,
13
+ } from "../src/rarebit-btw.mjs";
14
+ import { getRarebitArgumentCompletions, parseRarebitCommand } from "../src/rarebit-command.mjs";
15
+ import { resolveRarebitSettings } from "../src/rarebit-settings.mjs";
16
+
17
+ const user = (id, parentId, text) => ({
18
+ type: "message", id, parentId, timestamp: "2026-10-07T00:00:00.000Z",
19
+ message: { role: "user", content: text, timestamp: 0 },
20
+ });
21
+ const assistant = (id, parentId, content, stopReason = "stop") => ({
22
+ type: "message", id, parentId, timestamp: "2026-10-07T00:00:01.000Z",
23
+ message: { role: "assistant", content, stopReason, timestamp: 0 },
24
+ });
25
+ const toolResult = (id, parentId, toolCallId) => ({
26
+ type: "message", id, parentId, timestamp: "2026-10-07T00:00:02.000Z",
27
+ message: { role: "toolResult", toolCallId, toolName: "read", content: [], timestamp: 0 },
28
+ });
29
+
30
+ const settledBranch = [
31
+ user("u1", null, "synthetic request alpha"),
32
+ assistant("a1", "u1", [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], "toolUse"),
33
+ toolResult("t1", "a1", "c1"),
34
+ assistant("a2", "t1", [{ type: "text", text: "synthetic reply alpha" }]),
35
+ ];
36
+
37
+ function contextFor(branch, sessionFile = "/synthetic/source.jsonl") {
38
+ return {
39
+ cwd: "/synthetic/cwd",
40
+ model: { provider: "synthetic", id: "model", contextWindow: 100_000 },
41
+ sessionManager: {
42
+ getSessionFile: () => sessionFile,
43
+ getBranch: () => branch,
44
+ getHeader: () => ({ type: "session", version: 3, id: "source-id", cwd: "/synthetic/cwd" }),
45
+ },
46
+ };
47
+ }
48
+
49
+ test("btw parses leading flags in any order before the question", () => {
50
+ const parsed = (input) => {
51
+ const { flags, arguments: rest } = parseRarebitCommand(input);
52
+ return { flags, rest };
53
+ };
54
+ assert.deepEqual(parsed("btw"), { flags: [], rest: [] });
55
+ assert.deepEqual(parsed("btw why X?"), { flags: [], rest: ["why X?"] });
56
+ assert.deepEqual(parsed("btw --rarebits"), { flags: ["--rarebits"], rest: [] });
57
+ assert.deepEqual(parsed("btw --readonly --rarebits why --rarebits?"), { flags: ["--readonly", "--rarebits"], rest: ["why --rarebits?"] });
58
+ });
59
+
60
+ test("btw autocompletes unused flags until the question starts", () => {
61
+ const values = (prefix) => getRarebitArgumentCompletions(prefix)?.map(({ value }) => value) ?? null;
62
+ assert.deepEqual(values("btw "), ["btw --rarebits ", "btw --readonly "]);
63
+ assert.deepEqual(values("btw --ra"), ["btw --rarebits "]);
64
+ assert.deepEqual(values("btw --rarebits "), ["btw --rarebits --readonly "]);
65
+ assert.equal(values("btw --rarebits --readonly "), null);
66
+ assert.equal(values("btw why "), null);
67
+ });
68
+
69
+ test("rarebit.btw.model is optional and validated like rarebit.model", () => {
70
+ assert.equal(resolveRarebitSettings({ rarebit: { model: "a/b" } }).btwModel, undefined);
71
+ assert.deepEqual(resolveRarebitSettings({ rarebit: { btw: { model: "p/m:low" } } }).btwModel, { provider: "p", id: "m:low" });
72
+ assert.match(resolveRarebitSettings({ rarebit: { btw: { model: "bad" } } }).btwModelConfigurationError, /rarebit\.btw\.model/);
73
+ });
74
+
75
+ test("a mid-turn snapshot drops the assistant message with unresolved tool calls", () => {
76
+ const pending = assistant("a3", "a2", [{ type: "toolCall", id: "c2", name: "read", arguments: {} }], "toolUse");
77
+ assert.deepEqual(completedBranchPrefix([...settledBranch, pending]).map((e) => e.id), ["u1", "a1", "t1", "a2"]);
78
+ assert.equal(completedBranchPrefix(settledBranch), settledBranch);
79
+ });
80
+
81
+ test("full BTW writes the active branch under a new Session and a self-deleting launcher", async (t) => {
82
+ const tempRoot = await mkdtemp(join(tmpdir(), "rarebit-btw-test-"));
83
+ t.after(() => rm(tempRoot, { recursive: true, force: true }));
84
+ const btw = await prepareRarebitBtw(contextFor(settledBranch), {
85
+ question: "what was alpha?", tempRoot, thinking: "low", activeTools: ["read", "bash", "team_sync"],
86
+ });
87
+ const lines = (await readFile(btw.sessionFile, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
88
+ assert.equal(lines[0].type, "session");
89
+ assert.notEqual(lines[0].id, "source-id");
90
+ assert.equal(lines[0].parentSession, "/synthetic/source.jsonl");
91
+ assert.deepEqual(lines.slice(1).map((e) => e.id), ["u1", "a1", "t1", "a2"]);
92
+ assert.equal((await stat(btw.directory)).mode & 0o777, 0o700);
93
+ const launcher = await readFile(btw.launcherPath, "utf8");
94
+ assert.match(launcher, /trap .*rm -rf .*hc-rarebit-btw-.* EXIT/);
95
+ for (const flag of ["--tools' 'read,bash,team_sync", "--model' 'synthetic/model", "--thinking' 'low", "'--' 'what was alpha?'"])
96
+ assert.ok(launcher.includes(flag), flag);
97
+ assert.ok(!launcher.includes("--no-extensions"));
98
+ await btw.discard();
99
+ });
100
+
101
+ test("read-only BTW drops extensions and limits tools; a configured model drops parent thinking", async (t) => {
102
+ const tempRoot = await mkdtemp(join(tmpdir(), "rarebit-btw-test-"));
103
+ t.after(() => rm(tempRoot, { recursive: true, force: true }));
104
+ const btw = await prepareRarebitBtw(contextFor(settledBranch), {
105
+ tempRoot, readonly: true, thinking: "high", activeTools: ["bash"], model: { provider: "p", id: "m:low" },
106
+ });
107
+ const launcher = await readFile(btw.launcherPath, "utf8");
108
+ for (const flag of ["--tools' 'read,grep,find,ls", "--no-extensions", "--no-mcp", "--model' 'p/m:low", "Do not modify files"])
109
+ assert.ok(launcher.includes(flag), flag);
110
+ assert.ok(!launcher.includes("--thinking"));
111
+ assert.ok(!launcher.includes("bash"));
112
+ });
113
+
114
+ test("Rarebits BTW keeps only user requests and final replies", async (t) => {
115
+ const tempRoot = await mkdtemp(join(tmpdir(), "rarebit-btw-test-"));
116
+ t.after(() => rm(tempRoot, { recursive: true, force: true }));
117
+ const btw = await prepareRarebitBtw(contextFor(settledBranch), { mode: "rarebits", tempRoot });
118
+ const texts = (await readFile(btw.sessionFile, "utf8")).trim().split("\n").map((line) => JSON.parse(line))
119
+ .filter((entry) => entry.type === "message" && entry.rarebitFork?.kind === "import")
120
+ .map((entry) => entry.message.role);
121
+ assert.deepEqual(texts, ["user", "assistant"]);
122
+ assert.doesNotMatch(await readFile(btw.launcherPath, "utf8"), /' '--' '/);
123
+ });
124
+
125
+ test("the pane opener replaces another BTW pane in the same tab only, never its own", async () => {
126
+ const calls = [];
127
+ const replies = {
128
+ "pane get": { result: { pane: { tab_id: "tab-1" } } },
129
+ "pane list": { result: { panes: [
130
+ { pane_id: "old", tab_id: "tab-1", label: RAREBIT_BTW_PANE_LABEL },
131
+ { pane_id: "other-tab", tab_id: "tab-2", label: RAREBIT_BTW_PANE_LABEL },
132
+ { pane_id: "main", tab_id: "tab-1", label: RAREBIT_BTW_PANE_LABEL },
133
+ ] } },
134
+ "pane split": { result: { pane: { pane_id: "new" } } },
135
+ };
136
+ const run = async (_bin, args) => {
137
+ calls.push(args.join(" "));
138
+ return { stdout: JSON.stringify(replies[args.slice(0, 2).join(" ")] ?? {}) };
139
+ };
140
+ const opened = await openRarebitBtwPane({ launcherPath: "/tmp/x/launch.sh", cwd: "/w", paneId: "main", run });
141
+ assert.equal(opened.paneId, "new");
142
+ assert.deepEqual(calls.filter((c) => c.startsWith("pane close")), ["pane close old"]);
143
+ assert.ok(calls.includes(`pane rename new ${RAREBIT_BTW_PANE_LABEL}`));
144
+ assert.ok(calls.includes("pane run new exec sh '/tmp/x/launch.sh'"));
145
+ });
146
+
147
+ test("the cleanup watcher is detached and removes the snapshot after the pane is gone", () => {
148
+ let spawned;
149
+ watchRarebitBtwPane({
150
+ paneId: "w1:p1", directory: "/tmp/hc-rarebit-btw-x", herdrBin: "herdr",
151
+ spawnImpl: (command, args, options) => {
152
+ spawned = { command, args, options };
153
+ return { unref() {} };
154
+ },
155
+ });
156
+ assert.equal(spawned.options.detached, true);
157
+ assert.match(spawned.args[1], /while \[ -d '\/tmp\/hc-rarebit-btw-x' \] && 'herdr' pane get 'w1:p1'.*done; rm -rf '\/tmp\/hc-rarebit-btw-x'/);
158
+ });
@@ -14,11 +14,11 @@ const values = (prefix) =>
14
14
  test("one grammar derives command description and usage", () => {
15
15
  assert.equal(
16
16
  rarebitCommandDescription(),
17
- "Rarebit menu/settings/view/help/status/recall/fork/config/auto-title/title/recap/got-it/summarize",
17
+ "Rarebit menu/settings/view/help/status/recall/btw/fork/config/auto-title/title/recap/got-it/summarize",
18
18
  );
19
19
  assert.equal(
20
20
  rarebitCommandUsage(),
21
- "Usage: /rarebit [menu|settings|view|help|status|recall|fork|config|auto-title|title|recap|got-it|summarize]",
21
+ "Usage: /rarebit [menu|settings|view|help|status|recall|btw|fork|config|auto-title|title|recap|got-it|summarize]",
22
22
  );
23
23
  assert.equal(
24
24
  rarebitCommandUsage("config"),
@@ -105,6 +105,7 @@ test("autocomplete covers subcommands and discrete nested arguments", () => {
105
105
  "help",
106
106
  "status",
107
107
  "recall",
108
+ "btw",
108
109
  "fork",
109
110
  "config",
110
111
  "auto-title",