pi-btw 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,11 @@ A small [pi](https://github.com/earendil-works/pi-mono) extension that adds a `/
23
23
 
24
24
  ## Install
25
25
 
26
- pi-btw requires Pi 0.85.1 or newer.
26
+ pi-btw supports Pi 0.85.1 through 1.x.
27
+
28
+ Development dependencies target Pi 0.99.2. CI runs the tests and typecheck
29
+ against the locked dependencies, Pi 0.85.1, and the latest published Pi release
30
+ on each PR and weekly. Pi 0.99.2 requires Node.js 22.19.0 or newer.
27
31
 
28
32
  ### From npm (after publish)
29
33
 
@@ -174,8 +178,53 @@ BTW is implemented as an actual pi sub-session with its own in-memory session st
174
178
  - BTW can inherit the main thread model/thinking settings or use BTW-only overrides via `/btw:model` and `/btw:thinking`
175
179
  - `/btw:summarize` uses the current effective BTW model but keeps thinking off
176
180
  - the overlay transcript/status line is driven from sub-session events, so tool activity, streaming deltas, failures, and recovery are all visible without scraping rendered output
181
+ - child prompts preserve the main session's instructions and append an authoritative list of their own tools; inherited tool/skill instructions and historical tool calls do not grant additional capabilities
177
182
  - handoff commands (`/btw:inject` and `/btw:summarize`) read from the BTW sub-session thread rather than maintaining a separate manual transcript model
178
183
 
184
+ ### Opt-in extension tools
185
+
186
+ BTW loads no extensions by default. To enable tools such as `web_search` and
187
+ `fetch_content` in `/btw`, `/side`, and `/btw:tangent`, create
188
+ `~/.pi/agent/btw.json` (or `btw.json` in your `PI_CODING_AGENT_DIR`):
189
+
190
+ ```json
191
+ {
192
+ "extensions": ["npm:pi-web-access"]
193
+ }
194
+ ```
195
+
196
+ A trusted project's `.pi/btw.json` can override this list. Lists replace rather
197
+ than merge; `{"extensions": []}` disables global BTW extensions for that
198
+ project. An omitted `extensions` key inherits the global list. Untrusted
199
+ projects do not contribute configuration. Config is read when a child session
200
+ is created; use `/btw:clear` to apply changes to an existing thread.
201
+
202
+ Sources can be Pi `npm:` or `git:` packages, or local extension files/package
203
+ directories. Local paths are relative to the config file's directory. Remote
204
+ packages are resolved into a separate BTW cache under the agent directory's
205
+ `btw/` folder and can be installed on first use. Pin a source version when you
206
+ need reproducible behavior.
207
+
208
+ Only listed sources are loaded. BTW runs their lifecycle handlers headlessly
209
+ (`ctx.hasUI === false`) and exposes their tools alongside `read`, `bash`,
210
+ `edit`, and `write`, including tools registered at startup. The capability
211
+ note follows the child's active tools. Load/startup errors stop child creation
212
+ and are reported with the failing source; clear, mode/model changes, and parent
213
+ shutdown run extension cleanup before disposal.
214
+
215
+ Choose extensions that support headless sessions. Tools requiring interactive
216
+ dialogs (such as `ask_user`) need additional UI integration. BTW does not import
217
+ extension skills, prompt templates, themes, widgets, or shortcuts into its UI.
218
+ Extensions are trusted code, not sandboxed: separate package installs avoid
219
+ sharing the parent's cached extension factory, but extensions may still use
220
+ shared files or external services. A local source already registered by the
221
+ parent is rejected; use an `npm:` or `git:` source for a separate install.
222
+
223
+ `/btw:ask` never reads this configuration or loads extension tools, so it retains
224
+ its built-in read-only tool set. `/btw:summarize` remains tool-free. Extension
225
+ configuration is machine/project configuration, not persisted in hidden
226
+ conversation-history entries.
227
+
179
228
  ### In-modal slash behavior
180
229
 
181
230
  Inside the BTW modal composer, slash handling is split at the BTW/session boundary:
@@ -0,0 +1,111 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import {
5
+ DefaultPackageManager,
6
+ DefaultResourceLoader,
7
+ SettingsManager,
8
+ type ResourceLoader,
9
+ } from "@earendil-works/pi-coding-agent";
10
+
11
+ /** Resolve local paths relative to the config that owns them, not process.cwd(). */
12
+ function resolveSource(source: string, configPath: string): string {
13
+ if (source.startsWith("npm:") || source.startsWith("git:")) return source;
14
+ if (source.startsWith("~/")) return join(homedir(), source.slice(2));
15
+ return resolve(dirname(configPath), source);
16
+ }
17
+
18
+ async function readSources(path: string): Promise<string[] | undefined> {
19
+ let text: string;
20
+ try {
21
+ text = await readFile(path, "utf8");
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
24
+ throw new Error(`Cannot read BTW config ${path}: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+
27
+ let config: unknown;
28
+ try {
29
+ config = JSON.parse(text);
30
+ } catch {
31
+ throw new Error(`Invalid BTW config ${path}: expected JSON.`);
32
+ }
33
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
34
+ throw new Error(`Invalid BTW config ${path}: expected an object.`);
35
+ }
36
+ const fields = Object.keys(config);
37
+ if (fields.some((field) => field !== "extensions")) {
38
+ throw new Error(`Invalid BTW config ${path}: only the "extensions" key is supported.`);
39
+ }
40
+ if (!("extensions" in config)) return undefined;
41
+ if (!Array.isArray(config.extensions) || config.extensions.some((source) => typeof source !== "string" || !source.trim())) {
42
+ throw new Error(`Invalid BTW config ${path}: "extensions" must be an array of nonempty source strings.`);
43
+ }
44
+ return [...new Set(config.extensions.map((source: string) => resolveSource(source.trim(), path)))];
45
+ }
46
+
47
+ /** Project lists replace global lists, including an explicit empty list. */
48
+ export async function readBtwExtensionSources(options: {
49
+ cwd: string;
50
+ agentDir: string;
51
+ projectTrusted: boolean;
52
+ }): Promise<string[]> {
53
+ const globalSources = await readSources(join(options.agentDir, "btw.json"));
54
+ const projectSources = options.projectTrusted
55
+ ? await readSources(join(options.cwd, ".pi", "btw.json"))
56
+ : undefined;
57
+ return projectSources ?? globalSources ?? [];
58
+ }
59
+
60
+ async function canonicalPath(path: string): Promise<string> {
61
+ try {
62
+ return await realpath(path);
63
+ } catch {
64
+ return resolve(path);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Resolve the allowlist before running any extension factories. Remote packages
70
+ * have a BTW-owned install root so Pi's factory cache cannot reuse the parent's
71
+ * module globals. Local sources already loaded by the parent are rejected.
72
+ */
73
+ export async function loadBtwExtensionResources(options: {
74
+ cwd: string;
75
+ agentDir: string;
76
+ sources: string[];
77
+ parentExtensionPaths: string[];
78
+ }): Promise<ResourceLoader> {
79
+ const agentDir = join(options.agentDir, "btw");
80
+ const settingsManager = SettingsManager.inMemory();
81
+ const packages = new DefaultPackageManager({ cwd: options.cwd, agentDir, settingsManager });
82
+ const selectedPaths = new Set<string>();
83
+ for (const source of options.sources) {
84
+ const resolved = await packages.resolveExtensionSources([source], { temporary: true });
85
+ const paths = resolved.extensions.filter((resource) => resource.enabled).map((resource) => resource.path);
86
+ if (paths.length === 0) throw new Error(`BTW source ${source} did not resolve to any extensions.`);
87
+ for (const path of paths) selectedPaths.add(path);
88
+ }
89
+ const paths = [...selectedPaths];
90
+ const parentPaths = new Set(await Promise.all(options.parentExtensionPaths.map(canonicalPath)));
91
+ for (const path of paths) {
92
+ if (parentPaths.has(await canonicalPath(path))) {
93
+ throw new Error(
94
+ `BTW cannot reload the parent's extension ${path}: Pi can share its module state. Use an npm: or git: source for a separate BTW install.`,
95
+ );
96
+ }
97
+ }
98
+ const loader = new DefaultResourceLoader({
99
+ cwd: options.cwd,
100
+ agentDir,
101
+ settingsManager,
102
+ noExtensions: true,
103
+ noSkills: true,
104
+ noPromptTemplates: true,
105
+ noThemes: true,
106
+ noContextFiles: true,
107
+ additionalExtensionPaths: paths,
108
+ });
109
+ await loader.reload();
110
+ return loader;
111
+ }
package/extensions/btw.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  buildSessionContext,
3
3
  createAgentSession,
4
4
  createExtensionRuntime,
5
+ getAgentDir,
5
6
  getMarkdownTheme,
6
7
  ModelRuntime,
7
8
  SessionManager,
@@ -38,6 +39,7 @@ import {
38
39
  type OverlayOptions,
39
40
  type TUI,
40
41
  } from "@earendil-works/pi-tui";
42
+ import { loadBtwExtensionResources, readBtwExtensionSources } from "./btw-extension-tools";
41
43
 
42
44
  const BTW_MESSAGE_TYPE = "btw-note";
43
45
  const BTW_ENTRY_TYPE = "btw-thread-entry";
@@ -270,6 +272,7 @@ type BtwSessionRuntime = {
270
272
  sideThreadStartIndex: number;
271
273
  abortPromise?: Promise<void>;
272
274
  promptQueue: Promise<void>;
275
+ hasExtensions: boolean;
273
276
  };
274
277
 
275
278
  type OverlayRuntime = {
@@ -298,10 +301,28 @@ function stripDynamicSystemPromptFooter(systemPrompt: string): string {
298
301
 
299
302
  function createBtwResourceLoader(
300
303
  ctx: ExtensionCommandContext,
304
+ tools: readonly string[] | (() => readonly string[]),
301
305
  appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT],
306
+ extensionResources?: ResourceLoader,
302
307
  ): ResourceLoader {
303
- const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
308
+ const extensionsResult = extensionResources?.getExtensions() ?? { extensions: [], errors: [], runtime: createExtensionRuntime() };
304
309
  const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt());
310
+ // Preserve the parent's project/custom instructions, but override capability
311
+ // claims in its rendered prompt and in the history seeded into this child.
312
+ const capabilities = () => {
313
+ const availableTools = typeof tools === "function" ? tools() : tools;
314
+ return [
315
+ "<btw_capabilities>",
316
+ availableTools.length > 0
317
+ ? `Available tools in this BTW session: ${availableTools.join(", ")}.`
318
+ : "No tools are available in this BTW session.",
319
+ "This capability list is authoritative for this child session.",
320
+ "Tool and skill instructions inherited from the main session may describe tools that are unavailable here.",
321
+ "Previous tool calls in inherited conversation are historical context, not available capabilities.",
322
+ "Only call tools listed above; do not infer additional tools from the main session.",
323
+ "</btw_capabilities>",
324
+ ].join("\n");
325
+ };
305
326
 
306
327
  const resourceLoader: ResourceLoader = {
307
328
  getExtensions: () => extensionsResult,
@@ -311,7 +332,7 @@ function createBtwResourceLoader(
311
332
  getAgentsFiles: () => ({ agentsFiles: [] }),
312
333
  getSystemPrompt: () => systemPrompt,
313
334
  getSystemPromptSource: () => undefined,
314
- getAppendSystemPrompt: () => appendSystemPrompt,
335
+ getAppendSystemPrompt: () => [...appendSystemPrompt, capabilities()],
315
336
  getAppendSystemPromptSources: () => [],
316
337
  extendResources: () => {},
317
338
  reload: async (_options) => {},
@@ -1628,6 +1649,7 @@ export default function (pi: ExtensionAPI) {
1628
1649
  let activeBtwSession: BtwSessionRuntime | null = null;
1629
1650
  let btwLifecycleGeneration = 0;
1630
1651
  let btwSubmissionQueue = Promise.resolve();
1652
+ let btwSessionCreationQueue = Promise.resolve();
1631
1653
 
1632
1654
  function invalidateBtwLifecycle(): void {
1633
1655
  btwLifecycleGeneration += 1;
@@ -1803,7 +1825,17 @@ export default function (pi: ExtensionAPI) {
1803
1825
 
1804
1826
  clearBtwSessionSubscriptions(current);
1805
1827
  await requestBtwSessionAbort(current);
1806
- current.session.dispose();
1828
+ await disposeChildSession(current.session, current.hasExtensions);
1829
+ }
1830
+
1831
+ async function disposeChildSession(session: AgentSession, hasExtensions: boolean): Promise<void> {
1832
+ try {
1833
+ if (hasExtensions) {
1834
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
1835
+ }
1836
+ } finally {
1837
+ session.dispose();
1838
+ }
1807
1839
  }
1808
1840
 
1809
1841
  async function dismissOverlaySession(): Promise<void> {
@@ -1974,8 +2006,23 @@ export default function (pi: ExtensionAPI) {
1974
2006
  throw new Error(settings.fallbackReason || "No active model selected.");
1975
2007
  }
1976
2008
 
2009
+ const agentDir = getAgentDir();
2010
+ const sources = mode === "readonly" ? [] : await readBtwExtensionSources({
2011
+ cwd: ctx.cwd,
2012
+ agentDir,
2013
+ projectTrusted: ctx.isProjectTrusted(),
2014
+ });
1977
2015
  const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, settings.model);
1978
- const sessionManager = SessionManager.inMemory();
2016
+ const extensionResources = sources.length > 0 ? await loadBtwExtensionResources({
2017
+ cwd: ctx.cwd,
2018
+ agentDir,
2019
+ sources,
2020
+ parentExtensionPaths: [
2021
+ ...pi.getAllTools().map((tool) => tool.sourceInfo.path),
2022
+ ...pi.getCommands().flatMap((command) => command.sourceInfo ? [command.sourceInfo.path] : []),
2023
+ ],
2024
+ }) : undefined;
2025
+ const sessionManager = SessionManager.inMemory(ctx.cwd);
1979
2026
  const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model);
1980
2027
 
1981
2028
  // The session manager is the source of provider context. Seed it before
@@ -1984,23 +2031,85 @@ export default function (pi: ExtensionAPI) {
1984
2031
  sessionManager.appendMessage(message);
1985
2032
  }
1986
2033
 
2034
+ const builtinTools = BTW_TOOLS_BY_MODE[mode];
2035
+ let childSession: AgentSession | undefined;
1987
2036
  const sessionOptions: CreateAgentSessionOptions = {
2037
+ cwd: ctx.cwd,
1988
2038
  sessionManager,
1989
2039
  model: settings.model,
1990
2040
  ...modelRuntimeOptions,
1991
2041
  thinkingLevel: settings.thinkingLevel,
1992
- // Read-only mode narrows this to pi's built-in read-only toolset.
1993
- tools: [...BTW_TOOLS_BY_MODE[mode]],
1994
- resourceLoader: createBtwResourceLoader(ctx),
2042
+ // Extension sessions need a live registry for tools registered at startup
2043
+ // or later. Read-only sessions keep the structural built-in allowlist.
2044
+ ...(extensionResources ? { noTools: "builtin" as const } : { tools: [...builtinTools] }),
2045
+ resourceLoader: createBtwResourceLoader(
2046
+ ctx, () => childSession?.getActiveToolNames() ?? builtinTools, [BTW_SYSTEM_PROMPT], extensionResources,
2047
+ ),
1995
2048
  };
1996
- const { session } = await createAgentSession(sessionOptions);
2049
+ const { session, extensionsResult } = await createAgentSession(sessionOptions);
2050
+ childSession = session;
2051
+ const hasExtensions = !!extensionResources;
2052
+ if (hasExtensions) {
2053
+ try {
2054
+ if (extensionsResult.errors.length > 0) {
2055
+ throw new Error(extensionsResult.errors.map((error) => `${error.path}: ${error.error}`).join("\n"));
2056
+ }
2057
+ const collectToolNames = () => {
2058
+ const builtinNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "powershell"]);
2059
+ const extensionNames = new Set<string>();
2060
+ for (const extension of extensionsResult.extensions) {
2061
+ for (const name of extension.tools.keys()) {
2062
+ if (builtinNames.has(name) || extensionNames.has(name)) {
2063
+ throw new Error(`BTW extension tool name collision: ${name}.`);
2064
+ }
2065
+ extensionNames.add(name);
2066
+ }
2067
+ }
2068
+ return [...extensionNames];
2069
+ };
2070
+ session.setActiveToolsByName([...builtinTools, ...collectToolNames()]);
2071
+ const startupErrors: string[] = [];
2072
+ let starting = true;
2073
+ await session.bindExtensions({
2074
+ onError: (error) => {
2075
+ const message = `${error.extensionPath}: ${error.error}`;
2076
+ if (starting) startupErrors.push(message);
2077
+ else notify(ctx, `BTW extension error: ${message}`, "error");
2078
+ },
2079
+ });
2080
+ starting = false;
2081
+ if (startupErrors.length > 0) throw new Error(startupErrors.join("\n"));
2082
+ collectToolNames();
2083
+ } catch (error) {
2084
+ try {
2085
+ await session.abort();
2086
+ } finally {
2087
+ await disposeChildSession(session, true);
2088
+ }
2089
+ throw error;
2090
+ }
2091
+ }
1997
2092
 
1998
- return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve() };
2093
+ return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve(), hasExtensions };
2094
+ }
2095
+
2096
+ function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
2097
+ const generation = btwLifecycleGeneration;
2098
+ // Composer-only commands can overlap while npm resolution is in flight.
2099
+ // Serialize creation so they reuse one child and never race package installs.
2100
+ const creation = btwSessionCreationQueue.then(() => createOrReuseBtwSession(ctx, mode, generation));
2101
+ btwSessionCreationQueue = creation.then(() => {}, () => {});
2102
+ return creation;
1999
2103
  }
2000
2104
 
2001
- async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
2105
+ async function createOrReuseBtwSession(
2106
+ ctx: ExtensionCommandContext,
2107
+ mode: BtwThreadMode,
2108
+ generation: number,
2109
+ ): Promise<BtwSessionRuntime | null> {
2110
+ if (generation !== btwLifecycleGeneration) return null;
2002
2111
  const settings = await resolveBtwSettings(ctx, true);
2003
- if (!settings.model) {
2112
+ if (generation !== btwLifecycleGeneration || !settings.model) {
2004
2113
  return null;
2005
2114
  }
2006
2115
 
@@ -2009,7 +2118,22 @@ export default function (pi: ExtensionAPI) {
2009
2118
  }
2010
2119
 
2011
2120
  await disposeBtwSession();
2012
- activeBtwSession = await createBtwSubSession(ctx, mode, settings);
2121
+ if (generation !== btwLifecycleGeneration) return null;
2122
+ try {
2123
+ const created = await createBtwSubSession(ctx, mode, settings);
2124
+ if (generation !== btwLifecycleGeneration) {
2125
+ await requestBtwSessionAbort(created);
2126
+ await disposeChildSession(created.session, created.hasExtensions);
2127
+ return null;
2128
+ }
2129
+ activeBtwSession = created;
2130
+ } catch (error) {
2131
+ if (generation !== btwLifecycleGeneration) return null;
2132
+ const message = `Could not start BTW session: ${error instanceof Error ? error.message : String(error)}`;
2133
+ setOverlayStatus(message, ctx);
2134
+ notify(ctx, message, "error");
2135
+ return null;
2136
+ }
2013
2137
  return activeBtwSession;
2014
2138
  }
2015
2139
 
@@ -2501,8 +2625,6 @@ export default function (pi: ExtensionAPI) {
2501
2625
  return;
2502
2626
  }
2503
2627
  if (!sessionRuntime) {
2504
- setOverlayStatus("No active model selected.", ctx);
2505
- notify(ctx, "No active model selected.", "error");
2506
2628
  return;
2507
2629
  }
2508
2630
 
@@ -2670,7 +2792,7 @@ export default function (pi: ExtensionAPI) {
2670
2792
  ...modelRuntimeOptions,
2671
2793
  thinkingLevel: "off",
2672
2794
  tools: [],
2673
- resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
2795
+ resourceLoader: createBtwResourceLoader(ctx, [], [BTW_SUMMARIZE_SYSTEM_PROMPT]),
2674
2796
  };
2675
2797
  const { session } = await createAgentSession(sessionOptions);
2676
2798
 
@@ -2861,4 +2983,3 @@ export default function (pi: ExtensionAPI) {
2861
2983
  },
2862
2984
  });
2863
2985
  }
2864
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-btw",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "A pi extension for parallel side conversations with /btw",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,15 +44,15 @@
44
44
  "image": "https://raw.githubusercontent.com/dbachelder/pi-btw/main/docs/btw-overlay.png"
45
45
  },
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-ai": ">=0.85.1 <1",
48
- "@earendil-works/pi-coding-agent": ">=0.85.1 <1",
49
- "@earendil-works/pi-tui": ">=0.85.1 <1"
47
+ "@earendil-works/pi-ai": ">=0.85.1 <2",
48
+ "@earendil-works/pi-coding-agent": ">=0.85.1 <2",
49
+ "@earendil-works/pi-tui": ">=0.85.1 <2"
50
50
  },
51
51
  "devDependencies": {
52
- "@earendil-works/pi-ai": "0.87.1",
53
- "@earendil-works/pi-coding-agent": "0.87.1",
54
- "@earendil-works/pi-tui": "0.87.1",
52
+ "@earendil-works/pi-ai": "0.99.2",
53
+ "@earendil-works/pi-coding-agent": "0.99.2",
54
+ "@earendil-works/pi-tui": "0.99.2",
55
55
  "typescript": "^6.0.2",
56
- "vitest": "^4.1.0"
56
+ "vitest": "^4.1.11"
57
57
  }
58
58
  }