pi-btw 0.7.1 → 0.8.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/README.md CHANGED
@@ -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 = {
@@ -304,6 +321,7 @@ function createBtwResourceLoader(
304
321
  tools: readonly string[] | (() => readonly string[]),
305
322
  appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT],
306
323
  extensionResources?: ResourceLoader,
324
+ workspaceLabel = `local ${ctx.cwd}`,
307
325
  ): ResourceLoader {
308
326
  const extensionsResult = extensionResources?.getExtensions() ?? { extensions: [], errors: [], runtime: createExtensionRuntime() };
309
327
  const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt());
@@ -313,6 +331,7 @@ function createBtwResourceLoader(
313
331
  const availableTools = typeof tools === "function" ? tools() : tools;
314
332
  return [
315
333
  "<btw_capabilities>",
334
+ `Workspace for built-in file and shell tools: ${workspaceLabel}.`,
316
335
  availableTools.length > 0
317
336
  ? `Available tools in this BTW session: ${availableTools.join(", ")}.`
318
337
  : "No tools are available in this BTW session.",
@@ -475,6 +494,15 @@ const BTW_TOOLS_BY_MODE: Record<BtwThreadMode, readonly string[]> = {
475
494
  readonly: ["read", "grep", "find", "ls"],
476
495
  };
477
496
 
497
+ function withoutParentToolDeclarations(message: Message): Message {
498
+ if ((message as { role: string }).role !== "system") return message;
499
+ const { toolsAdded: _added, toolsRemoved: _removed, ...rest } = message as Message & {
500
+ toolsAdded?: unknown;
501
+ toolsRemoved?: unknown;
502
+ };
503
+ return rest as Message;
504
+ }
505
+
478
506
  function buildBtwSeedState(
479
507
  ctx: ExtensionCommandContext,
480
508
  thread: BtwDetails[],
@@ -488,7 +516,7 @@ function buildBtwSeedState(
488
516
  messages.push(
489
517
  ...(buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages as Message[]).filter(
490
518
  (message) => !isVisibleBtwMessage(message),
491
- ),
519
+ ).map(withoutParentToolDeclarations),
492
520
  );
493
521
  } catch {
494
522
  messages.push(
@@ -502,7 +530,9 @@ function buildBtwSeedState(
502
530
  return [];
503
531
  }
504
532
 
505
- return isVisibleBtwMessage({ role: message.role, customType: message.customType }) ? [] : [message as Message];
533
+ return isVisibleBtwMessage({ role: message.role, customType: message.customType })
534
+ ? []
535
+ : [withoutParentToolDeclarations(message as Message)];
506
536
  }),
507
537
  );
508
538
  }
@@ -1284,8 +1314,9 @@ function buildTranscriptBadge(
1284
1314
  return theme.bg(background, theme.fg(foreground, theme.bold(` ${label} `)));
1285
1315
  }
1286
1316
 
1287
- class BtwOverlayComponent extends Container implements Focusable {
1317
+ export class BtwOverlayComponent extends Container implements Focusable {
1288
1318
  private readonly input: Input;
1319
+ private readonly handleRawInput: (data: string) => void;
1289
1320
  private readonly transcript: Container;
1290
1321
  private readonly statusText: Text;
1291
1322
  private readonly modeText: Text;
@@ -1295,6 +1326,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1295
1326
  private readonly getStatus: () => string | null;
1296
1327
  private readonly getMode: () => BtwThreadMode;
1297
1328
  private readonly getWidthMode: () => BtwOverlayWidthMode;
1329
+ private readonly getWorkspaceLabel: () => string;
1298
1330
  private readonly onSubmitCallback: (value: string) => void;
1299
1331
  private readonly onDismissCallback: () => void;
1300
1332
  private readonly onUnfocusCallback: () => void;
@@ -1313,6 +1345,11 @@ class BtwOverlayComponent extends Container implements Focusable {
1313
1345
  private summaryTextValue = "";
1314
1346
  private statusTextValue = "";
1315
1347
  private hintsTextValue = "";
1348
+ private readonly keybindings: KeybindingsManager;
1349
+ private completionQuery = "";
1350
+ private completionDismissedFor: string | null = null;
1351
+ private completionItems: typeof BTW_COMMANDS[number][] = [];
1352
+ private completionIndex = 0;
1316
1353
 
1317
1354
  get focused(): boolean {
1318
1355
  return this._focused;
@@ -1335,10 +1372,12 @@ class BtwOverlayComponent extends Container implements Focusable {
1335
1372
  onDismiss: () => void,
1336
1373
  onUnfocus: () => void,
1337
1374
  onToggleWidth: () => void,
1375
+ getWorkspaceLabel: () => string = () => "local",
1338
1376
  ) {
1339
1377
  super();
1340
1378
  this.tui = tui;
1341
1379
  this.theme = theme;
1380
+ this.keybindings = keybindings;
1342
1381
  this.markdownTheme = getMarkdownTheme();
1343
1382
  // Fullscreen Pi owns mouse reporting for the entire terminal session. In
1344
1383
  // regular mode BTW manages it while the overlay exists.
@@ -1347,6 +1386,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1347
1386
  this.getStatus = getStatus;
1348
1387
  this.getMode = getMode;
1349
1388
  this.getWidthMode = getWidthMode;
1389
+ this.getWorkspaceLabel = getWorkspaceLabel;
1350
1390
  this.onSubmitCallback = onSubmit;
1351
1391
  this.onDismissCallback = onDismiss;
1352
1392
  this.onUnfocusCallback = onUnfocus;
@@ -1373,11 +1413,11 @@ class BtwOverlayComponent extends Container implements Focusable {
1373
1413
  }
1374
1414
 
1375
1415
  const originalHandleInput = this.input.handleInput.bind(this.input);
1416
+ this.handleRawInput = originalHandleInput;
1376
1417
  this.input.handleInput = (data: string) => {
1377
1418
  if (keybindings.matches(data, "app.clear")) {
1378
1419
  if (this.input.getValue().length > 0) {
1379
- this.input.setValue("");
1380
- this.tui.requestRender();
1420
+ this.setDraft("");
1381
1421
  return;
1382
1422
  }
1383
1423
 
@@ -1385,16 +1425,60 @@ class BtwOverlayComponent extends Container implements Focusable {
1385
1425
  return;
1386
1426
  }
1387
1427
 
1428
+ if (this.handleCompletionInput(data)) return;
1429
+
1388
1430
  if (keybindings.matches(data, "tui.select.cancel")) {
1389
1431
  this.onDismissCallback();
1390
1432
  return;
1391
1433
  }
1392
1434
  originalHandleInput(data);
1435
+ this.updateCompletions();
1436
+ this.tui.requestRender();
1393
1437
  };
1394
1438
 
1395
1439
  this.refresh();
1396
1440
  }
1397
1441
 
1442
+ private updateCompletions(): void {
1443
+ const value = this.input.getValue();
1444
+ if (value !== this.completionQuery) {
1445
+ this.completionQuery = value;
1446
+ this.completionIndex = 0;
1447
+ this.completionDismissedFor = null;
1448
+ }
1449
+ this.completionItems = /^\/[^\s]*$/.test(value) && value !== this.completionDismissedFor
1450
+ ? BTW_COMMANDS.filter((command) => `/${command.name}`.startsWith(value)) : [];
1451
+ }
1452
+
1453
+ private handleCompletionInput(data: string): boolean {
1454
+ this.updateCompletions();
1455
+ if (!this.completionItems.length || this.keybindings.matches(data, "app.clear")) return false;
1456
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
1457
+ const delta = matchesKey(data, Key.up) ? -1 : 1;
1458
+ this.completionIndex = (this.completionIndex + delta + this.completionItems.length) % this.completionItems.length;
1459
+ } else if (matchesKey(data, Key.tab) || (matchesKey(data, Key.enter)
1460
+ && !BTW_COMMANDS.some((command) => `/${command.name}` === this.input.getValue()))) {
1461
+ this.setDraft(`/${this.completionItems[this.completionIndex].name} `);
1462
+ } else if (this.keybindings.matches(data, "tui.select.cancel")) {
1463
+ this.completionDismissedFor = this.input.getValue();
1464
+ this.completionItems = [];
1465
+ } else {
1466
+ return false;
1467
+ }
1468
+ this.tui.requestRender();
1469
+ return true;
1470
+ }
1471
+
1472
+ private completionRows(): string[] {
1473
+ this.updateCompletions();
1474
+ const start = Math.max(0, this.completionIndex - 4);
1475
+ return this.completionItems.slice(start, start + 5).map((command, index) => {
1476
+ const selected = start + index === this.completionIndex;
1477
+ return this.theme.fg(selected ? "accent" : "dim",
1478
+ `${selected ? "→" : " "} /${command.name} ${command.description}`);
1479
+ });
1480
+ }
1481
+
1398
1482
  private get borderless(): boolean {
1399
1483
  // Full-width mode drops the vertical bars and corner glyphs so a terminal
1400
1484
  // Shift+drag selection captures only the dialog's own text — with side
@@ -1489,6 +1573,8 @@ class BtwOverlayComponent extends Container implements Focusable {
1489
1573
  return;
1490
1574
  }
1491
1575
 
1576
+ if (this.handleCompletionInput(data)) return;
1577
+
1492
1578
  if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.up)) {
1493
1579
  const step = matchesKey(data, Key.pageUp) ? Math.max(1, this.transcriptViewportHeight - 1) : 1;
1494
1580
  this.scrollTranscript(-step);
@@ -1542,7 +1628,8 @@ class BtwOverlayComponent extends Container implements Focusable {
1542
1628
  const transcriptLines = this.wrapTranscript(innerWidth);
1543
1629
  const dialogHeight = this.getDialogHeight();
1544
1630
  const chromeHeight = BTW_OVERLAY_CHROME_LINES;
1545
- const transcriptHeight = Math.max(6, dialogHeight - chromeHeight);
1631
+ const completionRows = this.completionRows();
1632
+ const transcriptHeight = Math.max(1, dialogHeight - chromeHeight - completionRows.length);
1546
1633
  this.transcriptViewportHeight = transcriptHeight;
1547
1634
 
1548
1635
  const maxScroll = Math.max(0, transcriptLines.length - transcriptHeight);
@@ -1582,6 +1669,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1582
1669
 
1583
1670
  lines.push(this.ruleLine(innerWidth));
1584
1671
  lines.push(this.frameLine(this.theme.fg("warning", this.statusTextValue.trim()), innerWidth));
1672
+ for (const row of completionRows) lines.push(this.frameLine(row, innerWidth));
1585
1673
  lines.push(this.inputFrameLine(dialogWidth));
1586
1674
  lines.push(this.frameLine(this.theme.fg("dim", this.hintsTextValue.trim()), innerWidth));
1587
1675
  lines.push(this.borderLine(innerWidth, "bottom"));
@@ -1590,7 +1678,11 @@ class BtwOverlayComponent extends Container implements Focusable {
1590
1678
  }
1591
1679
 
1592
1680
  setDraft(value: string): void {
1593
- this.input.setValue(value);
1681
+ // Input.setValue preserves the cursor. Paste into an empty input so a
1682
+ // completed command leaves the cursor after its trailing space.
1683
+ this.input.setValue("");
1684
+ if (value) this.handleRawInput(`\x1b[200~${value}\x1b[201~`);
1685
+ this.updateCompletions();
1594
1686
  this.tui.requestRender();
1595
1687
  }
1596
1688
 
@@ -1612,7 +1704,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1612
1704
  }
1613
1705
 
1614
1706
  refresh(): void {
1615
- this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
1707
+ this.modeTextValue = `${getOverlayTitle(this.getMode())} · ${this.getWorkspaceLabel()} · hidden thread preserved`;
1616
1708
  this.modeText.setText(this.modeTextValue);
1617
1709
  const entries = this.readTranscriptEntries();
1618
1710
  const exchanges = getCompletedExchangeCount(entries);
@@ -1629,7 +1721,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1629
1721
  const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
1630
1722
  this.statusTextValue = status;
1631
1723
  this.statusText.setText(this.statusTextValue);
1632
- this.hintsTextValue = `Scroll wheel ↑↓ PgUp/PgDn · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
1724
+ this.hintsTextValue = `↑↓ select/scroll · Tab complete · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
1633
1725
  this.hintsText.setText(this.hintsTextValue);
1634
1726
  this.tui.requestRender();
1635
1727
  }
@@ -1825,16 +1917,20 @@ export default function (pi: ExtensionAPI) {
1825
1917
 
1826
1918
  clearBtwSessionSubscriptions(current);
1827
1919
  await requestBtwSessionAbort(current);
1828
- await disposeChildSession(current.session, current.hasExtensions);
1920
+ await disposeChildSession(current.session, current.hasExtensions, current.workspace);
1829
1921
  }
1830
1922
 
1831
- async function disposeChildSession(session: AgentSession, hasExtensions: boolean): Promise<void> {
1923
+ async function disposeChildSession(session: AgentSession, hasExtensions: boolean, workspace?: BtwSshWorkspace): Promise<void> {
1832
1924
  try {
1833
1925
  if (hasExtensions) {
1834
1926
  await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
1835
1927
  }
1836
1928
  } finally {
1837
- session.dispose();
1929
+ try {
1930
+ session.dispose();
1931
+ } finally {
1932
+ await workspace?.dispose();
1933
+ }
1838
1934
  }
1839
1935
  }
1840
1936
 
@@ -2032,6 +2128,13 @@ export default function (pi: ExtensionAPI) {
2032
2128
  }
2033
2129
 
2034
2130
  const builtinTools = BTW_TOOLS_BY_MODE[mode];
2131
+ const workspaceMode = await readBtwWorkspaceMode({ cwd: ctx.cwd, agentDir, projectTrusted: ctx.isProjectTrusted() });
2132
+ const workspace = workspaceMode === "ssh" ? await createBtwSshWorkspace(pi, ctx, mode === "readonly") : undefined;
2133
+ const workspaceIdentity = workspace?.identity ?? `local:${ctx.cwd}`;
2134
+ if (pendingThread.some((entry) => (entry.workspaceIdentity ?? `local:${ctx.cwd}`) !== workspaceIdentity)) {
2135
+ await workspace?.dispose();
2136
+ throw new Error("BTW workspace differs from its saved thread. Run /btw:clear before continuing.");
2137
+ }
2035
2138
  let childSession: AgentSession | undefined;
2036
2139
  const sessionOptions: CreateAgentSessionOptions = {
2037
2140
  cwd: ctx.cwd,
@@ -2039,14 +2142,22 @@ export default function (pi: ExtensionAPI) {
2039
2142
  model: settings.model,
2040
2143
  ...modelRuntimeOptions,
2041
2144
  thinkingLevel: settings.thinkingLevel,
2145
+ ...(workspace ? { customTools: workspace.tools } : {}),
2042
2146
  // Extension sessions need a live registry for tools registered at startup
2043
2147
  // or later. Read-only sessions keep the structural built-in allowlist.
2044
2148
  ...(extensionResources ? { noTools: "builtin" as const } : { tools: [...builtinTools] }),
2045
2149
  resourceLoader: createBtwResourceLoader(
2046
- ctx, () => childSession?.getActiveToolNames() ?? builtinTools, [BTW_SYSTEM_PROMPT], extensionResources,
2150
+ ctx, () => childSession?.getActiveToolNames() ?? builtinTools, [BTW_SYSTEM_PROMPT], extensionResources, workspace?.label,
2047
2151
  ),
2048
2152
  };
2049
- const { session, extensionsResult } = await createAgentSession(sessionOptions);
2153
+ let created: Awaited<ReturnType<typeof createAgentSession>>;
2154
+ try {
2155
+ created = await createAgentSession(sessionOptions);
2156
+ } catch (error) {
2157
+ await workspace?.dispose();
2158
+ throw error;
2159
+ }
2160
+ const { session, extensionsResult } = created;
2050
2161
  childSession = session;
2051
2162
  const hasExtensions = !!extensionResources;
2052
2163
  if (hasExtensions) {
@@ -2084,13 +2195,13 @@ export default function (pi: ExtensionAPI) {
2084
2195
  try {
2085
2196
  await session.abort();
2086
2197
  } finally {
2087
- await disposeChildSession(session, true);
2198
+ await disposeChildSession(session, true, workspace);
2088
2199
  }
2089
2200
  throw error;
2090
2201
  }
2091
2202
  }
2092
2203
 
2093
- return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve(), hasExtensions };
2204
+ return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve(), hasExtensions, workspace, workspaceIdentity };
2094
2205
  }
2095
2206
 
2096
2207
  function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
@@ -2114,7 +2225,15 @@ export default function (pi: ExtensionAPI) {
2114
2225
  }
2115
2226
 
2116
2227
  if (activeBtwSession?.mode === mode) {
2117
- return activeBtwSession;
2228
+ try {
2229
+ activeBtwSession.workspace?.assertCurrent();
2230
+ return activeBtwSession;
2231
+ } catch (error) {
2232
+ const message = error instanceof Error ? error.message : String(error);
2233
+ setOverlayStatus(message, ctx);
2234
+ notify(ctx, message, "error");
2235
+ return null;
2236
+ }
2118
2237
  }
2119
2238
 
2120
2239
  await disposeBtwSession();
@@ -2123,7 +2242,7 @@ export default function (pi: ExtensionAPI) {
2123
2242
  const created = await createBtwSubSession(ctx, mode, settings);
2124
2243
  if (generation !== btwLifecycleGeneration) {
2125
2244
  await requestBtwSessionAbort(created);
2126
- await disposeChildSession(created.session, created.hasExtensions);
2245
+ await disposeChildSession(created.session, created.hasExtensions, created.workspace);
2127
2246
  return null;
2128
2247
  }
2129
2248
  activeBtwSession = created;
@@ -2195,6 +2314,7 @@ export default function (pi: ExtensionAPI) {
2195
2314
  () => {
2196
2315
  void toggleOverlayWidth(ctx);
2197
2316
  },
2317
+ () => activeBtwSession?.workspace?.label ?? "local",
2198
2318
  );
2199
2319
 
2200
2320
  overlay.focused = runtime.handle?.isFocused() ?? true;
@@ -2441,13 +2561,13 @@ export default function (pi: ExtensionAPI) {
2441
2561
 
2442
2562
  function parseOverlayBtwCommand(value: string): { name: string; args: string } | null {
2443
2563
  const trimmed = value.trim();
2444
- const match = trimmed.match(/^\/(btw:(?:new|ask|tangent|clear|inject|summarize|model|thinking))(?:\s+(.*))?$/);
2445
- if (!match) {
2564
+ const match = trimmed.match(/^\/(\S+)(?:\s+([\s\S]*))?$/);
2565
+ if (!match || !BTW_COMMANDS.some((command) => command.name === match[1])) {
2446
2566
  return null;
2447
2567
  }
2448
2568
 
2449
2569
  return {
2450
- name: match[1],
2570
+ name: match[1] === "side" ? "btw" : match[1],
2451
2571
  args: match[2]?.trim() ?? "",
2452
2572
  };
2453
2573
  }
@@ -2669,6 +2789,7 @@ export default function (pi: ExtensionAPI) {
2669
2789
  await ensureOverlay(ctx);
2670
2790
 
2671
2791
  try {
2792
+ sessionRuntime.workspace?.assertCurrent();
2672
2793
  await session.prompt(question, { source: "extension" });
2673
2794
  if (!isCurrentGeneration()) {
2674
2795
  return;
@@ -2704,6 +2825,7 @@ export default function (pi: ExtensionAPI) {
2704
2825
  thinkingLevel,
2705
2826
  timestamp: Date.now(),
2706
2827
  usage: response.usage,
2828
+ workspaceIdentity: sessionRuntime.workspaceIdentity,
2707
2829
  };
2708
2830
 
2709
2831
  pendingThread.push(details);
@@ -2913,73 +3035,13 @@ export default function (pi: ExtensionAPI) {
2913
3035
  },
2914
3036
  });
2915
3037
 
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
- });
3038
+ for (const command of BTW_COMMANDS) {
3039
+ pi.registerCommand(command.name, {
3040
+ description: command.description,
3041
+ handler: async (args, ctx) => {
3042
+ await dispatchBtwCommand(command.name === "side" ? "btw" : command.name, args, ctx);
3043
+ },
3044
+ });
3045
+ }
2978
3046
 
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
3047
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-btw",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "A pi extension for parallel side conversations with /btw",
5
5
  "type": "module",
6
6
  "license": "MIT",