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 +46 -5
- package/extensions/btw-extension-tools.ts +27 -9
- package/extensions/btw-ssh-workspace.ts +254 -0
- package/extensions/btw.ts +179 -96
- package/package.json +4 -4
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
|
|
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
|
|
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`
|
|
224
|
-
its
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
60
|
+
const globalSources = await readConfig(join(options.agentDir, "btw.json"));
|
|
54
61
|
const projectSources = options.projectTrusted
|
|
55
|
-
? await
|
|
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
|
-
|
|
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 })
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(/^\/(
|
|
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
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
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.
|
|
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": "
|
|
53
|
-
"@earendil-works/pi-coding-agent": "
|
|
54
|
-
"@earendil-works/pi-tui": "
|
|
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
|
}
|