pi-btw 0.7.1 → 0.8.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/README.md CHANGED
@@ -25,9 +25,9 @@ A small [pi](https://github.com/earendil-works/pi-mono) extension that adds a `/
25
25
 
26
26
  pi-btw supports Pi 0.85.1 through 1.x.
27
27
 
28
- Development dependencies target Pi 0.99.2. CI runs the tests and typecheck
28
+ Development dependencies target Pi 1.5.0. CI runs the tests and typecheck
29
29
  against the locked dependencies, Pi 0.85.1, and the latest published Pi release
30
- on each PR and weekly. Pi 0.99.2 requires Node.js 22.19.0 or newer.
30
+ on each PR and weekly. Pi 1.5.0 requires Node.js 22.19.0 or newer.
31
31
 
32
32
  ### From npm (after publish)
33
33
 
@@ -220,16 +220,57 @@ sharing the parent's cached extension factory, but extensions may still use
220
220
  shared files or external services. A local source already registered by the
221
221
  parent is rejected; use an `npm:` or `git:` source for a separate install.
222
222
 
223
- `/btw:ask` never reads this configuration or loads extension tools, so it retains
224
- its built-in read-only tool set. `/btw:summarize` remains tool-free. Extension
223
+ `/btw:ask` ignores the `extensions` setting and never loads extension tools.
224
+ It reads only the workspace selection and retains its read-only tool set. `/btw:summarize` remains tool-free. Extension
225
225
  configuration is machine/project configuration, not persisted in hidden
226
226
  conversation-history entries.
227
227
 
228
+ ### SSH workspaces
229
+
230
+ BTW uses local tools by default, even when the parent uses an SSH workspace.
231
+ The overlay identifies its workspace, and the capability note tells the child
232
+ where its tools operate. To use the active parent SSH workspace, set this in
233
+ `~/.pi/agent/btw.json` or a trusted project's `.pi/btw.json`:
234
+
235
+ ```json
236
+ {
237
+ "workspace": "ssh"
238
+ }
239
+ ```
240
+
241
+ This requires `@99percentpeople/pi-ssh-remote` 0.6.2 or newer in the parent.
242
+ Do not add that package to BTW's `extensions` list. BTW uses its public transport
243
+ and remote adapter APIs directly, creates a separate connection to the parent's
244
+ selected target/port/directory, and never runs its extension factory or lifecycle
245
+ handlers. Authentication uses SSH keys, your SSH agent, and the parent's SSH
246
+ config file; BTW does not copy passwords or open password dialogs.
247
+
248
+ `/btw`, `/side`, and `/btw:tangent` expose remote `read`, `bash`, `edit`, and
249
+ `write`. `/btw:ask` exposes only remote `read`, `grep`, `find`, and `ls` and
250
+ still loads no arbitrary extensions. Connection and tool failures are reported;
251
+ there is no fallback to local operations. Local instructions and session files
252
+ remain local, and remote shells use the remote environment.
253
+
254
+ Use `/btw:clear` after changing the parent host/directory, running `/ssh-exit`,
255
+ or changing workspace configuration. An existing remote thread blocks further
256
+ operations after parent workspace changes or disconnects, and saved threads
257
+ cannot resume against a different workspace. A new SSH thread requires an active
258
+ parent SSH workspace. Set `"workspace": "local"` to select local tools explicitly.
259
+ The project setting overrides the global setting independently of `extensions`;
260
+ untrusted project settings are ignored.
261
+
228
262
  ### In-modal slash behavior
229
263
 
264
+ Type `/` at the start of the composer to see BTW-owned commands. Keep typing to
265
+ filter, use Up/Down to select, and Tab or Enter to complete a partial command.
266
+ Completion inserts the command and a trailing space without submitting. Enter on
267
+ an exact command or with arguments submits normally. Escape closes the list
268
+ first; a subsequent Escape keeps the existing abort/dismiss behavior. The menu
269
+ shares space with the transcript rather than growing the modal.
270
+
230
271
  Inside the BTW modal composer, slash handling is split at the BTW/session boundary:
231
272
 
232
- - `/btw:new`, `/btw:tangent`, `/btw:ask`, `/btw:clear`, `/btw:model`, `/btw:thinking`, `/btw:inject`, and `/btw:summarize` stay owned by BTW because they control BTW lifecycle, configuration, or handoff behavior
273
+ - `/btw`, `/side`, `/btw:new`, `/btw:tangent`, `/btw:ask`, `/btw:clear`, `/btw:model`, `/btw:thinking`, `/btw:inject`, and `/btw:summarize` stay owned by BTW because they control BTW lifecycle, configuration, or handoff behavior
233
274
  - any other slash-prefixed input is routed through the BTW sub-session's normal `prompt()` path
234
275
  - this means ordinary pi slash commands like `/help` are handled by the sub-session instead of being rejected by a modal-only fallback
235
276
  - if the sub-session cannot handle a slash command, BTW surfaces the real sub-session failure through the transcript/status state instead of inventing an "unsupported slash input" warning
@@ -15,7 +15,9 @@ function resolveSource(source: string, configPath: string): string {
15
15
  return resolve(dirname(configPath), source);
16
16
  }
17
17
 
18
- async function readSources(path: string): Promise<string[] | undefined> {
18
+ type BtwConfig = { extensions?: string[]; workspace?: "local" | "ssh" };
19
+
20
+ async function readConfig(path: string, includeExtensions = true): Promise<BtwConfig | undefined> {
19
21
  let text: string;
20
22
  try {
21
23
  text = await readFile(path, "utf8");
@@ -34,14 +36,19 @@ async function readSources(path: string): Promise<string[] | undefined> {
34
36
  throw new Error(`Invalid BTW config ${path}: expected an object.`);
35
37
  }
36
38
  const fields = Object.keys(config);
37
- if (fields.some((field) => field !== "extensions")) {
38
- throw new Error(`Invalid BTW config ${path}: only the "extensions" key is supported.`);
39
+ if (fields.some((field) => field !== "extensions" && field !== "workspace")) {
40
+ throw new Error(`Invalid BTW config ${path}: only "extensions" and "workspace" keys are supported.`);
41
+ }
42
+ if ("workspace" in config && config.workspace !== "local" && config.workspace !== "ssh") {
43
+ throw new Error(`Invalid BTW config ${path}: "workspace" must be "local" or "ssh".`);
39
44
  }
40
- if (!("extensions" in config)) return undefined;
41
- if (!Array.isArray(config.extensions) || config.extensions.some((source) => typeof source !== "string" || !source.trim())) {
45
+ if (includeExtensions && "extensions" in config && (!Array.isArray(config.extensions) || config.extensions.some((source) => typeof source !== "string" || !source.trim()))) {
42
46
  throw new Error(`Invalid BTW config ${path}: "extensions" must be an array of nonempty source strings.`);
43
47
  }
44
- return [...new Set(config.extensions.map((source: string) => resolveSource(source.trim(), path)))];
48
+ return {
49
+ ...(includeExtensions && "extensions" in config && Array.isArray(config.extensions) ? { extensions: [...new Set(config.extensions.map((source: string) => resolveSource(source.trim(), path)))] } : {}),
50
+ ...("workspace" in config ? { workspace: config.workspace as "local" | "ssh" } : {}),
51
+ };
45
52
  }
46
53
 
47
54
  /** Project lists replace global lists, including an explicit empty list. */
@@ -50,11 +57,22 @@ export async function readBtwExtensionSources(options: {
50
57
  agentDir: string;
51
58
  projectTrusted: boolean;
52
59
  }): Promise<string[]> {
53
- const globalSources = await readSources(join(options.agentDir, "btw.json"));
60
+ const globalSources = await readConfig(join(options.agentDir, "btw.json"));
54
61
  const projectSources = options.projectTrusted
55
- ? await readSources(join(options.cwd, ".pi", "btw.json"))
62
+ ? await readConfig(join(options.cwd, ".pi", "btw.json"))
56
63
  : undefined;
57
- return projectSources ?? globalSources ?? [];
64
+ return projectSources?.extensions ?? globalSources?.extensions ?? [];
65
+ }
66
+
67
+ /** Workspace selection is independent of extension tool loading, including ask mode. */
68
+ export async function readBtwWorkspaceMode(options: {
69
+ cwd: string;
70
+ agentDir: string;
71
+ projectTrusted: boolean;
72
+ }): Promise<"local" | "ssh"> {
73
+ const globalConfig = await readConfig(join(options.agentDir, "btw.json"), false);
74
+ const projectConfig = options.projectTrusted ? await readConfig(join(options.cwd, ".pi", "btw.json"), false) : undefined;
75
+ return projectConfig?.workspace ?? globalConfig?.workspace ?? "local";
58
76
  }
59
77
 
60
78
  async function canonicalPath(path: string): Promise<string> {
@@ -0,0 +1,254 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import {
3
+ createReadToolDefinition, createWriteToolDefinition, createEditToolDefinition,
4
+ createBashToolDefinition, createGrepToolDefinition, createFindToolDefinition,
5
+ createLsToolDefinition, defineTool, truncateHead, truncateLine,
6
+ type ExtensionAPI, type ExtensionCommandContext, type ToolDefinition,
7
+ type AgentToolResult, type BashOperations,
8
+ } from "@earendil-works/pi-coding-agent";
9
+
10
+ type SshState = {
11
+ target: string; port?: number; configFile?: string; remoteCwd: string;
12
+ remoteHome: string; remotePlatform: string; remoteShell: string;
13
+ };
14
+ type Workspace = { cwd: string; home: string; platform: string; shell: string };
15
+ type Entry = { path: string; isDirectory: boolean };
16
+ type Match = { path: string; toolPath: string; lineNumber: number; line: string };
17
+
18
+ // The published SSH extension exposes these backend APIs independently of its
19
+ // default extension factory. Importing them does not start or stop the parent.
20
+ type Adapter = {
21
+ toToolPath(path: string, workspace: Workspace): string;
22
+ mapCwd(path: string, localCwd: string, workspace: Workspace): string;
23
+ readFile(path: string, signal?: AbortSignal): Promise<Buffer>;
24
+ access(path: string, mode: "read" | "write", signal?: AbortSignal): Promise<void>;
25
+ detectImageMimeType(path: string, signal?: AbortSignal): Promise<string | null>;
26
+ mkdir(path: string, signal?: AbortSignal): Promise<void>;
27
+ writeFile(path: string, data: Buffer | string, signal?: AbortSignal): Promise<void>;
28
+ runShell(command: string, cwd: string, options: {
29
+ signal?: AbortSignal; timeoutSeconds?: number; env?: NodeJS.ProcessEnv;
30
+ captureOutput: boolean; onStdout: (data: Buffer) => void; onStderr: (data: Buffer) => void;
31
+ }): Promise<number | null>;
32
+ listDirectory(path: string, signal?: AbortSignal): Promise<Array<{ name: string; isDirectory: boolean }>>;
33
+ findEntries(path: string, pattern: string, limit: number, signal?: AbortSignal): Promise<Entry[]>;
34
+ grep(path: string, pattern: string, options: {
35
+ glob?: string; ignoreCase?: boolean; literal?: boolean; limit: number;
36
+ }, signal?: AbortSignal): Promise<Match[]>;
37
+ };
38
+ type Client = { dispose(): Promise<void>; onDisconnect?(handler: (error: Error) => void): () => void };
39
+ type SshApi = {
40
+ findSshEnvironmentState(entries: readonly unknown[]): { mode: "local" } | { mode: "remote"; session: SshState } | undefined;
41
+ createSshTransportClient(options: {
42
+ target: string; port?: number; configFile?: string; batchMode: boolean; connectTimeoutSeconds: number;
43
+ }, settings: { platform: NodeJS.Platform; preference: "auto" }): Client;
44
+ selectRemoteAdapter(client: Client, options: {
45
+ localPlatform: NodeJS.Platform; preference: string; expectedPlatform: string;
46
+ expectedShell: string; requestedCwd: string;
47
+ }): Promise<{ adapter: Adapter; workspace: Workspace }>;
48
+ };
49
+
50
+ export type BtwSshWorkspace = {
51
+ label: string;
52
+ identity: string;
53
+ tools: ToolDefinition[];
54
+ assertCurrent(): void;
55
+ dispose(): Promise<void>;
56
+ };
57
+
58
+ function limit(value: number | undefined, fallback: number): number {
59
+ if (value === undefined) return fallback;
60
+ if (!Number.isFinite(value) || value <= 0) throw new Error("Limit must be a positive finite number.");
61
+ return Math.max(1, Math.floor(value));
62
+ }
63
+
64
+ type SearchDetails = {
65
+ truncation?: ReturnType<typeof truncateHead>; entryLimitReached?: number;
66
+ resultLimitReached?: number; matchLimitReached?: number;
67
+ };
68
+
69
+ function textResult(text: string, details: SearchDetails = {}): AgentToolResult<SearchDetails> {
70
+ const truncation = truncateHead(text, { maxLines: Number.MAX_SAFE_INTEGER });
71
+ if (truncation.truncated) details.truncation = truncation;
72
+ const notices = Object.entries(details).filter(([key]) => key.endsWith("LimitReached"))
73
+ .map(([, value]) => `${value} results limit reached; increase limit or narrow the search.`);
74
+ if (truncation.truncated) notices.push("Output truncated; narrow the search.");
75
+ return {
76
+ content: [{ type: "text", text: truncation.content + (notices.length ? `\n\n[${notices.join(" ")}]` : "") }],
77
+ details,
78
+ };
79
+ }
80
+
81
+ async function displayFileErrors<T extends AgentToolResult<unknown>>(
82
+ displayPath: string, toolPath: string, run: () => Promise<T>,
83
+ ): Promise<T> {
84
+ try {
85
+ // Successful results may contain literal file contents or diff lines.
86
+ // Never rewrite them just to make encoded remote paths prettier.
87
+ return await run();
88
+ } catch (error) {
89
+ throw new Error((error instanceof Error ? error.message : String(error)).replaceAll(toolPath, displayPath));
90
+ }
91
+ }
92
+
93
+ export async function createBtwSshWorkspace(
94
+ pi: ExtensionAPI,
95
+ ctx: ExtensionCommandContext,
96
+ readonly: boolean,
97
+ ): Promise<BtwSshWorkspace> {
98
+ const source = pi.getAllTools().find((tool) => tool.name === "read")?.sourceInfo.path;
99
+ if (!source || source.startsWith("<")) {
100
+ throw new Error('workspace: "ssh" requires the parent pi-ssh-remote extension (0.6.2 or newer).');
101
+ }
102
+ const api = await import(/* @vite-ignore */ pathToFileURL(source).href) as Partial<SshApi>;
103
+ if (!api.findSshEnvironmentState || !api.createSshTransportClient || !api.selectRemoteAdapter) {
104
+ throw new Error("The parent read backend does not expose the supported pi-ssh-remote APIs.");
105
+ }
106
+ const getState = () => api.findSshEnvironmentState!(ctx.sessionManager.getBranch());
107
+ const environment = getState();
108
+ if (environment?.mode !== "remote") {
109
+ throw new Error('No active parent SSH workspace. Connect SSH first, or select workspace: "local".');
110
+ }
111
+ const state = environment.session;
112
+ const signature = JSON.stringify(state);
113
+ let unavailable: string | undefined;
114
+ let disposed = false;
115
+ const assertCurrent = () => {
116
+ const current = getState();
117
+ if (disposed || unavailable || current?.mode !== "remote" || JSON.stringify(current.session) !== signature) {
118
+ throw new Error(`${unavailable ?? "Parent SSH workspace changed."} Run /btw:clear before continuing.`);
119
+ }
120
+ };
121
+ const unsubscribe = pi.events.on("ssh-remote:environment", () => {
122
+ unavailable = "Parent SSH workspace changed.";
123
+ });
124
+ let client: Client | undefined;
125
+ let removeDisconnectListener: (() => void) | undefined;
126
+ const dispose = async () => {
127
+ if (disposed) return;
128
+ disposed = true;
129
+ unsubscribe();
130
+ removeDisconnectListener?.();
131
+ await client?.dispose();
132
+ };
133
+
134
+ try {
135
+ client = api.createSshTransportClient({
136
+ target: state.target, port: state.port, configFile: state.configFile,
137
+ batchMode: true, connectTimeoutSeconds: 10,
138
+ }, { platform: process.platform, preference: "auto" });
139
+ const { adapter, workspace } = await api.selectRemoteAdapter(client, {
140
+ localPlatform: process.platform, preference: state.remoteShell,
141
+ expectedPlatform: state.remotePlatform, expectedShell: state.remoteShell, requestedCwd: state.remoteCwd,
142
+ });
143
+ removeDisconnectListener = client.onDisconnect?.((error) => { unavailable = `BTW SSH disconnected: ${error.message}`; });
144
+ assertCurrent();
145
+ const cwd = adapter.toToolPath(workspace.cwd, workspace);
146
+ const filePath = (path: string) => adapter.toToolPath(path, workspace);
147
+ const searchPath = (path?: string) => adapter.toToolPath(adapter.mapCwd(path || ".", ctx.cwd, workspace), workspace);
148
+ const read = createReadToolDefinition(cwd);
149
+ const tools: ToolDefinition[] = [defineTool({
150
+ ...read,
151
+ async execute(id, args, signal, onUpdate, toolCtx) {
152
+ assertCurrent();
153
+ const path = filePath(args.path);
154
+ return displayFileErrors(args.path, path, () => createReadToolDefinition(cwd, { operations: {
155
+ readFile: (path) => adapter.readFile(path, signal),
156
+ access: (path) => adapter.access(path, "read", signal),
157
+ detectImageMimeType: (path) => adapter.detectImageMimeType(path, signal),
158
+ } }).execute(id, { ...args, path }, signal, onUpdate, toolCtx));
159
+ },
160
+ })];
161
+
162
+ if (readonly) {
163
+ tools.push(defineTool({
164
+ ...createLsToolDefinition(cwd),
165
+ async execute(_id, args, signal) {
166
+ assertCurrent();
167
+ const count = limit(args.limit, 500);
168
+ const entries = (await adapter.listDirectory(searchPath(args.path), signal))
169
+ .sort((a, b) => a.name.localeCompare(b.name));
170
+ return textResult(entries.slice(0, count).map((entry) => entry.name + (entry.isDirectory ? "/" : "")).join("\n") || "(empty directory)",
171
+ entries.length > count ? { entryLimitReached: count } : {});
172
+ },
173
+ }), defineTool({
174
+ ...createFindToolDefinition(cwd),
175
+ async execute(_id, args, signal) {
176
+ assertCurrent();
177
+ const count = limit(args.limit, 1000);
178
+ const entries = await adapter.findEntries(searchPath(args.path), args.pattern, count + 1, signal);
179
+ return textResult(entries.slice(0, count).map((entry) => entry.path + (entry.isDirectory ? "/" : "")).join("\n") || "No files found matching pattern",
180
+ entries.length > count ? { resultLimitReached: count } : {});
181
+ },
182
+ }), defineTool({
183
+ ...createGrepToolDefinition(cwd),
184
+ async execute(_id, args, signal) {
185
+ assertCurrent();
186
+ const count = limit(args.limit, 100);
187
+ const matches = await adapter.grep(searchPath(args.path), args.pattern, {
188
+ glob: args.glob, ignoreCase: args.ignoreCase, literal: args.literal, limit: count + 1,
189
+ }, signal);
190
+ const context = args.context === undefined ? 0 : Math.max(0, Math.floor(args.context));
191
+ if (!Number.isFinite(context)) throw new Error("Context must be a finite number.");
192
+ const lines: string[] = [];
193
+ const files = new Map<string, string[]>();
194
+ for (const match of matches.slice(0, count)) {
195
+ signal?.throwIfAborted();
196
+ if (!context) {
197
+ lines.push(`${match.path}:${match.lineNumber}: ${truncateLine(match.line).text}`);
198
+ continue;
199
+ }
200
+ let content = files.get(match.toolPath);
201
+ if (!content) {
202
+ content = (await adapter.readFile(match.toolPath, signal)).toString("utf8").replace(/\r\n/g, "\n").split("\n");
203
+ files.set(match.toolPath, content);
204
+ }
205
+ for (let line = Math.max(1, match.lineNumber - context); line <= Math.min(content.length, match.lineNumber + context); line++) {
206
+ const separator = line === match.lineNumber ? ":" : "-";
207
+ lines.push(`${match.path}${separator}${line}${separator} ${truncateLine(content[line - 1]).text}`);
208
+ }
209
+ }
210
+ return textResult(lines.join("\n") || "No matches found", matches.length > count ? { matchLimitReached: count } : {});
211
+ },
212
+ }));
213
+ } else {
214
+ tools.push(defineTool({
215
+ ...createWriteToolDefinition(cwd),
216
+ async execute(id, args, signal, onUpdate, toolCtx) {
217
+ assertCurrent();
218
+ const path = filePath(args.path);
219
+ return displayFileErrors(args.path, path, () => createWriteToolDefinition(cwd, { operations: {
220
+ mkdir: (path) => adapter.mkdir(path, signal),
221
+ writeFile: (path, data) => adapter.writeFile(path, data, signal),
222
+ } }).execute(id, { ...args, path }, signal, onUpdate, toolCtx));
223
+ },
224
+ }), defineTool({
225
+ ...createEditToolDefinition(cwd),
226
+ async execute(id, args, signal, onUpdate, toolCtx) {
227
+ assertCurrent();
228
+ const path = filePath(args.path);
229
+ return displayFileErrors(args.path, path, () => createEditToolDefinition(cwd, { operations: {
230
+ readFile: (path) => adapter.readFile(path, signal),
231
+ access: (path) => adapter.access(path, "write", signal),
232
+ writeFile: (path, data) => adapter.writeFile(path, data, signal),
233
+ } }).execute(id, { ...args, path }, signal, onUpdate, toolCtx));
234
+ },
235
+ }), defineTool({
236
+ ...createBashToolDefinition(cwd),
237
+ async execute(id, args, signal, onUpdate, toolCtx) {
238
+ assertCurrent();
239
+ const operations: BashOperations = {
240
+ exec: async (command, _cwd, options) => ({ exitCode: await adapter.runShell(command, workspace.cwd, {
241
+ signal: options.signal, timeoutSeconds: options.timeout,
242
+ captureOutput: false, onStdout: options.onData, onStderr: options.onData,
243
+ }) }),
244
+ };
245
+ return createBashToolDefinition(cwd, { operations, exposeSessionEnvironment: false }).execute(id, args, signal, onUpdate, toolCtx);
246
+ },
247
+ }));
248
+ }
249
+ return { label: `SSH ${state.target}${state.port ? `:${state.port}` : ""}:${workspace.cwd}`, identity: `ssh:${signature}`, tools, assertCurrent, dispose };
250
+ } catch (error) {
251
+ await dispose();
252
+ throw new Error(`Could not connect BTW SSH workspace: ${error instanceof Error ? error.message : String(error)}`);
253
+ }
254
+ }
package/extensions/btw.ts CHANGED
@@ -39,7 +39,21 @@ import {
39
39
  type OverlayOptions,
40
40
  type TUI,
41
41
  } from "@earendil-works/pi-tui";
42
- import { loadBtwExtensionResources, readBtwExtensionSources } from "./btw-extension-tools";
42
+ import { loadBtwExtensionResources, readBtwExtensionSources, readBtwWorkspaceMode } from "./btw-extension-tools";
43
+ import { createBtwSshWorkspace, type BtwSshWorkspace } from "./btw-ssh-workspace";
44
+
45
+ export const BTW_COMMANDS = [
46
+ { name: "btw", description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note." },
47
+ { name: "side", description: "Alias for /btw: continue a side conversation in a focused BTW modal." },
48
+ { name: "btw:tangent", description: "Start or continue a contextless BTW tangent in the focused BTW modal." },
49
+ { name: "btw:ask", description: "Ask a read-only side question: inherits main-session context but exposes only read/grep/find/ls tools." },
50
+ { name: "btw:new", description: "Start a fresh BTW thread with main-session context. Optionally ask the first question immediately." },
51
+ { name: "btw:clear", description: "Dismiss the BTW modal/widget and clear the current thread." },
52
+ { name: "btw:inject", description: "Inject the full BTW thread into the main agent as a user message." },
53
+ { name: "btw:summarize", description: "Summarize the BTW thread, then inject the summary into the main agent." },
54
+ { name: "btw:model", description: "Show, set, or clear the BTW-only model override." },
55
+ { name: "btw:thinking", description: "Show, set, or clear the BTW-only thinking override." },
56
+ ] as const;
43
57
 
44
58
  const BTW_MESSAGE_TYPE = "btw-note";
45
59
  const BTW_ENTRY_TYPE = "btw-thread-entry";
@@ -196,6 +210,7 @@ type BtwDetails = {
196
210
  thinkingLevel: SessionThinkingLevel;
197
211
  timestamp: number;
198
212
  usage?: AssistantMessage["usage"];
213
+ workspaceIdentity?: string;
199
214
  };
200
215
 
201
216
  type ParsedBtwArgs = {
@@ -273,6 +288,8 @@ type BtwSessionRuntime = {
273
288
  abortPromise?: Promise<void>;
274
289
  promptQueue: Promise<void>;
275
290
  hasExtensions: boolean;
291
+ workspace?: BtwSshWorkspace;
292
+ workspaceIdentity: string;
276
293
  };
277
294
 
278
295
  type OverlayRuntime = {
@@ -294,8 +311,11 @@ function isCustomEntry(entry: unknown, customType: string): entry is { type: "cu
294
311
 
295
312
  function stripDynamicSystemPromptFooter(systemPrompt: string): string {
296
313
  return systemPrompt
297
- .replace(/\nCurrent date and time:[^\n]*(?:\nCurrent working directory:[^\n]*)?$/u, "")
298
- .replace(/\nCurrent working directory:[^\n]*$/u, "")
314
+ // Older Pi ends a custom prompt's footer with a newline, so allow trailing whitespace.
315
+ .replace(/\nCurrent date and time:[^\n]*(?:\nCurrent working directory:[^\n]*)?\s*$/u, "")
316
+ .replace(/\nCurrent working directory:[^\n]*\s*$/u, "")
317
+ // Pi 0.99+ renders the cwd as a `<cwd>` section, which the child adds again itself.
318
+ .replace(/(?:^|\n\n)<cwd>\n[^\n]*\n<\/cwd>(?=\n\n|$)/gu, "")
299
319
  .trim();
300
320
  }
301
321
 
@@ -304,6 +324,7 @@ function createBtwResourceLoader(
304
324
  tools: readonly string[] | (() => readonly string[]),
305
325
  appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT],
306
326
  extensionResources?: ResourceLoader,
327
+ workspaceLabel = `local ${ctx.cwd}`,
307
328
  ): ResourceLoader {
308
329
  const extensionsResult = extensionResources?.getExtensions() ?? { extensions: [], errors: [], runtime: createExtensionRuntime() };
309
330
  const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt());
@@ -313,6 +334,7 @@ function createBtwResourceLoader(
313
334
  const availableTools = typeof tools === "function" ? tools() : tools;
314
335
  return [
315
336
  "<btw_capabilities>",
337
+ `Workspace for built-in file and shell tools: ${workspaceLabel}.`,
316
338
  availableTools.length > 0
317
339
  ? `Available tools in this BTW session: ${availableTools.join(", ")}.`
318
340
  : "No tools are available in this BTW session.",
@@ -475,6 +497,15 @@ const BTW_TOOLS_BY_MODE: Record<BtwThreadMode, readonly string[]> = {
475
497
  readonly: ["read", "grep", "find", "ls"],
476
498
  };
477
499
 
500
+ function withoutParentToolDeclarations(message: Message): Message {
501
+ if ((message as { role: string }).role !== "system") return message;
502
+ const { toolsAdded: _added, toolsRemoved: _removed, ...rest } = message as Message & {
503
+ toolsAdded?: unknown;
504
+ toolsRemoved?: unknown;
505
+ };
506
+ return rest as Message;
507
+ }
508
+
478
509
  function buildBtwSeedState(
479
510
  ctx: ExtensionCommandContext,
480
511
  thread: BtwDetails[],
@@ -488,7 +519,7 @@ function buildBtwSeedState(
488
519
  messages.push(
489
520
  ...(buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages as Message[]).filter(
490
521
  (message) => !isVisibleBtwMessage(message),
491
- ),
522
+ ).map(withoutParentToolDeclarations),
492
523
  );
493
524
  } catch {
494
525
  messages.push(
@@ -502,7 +533,9 @@ function buildBtwSeedState(
502
533
  return [];
503
534
  }
504
535
 
505
- return isVisibleBtwMessage({ role: message.role, customType: message.customType }) ? [] : [message as Message];
536
+ return isVisibleBtwMessage({ role: message.role, customType: message.customType })
537
+ ? []
538
+ : [withoutParentToolDeclarations(message as Message)];
506
539
  }),
507
540
  );
508
541
  }
@@ -1284,8 +1317,9 @@ function buildTranscriptBadge(
1284
1317
  return theme.bg(background, theme.fg(foreground, theme.bold(` ${label} `)));
1285
1318
  }
1286
1319
 
1287
- class BtwOverlayComponent extends Container implements Focusable {
1320
+ export class BtwOverlayComponent extends Container implements Focusable {
1288
1321
  private readonly input: Input;
1322
+ private readonly handleRawInput: (data: string) => void;
1289
1323
  private readonly transcript: Container;
1290
1324
  private readonly statusText: Text;
1291
1325
  private readonly modeText: Text;
@@ -1295,6 +1329,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1295
1329
  private readonly getStatus: () => string | null;
1296
1330
  private readonly getMode: () => BtwThreadMode;
1297
1331
  private readonly getWidthMode: () => BtwOverlayWidthMode;
1332
+ private readonly getWorkspaceLabel: () => string;
1298
1333
  private readonly onSubmitCallback: (value: string) => void;
1299
1334
  private readonly onDismissCallback: () => void;
1300
1335
  private readonly onUnfocusCallback: () => void;
@@ -1313,6 +1348,11 @@ class BtwOverlayComponent extends Container implements Focusable {
1313
1348
  private summaryTextValue = "";
1314
1349
  private statusTextValue = "";
1315
1350
  private hintsTextValue = "";
1351
+ private readonly keybindings: KeybindingsManager;
1352
+ private completionQuery = "";
1353
+ private completionDismissedFor: string | null = null;
1354
+ private completionItems: typeof BTW_COMMANDS[number][] = [];
1355
+ private completionIndex = 0;
1316
1356
 
1317
1357
  get focused(): boolean {
1318
1358
  return this._focused;
@@ -1335,10 +1375,12 @@ class BtwOverlayComponent extends Container implements Focusable {
1335
1375
  onDismiss: () => void,
1336
1376
  onUnfocus: () => void,
1337
1377
  onToggleWidth: () => void,
1378
+ getWorkspaceLabel: () => string = () => "local",
1338
1379
  ) {
1339
1380
  super();
1340
1381
  this.tui = tui;
1341
1382
  this.theme = theme;
1383
+ this.keybindings = keybindings;
1342
1384
  this.markdownTheme = getMarkdownTheme();
1343
1385
  // Fullscreen Pi owns mouse reporting for the entire terminal session. In
1344
1386
  // regular mode BTW manages it while the overlay exists.
@@ -1347,6 +1389,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1347
1389
  this.getStatus = getStatus;
1348
1390
  this.getMode = getMode;
1349
1391
  this.getWidthMode = getWidthMode;
1392
+ this.getWorkspaceLabel = getWorkspaceLabel;
1350
1393
  this.onSubmitCallback = onSubmit;
1351
1394
  this.onDismissCallback = onDismiss;
1352
1395
  this.onUnfocusCallback = onUnfocus;
@@ -1373,11 +1416,11 @@ class BtwOverlayComponent extends Container implements Focusable {
1373
1416
  }
1374
1417
 
1375
1418
  const originalHandleInput = this.input.handleInput.bind(this.input);
1419
+ this.handleRawInput = originalHandleInput;
1376
1420
  this.input.handleInput = (data: string) => {
1377
1421
  if (keybindings.matches(data, "app.clear")) {
1378
1422
  if (this.input.getValue().length > 0) {
1379
- this.input.setValue("");
1380
- this.tui.requestRender();
1423
+ this.setDraft("");
1381
1424
  return;
1382
1425
  }
1383
1426
 
@@ -1385,16 +1428,60 @@ class BtwOverlayComponent extends Container implements Focusable {
1385
1428
  return;
1386
1429
  }
1387
1430
 
1431
+ if (this.handleCompletionInput(data)) return;
1432
+
1388
1433
  if (keybindings.matches(data, "tui.select.cancel")) {
1389
1434
  this.onDismissCallback();
1390
1435
  return;
1391
1436
  }
1392
1437
  originalHandleInput(data);
1438
+ this.updateCompletions();
1439
+ this.tui.requestRender();
1393
1440
  };
1394
1441
 
1395
1442
  this.refresh();
1396
1443
  }
1397
1444
 
1445
+ private updateCompletions(): void {
1446
+ const value = this.input.getValue();
1447
+ if (value !== this.completionQuery) {
1448
+ this.completionQuery = value;
1449
+ this.completionIndex = 0;
1450
+ this.completionDismissedFor = null;
1451
+ }
1452
+ this.completionItems = /^\/[^\s]*$/.test(value) && value !== this.completionDismissedFor
1453
+ ? BTW_COMMANDS.filter((command) => `/${command.name}`.startsWith(value)) : [];
1454
+ }
1455
+
1456
+ private handleCompletionInput(data: string): boolean {
1457
+ this.updateCompletions();
1458
+ if (!this.completionItems.length || this.keybindings.matches(data, "app.clear")) return false;
1459
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
1460
+ const delta = matchesKey(data, Key.up) ? -1 : 1;
1461
+ this.completionIndex = (this.completionIndex + delta + this.completionItems.length) % this.completionItems.length;
1462
+ } else if (matchesKey(data, Key.tab) || (matchesKey(data, Key.enter)
1463
+ && !BTW_COMMANDS.some((command) => `/${command.name}` === this.input.getValue()))) {
1464
+ this.setDraft(`/${this.completionItems[this.completionIndex].name} `);
1465
+ } else if (this.keybindings.matches(data, "tui.select.cancel")) {
1466
+ this.completionDismissedFor = this.input.getValue();
1467
+ this.completionItems = [];
1468
+ } else {
1469
+ return false;
1470
+ }
1471
+ this.tui.requestRender();
1472
+ return true;
1473
+ }
1474
+
1475
+ private completionRows(): string[] {
1476
+ this.updateCompletions();
1477
+ const start = Math.max(0, this.completionIndex - 4);
1478
+ return this.completionItems.slice(start, start + 5).map((command, index) => {
1479
+ const selected = start + index === this.completionIndex;
1480
+ return this.theme.fg(selected ? "accent" : "dim",
1481
+ `${selected ? "→" : " "} /${command.name} ${command.description}`);
1482
+ });
1483
+ }
1484
+
1398
1485
  private get borderless(): boolean {
1399
1486
  // Full-width mode drops the vertical bars and corner glyphs so a terminal
1400
1487
  // Shift+drag selection captures only the dialog's own text — with side
@@ -1489,6 +1576,8 @@ class BtwOverlayComponent extends Container implements Focusable {
1489
1576
  return;
1490
1577
  }
1491
1578
 
1579
+ if (this.handleCompletionInput(data)) return;
1580
+
1492
1581
  if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.up)) {
1493
1582
  const step = matchesKey(data, Key.pageUp) ? Math.max(1, this.transcriptViewportHeight - 1) : 1;
1494
1583
  this.scrollTranscript(-step);
@@ -1542,7 +1631,8 @@ class BtwOverlayComponent extends Container implements Focusable {
1542
1631
  const transcriptLines = this.wrapTranscript(innerWidth);
1543
1632
  const dialogHeight = this.getDialogHeight();
1544
1633
  const chromeHeight = BTW_OVERLAY_CHROME_LINES;
1545
- const transcriptHeight = Math.max(6, dialogHeight - chromeHeight);
1634
+ const completionRows = this.completionRows();
1635
+ const transcriptHeight = Math.max(1, dialogHeight - chromeHeight - completionRows.length);
1546
1636
  this.transcriptViewportHeight = transcriptHeight;
1547
1637
 
1548
1638
  const maxScroll = Math.max(0, transcriptLines.length - transcriptHeight);
@@ -1582,6 +1672,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1582
1672
 
1583
1673
  lines.push(this.ruleLine(innerWidth));
1584
1674
  lines.push(this.frameLine(this.theme.fg("warning", this.statusTextValue.trim()), innerWidth));
1675
+ for (const row of completionRows) lines.push(this.frameLine(row, innerWidth));
1585
1676
  lines.push(this.inputFrameLine(dialogWidth));
1586
1677
  lines.push(this.frameLine(this.theme.fg("dim", this.hintsTextValue.trim()), innerWidth));
1587
1678
  lines.push(this.borderLine(innerWidth, "bottom"));
@@ -1590,7 +1681,11 @@ class BtwOverlayComponent extends Container implements Focusable {
1590
1681
  }
1591
1682
 
1592
1683
  setDraft(value: string): void {
1593
- this.input.setValue(value);
1684
+ // Input.setValue preserves the cursor. Paste into an empty input so a
1685
+ // completed command leaves the cursor after its trailing space.
1686
+ this.input.setValue("");
1687
+ if (value) this.handleRawInput(`\x1b[200~${value}\x1b[201~`);
1688
+ this.updateCompletions();
1594
1689
  this.tui.requestRender();
1595
1690
  }
1596
1691
 
@@ -1612,7 +1707,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1612
1707
  }
1613
1708
 
1614
1709
  refresh(): void {
1615
- this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
1710
+ this.modeTextValue = `${getOverlayTitle(this.getMode())} · ${this.getWorkspaceLabel()} · hidden thread preserved`;
1616
1711
  this.modeText.setText(this.modeTextValue);
1617
1712
  const entries = this.readTranscriptEntries();
1618
1713
  const exchanges = getCompletedExchangeCount(entries);
@@ -1629,7 +1724,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1629
1724
  const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
1630
1725
  this.statusTextValue = status;
1631
1726
  this.statusText.setText(this.statusTextValue);
1632
- this.hintsTextValue = `Scroll wheel ↑↓ PgUp/PgDn · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
1727
+ this.hintsTextValue = `↑↓ select/scroll · Tab complete · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
1633
1728
  this.hintsText.setText(this.hintsTextValue);
1634
1729
  this.tui.requestRender();
1635
1730
  }
@@ -1825,16 +1920,20 @@ export default function (pi: ExtensionAPI) {
1825
1920
 
1826
1921
  clearBtwSessionSubscriptions(current);
1827
1922
  await requestBtwSessionAbort(current);
1828
- await disposeChildSession(current.session, current.hasExtensions);
1923
+ await disposeChildSession(current.session, current.hasExtensions, current.workspace);
1829
1924
  }
1830
1925
 
1831
- async function disposeChildSession(session: AgentSession, hasExtensions: boolean): Promise<void> {
1926
+ async function disposeChildSession(session: AgentSession, hasExtensions: boolean, workspace?: BtwSshWorkspace): Promise<void> {
1832
1927
  try {
1833
1928
  if (hasExtensions) {
1834
1929
  await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
1835
1930
  }
1836
1931
  } finally {
1837
- session.dispose();
1932
+ try {
1933
+ session.dispose();
1934
+ } finally {
1935
+ await workspace?.dispose();
1936
+ }
1838
1937
  }
1839
1938
  }
1840
1939
 
@@ -2032,6 +2131,13 @@ export default function (pi: ExtensionAPI) {
2032
2131
  }
2033
2132
 
2034
2133
  const builtinTools = BTW_TOOLS_BY_MODE[mode];
2134
+ const workspaceMode = await readBtwWorkspaceMode({ cwd: ctx.cwd, agentDir, projectTrusted: ctx.isProjectTrusted() });
2135
+ const workspace = workspaceMode === "ssh" ? await createBtwSshWorkspace(pi, ctx, mode === "readonly") : undefined;
2136
+ const workspaceIdentity = workspace?.identity ?? `local:${ctx.cwd}`;
2137
+ if (pendingThread.some((entry) => (entry.workspaceIdentity ?? `local:${ctx.cwd}`) !== workspaceIdentity)) {
2138
+ await workspace?.dispose();
2139
+ throw new Error("BTW workspace differs from its saved thread. Run /btw:clear before continuing.");
2140
+ }
2035
2141
  let childSession: AgentSession | undefined;
2036
2142
  const sessionOptions: CreateAgentSessionOptions = {
2037
2143
  cwd: ctx.cwd,
@@ -2039,14 +2145,22 @@ export default function (pi: ExtensionAPI) {
2039
2145
  model: settings.model,
2040
2146
  ...modelRuntimeOptions,
2041
2147
  thinkingLevel: settings.thinkingLevel,
2148
+ ...(workspace ? { customTools: workspace.tools } : {}),
2042
2149
  // Extension sessions need a live registry for tools registered at startup
2043
2150
  // or later. Read-only sessions keep the structural built-in allowlist.
2044
2151
  ...(extensionResources ? { noTools: "builtin" as const } : { tools: [...builtinTools] }),
2045
2152
  resourceLoader: createBtwResourceLoader(
2046
- ctx, () => childSession?.getActiveToolNames() ?? builtinTools, [BTW_SYSTEM_PROMPT], extensionResources,
2153
+ ctx, () => childSession?.getActiveToolNames() ?? builtinTools, [BTW_SYSTEM_PROMPT], extensionResources, workspace?.label,
2047
2154
  ),
2048
2155
  };
2049
- const { session, extensionsResult } = await createAgentSession(sessionOptions);
2156
+ let created: Awaited<ReturnType<typeof createAgentSession>>;
2157
+ try {
2158
+ created = await createAgentSession(sessionOptions);
2159
+ } catch (error) {
2160
+ await workspace?.dispose();
2161
+ throw error;
2162
+ }
2163
+ const { session, extensionsResult } = created;
2050
2164
  childSession = session;
2051
2165
  const hasExtensions = !!extensionResources;
2052
2166
  if (hasExtensions) {
@@ -2084,13 +2198,13 @@ export default function (pi: ExtensionAPI) {
2084
2198
  try {
2085
2199
  await session.abort();
2086
2200
  } finally {
2087
- await disposeChildSession(session, true);
2201
+ await disposeChildSession(session, true, workspace);
2088
2202
  }
2089
2203
  throw error;
2090
2204
  }
2091
2205
  }
2092
2206
 
2093
- return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve(), hasExtensions };
2207
+ return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve(), hasExtensions, workspace, workspaceIdentity };
2094
2208
  }
2095
2209
 
2096
2210
  function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
@@ -2114,7 +2228,15 @@ export default function (pi: ExtensionAPI) {
2114
2228
  }
2115
2229
 
2116
2230
  if (activeBtwSession?.mode === mode) {
2117
- return activeBtwSession;
2231
+ try {
2232
+ activeBtwSession.workspace?.assertCurrent();
2233
+ return activeBtwSession;
2234
+ } catch (error) {
2235
+ const message = error instanceof Error ? error.message : String(error);
2236
+ setOverlayStatus(message, ctx);
2237
+ notify(ctx, message, "error");
2238
+ return null;
2239
+ }
2118
2240
  }
2119
2241
 
2120
2242
  await disposeBtwSession();
@@ -2123,7 +2245,7 @@ export default function (pi: ExtensionAPI) {
2123
2245
  const created = await createBtwSubSession(ctx, mode, settings);
2124
2246
  if (generation !== btwLifecycleGeneration) {
2125
2247
  await requestBtwSessionAbort(created);
2126
- await disposeChildSession(created.session, created.hasExtensions);
2248
+ await disposeChildSession(created.session, created.hasExtensions, created.workspace);
2127
2249
  return null;
2128
2250
  }
2129
2251
  activeBtwSession = created;
@@ -2195,6 +2317,7 @@ export default function (pi: ExtensionAPI) {
2195
2317
  () => {
2196
2318
  void toggleOverlayWidth(ctx);
2197
2319
  },
2320
+ () => activeBtwSession?.workspace?.label ?? "local",
2198
2321
  );
2199
2322
 
2200
2323
  overlay.focused = runtime.handle?.isFocused() ?? true;
@@ -2258,7 +2381,7 @@ export default function (pi: ExtensionAPI) {
2258
2381
  await resetThread(ctx, true, "contextual");
2259
2382
  }
2260
2383
 
2261
- await runBtw(ctx, question, save, "contextual");
2384
+ await startBtw(ctx, question, save, "contextual");
2262
2385
  return true;
2263
2386
  }
2264
2387
 
@@ -2278,7 +2401,7 @@ export default function (pi: ExtensionAPI) {
2278
2401
  return true;
2279
2402
  }
2280
2403
 
2281
- await runBtw(ctx, question, save, "tangent");
2404
+ await startBtw(ctx, question, save, "tangent");
2282
2405
  return true;
2283
2406
  }
2284
2407
 
@@ -2301,7 +2424,7 @@ export default function (pi: ExtensionAPI) {
2301
2424
  return true;
2302
2425
  }
2303
2426
 
2304
- await runBtw(ctx, question, save, "readonly");
2427
+ await startBtw(ctx, question, save, "readonly");
2305
2428
  return true;
2306
2429
  }
2307
2430
 
@@ -2314,7 +2437,7 @@ export default function (pi: ExtensionAPI) {
2314
2437
 
2315
2438
  await resetThread(ctx, true, "contextual");
2316
2439
  if (question) {
2317
- await runBtw(ctx, question, save, "contextual");
2440
+ await startBtw(ctx, question, save, "contextual");
2318
2441
  } else {
2319
2442
  await ensureBtwSession(ctx, "contextual");
2320
2443
  setOverlayStatus("Started a fresh BTW thread.", ctx);
@@ -2441,13 +2564,13 @@ export default function (pi: ExtensionAPI) {
2441
2564
 
2442
2565
  function parseOverlayBtwCommand(value: string): { name: string; args: string } | null {
2443
2566
  const trimmed = value.trim();
2444
- const match = trimmed.match(/^\/(btw:(?:new|ask|tangent|clear|inject|summarize|model|thinking))(?:\s+(.*))?$/);
2445
- if (!match) {
2567
+ const match = trimmed.match(/^\/(\S+)(?:\s+([\s\S]*))?$/);
2568
+ if (!match || !BTW_COMMANDS.some((command) => command.name === match[1])) {
2446
2569
  return null;
2447
2570
  }
2448
2571
 
2449
2572
  return {
2450
- name: match[1],
2573
+ name: match[1] === "side" ? "btw" : match[1],
2451
2574
  args: match[2]?.trim() ?? "",
2452
2575
  };
2453
2576
  }
@@ -2567,6 +2690,24 @@ export default function (pi: ExtensionAPI) {
2567
2690
  syncUi(ctx);
2568
2691
  }
2569
2692
 
2693
+ // Pi's interactive loop awaits command handlers, so waiting for the answer here
2694
+ // would hold every main-editor submit until BTW finishes. The overlay shows the
2695
+ // turn in the TUI; other modes deliver the answer as a session note, so a one-shot
2696
+ // run has to wait for it.
2697
+ function startBtw(
2698
+ ctx: ExtensionCommandContext,
2699
+ question: string,
2700
+ saveRequested: boolean,
2701
+ mode: BtwThreadMode,
2702
+ ): Promise<void> {
2703
+ const turn = runBtw(ctx, question, saveRequested, mode);
2704
+ if (!canRenderBtwOverlay(ctx)) {
2705
+ return turn;
2706
+ }
2707
+ turn.catch((error) => notify(ctx, error instanceof Error ? error.message : String(error), "error"));
2708
+ return Promise.resolve();
2709
+ }
2710
+
2570
2711
  async function runBtw(
2571
2712
  ctx: ExtensionCommandContext,
2572
2713
  question: string,
@@ -2669,6 +2810,7 @@ export default function (pi: ExtensionAPI) {
2669
2810
  await ensureOverlay(ctx);
2670
2811
 
2671
2812
  try {
2813
+ sessionRuntime.workspace?.assertCurrent();
2672
2814
  await session.prompt(question, { source: "extension" });
2673
2815
  if (!isCurrentGeneration()) {
2674
2816
  return;
@@ -2704,6 +2846,7 @@ export default function (pi: ExtensionAPI) {
2704
2846
  thinkingLevel,
2705
2847
  timestamp: Date.now(),
2706
2848
  usage: response.usage,
2849
+ workspaceIdentity: sessionRuntime.workspaceIdentity,
2707
2850
  };
2708
2851
 
2709
2852
  pendingThread.push(details);
@@ -2913,73 +3056,13 @@ export default function (pi: ExtensionAPI) {
2913
3056
  },
2914
3057
  });
2915
3058
 
2916
- pi.registerCommand("btw", {
2917
- description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
2918
- handler: async (args, ctx) => {
2919
- await dispatchBtwCommand("btw", args, ctx);
2920
- },
2921
- });
2922
-
2923
- pi.registerCommand("side", {
2924
- description: "Alias for /btw: continue a side conversation in a focused BTW modal.",
2925
- handler: async (args, ctx) => {
2926
- await dispatchBtwCommand("btw", args, ctx);
2927
- },
2928
- });
2929
-
2930
- pi.registerCommand("btw:tangent", {
2931
- description: "Start or continue a contextless BTW tangent in the focused BTW modal.",
2932
- handler: async (args, ctx) => {
2933
- await dispatchBtwCommand("btw:tangent", args, ctx);
2934
- },
2935
- });
2936
-
2937
- pi.registerCommand("btw:ask", {
2938
- description: "Ask a read-only side question: inherits main-session context but exposes only read/grep/find/ls tools.",
2939
- handler: async (args, ctx) => {
2940
- await dispatchBtwCommand("btw:ask", args, ctx);
2941
- },
2942
- });
2943
-
2944
- pi.registerCommand("btw:new", {
2945
- description: "Start a fresh BTW thread with main-session context. Optionally ask the first question immediately.",
2946
- handler: async (args, ctx) => {
2947
- await dispatchBtwCommand("btw:new", args, ctx);
2948
- },
2949
- });
2950
-
2951
- pi.registerCommand("btw:clear", {
2952
- description: "Dismiss the BTW modal/widget and clear the current thread.",
2953
- handler: async (args, ctx) => {
2954
- await dispatchBtwCommand("btw:clear", args, ctx);
2955
- },
2956
- });
2957
-
2958
- pi.registerCommand("btw:inject", {
2959
- description: "Inject the full BTW thread into the main agent as a user message.",
2960
- handler: async (args, ctx) => {
2961
- await dispatchBtwCommand("btw:inject", args, ctx);
2962
- },
2963
- });
2964
-
2965
- pi.registerCommand("btw:summarize", {
2966
- description: "Summarize the BTW thread, then inject the summary into the main agent.",
2967
- handler: async (args, ctx) => {
2968
- await dispatchBtwCommand("btw:summarize", args, ctx);
2969
- },
2970
- });
2971
-
2972
- pi.registerCommand("btw:model", {
2973
- description: "Show, set, or clear the BTW-only model override.",
2974
- handler: async (args, ctx) => {
2975
- await dispatchBtwCommand("btw:model", args, ctx);
2976
- },
2977
- });
3059
+ for (const command of BTW_COMMANDS) {
3060
+ pi.registerCommand(command.name, {
3061
+ description: command.description,
3062
+ handler: async (args, ctx) => {
3063
+ await dispatchBtwCommand(command.name === "side" ? "btw" : command.name, args, ctx);
3064
+ },
3065
+ });
3066
+ }
2978
3067
 
2979
- pi.registerCommand("btw:thinking", {
2980
- description: "Show, set, or clear the BTW-only thinking override.",
2981
- handler: async (args, ctx) => {
2982
- await dispatchBtwCommand("btw:thinking", args, ctx);
2983
- },
2984
- });
2985
3068
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-btw",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "A pi extension for parallel side conversations with /btw",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,9 +49,9 @@
49
49
  "@earendil-works/pi-tui": ">=0.85.1 <2"
50
50
  },
51
51
  "devDependencies": {
52
- "@earendil-works/pi-ai": "0.99.2",
53
- "@earendil-works/pi-coding-agent": "0.99.2",
54
- "@earendil-works/pi-tui": "0.99.2",
52
+ "@earendil-works/pi-ai": "1.5.0",
53
+ "@earendil-works/pi-coding-agent": "1.5.0",
54
+ "@earendil-works/pi-tui": "1.5.0",
55
55
  "typescript": "^6.0.2",
56
56
  "vitest": "^4.1.11"
57
57
  }