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 +44 -3
- package/extensions/btw-extension-tools.ts +27 -9
- package/extensions/btw-ssh-workspace.ts +254 -0
- package/extensions/btw.ts +152 -90
- package/package.json +1 -1
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`
|
|
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 = {
|
|
@@ -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 })
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(/^\/(
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
}
|