@@ -23,8 +23,8 @@
-
-
+
+
@@ -96,11 +96,11 @@
- [Known Issues](#known-issues)
- [Features Overview](#features-overview)
- [Releases \& Downloads](#releases--downloads)
- - [Download Latest Release](#download-latest-release)
- - [Building from Source](#building-from-source)
+ - [Download Latest Release](#download-latest-release)
+ - [Building from Source](#building-from-source)
- [Development Configuration](#development-configuration)
- - [Telemetry and Error Tracking](#telemetry-and-error-tracking)
- - [Environment Variables](#environment-variables)
+ - [Telemetry and Error Tracking](#telemetry-and-error-tracking)
+ - [Environment Variables](#environment-variables)
- [Discussions](#discussions)
- [License](#license)
- [Team](#team)
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 187dfa98..3f978782 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,46 +1,37 @@
-# Power Platform ToolBox 1.2.2
+# Power Platform ToolBox 1.2.3
## Highlights
-- Open external URLs using the active connection's browser profile for consistent session isolation
-- Keep the app accessible on macOS with a system tray icon when the window is closed
-- Sync zoom level between the main window and all open tool BrowserViews
-- Allow tools to open `mailto:` links with explicit user consent and URL validation
-- Improve protocol handler behavior for stable builds (single-instance lock and safer registration rules)
-- Make marketplace filtering clearer with an active-filter indicator and one-click clear
-- Improve tool update availability visibility with a clearer text-based indicator
+- Support beta and pre-release npm packages in tool discovery and installs
+- Enhanced inter-tool communications; both one-way (send and forget) and two-way (send and receive)
+- Let tools declare capabilities; and find tools by capabilities
+- Tighten terminal command blocking during tool execution
+- Sanitize version scopes before running npm install
## Fixes
-- Zoom: correct zoom-in accelerator keys and re-fit BrowserView bounds after zooming
-- Links: avoid lowercasing full URLs during `mailto:` scheme checks
-- Links: improve parse-failure logging with scheme context while avoiding PII
-- UI: fix notification layering/z-index issues in the renderer
-- Tools sidebar: improve empty-state hint behavior for better guidance
-- Marketplace: fix tool icon theming so icons adapt correctly in light/dark mode
-- URLs: add support for URLs ending in `mcas.ms` where applicable
-- macOS: ensure tray Quit fully exits the app (track `isQuitting` correctly)
+- Terminal: improve blocking rules and related command handling
+- Terminal: keep stdout processing stable while filtering sentinel output
+- Install flow: sanitize npm version scopes before invoking installs
+- Marketplace: keep prerelease package handling aligned with tool registry metadata
+- Tools: preserve capability metadata for npm and locally loaded tools
## Developer & Build
-- toolboxAPI: remove deprecated loading screen API and associated handlers
-- `pptb-validate`: validate `pptb.config.json` invocation with semver enforcement
-- Types: rename `PptbConfig` → `PPTBConfig` and `validatePptbConfig` → `validatePPTBConfig` for consistent casing
-- CI/CD: separate stable vs insider release channels (including channel-specific icons)
-- Windows packaging: sign app binaries before packaging into installers
-- Workflows: tighten merge automation with explicit permissions and concurrency
+- Tool registry and preload APIs now carry prerelease package support end to end
+- Terminal manager refactor expands the blocking pipeline and stdout handling internals
## Install
-- Windows: Power-Platform-ToolBox-1.2.2-Setup.exe
-- macOS: Power-Platform-ToolBox-1.2.2.dmg (drag to Applications)
-- Linux: Power-Platform-ToolBox-1.2.2.AppImage (chmod +x, then run)
+- Windows: Power-Platform-ToolBox-1.2.3-Setup.exe
+- macOS: Power-Platform-ToolBox-1.2.3.dmg (drag to Applications)
+- Linux: Power-Platform-ToolBox-1.2.3.AppImage (chmod +x, then run)
## Notes
-- No manual migration needed; existing settings and connections continue to work.
-- Tool developers: `mailto:` opening may prompt for user consent; update config/type references to `PPTBConfig` if you used the older casing.
+- No manual migration needed.
+- Tool developers using prerelease packages should confirm their package metadata is published with the expected version scope.
## Full Changelog
-https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.2.1...v1.2.2
+https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.2.2...v1.2.3
diff --git a/docs/INTER_TOOL_INVOCATION.md b/docs/INTER_TOOL_INVOCATION.md
index f6dd64e1..3c9c25bc 100644
--- a/docs/INTER_TOOL_INVOCATION.md
+++ b/docs/INTER_TOOL_INVOCATION.md
@@ -9,21 +9,42 @@ This document covers the **Inter-Tool Invocation** feature of Power Platform Too
## Table of Contents
-1. [Overview](#overview)
-2. [Part 1 – Callee (the tool that accepts invocations)](#part-1--callee-the-tool-that-accepts-invocations)
- - [1.1 Declaring the invocation contract (`pptb.config.json`)](#11-declaring-the-invocation-contract-pptbconfigjson)
- - [1.2 Reading the launch context](#12-reading-the-launch-context)
- - [1.3 Returning data to the caller](#13-returning-data-to-the-caller)
- - [1.4 Handling standalone vs. invoked modes](#14-handling-standalone-vs-invoked-modes)
- - [1.5 Complete callee example](#15-complete-callee-example)
-3. [Part 2 – Caller (the tool that launches other tools)](#part-2--caller-the-tool-that-launches-other-tools)
- - [2.1 Launching a tool with prefill data](#21-launching-a-tool-with-prefill-data)
- - [2.2 Handling the return value](#22-handling-the-return-value)
- - [2.3 Connection overrides](#23-connection-overrides)
- - [2.4 Complete caller example](#24-complete-caller-example)
-4. [Lifecycle and Behaviour](#lifecycle-and-behaviour)
-5. [Validation and Tooling](#validation-and-tooling)
-6. [Troubleshooting](#troubleshooting)
+- [Inter-Tool Invocation](#inter-tool-invocation)
+ - [Table of Contents](#table-of-contents)
+ - [Overview](#overview)
+ - [Part 1 – Callee (the tool that accepts invocations)](#part-1--callee-the-tool-that-accepts-invocations)
+ - [1.1 Declaring the invocation contract (`pptb.config.json`)](#11-declaring-the-invocation-contract-pptbconfigjson)
+ - [1.2 Reading the launch context](#12-reading-the-launch-context)
+ - [1.3 Returning data to the caller](#13-returning-data-to-the-caller)
+ - [1.4 Handling standalone vs. invoked modes](#14-handling-standalone-vs-invoked-modes)
+ - [1.5 Complete callee example](#15-complete-callee-example)
+ - [Part 2 – Caller (the tool that launches other tools)](#part-2--caller-the-tool-that-launches-other-tools)
+ - [2.1 Launching a tool with prefill data](#21-launching-a-tool-with-prefill-data)
+ - [2.2 Handling the return value](#22-handling-the-return-value)
+ - [2.3 Connection auto-inheritance and overrides](#23-connection-auto-inheritance-and-overrides)
+ - [2.4 Tag-based capability discovery](#24-tag-based-capability-discovery)
+ - [Well-known capability tags](#well-known-capability-tags)
+ - [2.5 Complete caller example](#25-complete-caller-example)
+ - [End-to-End Scenario: FXS "Send To" Flyout](#end-to-end-scenario-fxs-send-to-flyout)
+ - [Scenario summary](#scenario-summary)
+ - [Step 1 – Callee tools declare the `"fetchxml"` capability](#step-1--callee-tools-declare-the-fetchxml-capability)
+ - [Step 2 – FXS builds its "Send To" flyout from discovered tools](#step-2--fxs-builds-its-send-to-flyout-from-discovered-tools)
+ - [Step 3 – User selects DMS; FXS launches it with `noReturn: true`](#step-3--user-selects-dms-fxs-launches-it-with-noreturn-true)
+ - [Step 4 – DMS reads the prefill data and uses it](#step-4--dms-reads-the-prefill-data-and-uses-it)
+ - [Step 5 – DMS closes; FXS Promise resolves](#step-5--dms-closes-fxs-promise-resolves)
+ - [Full sequence diagram](#full-sequence-diagram)
+ - [Validation and Tooling](#validation-and-tooling)
+ - [`pptb-validate`](#pptb-validate)
+ - [TypeScript types](#typescript-types)
+ - [Troubleshooting](#troubleshooting)
+ - [`launchTool` throws "Tool not found"](#launchtool-throws-tool-not-found)
+ - [`launchTool` throws "A callee invocation is already in progress"](#launchtool-throws-a-callee-invocation-is-already-in-progress)
+ - [`getLaunchContext()` returns `null` when expecting prefill data](#getlaunchcontext-returns-null-when-expecting-prefill-data)
+ - [Caller `Promise` resolves with `null` unexpectedly](#caller-promise-resolves-with-null-unexpectedly)
+ - [Changes to `pptb.config.json` are not picked up](#changes-to-pptbconfigjson-are-not-picked-up)
+ - [`returnData` appears to do nothing](#returndata-appears-to-do-nothing)
+ - [`findToolsByCapability` returns an empty array](#findtoolsbycapability-returns-an-empty-array)
+ - [References](#references)
---
@@ -43,13 +64,19 @@ invocation.launchTool( invocation.getLaunchContext()
result { selectedId: "a1b2c3",
= { selectedId: "a1b2c3", selectedName: "Contoso" }
selectedName: "Contoso" } )
+ ← PPTB auto-closes callee window
```
Key properties of the feature:
- **Promise-based**: `invocation.launchTool()` returns a `Promise` that resolves when the callee calls `returnData()`, or resolves to `null` if the callee closes without returning data.
- **Isolated windows**: the callee opens in its own BrowserView, just like a normally launched tool.
+- **Auto-close callee**: after the callee calls `returnData()`, PPTB **automatically closes the callee window** — the callee does not need to close itself.
+- **Connection auto-inheritance**: the callee automatically inherits the caller's active FXS connection (can be overridden via `options`).
+- **One-at-a-time**: only one active callee per caller is supported in this phase. A second `launchTool` call while a callee is active rejects with `"A callee invocation is already in progress"`.
- **Optional contract**: the callee declares the shape of its prefill data and return value in `pptb.config.json`; this is validated by `pptb-validate` but is not enforced at runtime.
+- **Tag-based capability discovery**: callee tools declare capability tags; caller tools can discover matching installed tools by tag.
+- **Shell-level "Return to Caller" banner**: PPTB injects a dismissable banner in the callee window so the user can return to the caller at any time — even before `returnData` is called.
- **Graceful degradation**: both the prefill data and the return value are plain JSON objects (`Record`), so missing fields degrade gracefully.
---
@@ -60,13 +87,14 @@ Key properties of the feature:
Create a file named `pptb.config.json` in the **root of your tool package** (next to `package.json`). This file declares:
-| Field | Required | Description |
-|-------|----------|-------------|
-| `invocation.version` | **Yes** (when `invocation` is present) | Semantic version of your invocation contract (e.g. `"1.0.0"`). Bump this when the shape of `prefill` or `returnTopic` changes. |
-| `invocation.prefill` | No | JSON-schema-style object describing the data a caller can pass in. |
-| `invocation.prefill.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. |
-| `invocation.returnTopic` | No | JSON-schema-style object describing the data your tool returns to its caller. |
-| `invocation.returnTopic.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. |
+| Field | Required | Description |
+| ----------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| `invocation.version` | **Yes** (when `invocation` is present) | Semantic version of your invocation contract (e.g. `"1.0.0"`). Bump this when the shape of `prefill` or `returnTopic` changes. |
+| `invocation.capabilities` | No | Array of capability tag strings (e.g. `["entity-picker"]`). Used by callers to discover this tool. |
+| `invocation.prefill` | No | JSON-schema-style object describing the data a caller can pass in. |
+| `invocation.prefill.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. |
+| `invocation.returnTopic` | No | JSON-schema-style object describing the data your tool returns to its caller. |
+| `invocation.returnTopic.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. |
**Example `pptb.config.json`:**
@@ -74,6 +102,7 @@ Create a file named `pptb.config.json` in the **root of your tool package** (nex
{
"invocation": {
"version": "1.0.0",
+ "capabilities": ["entity-picker"],
"prefill": {
"properties": {
"entityName": { "type": "string" },
@@ -135,6 +164,7 @@ await toolboxAPI.invocation.returnData({
selectedId: "a1b2c3d4-...",
selectedName: "Contoso Ltd.",
});
+// PPTB automatically closes this window after delivering the result.
```
**Signature:**
@@ -144,8 +174,8 @@ returnData(returnData: Record): Promise
```
- Resolves the `Promise` that the caller is awaiting in `invocation.launchTool()`.
+- **PPTB automatically closes the callee window** after the result has been delivered to the caller — the callee does **not** need to close itself.
- If the tool was **not** launched by another tool, this call is a **no-op** – it is safe to call unconditionally.
-- After calling `returnData`, it is your tool's responsibility to close its own window or update its UI as appropriate.
---
@@ -153,10 +183,10 @@ returnData(returnData: Record): Promise
A well-behaved callee works in both modes:
-| Mode | `getLaunchContext()` returns | Expected behaviour |
-|------|-----------------------------|--------------------|
-| Standalone (normal launch) | `null` | Show full UI, no pre-populated state |
-| Invoked by another tool | `Record` | Pre-populate UI from the context, show a "confirm / return" action |
+| Mode | `getLaunchContext()` returns | Expected behaviour |
+| -------------------------- | ---------------------------- | ------------------------------------------------------------------ |
+| Standalone (normal launch) | `null` | Show full UI, no pre-populated state |
+| Invoked by another tool | `Record` | Pre-populate UI from the context, show a "confirm / return" action |
```typescript
async function initTool() {
@@ -169,6 +199,7 @@ async function initTool() {
allowMultiSelect: (ctx.allowMultiSelect as boolean) ?? false,
onConfirm: async (selection) => {
await toolboxAPI.invocation.returnData(selection);
+ // PPTB will auto-close this window after returnData completes
},
});
} else {
@@ -190,6 +221,7 @@ The following is a minimal but complete callee implementation for an entity-pick
{
"invocation": {
"version": "1.0.0",
+ "capabilities": ["entity-picker"],
"prefill": {
"properties": {
"entityName": { "type": "string" },
@@ -218,7 +250,7 @@ async function main() {
const records = await loadRecords(entityName);
renderPicker(records, async (selected) => {
- // Send the selection back and let the caller handle closing / next steps
+ // Send the selection back; PPTB auto-closes this window
await toolboxAPI.invocation.returnData({
selectedId: selected.id,
selectedName: selected.name,
@@ -243,8 +275,8 @@ Use `toolboxAPI.invocation.launchTool()` to open another installed tool and pass
```typescript
const result = await toolboxAPI.invocation.launchTool(
- "@my-org/entity-picker", // npm package name of the target tool
- { entityName: "account" }, // prefill data (must match callee's prefill schema)
+ "@my-org/entity-picker", // npm package name of the target tool
+ { entityName: "account" }, // prefill data (must match callee's prefill schema)
);
```
@@ -257,64 +289,123 @@ launchTool(
options?: {
primaryConnectionId?: string | null;
secondaryConnectionId?: string | null;
+ noReturn?: boolean;
},
): Promise
```
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `targetToolId` | `string` | The npm package name of the tool to launch (e.g. `"@my-org/entity-picker"`). Must be installed. |
-| `prefillData` | `Record` | Optional data to pre-populate the callee's state. Shape should match the callee's `invocation.prefill` schema. |
-| `options.primaryConnectionId` | `string | null` | Override the primary Dataverse connection for the callee. |
-| `options.secondaryConnectionId` | `string | null` | Override the secondary Dataverse connection for the callee. |
+| Parameter | Type | Description |
+| ------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `targetToolId` | `string` | The npm package name of the tool to launch (e.g. `"@my-org/entity-picker"`). Must be installed. |
+| `prefillData` | `Record` | Optional data to pre-populate the callee's state. Shape should match the callee's `invocation.prefill` schema. |
+| `options.primaryConnectionId` | `string \| null` | Override the primary Dataverse connection for the callee. Omit to auto-inherit the caller's active FXS connection. |
+| `options.secondaryConnectionId` | `string \| null` | Override the secondary Dataverse connection for the callee. Omit to let PPTB prompt for it when the callee is a multi-connection tool. |
+| `options.noReturn` | `boolean` | When `true`, signals that the caller does not expect the callee to return data. The "Return to [Caller]" banner is suppressed entirely for the callee. The invocation lifecycle is otherwise identical — the Promise still resolves with `null` when the callee closes. |
-**Return value:** A `Promise` that resolves with the `Record` passed to `returnData()` by the callee, or `null` if the callee closes without returning data.
+**Return value:** A `Promise` that resolves with the `Record` passed to `returnData()` by the callee, or `null` if:
+
+- the callee closes without calling `returnData`, or
+- the user clicks the "Return to [this tool]" banner before the callee calls `returnData`.
+
+> **Important:** Only one callee per caller is active at a time. Calling `launchTool` a second time while a callee is still active throws `"A callee invocation is already in progress"`.
> **Important:** The target tool must be **installed** in PPTB. If the tool is not found, `launchTool` throws an error.
+> **Multi-connection auto-prompt:** If the callee tool declares `features.multiConnection: "required"` or `"optional"` in its manifest and no `options.secondaryConnectionId` is provided, PPTB automatically opens the multi-connection selector before launching the callee. If the user cancels the selector, `launchTool` throws `"Connection selection cancelled"`.
+
---
### 2.2 Handling the return value
```typescript
-const result = await toolboxAPI.invocation.launchTool(
- "@my-org/entity-picker",
- { entityName: "contact" },
-);
+const result = await toolboxAPI.invocation.launchTool("@my-org/entity-picker", { entityName: "contact" });
if (result !== null) {
const { selectedId, selectedName } = result as { selectedId: string; selectedName: string };
// Use the selection returned by the callee
populateField("regardingobjectid", selectedId, selectedName);
} else {
- // User closed the picker without making a selection – no change needed
+ // User dismissed the picker without selecting – no change needed
}
```
Always check for `null` before using the result. The `Promise` resolves to `null` in two scenarios:
1. The user closes the callee tool window without calling `returnData`.
-2. The callee explicitly calls `returnData({})` with an empty object (treat as cancelled if you expect specific fields).
+2. The user clicks the **"Return to [CallerTool]"** banner before the callee calls `returnData`.
---
-### 2.3 Connection overrides
+### 2.3 Connection auto-inheritance and overrides
-By default, the callee inherits no connection from the caller. Pass `options.primaryConnectionId` or `options.secondaryConnectionId` to forward a specific Dataverse connection:
+By default, the callee **automatically inherits the caller's active FXS connection**. No additional configuration is needed:
```typescript
-const connections = await toolboxAPI.connections.getActiveConnection();
+// Callee receives the same primary connection as this tool automatically
+const result = await toolboxAPI.invocation.launchTool("@my-org/entity-picker", { entityName: "account" });
+```
-const result = await toolboxAPI.invocation.launchTool(
- "@my-org/solution-importer",
- { solutionName: "MySolution" },
- { primaryConnectionId: connections?.id ?? null },
-);
+To override with a specific connection, pass `options.primaryConnectionId`:
+
+```typescript
+const result = await toolboxAPI.invocation.launchTool("@my-org/solution-importer", { solutionName: "MySolution" }, { primaryConnectionId: specificConnectionId });
+```
+
+Pass `null` to launch the callee with no connection:
+
+```typescript
+const result = await toolboxAPI.invocation.launchTool("@my-org/entity-picker", {}, { primaryConnectionId: null });
+```
+
+---
+
+### 2.4 Tag-based capability discovery
+
+Discover installed tools that support a given capability by querying their declared `capabilities` tags:
+
+```typescript
+const pickers = await toolboxAPI.invocation.findToolsByCapability("entity-picker");
+// pickers: Tool[] — all installed tools with "entity-picker" in their capabilities
+
+if (pickers.length > 0) {
+ const picker = pickers[0] as { id: string };
+ const result = await toolboxAPI.invocation.launchTool(picker.id, { entityName: "account" });
+}
+```
+
+**Signature:**
+
+```typescript
+findToolsByCapability(tag: CapabilityTag): Promise
+getKnownCapabilityTags(): Promise>
```
+`findToolsByCapability` returns an array of matching installed `Tool` objects (empty array if none found).
+
+`getKnownCapabilityTags` returns the full capability registry (fetched from Supabase at runtime, cached for 5 minutes; falls back to a built-in list when offline).
+
+#### Well-known capability tags
+
+The following tags are registered in the official capability registry. Use them to ensure your tool is discoverable by callers without typos:
+
+| Tag | Description |
+| ------------------- | -------------------------------------------- |
+| `fetchxml` | Accept or process FetchXML queries |
+| `entity-picker` | Browse and select a Dataverse entity (table) |
+| `record-selector` | Browse and select a Dataverse record |
+| `solution-selector` | Pick a Power Platform solution |
+
+> **Note:** The registry is configurable without an app update — new tags are added to the Supabase `capability_tags` table and become immediately discoverable at runtime via `getKnownCapabilityTags()`. The TypeScript `KnownCapabilityTag` union type and `pptb-validate` are updated in the next `@pptb/types` release.
+
+> **IDE auto-complete:** Import `CapabilityTag` from `@pptb/types/pptbConfig` to get IntelliSense for known tags:
+>
+> ```typescript
+> import type { CapabilityTag } from "@pptb/types/pptbConfig";
+> ```
+
---
-### 2.4 Complete caller example
+### 2.5 Complete caller example
```typescript
async function openEntityPicker(entityName: string) {
@@ -324,9 +415,10 @@ async function openEntityPicker(entityName: string) {
result = await toolboxAPI.invocation.launchTool(
"@my-org/entity-picker",
{ entityName, allowMultiSelect: false },
+ // primaryConnectionId omitted → callee inherits this tool's FXS connection
);
} catch (err) {
- // Tool not installed or launch failed
+ // Tool not installed, already has an active callee, or launch failed
await toolboxAPI.utils.showNotification({
title: "Cannot open picker",
body: err instanceof Error ? err.message : String(err),
@@ -336,7 +428,7 @@ async function openEntityPicker(entityName: string) {
}
if (result === null) {
- // User dismissed the picker
+ // User dismissed the picker (closed window or clicked "Return to Caller" banner)
return;
}
@@ -347,7 +439,208 @@ async function openEntityPicker(entityName: string) {
---
-## Lifecycle and Behaviour
+## End-to-End Scenario: FXS "Send To" Flyout
+
+This section illustrates a concrete real-world scenario where **FetchXML Studio (FXS)** exposes a "Send To ▾" flyout button that lets users push the current FetchXML query directly into another installed tool — such as **DRB** (Dataverse Rest Builder) or **DMS** (Data Migration Studio) — without expecting a return value.
+
+### Scenario summary
+
+| Step | What happens |
+| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | User composes a FetchXML query in FXS. |
+| 2 | User clicks the **"Send To ▾"** flyout button in FXS. |
+| 3 | PPTB queries all installed tools that declare the `"fetchxml"` capability — both DRB and DMS qualify. The flyout lists them as options. |
+| 4 | User selects **DMS**. |
+| 5 | PPTB opens DMS, inheriting FXS's active Dataverse connection as the primary connection. |
+| 6 | If DMS requires a **secondary connection** (e.g. it is a multi-connection tool for cross-environment migration), PPTB automatically shows the **multi-connection selector** before launching DMS — the user picks the second connection. |
+| 7 | DMS opens pre-populated with the FetchXML from step 1. |
+| 8 | Because `noReturn: true` was set, **no banner is shown** in the DMS window — FXS does not expect data back. |
+| 9 | The user continues in DMS independently. Closing DMS resolves the Promise on the FXS side with `null`. |
+
+---
+
+### Step 1 – Callee tools declare the `"fetchxml"` capability
+
+Both DRB and DMS include the following in their `pptb.config.json`:
+
+**DRB `pptb.config.json`**
+
+```json
+{
+ "invocation": {
+ "version": "1.0.0",
+ "capabilities": ["fetchxml"],
+ "prefill": {
+ "properties": {
+ "fetchXml": { "type": "string" }
+ }
+ }
+ }
+}
+```
+
+**DMS `pptb.config.json`** — DMS is a multi-connection tool so it has no `returnTopic`; it uses the FetchXML purely as input.
+
+```json
+{
+ "invocation": {
+ "version": "1.0.0",
+ "capabilities": ["fetchxml"],
+ "prefill": {
+ "properties": {
+ "fetchXml": { "type": "string" }
+ }
+ }
+ }
+}
+```
+
+> DMS's multi-connection requirement is declared in the standard tool manifest (`pptb.package.json`):
+>
+> ```json
+> { "features": { "multiConnection": "required" } }
+> ```
+>
+> PPTB detects this at launch time and automatically prompts the user to select a secondary connection when none is available from the caller.
+
+---
+
+### Step 2 – FXS builds its "Send To" flyout from discovered tools
+
+FXS calls `findToolsByCapability` on startup to discover all installed `fetchxml`-capable tools, then renders a flyout button for each one:
+
+```typescript
+// fxs/index.ts — called during tool initialisation
+async function setupSendToFlyout() {
+ // Discover all installed tools that accept fetchxml
+ const fetchXmlTools = await toolboxAPI.invocation.findToolsByCapability("fetchxml");
+
+ // Render one "Send to [ToolName]" item in the flyout for each discovered tool
+ renderSendToFlyout(fetchXmlTools as Array<{ id: string; name: string }>);
+}
+```
+
+---
+
+### Step 3 – User selects DMS; FXS launches it with `noReturn: true`
+
+When the user picks "Send to DMS" from the flyout, FXS calls `launchTool` with `noReturn: true` to signal that it does not expect DMS to return data:
+
+```typescript
+// fxs/index.ts
+async function sendCurrentQueryToTool(targetToolId: string) {
+ const currentFetchXml = getEditorContent(); // e.g. "…"
+
+ try {
+ // noReturn: true → DMS will NOT call returnData back to FXS.
+ // The Promise resolves with null when DMS closes or the user clicks "Return to FXS".
+ await toolboxAPI.invocation.launchTool(
+ targetToolId,
+ { fetchXml: currentFetchXml },
+ {
+ // primaryConnectionId omitted → FXS's active connection is inherited by DMS
+ // secondaryConnectionId omitted → PPTB shows multi-connection selector if DMS requires it
+ noReturn: true,
+ },
+ );
+ // Execution reaches here once DMS is closed (or user clicks "Return to FXS").
+ // No result data to process.
+ } catch (err) {
+ // Tool not installed, connection selection cancelled, or already has an active callee.
+ toolboxAPI.utils.showNotification({
+ title: "Send To failed",
+ body: err instanceof Error ? err.message : String(err),
+ type: "error",
+ });
+ }
+}
+```
+
+**What PPTB does behind the scenes:**
+
+1. Looks up the DMS tool manifest.
+2. Detects that DMS has `features.multiConnection: "required"` and no secondary connection was provided → opens the **multi-connection selector modal** in the PPTB shell. The user selects the target environment connection.
+3. Launches DMS with FXS's primary connection and the user-selected secondary connection.
+4. Pre-populates DMS with `{ fetchXml: "…" }`.
+5. Because `noReturn: true` was set, **no "Return to FXS" banner is shown** in the DMS window.
+
+---
+
+### Step 4 – DMS reads the prefill data and uses it
+
+DMS starts normally and reads the FetchXML from its launch context:
+
+```typescript
+// dms/index.ts
+async function main() {
+ const ctx = await toolboxAPI.invocation.getLaunchContext();
+
+ if (ctx) {
+ // Launched by FXS (or another tool) with a fetchXml payload
+ const fetchXml = ctx.fetchXml as string | undefined;
+ if (fetchXml) {
+ loadQueryIntoEditor(fetchXml);
+ }
+ // DMS does NOT call returnData — FXS launched it with noReturn: true.
+ // The user works in DMS independently; closing DMS resolves FXS's Promise with null.
+ } else {
+ // Standalone launch — show empty editor
+ renderEmptyEditor();
+ }
+}
+
+main();
+```
+
+> **Note:** DMS does not need to detect `noReturn` explicitly. The behaviour is the same as any other invocation: if `returnData` is never called and the tool is closed, the caller's Promise resolves with `null`. The `noReturn` flag only suppresses the banner — it does not change the invocation lifecycle.
+
+---
+
+### Step 5 – DMS closes; FXS Promise resolves
+
+Because `noReturn: true` was set, no banner is shown in the DMS window. The user works in DMS and closes it normally (or closes the tab). PPTB resolves FXS's Promise with `null`.
+
+| Action | Result |
+| ---------------------- | ------------------------------------------------ |
+| Close DMS tab normally | DMS is closed; FXS Promise resolves with `null`. |
+
+---
+
+### Full sequence diagram
+
+```
+FXS (Caller) PPTB Shell DMS (Callee)
+──────────── ────────── ────────────
+findToolsByCapability("fetchxml")
+ → [DRB, DMS]
+
+User clicks "Send to DMS"
+launchTool("dms", { fetchXml },
+ { noReturn: true })
+ │
+ ▼
+ DMS needs secondary connection
+ → show multi-connection selector
+ ← user picks target env
+ │
+ ▼
+ Launch DMS (primary = FXS conn,
+ secondary = user pick)
+ No banner shown (noReturn: true)
+ │
+ ▼
+ getLaunchContext()
+ → { fetchXml: "…" }
+ loadQueryIntoEditor(fetchXml)
+ // user works in DMS …
+
+User closes DMS tab normally
+ ◄──────────────────────────────────────────────
+Promise resolves (null) DMS closed
+// No result to process
+```
+
+---
Understanding the full lifecycle helps when reasoning about edge cases:
@@ -355,38 +648,51 @@ Understanding the full lifecycle helps when reasoning about edge cases:
Caller tool calls invocation.launchTool(...)
│
▼
+One-at-a-time check: rejects if caller already has an active callee
+ │
+ ▼
PPTB main process creates a new BrowserView for the callee
+FXS connection auto-inherited from caller (unless overridden)
│
▼
Callee loads, receives toolContext with:
• toolId, instanceId
• callerInstanceId ← present only for invocations
• prefillData ← the object passed by the caller
+ • connectionId ← auto-inherited from caller's FXS connection
+ │
+ ▼
+PPTB injects "Return to [CallerToolName]" banner in the callee window
+(skipped if launchTool was called with noReturn: true)
│
▼
Callee calls getLaunchContext() → returns prefillData
│
▼ (user interacts with callee UI)
│
- ┌───┴────────────────────────┐
- │ │
- ▼ ▼
-Callee calls returnData(...) Callee window is closed
- │ │
- ▼ ▼
-PPTB sends result to caller PPTB sends null to caller
- │ │
- └───────────────┬────────────┘
- │
- ▼
- Caller's Promise resolves (returnData value OR null)
+ ┌───┴──────────────────────────────────────┐──────────────────────────┐
+ │ │ │
+ ▼ ▼ ▼
+Callee calls returnData(...) User closes callee window User clicks "Return to Caller"
+ │ │ banner button
+ ▼ ▼ │
+PPTB sends result to caller PPTB sends null to caller ▼
+PPTB auto-closes callee window │ PPTB sends null to caller
+ │ │ PPTB auto-closes callee window
+ └──────────────────┬──────────────────────┘──────────────────────────┘
+ │
+ ▼
+ Caller's Promise resolves (returnData value OR null)
```
**Key points:**
- The callee opens in its **own window** (BrowserView) and is visible as a separate tab in the PPTB tool panel.
-- The caller's `launchTool()` Promise **never rejects** under normal operation – it always resolves (possibly with `null`). Rejections only occur if the target tool is not installed or if the launch itself fails due to a system error.
-- It is the callee's responsibility to **close its own window** after calling `returnData`, if appropriate for the UX. PPTB does not close the callee automatically.
+- The caller's `launchTool()` Promise **never rejects** under normal operation – it always resolves (possibly with `null`). Rejections only occur if the target tool is not installed, if the caller already has an active callee, or if the launch itself fails.
+- **Auto-close**: after `returnData` is called, PPTB automatically closes the callee window. The callee does **not** need to close itself.
+- **Banner early-return**: if the user clicks the "Return to [CallerToolName]" banner before the callee calls `returnData`, the caller's Promise resolves with `null` and the callee window is closed. This is treated the same as closing the window.
+- **Banner dismiss (✕)**: clicking the dismiss button hides the banner for the session but does **not** end the invocation. The callee stays open and can still call `returnData` normally.
+- **One-at-a-time**: only one active callee per caller is allowed. A second `launchTool` call from the same caller while a callee is active rejects with `"A callee invocation is already in progress"`.
- A callee that never calls `returnData` will keep the caller's Promise pending until the callee window is closed by the user.
---
@@ -406,24 +712,33 @@ npx pptb-validate
The validator checks:
- `invocation.version` is present and a valid semver string.
+- `invocation.capabilities` (when present) is an array of non-empty strings where each value is a recognised capability tag (warning issued for unrecognised tags — see well-known tags above).
- `invocation.prefill.properties` values are valid JSON-schema property descriptors.
- `invocation.returnTopic.properties` values are valid JSON-schema property descriptors.
+The validator prints the list of known capability tags in its output summary so you can quickly check which tags are available.
+
### TypeScript types
The `@pptb/types` package ships type definitions for the entire invocation API:
```typescript
-// The three methods live on toolboxAPI.invocation
-toolboxAPI.invocation.getLaunchContext() // Promise | null>
-toolboxAPI.invocation.returnData(data) // Promise
-toolboxAPI.invocation.launchTool(...) // Promise
+// All methods live on toolboxAPI.invocation
+toolboxAPI.invocation.getLaunchContext() // Promise | null>
+toolboxAPI.invocation.returnData(data) // Promise (auto-closes callee after call)
+toolboxAPI.invocation.launchTool(...) // Promise
+toolboxAPI.invocation.findToolsByCapability(tag) // Promise — tag is CapabilityTag
+toolboxAPI.invocation.getKnownCapabilityTags() // Promise>
```
-For the shape of `pptb.config.json`, import from the bundled declaration file:
+For auto-complete on capability tags, import `CapabilityTag` / `KnownCapabilityTag` from the bundled declaration file:
```typescript
-import type { PPTBConfig, InvocationConfig } from "@pptb/types/pptbConfig";
+import type { PPTBConfig, InvocationConfig, CapabilityTag, KnownCapabilityTag } from "@pptb/types/pptbConfig";
+
+// IDE will suggest known tags when typing:
+const tag: CapabilityTag = "fetchxml";
+const tools = await toolboxAPI.invocation.findToolsByCapability(tag);
```
---
@@ -434,28 +749,41 @@ import type { PPTBConfig, InvocationConfig } from "@pptb/types/pptbConfig";
The target tool is not installed. Ask the user to install it from the PPTB Marketplace, or check that the `targetToolId` matches the exact npm package name (`name` field in the tool's `package.json`).
+### `launchTool` throws "A callee invocation is already in progress"
+
+Your tool already has an active callee open. Wait for the current invocation to resolve (or reject) before calling `launchTool` again. Only one callee per caller is supported in this phase.
+
### `getLaunchContext()` returns `null` when expecting prefill data
The tool was opened by the user directly rather than via `launchTool`. Ensure the caller is using `toolboxAPI.invocation.launchTool()` and not the standard tool launch mechanism.
### Caller `Promise` resolves with `null` unexpectedly
-The callee window was closed by the user (or programmatically) before `returnData()` was called. This is by design – always handle the `null` case in the caller.
+One of the following occurred:
+
+1. The callee window was closed by the user before `returnData()` was called.
+2. The user clicked the "Return to [CallerTool]" banner button before the callee called `returnData()`.
+
+Both scenarios are by design — always handle the `null` case in the caller.
### Changes to `pptb.config.json` are not picked up
-Restart the tool or reload it in PPTB. The config file is read at install/load time; changes during development require a reload.
+Capabilities and the invocation contract are read when a tool is **installed**. If you change `pptb.config.json` in a locally-loaded development tool, reload or reinstall the tool in PPTB.
### `returnData` appears to do nothing
Confirm that `getLaunchContext()` returned a non-null value first. If `getLaunchContext()` returns `null`, `returnData` is a no-op because the tool was not launched by another tool.
+### `findToolsByCapability` returns an empty array
+
+No installed tools declare the queried capability tag in their `pptb.config.json`. Check that the target tool's `pptb.config.json` has the correct tag in `invocation.capabilities` and was reinstalled after the change.
+
---
## References
-- [`packages/toolboxAPI.d.ts`](../packages/toolboxAPI.d.ts) – Full TypeScript type definitions for the invocation API (`InvocationAPI` interface)
+- [`packages/toolboxAPI.d.ts`](../packages/toolboxAPI.d.ts) – Full TypeScript type definitions for the invocation API (`InvocationAPI`)
- [`packages/pptbConfig.d.ts`](../packages/pptbConfig.d.ts) – Type definitions for `pptb.config.json` (`PPTBConfig`, `InvocationConfig`)
- [`packages/README.md`](../packages/README.md) – Developer guide for the `@pptb/types` package, including the API reference
-- [`src/main/managers/toolWindowManager.ts`](../src/main/managers/toolWindowManager.ts) – Host-side implementation (`launchToolWithContext`, `resolveInvocation`)
+- [`src/main/managers/toolWindowManager.ts`](../src/main/managers/toolWindowManager.ts) – Host-side implementation (`launchToolWithContext`, `resolveInvocation`, `activeCallees`)
- [`src/main/toolPreloadBridge.ts`](../src/main/toolPreloadBridge.ts) – Preload-side implementation of the `invocation` namespace
diff --git a/package.json b/package.json
index 77109911..ea60b0c2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "powerplatform-toolbox",
- "version": "1.2.2",
+ "version": "1.2.3",
"description": "A universal desktop app that contains multiple tools to ease the customization and configuration of Power Platform",
"main": "dist/main/index.js",
"scripts": {
diff --git a/packages/README.md b/packages/README.md
index 5ad037ba..4c4a7d5d 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -119,6 +119,7 @@ In addition to `package.json`, the validator automatically checks a `pptb.config
| Field | Required | Rules |
| --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- |
| `invocation.version` | ✅\*\* | Must be a valid **semantic version** string (e.g. `"1.0.0"`). Tool developers own this version and bump it when the invocation contract changes. |
+| `invocation.capabilities` | ❌ | Array of non-empty string tags (e.g. `["entity-picker"]`). Used by callers to discover this tool via `findToolsByCapability`. |
| `invocation.prefill` | ❌ | JSON-schema-style object describing data callers can pre-populate |
| `invocation.prefill.properties` | ❌ | Map of property names to `{ type?, enum?, items? }` descriptors |
| `invocation.returnTopic` | ❌ | JSON-schema-style object describing the data this tool returns to its caller |
@@ -132,6 +133,7 @@ In addition to `package.json`, the validator automatically checks a `pptb.config
{
"invocation": {
"version": "1.0.0",
+ "capabilities": ["entity-picker"],
"prefill": {
"properties": {
"entityName": { "type": "string" },
@@ -255,8 +257,8 @@ const terminal = await toolboxAPI.terminal.create({
cwd: "/path/to/directory",
});
-// Execute a command
-const result = await toolboxAPI.terminal.execute(terminal.id, "npm install");
+// Execute a command (most commands are allowed; shells and privilege-escalation tools are blocked)
+const result = await toolboxAPI.terminal.execute(terminal.id, "pac auth list");
console.log("Exit code:", result.exitCode);
console.log("Output:", result.output);
@@ -296,13 +298,29 @@ Tools can launch one another and pass data between them using the `invocation` n
```typescript
// Tool A – launches the entity-picker tool and waits for a selection
+// The callee automatically inherits this tool's FXS connection
const result = await toolboxAPI.invocation.launchTool(
"@my-org/entity-picker",
{ entityName: "account", allowMultiSelect: false },
);
-if (result) {
+if (result !== null) {
console.log("Selected record id:", (result as { selectedId: string }).selectedId);
+} else {
+ // User dismissed the picker (closed window or clicked "Return to Caller" banner)
+}
+```
+
+> **One-at-a-time**: only one active callee per caller is supported. A second `launchTool` call while a callee is open throws `"A callee invocation is already in progress"`.
+
+#### Caller: tag-based capability discovery
+
+```typescript
+// Find all installed tools that declare the "entity-picker" capability
+const pickers = await toolboxAPI.invocation.findToolsByCapability("entity-picker");
+if (pickers.length > 0) {
+ const picker = pickers[0] as { id: string };
+ const result = await toolboxAPI.invocation.launchTool(picker.id, { entityName: "account" });
}
```
@@ -317,12 +335,17 @@ if (ctx) {
// When the user makes their selection:
await toolboxAPI.invocation.returnData({ selectedId: "a1b2c3...", selectedName: "Contoso" });
+ // PPTB automatically closes this window after delivering the result
}
```
> **Tip:** A tool that was *not* launched by another tool receives `null` from `getLaunchContext()`.
> Use this to show a standalone UI or redirect accordingly.
+> **Auto-close**: after calling `returnData`, PPTB automatically closes the callee window. The callee does **not** need to close itself.
+
+> **Banner early-return**: PPTB injects a "Return to [CallerToolName]" banner in the callee window. If the user clicks it before `returnData` is called, the caller's Promise resolves with `null` and the callee window is closed.
+
#### Declaring your invocation contract
Add a `pptb.config.json` alongside your `package.json` to tell callers what data you expect and return:
@@ -331,6 +354,7 @@ Add a `pptb.config.json` alongside your `package.json` to tell callers what data
{
"invocation": {
"version": "1.0.0",
+ "capabilities": ["entity-picker"],
"prefill": {
"properties": {
"entityName": { "type": "string" },
@@ -557,9 +581,13 @@ Core platform features organized into namespaces:
- **create(options: TerminalOptions)**: Promise
- Creates a new terminal attached to the tool (tool ID is auto-determined)
+ - Uses the preferred shell specified via `TerminalOptions.shell` if it is installed on the machine, otherwise falls back to the system default shell
- **execute(terminalId: string, command: string)**: Promise
- Executes a command in the specified terminal and returns its result
+ - Only commands that are **not** on the blocked list are executed; blocked commands are shell interpreters (`bash`, `sh`, `powershell`, `cmd`, etc.) and privilege-escalation tools (`sudo`, `su`, `runas`, etc.)
+ - `npx --shell/-c` flags are blocked to prevent shell pivot via npx; unquoted command substitution (`$(…)` and backticks) is also rejected
+ - Compound commands using `&&`, `||`, `;`, or `|` are supported — each segment is individually validated against the blocklist
- **close(terminalId: string)**: Promise
- Closes the specified terminal
@@ -602,12 +630,17 @@ Core platform features organized into namespaces:
- Returns the prefill data passed by the tool that launched this tool, or `null` when not launched via inter-tool invocation
- **returnData(returnData: Record\)**: Promise\
- - Sends data back to the caller tool and signals completion; no-op if not launched by another tool
+ - Sends data back to the caller tool and signals completion; PPTB **automatically closes the callee window** after delivery; no-op if not launched by another tool
- **launchTool(targetToolId, prefillData?, options?)**: Promise\
- Launches the specified tool, optionally with prefill data
- - Returns a Promise that resolves with the data returned by the callee (or `null` if it closes without returning)
- - `options.primaryConnectionId` / `options.secondaryConnectionId` – override connection for the callee
+ - Returns a Promise that resolves with the data returned by the callee, or `null` if it closes without returning or the user clicks the "Return to Caller" banner
+ - The callee automatically inherits the caller's FXS connection; pass `options.primaryConnectionId` to override
+ - Only one active callee per caller is allowed; throws `"A callee invocation is already in progress"` if a callee is already open
+ - Pass `options.noReturn: true` for one-way "Send To" flows; the banner is suppressed entirely for the callee
+
+- **findToolsByCapability(tag: string)**: Promise\
+ - Returns all installed tools that declare the given capability tag in their `pptb.config.json`
### Dataverse API (`window.dataverseAPI`)
diff --git a/packages/bin/pptb-validate.js b/packages/bin/pptb-validate.js
index 110f447d..71ba8b9b 100644
--- a/packages/bin/pptb-validate.js
+++ b/packages/bin/pptb-validate.js
@@ -16,7 +16,7 @@
const fs = require("fs");
const path = require("path");
-const { validatePackageJson, validatePPTBConfig } = require("../lib/validate");
+const { validatePackageJson, validatePPTBConfig, KNOWN_CAPABILITY_TAGS } = require("../lib/validate");
// ANSI colour helpers – gracefully degrade when colours are unsupported
const NO_COLOR = !process.stdout.isTTY || process.env.NO_COLOR;
@@ -219,9 +219,15 @@ async function main() {
console.log(` Features : multiConnection=${info.features.multiConnection}${info.features.minAPI ? `, minAPI=${info.features.minAPI}` : ""}`);
}
if (configResult !== null && configResult.packageInfo && configResult.packageInfo.invocation) {
- console.log(` Invocation : version=${configResult.packageInfo.invocation.version}`);
+ const inv = configResult.packageInfo.invocation;
+ console.log(` Invocation : version=${inv.version}`);
+ if (Array.isArray(inv.capabilities) && inv.capabilities.length > 0) {
+ console.log(` Capabilities: ${inv.capabilities.join(", ")}`);
+ }
}
console.log();
+ console.log(c.dim(`Known capability tags: ${KNOWN_CAPABILITY_TAGS.join(", ")}`));
+ console.log();
} else {
console.log(c.red(c.bold("✖ Validation failed")));
console.log();
diff --git a/packages/lib/validate.js b/packages/lib/validate.js
index 79387e6d..6cc60de7 100644
--- a/packages/lib/validate.js
+++ b/packages/lib/validate.js
@@ -55,6 +55,30 @@ const VALID_MULTI_CONNECTION_VALUES = ["required", "optional", "none"];
// Semver regex for minAPI validation
const SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/;
+/**
+ * Built-in list of well-known capability tags.
+ * Mirrors the entries in the Supabase `capability_tags` table.
+ * New tags are added to Supabase first (configurable without a deploy) and are
+ * then reflected here in the next package release so offline validation stays current.
+ *
+ * When a tool declares a tag that is not in this list the validator emits a warning
+ * (not an error) so existing tools are not broken by registry updates.
+ *
+ * **Keep in sync with `BUILT_IN_CAPABILITY_TAGS` in
+ * `src/main/managers/toolRegistryManager.ts`.** Because this file is a standalone
+ * Node.js module that cannot import from the Electron/TypeScript source tree, both
+ * lists must be updated together whenever a new tag is added.
+ */
+const KNOWN_CAPABILITY_TAGS = [
+ "fetchxml",
+ "entity-picker",
+ "record-selector",
+ "solution-selector",
+ "webresource-editor",
+ "plugin-inspector",
+ "pcf-control-builder",
+];
+
/**
* Checks if a string is a valid URL.
* @param {string} url
@@ -334,7 +358,7 @@ async function validatePackageJson(packageJson, options = {}) {
};
}
-module.exports = { validatePackageJson, validatePPTBConfig, isValidUrl, APPROVED_LICENSES };
+module.exports = { validatePackageJson, validatePPTBConfig, isValidUrl, APPROVED_LICENSES, KNOWN_CAPABILITY_TAGS };
/**
* Validates a tool's pptb.config.json against the official review criteria.
@@ -390,6 +414,25 @@ function validatePPTBConfig(config) {
validateJsonSchemaProperties("invocation.returnTopic", inv.returnTopic.properties, errors);
}
}
+
+ // invocation.capabilities – optional array of non-empty strings
+ if (inv.capabilities !== undefined) {
+ if (!Array.isArray(inv.capabilities)) {
+ errors.push("invocation.capabilities must be an array");
+ } else {
+ inv.capabilities.forEach((cap, idx) => {
+ if (typeof cap !== "string" || cap.trim().length === 0) {
+ errors.push(`invocation.capabilities[${idx}] must be a non-empty string`);
+ } else if (!KNOWN_CAPABILITY_TAGS.includes(cap.trim())) {
+ warnings.push(
+ `invocation.capabilities[${idx}] "${cap}" is not a recognised capability tag. ` +
+ `Known tags: ${KNOWN_CAPABILITY_TAGS.join(", ")}. ` +
+ "If this is a new tag, ensure it has been added to the capability registry.",
+ );
+ }
+ });
+ }
+ }
}
}
diff --git a/packages/package.json b/packages/package.json
index 446d86fd..616b2624 100644
--- a/packages/package.json
+++ b/packages/package.json
@@ -1,6 +1,6 @@
{
"name": "@pptb/types",
- "version": "1.2.2",
+ "version": "1.2.3",
"description": "Type definitions for Power Platform ToolBox APIs and validity checks for tool packages",
"main": "index.d.ts",
"types": "index.d.ts",
diff --git a/packages/pptbConfig.d.ts b/packages/pptbConfig.d.ts
index ef8c85f9..334a54cd 100644
--- a/packages/pptbConfig.d.ts
+++ b/packages/pptbConfig.d.ts
@@ -11,6 +11,7 @@
* {
* "invocation": {
* "version": "1.0.0",
+ * "capabilities": ["fetchxml"],
* "prefill": {
* "properties": {
* "entityName": { "type": "string" },
@@ -29,6 +30,50 @@
* ```
*/
+/**
+ * Well-known capability tags defined in the Power Platform ToolBox capability registry.
+ *
+ * Using one of these values provides IDE auto-complete and ensures compatibility with the
+ * official capability discovery system (`toolboxAPI.invocation.findToolsByCapability`).
+ * The authoritative list is maintained in the Supabase `capability_tags` table so new
+ * tags can be added without an app update. Fetch the current list at runtime via
+ * `toolboxAPI.invocation.getKnownCapabilityTags()`.
+ *
+ * | Tag | Description |
+ * | -------------------- | ----------------------------------------------------- |
+ * | `fetchxml` | Accept or process FetchXML queries |
+ * | `entity-picker` | Browse and select a Dataverse entity (table) |
+ * | `record-selector` | Browse and select a Dataverse record |
+ * | `solution-selector` | Pick a Power Platform solution |
+ * | `webresource-editor` | Edit or manage web resources |
+ * | `plugin-inspector` | Inspect or manage plugins and assemblies |
+ * | `pcf-control-builder`| Build or scaffold PCF controls |
+ */
+export type KnownCapabilityTag =
+ | "fetchxml"
+ | "entity-picker"
+ | "record-selector"
+ | "solution-selector"
+ | "webresource-editor"
+ | "plugin-inspector"
+ | "pcf-control-builder";
+
+/**
+ * A capability tag string accepted by `invocation.capabilities` and
+ * `toolboxAPI.invocation.findToolsByCapability()`.
+ *
+ * `KnownCapabilityTag` values offer IDE auto-complete and are validated by
+ * `pptb-validate`. Custom strings are permitted for organisation-internal tags,
+ * but will produce a validation warning unless the tag appears in the official
+ * capability registry.
+ *
+ * @example
+ * ```json
+ * { "invocation": { "version": "1.0.0", "capabilities": ["fetchxml", "entity-picker"] } }
+ * ```
+ */
+export type CapabilityTag = KnownCapabilityTag | (string & {});
+
/** A JSON-schema-style property descriptor used inside invocation definitions. */
export interface JsonSchemaProperty {
/** The JSON type of the value (e.g. "string", "number", "boolean", "object", "array"). */
@@ -67,6 +112,24 @@ export interface InvocationConfig {
prefill?: JsonSchemaObject;
/** Schema of the data this tool returns to its caller on completion. */
returnTopic?: JsonSchemaObject;
+ /**
+ * Capability tags declared by this tool.
+ *
+ * Callers use `toolboxAPI.invocation.findToolsByCapability(tag)` to discover tools
+ * that advertise a given capability. Prefer `KnownCapabilityTag` values for IDE
+ * auto-complete; custom strings are accepted but will produce a `pptb-validate`
+ * warning unless the tag is present in the official capability registry.
+ *
+ * Use `toolboxAPI.invocation.getKnownCapabilityTags()` at runtime to retrieve the
+ * full list from the registry (backed by a configurable Supabase table so new tags
+ * can be added without an app update).
+ *
+ * @example
+ * ```json
+ * { "invocation": { "version": "1.0.0", "capabilities": ["entity-picker", "fetchxml"] } }
+ * ```
+ */
+ capabilities?: CapabilityTag[];
}
/**
diff --git a/packages/toolboxAPI.d.ts b/packages/toolboxAPI.d.ts
index 693568a8..1036b4da 100644
--- a/packages/toolboxAPI.d.ts
+++ b/packages/toolboxAPI.d.ts
@@ -82,7 +82,10 @@ declare namespace ToolBoxAPI {
name: string;
url: string;
environment: "Dev" | "Test" | "UAT" | "Production";
- createdAt: string;
+ category?: string;
+ environmentColor?: string;
+ categoryColor?: string;
+ createdAt?: string;
lastUsedAt?: string;
/**
* @deprecated isActive is a legacy field that is no longer persisted.
@@ -109,9 +112,9 @@ declare namespace ToolBoxAPI {
*/
export interface TerminalOptions {
name: string;
- shell?: string;
+ shell?: string; // Preferred shell executable (e.g. "pwsh", "/bin/zsh"). Falls back to the system default if the requested shell is not found on the machine.
cwd?: string;
- env?: Record;
+ env?: Record; // PATH-like and shell bootstrap variables are filtered for tool security
visible?: boolean; // Whether terminal should be visible initially (default: true)
}
@@ -455,9 +458,9 @@ declare namespace ToolBoxAPI {
* Returns data back to the caller tool that launched this tool.
*
* The value resolves the `Promise` returned by the caller's
- * `invocation.launchTool()` call. After calling `returnData`, the PPTB host
- * will notify the caller; it is the callee's responsibility to close itself (or
- * update its UI) after the return.
+ * `invocation.launchTool()` call. **After calling `returnData`, PPTB
+ * automatically closes the callee window** — the callee does not need to
+ * close itself.
*
* If this tool was not launched by another tool, the call is a no-op.
*
@@ -469,17 +472,62 @@ declare namespace ToolBoxAPI {
* Launch another tool from within this tool and (optionally) pass prefill data.
*
* Returns a Promise that resolves with the data the target tool sends via
- * `invocation.returnData()`, or `null` if the target tool closes without
- * returning any data.
+ * `invocation.returnData()`, or `null` if:
+ * - the target tool closes without calling `returnData`, or
+ * - the user clicks the "Return to [this tool]" banner before the callee finalises.
+ *
+ * **One-at-a-time**: only one active callee per caller is supported. A second
+ * call while a callee is active throws `"A callee invocation is already in progress"`.
+ *
+ * **Connection auto-inheritance**: when `options.primaryConnectionId` is omitted,
+ * the callee automatically inherits the caller's active FXS connection.
+ *
+ * **Multi-connection auto-prompt**: when the callee declares
+ * `features.multiConnection: "required"` or `"optional"` and
+ * `options.secondaryConnectionId` is not provided, PPTB automatically shows
+ * the multi-connection selector before launching the callee. The Promise rejects
+ * if the user cancels the selector.
*
- * The target tool must be installed and its `pptb.config.json` must declare an
- * `invocation.prefill` schema that matches the shape of `prefillData`.
+ * **`noReturn`**: pass `true` when the caller does not expect the callee to
+ * return data (e.g. a "Send To" pattern where data is only sent one-way).
+ * When set, the "Return to [Caller]" banner is suppressed entirely for the callee.
+ * The invocation lifecycle is otherwise identical — the Promise still resolves
+ * with `null` when the callee closes.
*
* @param targetToolId The npm package name (toolId) of the tool to launch
* @param prefillData Data to pre-populate the target tool's state
- * @param options Optional connection overrides for the target tool
+ * @param options Optional connection overrides and launch flags
+ */
+ launchTool: (targetToolId: string, prefillData?: Record, options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null; noReturn?: boolean }) => Promise;
+
+ /**
+ * Find installed tools that declare a given capability tag in their
+ * `pptb.config.json` (`invocation.capabilities` array).
+ *
+ * Use a `KnownCapabilityTag` literal from `@pptb/types` for IDE auto-complete:
+ * ```ts
+ * import type { KnownCapabilityTag } from "@pptb/types/pptbConfig";
+ * const tools = await toolboxAPI.invocation.findToolsByCapability("fetchxml");
+ * ```
+ *
+ * @param tag The capability tag to search for (e.g. `"entity-picker"`)
+ * @returns Array of matching installed `ToolManifest` objects
+ */
+ findToolsByCapability: (tag: import("./pptbConfig").CapabilityTag) => Promise;
+
+ /**
+ * Returns the list of known (registered) capability tags from the capability registry.
+ *
+ * The registry is stored in a Supabase `capability_tags` table and fetched at
+ * startup (cached for 5 minutes). When Supabase is unavailable a built-in
+ * fallback list is returned, so the result is never empty.
+ *
+ * Use this at runtime to populate a "capabilities" picker or to validate a tag
+ * before calling `findToolsByCapability`.
+ *
+ * @returns Array of `{ tag: string; description: string }` entries ordered by tag name.
*/
- launchTool: (targetToolId: string, prefillData?: Record, options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null }) => Promise;
+ getKnownCapabilityTags: () => Promise>;
}
/**
diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts
index 2f83f788..0233eedd 100644
--- a/src/common/ipc/channels.ts
+++ b/src/common/ipc/channels.ts
@@ -77,6 +77,12 @@ export const TOOL_CHANNELS = {
CHECK_TOOL_UPDATES: "check-tool-updates",
UPDATE_TOOL: "update-tool",
IS_TOOL_UPDATING: "is-tool-updating",
+ /** Fetch the list of registered capability tags from Supabase (with built-in fallback). */
+ GET_KNOWN_CAPABILITY_TAGS: "get-known-capability-tags",
+ /** Check whether a beta (pre-release) npm package version exists for a given npm package name. */
+ CHECK_BETA_PACKAGE: "check-beta-package",
+ /** Install the beta (pre-release) npm package for a registry tool. */
+ INSTALL_PRERELEASE_TOOL: "install-prerelease-tool",
} as const;
// Tool Window-related IPC channels
@@ -91,6 +97,33 @@ export const TOOL_WINDOW_CHANNELS = {
HIDE_ALL: "tool-window:hide-all",
RENDERER_INITIALIZED: "tool-window:renderer-initialized",
RETURN_INVOCATION_DATA: "tool-window:return-invocation-data",
+ FIND_TOOLS_BY_CAPABILITY: "tool-window:find-tools-by-capability",
+ /** Pushed from main process to the PPTB renderer to show/hide the invocation banner. */
+ INVOCATION_BANNER_STATE: "tool-window:invocation-banner-state",
+ /**
+ * Pushed from main process to the PPTB renderer when an invoked callee tool requires
+ * connections that were not inherited from the caller (e.g. secondary connection needed
+ * by a multi-connection tool). The renderer shows the multi-connection selector and
+ * replies via PROVIDE_INVOCATION_CONNECTIONS.
+ */
+ INVOCATION_PROMPT_CONNECTIONS: "tool-window:invocation-prompt-connections",
+ /**
+ * Sent from the PPTB renderer back to the main process to provide the connection IDs
+ * selected by the user in response to an INVOCATION_PROMPT_CONNECTIONS request.
+ */
+ PROVIDE_INVOCATION_CONNECTIONS: "tool-window:provide-invocation-connections",
+ /**
+ * Pushed from the main process to the PPTB renderer when a callee tool has been
+ * successfully launched via an inter-tool invocation. The renderer creates a new tab
+ * for the callee so it can be independently managed (switched to, closed, etc.).
+ */
+ CALLEE_TOOL_OPENED: "tool-window:callee-tool-opened",
+ /**
+ * Pushed from the main process to the PPTB renderer when a callee tool has been
+ * auto-closed after returning data (resolveInvocation path). The renderer removes
+ * the callee's tab and switches back to the caller tool.
+ */
+ CALLEE_TOOL_CLOSED: "tool-window:callee-tool-closed",
} as const;
// Terminal-related IPC channels
diff --git a/src/common/types/api.ts b/src/common/types/api.ts
index c0d0deb8..36faed94 100644
--- a/src/common/types/api.ts
+++ b/src/common/types/api.ts
@@ -9,7 +9,7 @@ import { DataverseConnection } from "./connection";
import { DataverseExecuteRequest } from "./dataverse";
import { CspConsentRecord, LastUsedToolEntry, LastUsedToolUpdate, UserSettings } from "./settings";
import { Terminal, TerminalOptions } from "./terminal";
-import { Tool, ToolContext, ToolSettings } from "./tool";
+import { CapabilityTagEntry, Tool, ToolContext, ToolSettings } from "./tool";
/**
* Connections API namespace
@@ -154,6 +154,7 @@ export interface ToolboxAPI {
primaryConnectionId: string | null,
secondaryConnectionId: string | null,
prefillData: Record,
+ noReturn?: boolean,
) => Promise;
switchToolWindow: (toolId: string) => Promise;
closeToolWindow: (toolId: string) => Promise;
@@ -161,6 +162,28 @@ export interface ToolboxAPI {
getActiveToolWindow: () => Promise;
getOpenToolWindows: () => Promise;
updateToolConnection: (instanceId: string, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => Promise;
+ /** Find installed tools that declare a given capability tag in their pptb.config.json. */
+ findToolsByCapability: (tag: string) => Promise;
+ /** Returns the list of known capability tags from the registry (Supabase-backed, with built-in fallback). */
+ getKnownCapabilityTags: () => Promise;
+ /** Trigger banner "Return to Caller" — resolves the currently active callee's invocation with null and auto-closes it. */
+ returnToCallerBanner: () => Promise;
+ /** Subscribe to invocation banner state changes (main → renderer push). */
+ onInvocationBannerState: (callback: (state: { visible: boolean; callerToolName?: string }) => void) => void;
+ /** Subscribe to multi-connection prompts triggered when an invoked callee requires a secondary connection. */
+ onInvocationConnectionsPrompt: (callback: (prompt: { requestId: string; toolName: string; isSecondaryRequired: boolean; inheritedPrimaryConnectionId: string | null }) => void) => void;
+ /** Provide the selected connection IDs in response to an INVOCATION_PROMPT_CONNECTIONS request (or null to cancel). */
+ provideInvocationConnections: (requestId: string, result: { primaryConnectionId: string | null; secondaryConnectionId: string | null } | null) => Promise;
+ /**
+ * Subscribe to callee-tool-opened events. Fired once the callee BrowserView is ready
+ * so the renderer can create a dedicated tab for the callee instance.
+ */
+ onCalleeToolOpened: (callback: (data: { calleeInstanceId: string; callerInstanceId: string; tool: Tool; primaryConnectionId: string | null; secondaryConnectionId: string | null }) => void) => void;
+ /**
+ * Subscribe to callee-tool-closed events. Fired after the callee is auto-closed by
+ * the main process so the renderer can remove the callee tab and return focus to the caller.
+ */
+ onCalleeToolClosed: (callback: (data: { calleeInstanceId: string; callerInstanceId: string }) => void) => void;
// Favorite tools
addFavoriteTool: (toolId: string) => Promise;
@@ -197,6 +220,10 @@ export interface ToolboxAPI {
installToolFromRegistry: (toolId: string) => Promise<{ manifest: unknown; tool: Tool }>;
checkToolUpdates: (toolId: string) => Promise<{ hasUpdate: boolean; latestVersion?: string }>;
isToolUpdating: (toolId: string) => Promise;
+ /** Check whether a beta (pre-release) npm package version exists for the given npm package name. */
+ checkBetaPackage: (npmPackageName: string) => Promise<{ hasBeta: boolean; betaVersion?: string }>;
+ /** Install the beta (pre-release) npm package for a registry tool and return the loaded Tool. */
+ installPrereleaseToolFromNpm: (npmPackageName: string) => Promise;
// Utils namespace
utils: UtilsAPI;
diff --git a/src/common/types/tool.ts b/src/common/types/tool.ts
index 196a062b..e1b5a2fc 100644
--- a/src/common/types/tool.ts
+++ b/src/common/types/tool.ts
@@ -4,6 +4,20 @@
import { CspExceptions } from "./common";
+/**
+ * A single entry from the capability tag registry.
+ *
+ * The registry is stored in the Supabase `capability_tags` table and fetched at
+ * startup (with a TTL-based cache). A built-in fallback list is used when Supabase
+ * is unavailable so the application always has a baseline set of known tags.
+ */
+export interface CapabilityTagEntry {
+ /** The capability tag string (e.g. `"fetchxml"`, `"entity-picker"`). */
+ tag: string;
+ /** Human-readable description of what the capability represents. */
+ description: string;
+}
+
/**
* Tool features configuration
*/
@@ -52,6 +66,8 @@ export interface Tool {
minAPI?: string; // Minimum ToolBox API version required
maxAPI?: string; // Maximum ToolBox API version tested
isSupported?: boolean; // Whether this tool is compatible with current ToolBox version
+ /** Invocation capability tags declared in pptb.config.json (e.g. ["entity-picker"]). */
+ capabilities?: string[];
}
/**
@@ -82,6 +98,7 @@ export interface ToolRegistryEntry {
website?: string;
minAPI?: string; // Minimum ToolBox API version required (from features.minAPI)
maxAPI?: string; // Maximum ToolBox API version tested (from npm-shrinkwrap @pptb/types version)
+ npmPackageName?: string; // npm package name used for pre-release version detection
}
/**
@@ -113,6 +130,8 @@ export interface ToolManifest {
createdAt?: string;
minAPI?: string; // Minimum ToolBox API version required (from features.minAPI)
maxAPI?: string; // Maximum ToolBox API version tested (from npm-shrinkwrap @pptb/types version)
+ /** Invocation capability tags declared in pptb.config.json (e.g. ["entity-picker"]). */
+ capabilities?: string[];
}
/**
diff --git a/src/main/constants.ts b/src/main/constants.ts
index bec93f64..b07890f7 100644
--- a/src/main/constants.ts
+++ b/src/main/constants.ts
@@ -45,3 +45,143 @@ export const MIN_SUPPORTED_API_VERSION = "1.0.17";
* packages/-/icon-dark.png – dark theme icon for the tool/version
*/
export const AZURE_BLOB_BASE_URL = process.env.AZURE_BLOB_BASE_URL || "";
+
+/**
+ * Blocked terminal commands that tools are not allowed to execute through the TerminalManager.
+ * This is a security measure to prevent tools from executing potentially dangerous commands that could harm the user's system or compromise their security.
+ * Note that this is not an exhaustive list of all potentially dangerous commands, but it covers the most common ones.
+ * The check is done by comparing the command being executed against this list, ignoring case and allowing for additional arguments (e.g. "powershell -NoProfile" would still be blocked).
+ */
+export const BLOCKED_TERMINAL_COMMANDS = new Set([
+ // Unix/macOS shells
+ "bash",
+ "sh",
+ "zsh",
+ "fish",
+ "csh",
+ "ksh",
+ "dash",
+ "tcsh",
+ // Windows shells and their .exe variants
+ "cmd",
+ "cmd.exe",
+ "powershell",
+ "powershell.exe",
+ "pwsh",
+ "pwsh.exe",
+ // Privilege escalation
+ "sudo",
+ "su",
+ "runas",
+ "doas",
+ "pkexec",
+ // Scripting interpreters with shell-escape or arbitrary-eval capability
+ "python",
+ "python3",
+ "python2",
+ "perl",
+ "ruby",
+ "irb",
+ "php",
+ "php.exe",
+ "lua",
+ "tclsh",
+ "wish",
+ // Pagers/editors with built-in shell escapes
+ "less",
+ "more",
+ "man",
+ "vi",
+ "vim",
+ "nvim",
+ "emacs",
+ "nano",
+ // Utilities with side-channel command execution
+ "find",
+ "awk",
+ "gawk",
+ "nawk",
+ "xargs",
+ "make",
+ // Remote access / tunneling / exfiltration
+ "ssh",
+ "scp",
+ "sftp",
+ "rsync",
+ "telnet",
+ "ftp",
+ "nc",
+ "ncat",
+ "netcat",
+ "socat",
+ // WSL (escapes the sandbox into a full Linux shell)
+ "wsl",
+ "wsl.exe",
+ // Windows LOLBins
+ "mshta.exe",
+ "wscript.exe",
+ "cscript.exe",
+ "regsvr32.exe",
+ "rundll32.exe",
+ "certutil.exe",
+ "bitsadmin.exe",
+ "forfiles.exe",
+ "wmic.exe",
+ "installutil.exe",
+ "msiexec.exe",
+ "reg.exe",
+ "schtasks.exe",
+ "vssadmin.exe",
+ "wevtutil.exe",
+ // macOS-specific
+ "osascript",
+ "open",
+ "launchctl",
+ "defaults",
+ "dscl",
+ // Linux-specific
+ "crontab",
+ "at",
+ "systemd-run",
+ "chroot",
+ "useradd",
+ "usermod",
+ "passwd",
+]);
+
+/**
+ * Environment variables and npx flags that must be stripped from terminal sessions to prevent security issues or breakage.
+ * These are not necessarily blocked from being executed, but they will be removed from the environment or command line when a terminal session is created.
+ * For example, allowing PATH through could let a tool trick the user into executing a malicious version of a common command.
+ * Allowing "npx -s" or "npx --shell" could let a tool execute arbitrary shell commands, bypassing the blocked command list entirely.
+ * Note that this is not an exhaustive list of all potentially dangerous environment variables or npx flags, but it covers the most common ones.
+ */
+export const BLOCKED_TERMINAL_ENV_KEYS = new Set([
+ "PATH",
+ "PATHEXT",
+ "COMSPEC",
+ "SHELL",
+ "NODE_OPTIONS",
+ "BASH_ENV",
+ "ENV",
+ "PROMPT_COMMAND",
+ "ZDOTDIR",
+ // Dynamic-linker / runtime injection
+ "LD_PRELOAD",
+ "LD_LIBRARY_PATH",
+ "DYLD_INSERT_LIBRARIES",
+ "DYLD_LIBRARY_PATH",
+ // Tool-specific command/option injection
+ "GIT_SSH_COMMAND",
+ "PYTHONSTARTUP",
+ "PERL5OPT",
+ "RUBYOPT",
+]);
+
+/**
+ * Blocked npx flags that could be used to execute arbitrary shell commands, bypassing the blocked command list.
+ * This is a security measure to prevent tools from using npx as a way to execute commands that would otherwise be blocked.
+ * The check is done by looking for these flags anywhere in the command line, ignoring case and allowing for additional arguments (e.g. "npx -s" or "npx --shell" would both be blocked).
+ * For more information, see the npx documentation: https://www.npmjs.com/package/npx#options
+ */
+export const BLOCKED_NPX_FLAGS = new Set(["-c", "--call", "-s", "--shell"]);
diff --git a/src/main/index.ts b/src/main/index.ts
index be3157b0..389470a8 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -289,6 +289,7 @@ class ToolBoxApp {
ipcMain.removeHandler(TOOL_CHANNELS.INSTALL_TOOL_FROM_REGISTRY);
ipcMain.removeHandler(TOOL_CHANNELS.FETCH_REGISTRY_TOOLS);
ipcMain.removeHandler(TOOL_CHANNELS.FETCH_COMMUNITY_LINKS);
+ ipcMain.removeHandler(TOOL_CHANNELS.GET_KNOWN_CAPABILITY_TAGS);
ipcMain.removeHandler(TOOL_CHANNELS.CHECK_TOOL_UPDATES);
ipcMain.removeHandler(TOOL_CHANNELS.UPDATE_TOOL);
ipcMain.removeHandler(TOOL_CHANNELS.IS_TOOL_UPDATING);
@@ -818,6 +819,11 @@ class ToolBoxApp {
return await this.toolManager.fetchCommunityLinks();
});
+ // Fetch known capability tags from Supabase (with built-in fallback)
+ ipcMain.handle(TOOL_CHANNELS.GET_KNOWN_CAPABILITY_TAGS, async () => {
+ return await this.toolManager.getKnownCapabilityTags();
+ });
+
// Check for tool updates
ipcMain.handle(TOOL_CHANNELS.CHECK_TOOL_UPDATES, async (_, toolId) => {
// DEV MOCK: randomly flag ~50% of tools as having an update available
@@ -841,6 +847,18 @@ class ToolBoxApp {
return this.toolManager.isToolUpdating(toolId);
});
+ // Check whether a beta (pre-release) npm package version is available
+ ipcMain.handle(TOOL_CHANNELS.CHECK_BETA_PACKAGE, async (_, npmPackageName: string) => {
+ return await this.toolManager.checkBetaPackage(npmPackageName);
+ });
+
+ // Install the beta (pre-release) npm package for a registry tool
+ ipcMain.handle(TOOL_CHANNELS.INSTALL_PRERELEASE_TOOL, async (_, npmPackageName: string) => {
+ const tool = await this.toolManager.installPrereleaseToolFromNpm(npmPackageName);
+ this.settingsManager.addInstalledTool(tool.id);
+ return tool;
+ });
+
// Debug mode only - npm-based installation for tool developers
ipcMain.handle(TOOL_CHANNELS.INSTALL_TOOL, async (_, packageName) => {
await this.toolManager.installToolForDebug(packageName);
diff --git a/src/main/managers/browserviewProtocolManager.ts b/src/main/managers/browserviewProtocolManager.ts
index fc9f6ebc..378b90aa 100644
--- a/src/main/managers/browserviewProtocolManager.ts
+++ b/src/main/managers/browserviewProtocolManager.ts
@@ -1,10 +1,10 @@
import { app, protocol } from "electron";
import * as fs from "fs";
import * as path from "path";
+import { logError, logInfo } from "../../common/logger";
import { normalizeCspExceptionSource } from "../../common/types";
import { SettingsManager } from "./settingsManager";
import { ToolManager } from "./toolsManager";
-import { logInfo, logError } from "../../common/logger";
/**
* BrowserviewProtocolManager
@@ -196,8 +196,21 @@ export class BrowserviewProtocolManager {
// Local development tool
return tool.localPath;
} else if (tool.npmPackageName) {
+ /**
+ * Cleanup any version suffix from the package name to get the correct directory
+ * Handle scoped packages (e.g., @scope/package) and version suffixes (e.g., package-1.0.0)
+ * Scope "@" is preserved, but version suffixes are removed
+ * Some packages may not have version suffixes, so we should check for both possibilities
+ * Example transformations:
+ * - "my-tool@1.0.0" => "my-tool"
+ * - "@scope/my-tool@1.0.0" => "@scope/my-tool"
+ * - "my-tool" => "my-tool"
+ * - "@scope/my-tool" => "@scope/my-tool"
+ */
+ const packageDirName = tool.npmPackageName.replace(/@?([^@]+)(?:@[\d.]+)?$/, "");
+
// Npm-installed tool (debug mode)
- return path.join(this.toolsDir, "node_modules", tool.npmPackageName);
+ return path.join(this.toolsDir, "node_modules", packageDirName);
} else if (tool.id) {
// Registry-installed tool
return path.join(this.toolsDir, tool.id);
diff --git a/src/main/managers/terminalManager.ts b/src/main/managers/terminalManager.ts
index dc5a75af..0b4bb32b 100644
--- a/src/main/managers/terminalManager.ts
+++ b/src/main/managers/terminalManager.ts
@@ -1,8 +1,353 @@
-import { ChildProcessWithoutNullStreams, spawn } from "child_process";
+import { ChildProcessWithoutNullStreams, execFileSync, spawn } from "child_process";
import { randomUUID } from "crypto";
import { EventEmitter } from "events";
-import { Terminal, TerminalCommandResult, TerminalOptions } from "../../common/types";
+import { existsSync } from "fs";
+import { basename, isAbsolute } from "path";
import { logInfo, logWarn } from "../../common/logger";
+import { Terminal, TerminalCommandResult, TerminalOptions } from "../../common/types";
+import { BLOCKED_NPX_FLAGS, BLOCKED_TERMINAL_COMMANDS, BLOCKED_TERMINAL_ENV_KEYS } from "../constants";
+
+type ShellType = "posix" | "pwsh" | "cmd";
+
+interface ParsedTerminalCommand {
+ executable: string;
+ args: string[];
+ /** The validated command string as entered by the user (leading/trailing whitespace trimmed), written verbatim to the shell's stdin. */
+ rawCommand: string;
+}
+
+interface QueuedCommand {
+ parsedCommand: ParsedTerminalCommand;
+ commandId: string;
+ resolve: (result: TerminalCommandResult) => void;
+}
+
+interface PendingCommand {
+ commandId: string;
+ resolve: (result: TerminalCommandResult) => void;
+ output: string;
+ stderrBuffer: string;
+}
+
+interface SanitizedTerminalEnv {
+ env?: Record;
+ strippedKeys: string[];
+}
+
+function sanitizeTerminalEnv(env?: Record): SanitizedTerminalEnv {
+ if (!env) {
+ return { strippedKeys: [] };
+ }
+
+ const strippedKeys: string[] = [];
+ const sanitizedEnv = Object.entries(env).reduce>((result, [key, value]) => {
+ const normalizedKey = key.toUpperCase();
+ if (BLOCKED_TERMINAL_ENV_KEYS.has(normalizedKey)) {
+ strippedKeys.push(key);
+ return result;
+ }
+
+ result[key] = value;
+ return result;
+ }, {});
+
+ return {
+ env: Object.keys(sanitizedEnv).length > 0 ? sanitizedEnv : undefined,
+ strippedKeys,
+ };
+}
+
+function tokenizeTerminalCommand(command: string): string[] {
+ const tokens: string[] = [];
+ let currentToken = "";
+ let activeQuote: '"' | "'" | null = null;
+ let isEscaped = false;
+
+ for (let index = 0; index < command.length; index += 1) {
+ const char = command[index];
+
+ if (isEscaped) {
+ currentToken += char;
+ isEscaped = false;
+ continue;
+ }
+
+ if (char === "\\" && activeQuote !== "'") {
+ const nextChar = command[index + 1];
+ // Commands are executed with shell:false, so tokenization only needs to preserve quoted
+ // arguments and escaped whitespace/quotes without emulating full shell parsing.
+ if (nextChar === '"' || nextChar === "'" || nextChar === "\\" || /\s/.test(nextChar || "")) {
+ isEscaped = true;
+ continue;
+ }
+ }
+
+ if (activeQuote) {
+ if (char === activeQuote) {
+ activeQuote = null;
+ } else {
+ currentToken += char;
+ }
+ continue;
+ }
+
+ if (char === '"' || char === "'") {
+ activeQuote = char;
+ continue;
+ }
+
+ if (/\s/.test(char)) {
+ if (currentToken) {
+ tokens.push(currentToken);
+ currentToken = "";
+ }
+ continue;
+ }
+
+ currentToken += char;
+ }
+
+ if (activeQuote) {
+ throw new Error("Terminal command contains an unterminated quote.");
+ }
+
+ if (isEscaped) {
+ throw new Error("Terminal command ends with an incomplete escape sequence.");
+ }
+
+ if (currentToken) {
+ tokens.push(currentToken);
+ }
+
+ return tokens;
+}
+
+/**
+ * Checks for unquoted command substitution patterns ($(…) and backticks) that could
+ * be used to execute blocked commands indirectly when the raw command string is passed
+ * directly to the shell's stdin.
+ */
+function checkNoUnquotedSubstitution(command: string): void {
+ let activeQuote: '"' | "'" | null = null;
+ let isEscaped = false;
+
+ for (let i = 0; i < command.length; i++) {
+ const char = command[i];
+ const nextChar = command[i + 1];
+
+ if (isEscaped) {
+ isEscaped = false;
+ continue;
+ }
+
+ if (char === "\\" && activeQuote !== "'") {
+ isEscaped = true;
+ continue;
+ }
+
+ if (activeQuote) {
+ if (char === activeQuote) activeQuote = null;
+ continue;
+ }
+
+ if (char === '"' || char === "'") {
+ activeQuote = char;
+ continue;
+ }
+
+ if (char === "`") {
+ throw new Error("Terminal command contains unquoted command substitution.");
+ }
+ if (char === "$" && nextChar === "(") {
+ throw new Error("Terminal command contains unquoted command substitution.");
+ }
+ }
+}
+
+/**
+ * Splits a command string on unquoted shell sequence/pipe operators: &&, ||, ;, |
+ * Respects single and double quoted strings. Returns trimmed, non-empty segments.
+ */
+function splitCompoundCommand(command: string): string[] {
+ const segments: string[] = [];
+ let current = "";
+ let activeQuote: '"' | "'" | null = null;
+ let isEscaped = false;
+
+ for (let i = 0; i < command.length; i++) {
+ const char = command[i];
+ const nextChar = command[i + 1];
+
+ if (isEscaped) {
+ current += char;
+ isEscaped = false;
+ continue;
+ }
+
+ if (char === "\\" && activeQuote !== "'") {
+ isEscaped = true;
+ current += char;
+ continue;
+ }
+
+ if (activeQuote) {
+ if (char === activeQuote) activeQuote = null;
+ current += char;
+ continue;
+ }
+
+ if (char === '"' || char === "'") {
+ activeQuote = char;
+ current += char;
+ continue;
+ }
+
+ // && or || (two-character operators)
+ if ((char === "&" && nextChar === "&") || (char === "|" && nextChar === "|")) {
+ if (current.trim()) segments.push(current.trim());
+ current = "";
+ i++; // skip the second operator character
+ continue;
+ }
+
+ // Single | (pipe) or ; (sequence)
+ if (char === "|" || char === ";") {
+ if (current.trim()) segments.push(current.trim());
+ current = "";
+ continue;
+ }
+
+ current += char;
+ }
+
+ if (current.trim()) segments.push(current.trim());
+ return segments.length > 0 ? segments : [command.trim()];
+}
+
+/**
+ * Validates a single command segment (executable + args) against the blocklist.
+ * A "segment" is one command from a compound expression, e.g. `git status` from
+ * `cd /tmp && git status`.
+ */
+function validateCommandSegment(segment: string): void {
+ const trimmed = segment.trim();
+ if (!trimmed) return;
+
+ const tokens = tokenizeTerminalCommand(trimmed);
+ if (tokens.length === 0) return;
+
+ const rawExecutable = tokens[0];
+ const executable = rawExecutable.toLowerCase();
+
+ if (/[;&|<>]/.test(rawExecutable)) {
+ throw new Error(`Terminal command executable "${rawExecutable}" contains shell metacharacters.`);
+ }
+
+ if (BLOCKED_TERMINAL_COMMANDS.has(executable)) {
+ throw new Error(`Blocked unsafe terminal command "${rawExecutable}". Shell interpreters and privilege-escalation tools are not allowed.`);
+ }
+
+ if (executable === "npx" && tokens.slice(1).some((arg) => BLOCKED_NPX_FLAGS.has(arg.toLowerCase()))) {
+ throw new Error("Blocked unsafe npx invocation. Shell execution flags are not allowed.");
+ }
+}
+
+function parseTerminalCommand(command: string): ParsedTerminalCommand {
+ const trimmedCommand = command.trim();
+ if (!trimmedCommand) {
+ throw new Error("Terminal command cannot be empty.");
+ }
+
+ if (/[\r\n\u2028\u2029]/.test(trimmedCommand) || trimmedCommand.includes("\u000b") || trimmedCommand.includes("\u000c")) {
+ throw new Error("Multi-line terminal commands are not allowed.");
+ }
+
+ // Reject unquoted command substitution so blocked commands cannot be reached via $() or backticks.
+ checkNoUnquotedSubstitution(trimmedCommand);
+
+ // Split on compound operators and validate every constituent command.
+ const segments = splitCompoundCommand(trimmedCommand);
+ for (const segment of segments) {
+ validateCommandSegment(segment);
+ }
+
+ // Extract the first segment's executable for logging; the raw command string is passed
+ // verbatim to the shell so that operators like && and || are handled natively.
+ const firstTokens = tokenizeTerminalCommand(segments[0]);
+ return {
+ executable: firstTokens[0].toLowerCase(),
+ args: firstTokens.slice(1),
+ rawCommand: trimmedCommand,
+ };
+}
+
+function getShellType(shell: string): ShellType {
+ const name = basename(shell)
+ .toLowerCase()
+ .replace(/\.exe$/, "");
+ if (name === "pwsh" || name === "powershell") return "pwsh";
+ if (name === "cmd") return "cmd";
+ return "posix";
+}
+
+function getShellInteractiveArgs(shellType: ShellType): string[] {
+ if (shellType === "pwsh") return ["-NoLogo"];
+ // cmd.exe with no args starts an interactive session that reads from stdin
+ if (shellType === "cmd") return [];
+ return ["-i"];
+}
+
+/**
+ * Builds the text written to the shell's stdin to execute a validated command.
+ * The raw command string is passed verbatim so that shell operators (&&, ||, ;, |)
+ * are handled natively by the persistent shell process.
+ * A unique sentinel marker is written to stderr after the command so the caller
+ * can detect completion and extract the exit code without polluting visible output.
+ */
+function buildShellCommandInput(parsedCommand: ParsedTerminalCommand, commandId: string, shellType: ShellType): string {
+ const cmd = parsedCommand.rawCommand;
+ const sentinel = `PPTB_CMD_END_${commandId}`;
+
+ if (shellType === "pwsh") {
+ // Capture $? and $LASTEXITCODE immediately before any conditional to prevent them being overwritten
+ return `${cmd}\n$__pptb_ok__ = $?; $__pptb_lec__ = $LASTEXITCODE; $__pptb__ = if ($null -ne $__pptb_lec__) { $__pptb_lec__ } elseif ($__pptb_ok__) { 0 } else { 1 }; [Console]::Error.WriteLine("${sentinel}_$__pptb__")\n`;
+ }
+ if (shellType === "cmd") {
+ // Use >&2 (without the explicit 1) for stdout-to-stderr redirection in cmd.exe
+ return `${cmd}\r\necho ${sentinel}_%ERRORLEVEL% >&2\r\n`;
+ }
+ // POSIX: capture $? immediately after the command; write sentinel to stderr via printf
+ return `${cmd}\n__pptb__=$?; printf '%s\\n' "${sentinel}_$__pptb__" >&2\n`;
+}
+
+/**
+ * Resolves the preferred shell requested by a tool, falling back to the system
+ * default if the requested shell cannot be found on the current machine.
+ */
+function resolveShell(requestedShell: string, defaultShell: string, terminalId: string): string {
+ // Reject paths containing null bytes or obvious shell metacharacters to prevent injection
+ if (/[\0;&|<>]/.test(requestedShell)) {
+ logWarn(`Requested shell "${requestedShell}" contains invalid characters for terminal ${terminalId}; falling back to ${defaultShell}`);
+ return defaultShell;
+ }
+
+ if (isAbsolute(requestedShell)) {
+ if (existsSync(requestedShell)) {
+ return requestedShell;
+ }
+ logWarn(`Requested shell "${requestedShell}" not found for terminal ${terminalId}; falling back to ${defaultShell}`);
+ return defaultShell;
+ }
+
+ try {
+ const finder = process.platform === "win32" ? "where" : "which";
+ execFileSync(finder, [requestedShell], { encoding: "utf8", timeout: 3000 });
+ return requestedShell;
+ } catch {
+ logWarn(`Requested shell "${requestedShell}" not found in PATH for terminal ${terminalId}; falling back to ${defaultShell}`);
+ return defaultShell;
+ }
+}
/**
* Manages terminal instances for tools
@@ -28,30 +373,12 @@ export class TerminalManager extends EventEmitter {
}
}
- /**
- * Check if a shell exists and is executable
- */
- private async shellExists(shellPath: string): Promise {
- return new Promise((resolve) => {
- const testProcess = spawn(shellPath, ["--version"], { stdio: "ignore" });
- testProcess.on("error", () => resolve(false));
- testProcess.on("close", (code) => resolve(code !== null));
- });
- }
-
/**
* Create a new terminal for a tool
*/
async createTerminal(toolId: string, toolInstanceId: string | null, options: TerminalOptions): Promise {
const terminalId = randomUUID();
- let shell = options.shell || this.defaultShell;
-
- // Verify shell exists, fallback to default if not
- if (options.shell && !(await this.shellExists(options.shell))) {
- logWarn(`Shell ${options.shell} not found, using default shell ${this.defaultShell}`);
- shell = this.defaultShell;
- }
-
+ const shell = options.shell ? resolveShell(options.shell, this.defaultShell, terminalId) : this.defaultShell;
const cwd = options.cwd || process.cwd();
// Default to visible unless explicitly set to false
@@ -68,7 +395,12 @@ export class TerminalManager extends EventEmitter {
createdAt: new Date().toISOString(),
};
- const instance = new TerminalInstance(terminal, options.env);
+ const { env: sanitizedEnv, strippedKeys: strippedEnvKeys } = sanitizeTerminalEnv(options.env);
+ if (strippedEnvKeys.length > 0) {
+ logWarn(`Ignoring restricted terminal environment variables for terminal ${terminalId}: ${strippedEnvKeys.join(", ")}`);
+ }
+
+ const instance = new TerminalInstance(terminal, sanitizedEnv);
this.terminals.set(terminalId, instance);
// Forward terminal events with toolId for proper filtering
@@ -99,7 +431,23 @@ export class TerminalManager extends EventEmitter {
throw new Error(`Terminal ${terminalId} not found`);
}
- return instance.executeCommand(command);
+ let parsedCommand: ParsedTerminalCommand;
+ try {
+ parsedCommand = parseTerminalCommand(command);
+ } catch (error) {
+ const blockedResult: TerminalCommandResult = {
+ terminalId,
+ commandId: randomUUID(),
+ exitCode: 1,
+ error: error instanceof Error ? error.message : "Blocked unsafe terminal command.",
+ };
+
+ this.emit("terminal:error", { terminalId, toolId: instance.terminal.toolId, error: blockedResult.error });
+ this.emit("terminal:command:completed", { ...blockedResult, toolId: instance.terminal.toolId });
+ return blockedResult;
+ }
+
+ return instance.executeCommand(parsedCommand);
}
/**
@@ -194,164 +542,282 @@ export class TerminalManager extends EventEmitter {
}
/**
- * Internal terminal instance that manages a shell process
+ * Internal terminal instance that owns a persistent shell process.
+ * Commands are written to the shell's stdin and completion is detected
+ * via a unique sentinel marker written to stderr.
*/
class TerminalInstance extends EventEmitter {
public terminal: Terminal;
- private process: ChildProcessWithoutNullStreams | null = null;
- private commandQueue: Array<{ command: string; commandId: string; resolve: (result: TerminalCommandResult) => void; reject: (error: Error) => void }> = [];
- private currentCommand: { commandId: string; output: string; resolve: (result: TerminalCommandResult) => void; reject: (error: Error) => void } | null = null;
+ private shellProcess: ChildProcessWithoutNullStreams | null = null;
+ private env?: Record;
+ private commandQueue: QueuedCommand[] = [];
private isProcessing = false;
+ private pendingCommand: PendingCommand | null = null;
+ private shellType: ShellType;
+ /** Holds partial stdout data while a command is executing so sentinel echo lines can be filtered out before display. */
+ private stdoutLineBuffer: string = "";
constructor(terminal: Terminal, env?: Record) {
super();
this.terminal = terminal;
- this.startShellProcess(env);
+ this.env = env;
+ this.shellType = getShellType(terminal.shell);
+ this.startShellProcess();
}
/**
- * Start the shell process
+ * Start the persistent shell process so the terminal is not blank when a tool loads.
+ * Wires up stdout/stderr for display output and sentinel-based command completion.
*/
- private startShellProcess(env?: Record): void {
- const shellArgs = this.getShellArgs();
-
- // Ensure critical environment variables are set for proper shell initialization
+ startShellProcess(): void {
const processEnv = {
...process.env,
- ...env,
- // Ensure TERM is set for proper terminal emulation (needed for Oh-My-Posh and colors)
+ ...this.env,
TERM: process.env.TERM || "xterm-256color",
- // Ensure COLORTERM is set to indicate true color support
COLORTERM: process.env.COLORTERM || "truecolor",
};
- // Log shell startup for debugging (can be removed in production)
- logInfo(`[Terminal ${this.terminal.id}] Starting shell: ${this.terminal.shell} with args: ${shellArgs.join(" ")}`);
- logInfo(`[Terminal ${this.terminal.id}] Working directory: ${this.terminal.cwd}`);
- logInfo(`[Terminal ${this.terminal.id}] TERM: ${processEnv.TERM}, COLORTERM: ${processEnv.COLORTERM}`);
+ logInfo(`[Terminal ${this.terminal.id}] Starting shell: ${this.terminal.shell}`);
- this.process = spawn(this.terminal.shell, shellArgs, {
+ this.shellProcess = spawn(this.terminal.shell, getShellInteractiveArgs(this.shellType), {
cwd: this.terminal.cwd,
env: processEnv,
shell: false,
});
- this.process.stdout.on("data", (data: Buffer) => {
- const output = data.toString();
- this.emit("output", output);
-
- if (this.currentCommand) {
- this.currentCommand.output += output;
- }
+ this.shellProcess.stdout.on("data", (data: Buffer) => {
+ this.handleStdoutChunk(data.toString());
});
- this.process.stderr.on("data", (data: Buffer) => {
- const output = data.toString();
- this.emit("output", output);
-
- if (this.currentCommand) {
- this.currentCommand.output += output;
- }
+ this.shellProcess.stderr.on("data", (data: Buffer) => {
+ this.handleStderrChunk(data.toString());
});
- this.process.on("error", (error: Error) => {
+ this.shellProcess.on("error", (error: Error) => {
+ logWarn(`[Terminal ${this.terminal.id}] Shell process error: ${error.message}`);
this.emit("error", error.message);
- if (this.currentCommand) {
- this.currentCommand.reject(error);
- this.currentCommand = null;
+ if (this.pendingCommand) {
+ this.failPendingCommand(error.message);
}
});
- this.process.on("close", (code: number | null) => {
- if (this.currentCommand) {
- const result: TerminalCommandResult = {
- terminalId: this.terminal.id,
- commandId: this.currentCommand.commandId,
- output: this.currentCommand.output,
- exitCode: code ?? undefined,
- };
- this.currentCommand.resolve(result);
- this.emit("command:completed", result);
- this.currentCommand = null;
+ this.shellProcess.on("close", () => {
+ logInfo(`[Terminal ${this.terminal.id}] Shell process exited`);
+ this.shellProcess = null;
+ if (this.pendingCommand) {
+ this.failPendingCommand("Shell process exited unexpectedly.");
}
});
}
/**
- * Get appropriate shell arguments for interactive mode
+ * Handle a chunk of stdout output from the shell.
+ * When a command is executing, stdout is buffered by line so that any echoed sentinel
+ * command text (written to stdin for exit-code tracking) can be stripped before display.
+ * When no command is in flight the data is forwarded immediately.
*/
- private getShellArgs(): string[] {
- if (process.platform === "win32") {
- // Windows cmd.exe or PowerShell
- if (this.terminal.shell.toLowerCase().includes("powershell")) {
- return ["-NoLogo", "-NoExit"];
+ private handleStdoutChunk(text: string): void {
+ if (!this.pendingCommand) {
+ // No command in flight — pass through immediately.
+ this.emit("output", text);
+ return;
+ }
+
+ // Buffer and filter sentinel echoes while a command is executing.
+ this.stdoutLineBuffer += text;
+
+ const lastNewline = this.stdoutLineBuffer.lastIndexOf("\n");
+ if (lastNewline === -1) {
+ // No complete line yet; hold in buffer until the next chunk.
+ return;
+ }
+
+ const completeLines = this.stdoutLineBuffer.substring(0, lastNewline + 1);
+ this.stdoutLineBuffer = this.stdoutLineBuffer.substring(lastNewline + 1);
+
+ // Split on newlines, drop sentinel echo lines, then rejoin.
+ const filtered = completeLines
+ .split("\n")
+ .filter((line) => !this.isSentinelEchoLine(line))
+ .join("\n");
+
+ if (filtered) {
+ this.emit("output", filtered);
+ this.pendingCommand.output += filtered;
+ }
+ }
+
+ /**
+ * Returns true when a stdout line is the shell's echo of the internal sentinel command
+ * that was written to stdin for exit-code detection. Such lines must not be shown to
+ * the user because they are an implementation detail of the terminal manager.
+ */
+ private isSentinelEchoLine(line: string): boolean {
+ // Strip a trailing \r so both \n and \r\n line endings are handled.
+ const trimmed = line.replace(/\r$/, "");
+ if (this.shellType === "pwsh") {
+ return trimmed.includes("$__pptb_ok__ = $?;");
+ }
+ if (this.shellType === "cmd") {
+ return trimmed.includes("echo PPTB_CMD_END_");
+ }
+ // posix
+ return trimmed.includes("__pptb__=$?;");
+ }
+
+ /**
+ * Handle a chunk of stderr output from the shell.
+ * Normal stderr lines are forwarded to the terminal display.
+ * Sentinel lines (written after each command) are used to detect completion
+ * and extract the exit code without polluting the visible output.
+ */
+ private handleStderrChunk(text: string): void {
+ if (!this.pendingCommand) {
+ this.emit("output", text);
+ return;
+ }
+
+ this.pendingCommand.stderrBuffer += text;
+ const sentinel = `PPTB_CMD_END_${this.pendingCommand.commandId}_`;
+ const sentinelIdx = this.pendingCommand.stderrBuffer.indexOf(sentinel);
+
+ if (sentinelIdx !== -1) {
+ // Emit any stderr output that preceded the sentinel line
+ const beforeSentinel = this.pendingCommand.stderrBuffer.substring(0, sentinelIdx);
+ if (beforeSentinel) {
+ this.emit("output", beforeSentinel);
+ this.pendingCommand.output += beforeSentinel;
}
- return ["/Q"]; // Quiet mode for cmd
+ const afterSentinel = this.pendingCommand.stderrBuffer.substring(sentinelIdx + sentinel.length);
+ const exitCodeMatch = afterSentinel.match(/^(\d+)/);
+ // Default to 1 (failure) if the exit code portion is malformed or missing
+ if (!exitCodeMatch) {
+ logWarn(`Malformed command sentinel for command ${this.pendingCommand.commandId} in terminal ${this.terminal.id}; defaulting exit code to 1`);
+ }
+ const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1;
+ this.resolvePendingCommand(exitCode);
} else {
- // Unix-like shells - use both login and interactive modes
- // -l loads the login profile (e.g., .bash_profile, .zprofile)
- // -i makes it interactive (loads .bashrc, .zshrc)
- return ["-l", "-i"]; // Login + Interactive mode to load all profiles
+ // Sentinel not yet received; emit complete stderr lines in real-time
+ const lastNewline = this.pendingCommand.stderrBuffer.lastIndexOf("\n");
+ if (lastNewline !== -1) {
+ const safeToEmit = this.pendingCommand.stderrBuffer.substring(0, lastNewline + 1);
+ this.emit("output", safeToEmit);
+ this.pendingCommand.output += safeToEmit;
+ this.pendingCommand.stderrBuffer = this.pendingCommand.stderrBuffer.substring(lastNewline + 1);
+ }
}
}
/**
- * Execute a command in the terminal
+ * Flushes any remaining buffered stdout, filtering sentinel echo lines.
+ * Called before a pending command is resolved or failed so no output is lost.
*/
- async executeCommand(command: string): Promise {
- const commandId = randomUUID();
+ private flushStdoutBuffer(): void {
+ if (!this.stdoutLineBuffer || !this.pendingCommand) return;
+ const remaining = this.stdoutLineBuffer;
+ this.stdoutLineBuffer = "";
+ if (!this.isSentinelEchoLine(remaining)) {
+ this.emit("output", remaining);
+ this.pendingCommand.output += remaining;
+ }
+ }
+
+ private resolvePendingCommand(exitCode: number): void {
+ if (!this.pendingCommand) return;
- return new Promise((resolve, reject) => {
- this.commandQueue.push({ command, commandId, resolve, reject });
+ // Flush any stdout that arrived after the last newline boundary.
+ this.flushStdoutBuffer();
+
+ const pending = this.pendingCommand;
+ this.pendingCommand = null;
+ this.isProcessing = false;
+
+ const result: TerminalCommandResult = {
+ terminalId: this.terminal.id,
+ commandId: pending.commandId,
+ output: pending.output,
+ exitCode,
+ };
+ pending.resolve(result);
+ this.emit("command:completed", result);
+ this.processQueue();
+ }
+
+ private failPendingCommand(errorMsg: string): void {
+ if (!this.pendingCommand) return;
+
+ // Flush any stdout that arrived after the last newline boundary.
+ this.flushStdoutBuffer();
+
+ const pending = this.pendingCommand;
+ this.pendingCommand = null;
+ this.isProcessing = false;
+
+ const result: TerminalCommandResult = {
+ terminalId: this.terminal.id,
+ commandId: pending.commandId,
+ output: pending.output,
+ exitCode: 1,
+ error: errorMsg,
+ };
+ pending.resolve(result);
+ this.emit("command:completed", result);
+ this.processQueue();
+ }
+
+ /**
+ * Execute a validated command by writing it to the shell's stdin.
+ */
+ async executeCommand(parsedCommand: ParsedTerminalCommand): Promise {
+ const commandId = randomUUID();
+ return new Promise((resolve) => {
+ this.commandQueue.push({ parsedCommand, commandId, resolve });
this.processQueue();
});
}
/**
- * Process the command queue
+ * Dequeue the next command and write it to the shell's stdin.
*/
private processQueue(): void {
- if (this.isProcessing || this.commandQueue.length === 0 || !this.process) {
+ if (this.isProcessing || this.commandQueue.length === 0) {
return;
}
- this.isProcessing = true;
const queueItem = this.commandQueue.shift()!;
- this.currentCommand = {
+
+ if (!this.shellProcess || this.shellProcess.stdin.destroyed) {
+ const result: TerminalCommandResult = {
+ terminalId: this.terminal.id,
+ commandId: queueItem.commandId,
+ exitCode: 1,
+ error: "Shell process is not running.",
+ };
+ queueItem.resolve(result);
+ this.emit("command:completed", result);
+ this.processQueue();
+ return;
+ }
+
+ this.isProcessing = true;
+ this.pendingCommand = {
commandId: queueItem.commandId,
- output: "",
resolve: queueItem.resolve,
- reject: queueItem.reject,
+ output: "",
+ stderrBuffer: "",
};
- // Write command to stdin
- const commandWithNewline = queueItem.command + (process.platform === "win32" ? "\r\n" : "\n");
- this.process.stdin.write(commandWithNewline);
-
- // Set a timeout to resolve the command even if process doesn't close
- setTimeout(() => {
- if (this.currentCommand && this.currentCommand.commandId === queueItem.commandId) {
- const result: TerminalCommandResult = {
- terminalId: this.terminal.id,
- commandId: this.currentCommand.commandId,
- output: this.currentCommand.output,
- };
- this.currentCommand.resolve(result);
- this.emit("command:completed", result);
- this.currentCommand = null;
- this.isProcessing = false;
- this.processQueue();
- }
- }, 5000); // 5 second timeout for command execution
+ logInfo(`[Terminal ${this.terminal.id}] Executing: ${queueItem.parsedCommand.executable}`);
+ this.shellProcess.stdin.write(buildShellCommandInput(queueItem.parsedCommand, queueItem.commandId, this.shellType));
}
/**
- * Close the terminal
+ * Kill the shell process and clean up.
*/
close(): void {
- if (this.process) {
- this.process.kill();
- this.process = null;
+ if (this.shellProcess) {
+ this.shellProcess.kill();
+ this.shellProcess = null;
}
}
}
diff --git a/src/main/managers/toolRegistryManager.ts b/src/main/managers/toolRegistryManager.ts
index cdae6234..6d19d87a 100644
--- a/src/main/managers/toolRegistryManager.ts
+++ b/src/main/managers/toolRegistryManager.ts
@@ -6,7 +6,7 @@ import * as http from "http";
import * as https from "https";
import * as path from "path";
import { pipeline } from "stream/promises";
-import { CspExceptions, ToolManifest, ToolRegistryEntry, CommunityLinksCollection, CommunityLinksGroup, CommunityLinksItem } from "../../common/types";
+import { CapabilityTagEntry, CspExceptions, ToolManifest, ToolRegistryEntry, CommunityLinksCollection, CommunityLinksGroup, CommunityLinksItem } from "../../common/types";
import { AZURE_BLOB_BASE_URL, SUPABASE_ANON_KEY, SUPABASE_URL } from "../constants";
import { InstallIdManager } from "./installIdManager";
import { logInfo, logWarn, logError } from "../../common/logger";
@@ -93,6 +93,35 @@ interface SupabaseCommunityLink {
is_active: boolean;
}
+/**
+ * Supabase capability_tags table row
+ */
+interface SupabaseCapabilityTagRow {
+ tag: string;
+ description: string;
+}
+
+/**
+ * Built-in fallback capability tags used when Supabase is unreachable.
+ * The authoritative list is stored in the Supabase `capability_tags` table and
+ * fetched at startup; this list ensures the app always has a baseline set of
+ * known tags so tools can be validated even in an offline scenario.
+ *
+ * **Keep in sync with `KNOWN_CAPABILITY_TAGS` in `packages/lib/validate.js`.**
+ * Since the validator is a standalone Node.js CLI that cannot import from the
+ * Electron/TypeScript source tree, both lists must be updated together whenever
+ * new tags are added to the Supabase `capability_tags` table.
+ */
+const BUILT_IN_CAPABILITY_TAGS: CapabilityTagEntry[] = [
+ { tag: "fetchxml", description: "Accept or process FetchXML queries" },
+ { tag: "entity-picker", description: "Browse and select a Dataverse entity (table)" },
+ { tag: "record-selector", description: "Browse and select a Dataverse record" },
+ { tag: "solution-selector", description: "Pick a Power Platform solution" },
+ { tag: "webresource-editor", description: "Edit or manage web resources" },
+ { tag: "plugin-inspector", description: "Inspect or manage plugins and assemblies" },
+ { tag: "pcf-control-builder", description: "Build or scaffold PCF controls" },
+];
+
/**
* Local registry JSON file structure
*/
@@ -148,10 +177,17 @@ export class ToolRegistryManager extends EventEmitter {
source: "supabase" | "azureBlob" | "local";
} | null = null;
+ // Capability tags cache (fetched from Supabase `capability_tags` table)
+ private capabilityTagsCache: CapabilityTagEntry[] | null = null;
+ private capabilityTagsFetchedAtMs = 0;
+
// Multiple renderer modules request the registry during startup (homepage stats, marketplace, etc.).
// Keep this short so the marketplace stays fresh, but long enough to prevent thrash.
private static readonly REGISTRY_CACHE_TTL_MS = 30_000;
+ // Capability tags change rarely; use a longer TTL so the fetch happens at most once per session.
+ private static readonly CAPABILITY_TAGS_CACHE_TTL_MS = 300_000; // 5 minutes
+
constructor(toolsDirectory: string, supabaseUrl?: string, supabaseKey?: string, installIdManager?: InstallIdManager, azureBlobBaseUrl?: string) {
super();
this.toolsDirectory = toolsDirectory;
@@ -316,6 +352,7 @@ export class ToolRegistryManager extends EventEmitter {
status: (tool.status as "active" | "deprecated" | "archived" | undefined) || "active",
minAPI: tool.min_api, // Include min API version from database
maxAPI: tool.max_api, // Include max API version from database
+ npmPackageName: tool.packagename || undefined, // npm package name for pre-release detection
} as ToolRegistryEntry;
});
@@ -626,6 +663,31 @@ export class ToolRegistryManager extends EventEmitter {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
+ // Read optional pptb.config.json for invocation capabilities
+ let capabilities: string[] | undefined;
+ const pptbConfigPath = path.join(toolPath, "pptb.config.json");
+ if (fs.existsSync(pptbConfigPath)) {
+ try {
+ const pptbConfig = JSON.parse(fs.readFileSync(pptbConfigPath, "utf-8"));
+ const caps = pptbConfig?.invocation?.capabilities;
+ if (Array.isArray(caps) && caps.length > 0) {
+ capabilities = (caps as unknown[]).filter((c): c is string => typeof c === "string" && c.trim().length > 0);
+ }
+ } catch (err) {
+ logWarn(`[ToolRegistry] Could not read pptb.config.json for ${toolId}`, err);
+ }
+ }
+
+ // Validate declared capabilities against the known registry (warn on unknown tags)
+ if (capabilities && capabilities.length > 0) {
+ const knownTags = await this.getKnownCapabilityTags();
+ const knownTagSet = new Set(knownTags.map((t) => t.tag));
+ const unknownCaps = capabilities.filter((c) => !knownTagSet.has(c));
+ if (unknownCaps.length > 0) {
+ logWarn(`[ToolRegistry] Tool ${toolId} declares unrecognised capability tags: ${unknownCaps.join(", ")}. Ensure these tags exist in the capability registry or check for typos.`);
+ }
+ }
+
// Extract version information from registry (Supabase)
// These are pre-processed during tool intake and stored in the database
const minAPI: string | undefined = tool.minAPI; // From Supabase tools table (min_api column)
@@ -671,6 +733,7 @@ export class ToolRegistryManager extends EventEmitter {
publishedAt: tool.publishedAt,
minAPI, // Minimum API version required
maxAPI, // Maximum API version tested (from @pptb/types)
+ capabilities, // Invocation capability tags from pptb.config.json
};
// Save to manifest file
@@ -1085,4 +1148,134 @@ export class ToolRegistryManager extends EventEmitter {
return null;
}
}
+
+ /**
+ * Returns the list of known capability tags.
+ *
+ * On first call (or after the TTL expires) the list is fetched from the Supabase
+ * `capability_tags` table and cached. When Supabase is unavailable the built-in
+ * fallback list (`BUILT_IN_CAPABILITY_TAGS`) is returned so validation and
+ * auto-complete still work in offline scenarios.
+ */
+ async getKnownCapabilityTags(): Promise {
+ const now = Date.now();
+ if (this.capabilityTagsCache && now - this.capabilityTagsFetchedAtMs < ToolRegistryManager.CAPABILITY_TAGS_CACHE_TTL_MS) {
+ return this.capabilityTagsCache;
+ }
+
+ try {
+ const tags = await this.fetchKnownCapabilityTagsFromSupabase();
+ if (tags !== null) {
+ this.capabilityTagsCache = tags;
+ this.capabilityTagsFetchedAtMs = now;
+ return tags;
+ }
+ } catch (error) {
+ logWarn("[ToolRegistry] Could not fetch capability tags from Supabase, using built-in fallback", { error: error instanceof Error ? error.message : String(error) });
+ }
+
+ // Supabase unavailable: update the timestamp so we do not retry on every subsequent call
+ // while Supabase is down (rate-limit retries to once per TTL interval).
+ // If a previous successful fetch populated the cache, keep returning it; otherwise fall back
+ // to the built-in list so callers always receive a non-empty result.
+ this.capabilityTagsFetchedAtMs = now;
+ return this.capabilityTagsCache ?? BUILT_IN_CAPABILITY_TAGS;
+ }
+
+ /**
+ * Fetches capability tags from the Supabase `capability_tags` table.
+ * Returns `null` when Supabase is not configured or the query fails.
+ */
+ private async fetchKnownCapabilityTagsFromSupabase(): Promise {
+ if (!this.supabase || this.useLocalFallback) {
+ return null;
+ }
+
+ try {
+ logInfo("[ToolRegistry] Fetching capability tags from Supabase");
+
+ const { data, error } = await this.supabase
+ .from("capability_tags")
+ .select("tag, description")
+ .eq("is_active", true)
+ .order("tag", { ascending: true });
+
+ if (error) {
+ throw new Error(`Supabase capability_tags query failed: ${error.message}`);
+ }
+
+ if (!data || data.length === 0) {
+ logInfo("[ToolRegistry] No capability tags found in Supabase, using built-in fallback");
+ return null;
+ }
+
+ const rows = data as SupabaseCapabilityTagRow[];
+ const tags: CapabilityTagEntry[] = rows
+ .filter((r) => typeof r.tag === "string" && r.tag.trim().length > 0)
+ .map((r) => ({ tag: r.tag.trim(), description: typeof r.description === "string" ? r.description : "" }));
+
+ logInfo(`[ToolRegistry] Fetched ${tags.length} capability tags from Supabase`);
+ return tags;
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ logWarn("[ToolRegistry] Failed to fetch capability tags from Supabase", { error: errorMessage });
+ return null;
+ }
+ }
+
+ /**
+ * Check whether an npm package has a beta (pre-release) dist-tag on the npm registry.
+ * Uses the public npm registry REST API — no npm CLI required.
+ * @param npmPackageName - the npm package name (e.g. "@pptoolbox/my-tool")
+ * @returns an object with `hasBeta` flag and the `betaVersion` string when available
+ */
+ async checkBetaPackage(npmPackageName: string): Promise<{ hasBeta: boolean; betaVersion?: string }> {
+ if (!npmPackageName || typeof npmPackageName !== "string") {
+ return { hasBeta: false };
+ }
+
+ try {
+ logInfo(`[ToolRegistry] Checking for beta package: ${npmPackageName}`);
+
+ // encodeURIComponent handles both scoped (@org/name → %40org%2Fname) and plain names.
+ const encodedName = encodeURIComponent(npmPackageName);
+
+ const url = `https://registry.npmjs.org/-/package/${encodedName}/dist-tags`;
+
+ const rawJson = await new Promise((resolve, reject) => {
+ https
+ .get(url, { timeout: 10000 }, (res) => {
+ if (res.statusCode === 404) {
+ // Package not found on npm — no beta available
+ resolve("{}");
+ return;
+ }
+ if (res.statusCode !== 200) {
+ reject(new Error(`npm registry request failed: HTTP ${res.statusCode}`));
+ return;
+ }
+ const chunks: Buffer[] = [];
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
+ res.on("error", reject);
+ })
+ .on("error", reject)
+ .on("timeout", () => reject(new Error("npm registry request timed out")));
+ });
+
+ const distTags = JSON.parse(rawJson) as Record;
+ const betaVersion = distTags["beta"];
+
+ if (betaVersion && typeof betaVersion === "string") {
+ logInfo(`[ToolRegistry] Beta version found for ${npmPackageName}: ${betaVersion}`);
+ return { hasBeta: true, betaVersion };
+ }
+
+ logInfo(`[ToolRegistry] No beta version found for ${npmPackageName}`);
+ return { hasBeta: false };
+ } catch (error) {
+ logWarn(`[ToolRegistry] Failed to check beta package for ${npmPackageName}`, { error: error instanceof Error ? error.message : String(error) });
+ return { hasBeta: false };
+ }
+ }
}
diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts
index f7c56d00..ea1b8c4c 100644
--- a/src/main/managers/toolWindowManager.ts
+++ b/src/main/managers/toolWindowManager.ts
@@ -47,12 +47,15 @@ export class ToolWindowManager {
*/
private toolViews: Map* instanceId: string */ string, BrowserView> = new Map();
private toolConnectionInfo: Map = new Map(); // Maps instanceId -> connection info
+ /** Maps instanceId → tool display name (used for the "Return to [CallerToolName]" banner). */
+ private toolInstanceNames: Map = new Map();
/**
* Pending invocation contexts – created when one tool launches another with prefill data.
* The entry is keyed by the *callee* instanceId and holds:
* - the prefill data passed by the caller
* - the caller's instanceId so we can forward the return value
* - resolve/reject callbacks for the Promise returned to the caller tool
+ * - resolved flag to prevent double-resolution (e.g. when auto-close calls closeTool after resolveInvocation)
*/
private pendingInvocations: Map<
string, // calleeInstanceId
@@ -61,8 +64,29 @@ export class ToolWindowManager {
prefillData: Record;
resolve: (data: unknown) => void;
reject: (reason: unknown) => void;
+ resolved: boolean;
+ /** When true the caller does not expect return data; banner shows a "nothing returned" warning. */
+ noReturn?: boolean;
}
> = new Map();
+ /**
+ * Pending requests for connection selection shown to the user via the main renderer.
+ * Keyed by requestId; resolved by PROVIDE_INVOCATION_CONNECTIONS from the renderer.
+ */
+ private pendingConnectionPrompts: Map<
+ string, // requestId
+ {
+ resolve: (result: { primaryConnectionId: string | null; secondaryConnectionId: string | null }) => void;
+ reject: (reason: Error) => void;
+ }
+ > = new Map();
+ /**
+ * Tracks the one active callee per caller (one-at-a-time enforcement).
+ * Maps callerInstanceId → calleeInstanceId.
+ * The reverse lookup (calleeInstanceId → callerInstanceId) is obtained directly
+ * from pendingInvocations, which already stores callerInstanceId per callee entry.
+ */
+ private activeCallees: Map = new Map();
// NOTE: Despite the name, this stores the active tool *instanceId* (not the toolId).
// The property name is retained for backward compatibility; prefer `instanceId` terminology elsewhere.
private activeToolId: string | null = null;
@@ -70,6 +94,7 @@ export class ToolWindowManager {
private frameScheduled = false;
private boundsResponseListener: (event: Electron.IpcMainEvent, bounds: { x: number; y: number; width: number; height: number }) => void;
private terminalVisibilityListener: () => void;
+ private bannerVisibilityListener: () => void;
private sidebarLayoutListener: () => void;
private refreshBoundsListener: () => void;
private focusListener: () => void;
@@ -124,6 +149,9 @@ export class ToolWindowManager {
this.terminalVisibilityListener = () => {
this.scheduleBoundsUpdate();
};
+ this.bannerVisibilityListener = () => {
+ this.scheduleBoundsUpdate();
+ };
this.sidebarLayoutListener = () => {
this.scheduleBoundsUpdate();
setTimeout(() => this.scheduleBoundsUpdate(), 140);
@@ -150,6 +178,7 @@ export class ToolWindowManager {
ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION);
ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.HIDE_ALL);
ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA);
+ ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY);
}
/**
@@ -179,14 +208,51 @@ export class ToolWindowManager {
primaryConnectionId: string | null,
secondaryConnectionId: string | null,
prefillData: Record,
+ noReturn?: boolean,
) => {
- return this.launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData);
+ return this.launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData, noReturn);
},
);
- // Handle data returned by a callee tool back to its caller
- ipcMain.handle(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA, async (event, calleeInstanceId: string, returnData: unknown) => {
- return this.resolveInvocation(calleeInstanceId, returnData);
+ // Receive the connection IDs selected by the user via the multi-connection modal
+ // (in response to an INVOCATION_PROMPT_CONNECTIONS push to the main renderer).
+ ipcMain.handle(
+ TOOL_WINDOW_CHANNELS.PROVIDE_INVOCATION_CONNECTIONS,
+ async (
+ _event,
+ requestId: string,
+ result: { primaryConnectionId: string | null; secondaryConnectionId: string | null } | null,
+ ) => {
+ const prompt = this.pendingConnectionPrompts.get(requestId);
+ if (!prompt) return;
+ this.pendingConnectionPrompts.delete(requestId);
+ if (result) {
+ prompt.resolve(result);
+ } else {
+ prompt.reject(new Error("Connection selection cancelled"));
+ }
+ },
+ );
+
+ // Handle data returned by a callee tool back to its caller.
+ // calleeInstanceId is provided by callee tools calling returnData() directly.
+ // When the banner's "Return to Caller" button is clicked, no calleeInstanceId is
+ // passed — the main process falls back to the currently active tool (activeToolId).
+ // The banner is only visible while its callee is the active tool (switchToTool hides
+ // the banner whenever the active tool changes to a non-callee), so activeToolId is
+ // always the correct callee when the Return button is clicked. A guard on
+ // pendingInvocations defends against any residual race conditions.
+ ipcMain.handle(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA, async (event, calleeInstanceId: string | null, returnData: unknown) => {
+ const effectiveCalleeId = calleeInstanceId ?? this.activeToolId;
+ if (!effectiveCalleeId) {
+ logWarn("[ToolWindowManager] RETURN_INVOCATION_DATA: no callee instance ID and no active tool");
+ return;
+ }
+ if (!this.pendingInvocations.has(effectiveCalleeId)) {
+ logWarn(`[ToolWindowManager] RETURN_INVOCATION_DATA: ${effectiveCalleeId} has no pending invocation — ignoring`);
+ return;
+ }
+ return this.resolveInvocation(effectiveCalleeId, returnData);
});
// Switch to a different tool
@@ -219,9 +285,16 @@ export class ToolWindowManager {
this.mainWindow.setBrowserView(null);
this.activeToolId = null;
this.invokeActiveToolChangedCallback();
+ this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false });
return true;
});
+ // Find installed tools that declare a given capability tag
+ ipcMain.handle(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY, async (_event, tag: string) => {
+ const allTools = this.toolManager.getAllTools();
+ return allTools.filter((t) => Array.isArray(t.capabilities) && t.capabilities.includes(tag));
+ });
+
// Restore renderer-provided bounds flow
ipcMain.on("get-tool-panel-bounds-response", this.boundsResponseListener);
@@ -245,6 +318,7 @@ export class ToolWindowManager {
// Handle terminal panel visibility changes
// When terminal is shown/hidden, we need to adjust BrowserView bounds
ipcMain.on("terminal-visibility-changed", this.terminalVisibilityListener);
+ ipcMain.on("invocation-banner-visibility-changed", this.bannerVisibilityListener);
ipcMain.on("sidebar-layout-changed", this.sidebarLayoutListener);
// Periodic frame scheduling helper
@@ -332,6 +406,8 @@ export class ToolWindowManager {
// Store the view with instanceId as key
this.toolViews.set(instanceId, toolView);
+ // Store the tool display name for the "Return to [CallerToolName]" banner
+ this.toolInstanceNames.set(instanceId, tool.name);
// Get connection information for this tool instance
// Connections are passed from frontend (per-instance), not retrieved from settings
@@ -433,12 +509,19 @@ export class ToolWindowManager {
* Called when Tool A wants to launch Tool B with prefill data and (optionally) receive
* a return value when Tool B calls returnInvocationData().
*
+ * One-at-a-time enforcement: rejects if the caller already has an active callee.
+ * FXS connection auto-inheritance: when primaryConnectionId is null, the caller's
+ * active FXS connection is inherited automatically.
+ * Multi-connection: when the callee tool requires a secondary connection that was not
+ * provided, the user is prompted via the PPTB renderer before the tool is launched.
+ *
* @param callerInstanceId The instanceId of the tool initiating the launch
* @param calleeInstanceId The instanceId to use for the new tool window
* @param tool The tool manifest to launch
- * @param primaryConnectionId Primary connection for the callee
+ * @param primaryConnectionId Primary connection for the callee (null = auto-inherit from caller)
* @param secondaryConnectionId Secondary connection for the callee (optional)
* @param prefillData Arbitrary data to pre-populate the callee's state
+ * @param noReturn When true, the caller does not expect return data; banner is suppressed for the callee
* @returns A Promise that resolves with the data returned by the callee, or null if the callee closes without returning data
*/
async launchToolWithContext(
@@ -448,37 +531,109 @@ export class ToolWindowManager {
primaryConnectionId: string | null,
secondaryConnectionId: string | null,
prefillData: Record,
+ noReturn?: boolean,
): Promise {
+ // One-at-a-time enforcement
+ if (this.activeCallees.has(callerInstanceId)) {
+ throw new Error("A callee invocation is already in progress");
+ }
+
+ // FXS connection auto-inheritance: use caller's primary connection when none is specified
+ const effectivePrimaryConnectionId = primaryConnectionId ?? this.toolConnectionInfo.get(callerInstanceId)?.primaryConnectionId ?? null;
+
+ // Multi-connection: if the callee requires a secondary connection but none was provided,
+ // ask the main renderer to show the multi-connection selector before launching the tool.
+ const multiConnectionMode = tool.features?.multiConnection ?? "none";
+ const needsSecondary = multiConnectionMode === "required" || multiConnectionMode === "optional";
+ let effectiveSecondaryConnectionId = secondaryConnectionId;
+
+ if (needsSecondary && !effectiveSecondaryConnectionId) {
+ const isSecondaryRequired = multiConnectionMode === "required";
+ const requestId = `invocation-conn-${callerInstanceId}-${Date.now()}`;
+ try {
+ const connectionResult = await this.promptForInvocationConnections(
+ requestId,
+ tool.name,
+ isSecondaryRequired,
+ effectivePrimaryConnectionId,
+ );
+ effectiveSecondaryConnectionId = connectionResult.secondaryConnectionId;
+ } catch (err) {
+ throw new Error(`Connection selection cancelled: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
return new Promise((resolve, reject) => {
this.pendingInvocations.set(calleeInstanceId, {
callerInstanceId,
prefillData,
resolve,
reject,
+ resolved: false,
+ noReturn: noReturn ?? false,
});
+ this.activeCallees.set(callerInstanceId, calleeInstanceId);
- this.launchTool(calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData)
+ this.launchTool(calleeInstanceId, tool, effectivePrimaryConnectionId, effectiveSecondaryConnectionId, prefillData)
.then((launched) => {
if (!launched) {
this.pendingInvocations.delete(calleeInstanceId);
+ this.activeCallees.delete(callerInstanceId);
reject(new Error(`Failed to launch tool instance ${calleeInstanceId}`));
+ return;
}
+ // Notify the renderer to create a tab for the callee so it appears as a
+ // separate instance (its own tab) rather than replacing the caller's view.
+ this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_OPENED, {
+ calleeInstanceId,
+ callerInstanceId,
+ tool,
+ primaryConnectionId: effectivePrimaryConnectionId,
+ secondaryConnectionId: effectiveSecondaryConnectionId,
+ });
})
.catch((error) => {
this.pendingInvocations.delete(calleeInstanceId);
+ this.activeCallees.delete(callerInstanceId);
reject(error as Error);
});
});
}
+ /**
+ * Ask the main renderer to show the multi-connection selector for an invoked callee tool.
+ *
+ * Returns a Promise that resolves with the selected connection IDs once the user confirms,
+ * or rejects if the user cancels the dialog.
+ */
+ private promptForInvocationConnections(
+ requestId: string,
+ toolName: string,
+ isSecondaryRequired: boolean,
+ inheritedPrimaryConnectionId: string | null,
+ ): Promise<{ primaryConnectionId: string | null; secondaryConnectionId: string | null }> {
+ return new Promise((resolve, reject) => {
+ this.pendingConnectionPrompts.set(requestId, { resolve, reject });
+ this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_PROMPT_CONNECTIONS, {
+ requestId,
+ toolName,
+ isSecondaryRequired,
+ inheritedPrimaryConnectionId,
+ });
+ });
+ }
+
/**
* Called by the callee tool's preload bridge when it is ready to return data to its caller.
*
* Resolves the pending Promise created in launchToolWithContext and notifies the
- * caller tool via IPC so it can continue its workflow.
+ * caller tool via IPC so it can continue its workflow. After delivering the result,
+ * the callee window is automatically closed.
+ *
+ * Accepts null as a valid payload (banner early-return path).
*
* @param calleeInstanceId The instanceId of the tool returning data
- * @param returnData The data to hand back to the caller
+ * @param returnData The data to hand back to the caller (null for early-return via banner)
*/
resolveInvocation(calleeInstanceId: string, returnData: unknown): void {
const pending = this.pendingInvocations.get(calleeInstanceId);
@@ -487,7 +642,14 @@ export class ToolWindowManager {
return;
}
+ if (pending.resolved) {
+ logWarn(`[ToolWindowManager] resolveInvocation: already resolved for ${calleeInstanceId}`);
+ return;
+ }
+
+ pending.resolved = true;
this.pendingInvocations.delete(calleeInstanceId);
+ this.activeCallees.delete(pending.callerInstanceId);
// Notify the caller tool (if it is still open) via an IPC push
const callerView = this.toolViews.get(pending.callerInstanceId);
@@ -500,6 +662,21 @@ export class ToolWindowManager {
// Resolve the JS Promise held by launchToolWithContext
pending.resolve(returnData);
+
+ // Auto-close the callee window now that the result has been delivered.
+ // After the BrowserView is destroyed, notify the renderer to remove the callee
+ // tab and switch back to the caller.
+ const callerInstanceId = pending.callerInstanceId;
+ this.closeTool(calleeInstanceId)
+ .then(() => {
+ this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_CLOSED, {
+ calleeInstanceId,
+ callerInstanceId,
+ });
+ })
+ .catch((err) => {
+ logWarn(`[ToolWindowManager] Auto-close of callee ${calleeInstanceId} failed`, err);
+ });
}
async switchToTool(instanceId: string): Promise {
@@ -529,6 +706,24 @@ export class ToolWindowManager {
this.activeToolId = instanceId;
this.invokeActiveToolChangedCallback();
+ // Push banner state to the renderer: show the "Return to [CallerToolName]" banner
+ // if this tool was launched by another tool, otherwise hide it.
+ const invocationEntry = this.pendingInvocations.get(instanceId);
+ if (invocationEntry) {
+ const callerToolName = this.toolInstanceNames.get(invocationEntry.callerInstanceId) ?? "Caller";
+ // noReturn invocations do not show a banner — the caller does not expect data back
+ if (invocationEntry.noReturn) {
+ this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false });
+ } else {
+ this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, {
+ visible: true,
+ callerToolName,
+ });
+ }
+ } else {
+ this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false });
+ }
+
logInfo(`[ToolWindowManager] Switched to tool instance: ${instanceId}, requesting bounds...`);
// Request bounds update from renderer
@@ -568,22 +763,32 @@ export class ToolWindowManager {
// Remove from maps - also clean up connection info
this.toolViews.delete(instanceId);
this.toolConnectionInfo.delete(instanceId);
+ this.toolInstanceNames.delete(instanceId);
// If the tool was launched by another tool (inter-tool invocation) and it closes
// without calling returnData, resolve the caller's Promise with null so the caller
// doesn't hang indefinitely.
+ // Guard: skip resolve if resolveInvocation already handled it (auto-close path).
const pending = this.pendingInvocations.get(instanceId);
if (pending) {
this.pendingInvocations.delete(instanceId);
- // Notify the caller view (if still alive)
- const callerView = this.toolViews.get(pending.callerInstanceId);
- if (callerView && !callerView.webContents.isDestroyed()) {
- callerView.webContents.send("toolbox:invocation-result", {
- calleeInstanceId: instanceId,
- returnData: null,
- });
+ this.activeCallees.delete(pending.callerInstanceId);
+ if (!pending.resolved) {
+ // Notify the caller view (if still alive)
+ const callerView = this.toolViews.get(pending.callerInstanceId);
+ if (callerView && !callerView.webContents.isDestroyed()) {
+ callerView.webContents.send("toolbox:invocation-result", {
+ calleeInstanceId: instanceId,
+ returnData: null,
+ });
+ }
+ pending.resolve(null);
}
- pending.resolve(null);
+ }
+
+ // If this was the active tool, hide the banner in the renderer
+ if (this.activeToolId === null) {
+ this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false });
}
// Dispose any terminals created by this tool instance
@@ -952,9 +1157,11 @@ export class ToolWindowManager {
ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_OPEN_TOOLS);
ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION);
ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA);
+ ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY);
if (this.boundsResponseListener) ipcMain.removeListener("get-tool-panel-bounds-response", this.boundsResponseListener);
if (this.terminalVisibilityListener) ipcMain.removeListener("terminal-visibility-changed", this.terminalVisibilityListener);
+ if (this.bannerVisibilityListener) ipcMain.removeListener("invocation-banner-visibility-changed", this.bannerVisibilityListener);
if (this.sidebarLayoutListener) ipcMain.removeListener("sidebar-layout-changed", this.sidebarLayoutListener);
if (this.rendererInitializedListener) ipcMain.removeListener(TOOL_WINDOW_CHANNELS.RENDERER_INITIALIZED, this.rendererInitializedListener);
diff --git a/src/main/managers/toolsManager.ts b/src/main/managers/toolsManager.ts
index 9ed94038..fd1e4f73 100644
--- a/src/main/managers/toolsManager.ts
+++ b/src/main/managers/toolsManager.ts
@@ -3,11 +3,11 @@ import { EventEmitter } from "events";
import * as fs from "fs";
import * as path from "path";
import { pathToFileURL } from "url";
-import { CspExceptions, Tool, ToolFeatures, ToolManifest, CommunityLinksCollection } from "../../common/types";
+import { logError, logInfo, logWarn } from "../../common/logger";
+import { CapabilityTagEntry, CommunityLinksCollection, CspExceptions, Tool, ToolFeatures, ToolManifest } from "../../common/types";
import { InstallIdManager } from "./installIdManager";
import { ToolRegistryManager } from "./toolRegistryManager";
import { VersionManager } from "./versionManager";
-import { logInfo, logError } from "../../common/logger";
/**
* Package.json structure for tool validation
@@ -79,6 +79,7 @@ export class ToolManager extends EventEmitter {
minAPI: manifest.minAPI,
maxAPI: manifest.maxAPI,
isSupported: VersionManager.isToolSupported(manifest.minAPI, manifest.maxAPI),
+ capabilities: manifest.capabilities,
};
const cached = this.analyticsCache.get(tool.id);
@@ -149,6 +150,7 @@ export class ToolManager extends EventEmitter {
minAPI: manifest.minAPI,
maxAPI: manifest.maxAPI,
isSupported: VersionManager.isToolSupported(manifest.minAPI, manifest.maxAPI),
+ capabilities: manifest.capabilities,
};
const cached = this.analyticsCache.get(tool.id);
@@ -190,7 +192,8 @@ export class ToolManager extends EventEmitter {
unloadTool(toolId: string): void {
const tool = this.tools.get(toolId);
if (tool) {
- this.tools.delete(toolId);
+ // Too soon to delete it
+ //this.tools.delete(toolId);
this.emit("tool:unloaded", tool);
}
}
@@ -272,7 +275,7 @@ export class ToolManager extends EventEmitter {
*/
async fetchAvailableTools(): Promise {
const registryTools = await this.registryManager.fetchRegistry();
-
+
// Convert ToolRegistryEntry[] to Tool[] and add isSupported field
return registryTools.map((registryTool) => {
const tool: Tool = {
@@ -291,6 +294,13 @@ export class ToolManager extends EventEmitter {
return this.registryManager.fetchCommunityLinks();
}
+ /**
+ * Returns the list of known capability tags from the registry (backed by Supabase with fallback).
+ */
+ async getKnownCapabilityTags(): Promise {
+ return this.registryManager.getKnownCapabilityTags();
+ }
+
/**
* Check for tool updates
*/
@@ -344,10 +354,29 @@ export class ToolManager extends EventEmitter {
}
/**
- * Uninstall a tool from registry
+ * Uninstall a tool (handles registry, npm, and local tools)
*/
async uninstallTool(toolId: string): Promise {
- await this.registryManager.uninstallTool(toolId);
+ const tool = this.tools.get(toolId);
+
+ if (tool) {
+ this.tools.delete(toolId);
+ this.emit("tool:unloaded", tool);
+ }
+
+ if (tool?.npmPackageName) {
+ const packageDir = this.resolvePackageDirectoryName(tool.npmPackageName);
+ const toolPath = path.join(this.toolsDirectory, "node_modules", packageDir);
+ if (fs.existsSync(toolPath)) {
+ fs.rmSync(toolPath, { recursive: true, force: true });
+ }
+ } else if (tool?.localPath) {
+ if (fs.existsSync(tool.localPath)) {
+ fs.rmSync(tool.localPath, { recursive: true, force: true });
+ }
+ } else {
+ await this.registryManager.uninstallTool(toolId);
+ }
}
/**
@@ -372,13 +401,34 @@ export class ToolManager extends EventEmitter {
/**
* Check if a package manager is available globally (debug mode only)
*/
+ private buildEnv(): Record {
+ const paths = [...(process.env.PATH || "").split(path.delimiter).filter(Boolean)];
+
+ if (process.platform === "darwin") {
+ paths.push("/usr/local/bin", "/opt/homebrew/bin");
+ } else if (process.platform === "linux") {
+ paths.push("/usr/local/bin", path.join(process.env.HOME || "", ".local", "bin"));
+ }
+
+ const home = process.env.HOME || "";
+ if (home) {
+ paths.push(path.join(home, ".npm-global", "bin"));
+ paths.push(path.join(home, ".nvm", "versions", "node", process.version, "bin"));
+ }
+
+ return {
+ ...process.env,
+ PATH: [...new Set(paths)].join(path.delimiter),
+ };
+ }
+
private async checkPackageManager(command: string): Promise {
return new Promise((resolve) => {
const isWindows = process.platform === "win32";
const cmd = isWindows ? `${command}.cmd` : command;
+ const env = this.buildEnv();
- // Don't use shell to avoid issues with spaces in paths
- const check = spawn(cmd, ["--version"]);
+ const check = spawn(cmd, ["--version"], { env });
check.on("close", (code: number) => {
resolve(code === 0);
@@ -394,19 +444,19 @@ export class ToolManager extends EventEmitter {
* Get the available package manager (debug mode only)
* Returns null if neither is available
*/
- private async getAvailablePackageManager(): Promise<{ command: string; name: string } | null> {
+ private async getAvailablePackageManager(): Promise<{ command: string; name: string; env: Record } | null> {
// Check for pnpm first (preferred)
const hasPnpm = await this.checkPackageManager("pnpm");
if (hasPnpm) {
logInfo(`[ToolManager] Found pnpm globally installed`);
- return { command: process.platform === "win32" ? "pnpm.cmd" : "pnpm", name: "pnpm" };
+ return { command: process.platform === "win32" ? "pnpm.cmd" : "pnpm", name: "pnpm", env: this.buildEnv() };
}
// Fallback to npm
const hasNpm = await this.checkPackageManager("npm");
if (hasNpm) {
logInfo(`[ToolManager] Found npm globally installed`);
- return { command: process.platform === "win32" ? "npm.cmd" : "npm", name: "npm" };
+ return { command: process.platform === "win32" ? "npm.cmd" : "npm", name: "npm", env: this.buildEnv() };
}
logError(`[ToolManager] Neither pnpm nor npm found globally installed`);
@@ -437,7 +487,7 @@ export class ToolManager extends EventEmitter {
// Don't use shell: true to avoid issues with spaces in paths
// The command array is already in the correct format for spawn
- const install = spawn(pkgManager.command, args);
+ const install = spawn(pkgManager.command, args, { env: pkgManager.env });
let stderr = "";
@@ -473,6 +523,68 @@ export class ToolManager extends EventEmitter {
});
}
+ /**
+ * Resolve the actual directory name for an npm package inside node_modules.
+ * Strips any trailing version/tag specifier so the path is valid on disk.
+ * Examples:
+ * "@org/name@1.0.0" → "@org/name"
+ * "@org/name@beta" → "@org/name"
+ * "my-tool@beta" → "my-tool"
+ * "@org/name" → "@org/name" (unchanged)
+ * "my-tool" → "my-tool" (unchanged)
+ */
+ private resolvePackageDirectoryName(packageName: string): string {
+ if (packageName.startsWith("@")) {
+ // Scoped package: @scope/name[@version]
+ // Find the '@' that separates the name from the version specifier.
+ const withoutLeadingAt = packageName.slice(1); // "scope/name@version"
+ const versionAtIndex = withoutLeadingAt.indexOf("@");
+ if (versionAtIndex !== -1) {
+ return "@" + withoutLeadingAt.slice(0, versionAtIndex);
+ }
+ return packageName;
+ } else {
+ // Regular package: name[@version]
+ const atIndex = packageName.indexOf("@");
+ if (atIndex !== -1) {
+ return packageName.slice(0, atIndex);
+ }
+ return packageName;
+ }
+ }
+
+ /**
+ * Check whether a beta (pre-release) npm package version is available.
+ * @param npmPackageName - the npm package name (e.g. "@pptoolbox/my-tool")
+ */
+ async checkBetaPackage(npmPackageName: string): Promise<{ hasBeta: boolean; betaVersion?: string }> {
+ return this.registryManager.checkBetaPackage(npmPackageName);
+ }
+
+ /**
+ * Install the beta (pre-release) version of a registry tool via npm.
+ * Installs the `@beta` dist-tag of the given npm package and loads it.
+ * @param npmPackageName - the npm package name (e.g. "@pptoolbox/my-tool")
+ */
+ async installPrereleaseToolFromNpm(npmPackageName: string): Promise {
+ logInfo(`[ToolManager] Installing pre-release (beta) tool: ${npmPackageName}`);
+ const betaPackageSpec = `${npmPackageName}@beta`;
+ try {
+ await this.installToolForDebug(betaPackageSpec);
+ } catch (installError) {
+ const msg = installError instanceof Error ? installError.message : String(installError);
+ throw new Error(`Failed to install pre-release package '${betaPackageSpec}': ${msg}`);
+ }
+ // Use the base package name (no version specifier) to locate the installed directory.
+ try {
+ const tool = await this.loadNpmTool(npmPackageName);
+ return tool;
+ } catch (loadError) {
+ const msg = loadError instanceof Error ? loadError.message : String(loadError);
+ throw new Error(`Pre-release package '${betaPackageSpec}' was installed but could not be loaded: ${msg}`);
+ }
+ }
+
/**
* Get installation instructions for package managers (debug mode only)
*/
@@ -502,13 +614,18 @@ export class ToolManager extends EventEmitter {
/**
* Load an npm-installed tool from node_modules (DEBUG MODE ONLY)
* This is called after installToolForDebug to register the tool in the tools map
- * @param packageName - npm package name
+ * @param packageName - npm package name (may include a version/tag specifier like "@beta" or "@1.0.0")
*/
async loadNpmTool(packageName: string): Promise {
logInfo(`[ToolManager] [DEBUG] Loading npm tool: ${packageName}`);
+ // Resolve the actual directory name in node_modules — strip any version/tag specifier.
+ // For scoped packages: @org/name@version → @org/name
+ // For regular packages: name@version → name
+ const packageDirName = this.resolvePackageDirectoryName(packageName);
+
// Construct path to the installed package
- const toolPath = path.join(this.toolsDirectory, "node_modules", packageName);
+ const toolPath = path.join(this.toolsDirectory, "node_modules", packageDirName);
// Verify the path exists
if (!fs.existsSync(toolPath)) {
@@ -548,6 +665,32 @@ export class ToolManager extends EventEmitter {
// Create a tool object with npm path metadata
const toolId = `npm-${sanitizedToolId}`;
+
+ // Read optional pptb.config.json for invocation capabilities
+ let capabilities: string[] | undefined;
+ const pptbConfigPath = path.join(toolPath, "pptb.config.json");
+ if (fs.existsSync(pptbConfigPath)) {
+ try {
+ const pptbConfig = JSON.parse(fs.readFileSync(pptbConfigPath, "utf-8"));
+ const caps = pptbConfig?.invocation?.capabilities;
+ if (Array.isArray(caps) && caps.length > 0) {
+ capabilities = (caps as unknown[]).filter((c): c is string => typeof c === "string" && c.trim().length > 0);
+ }
+ } catch (err) {
+ logWarn(`[ToolRegistry] Could not read pptb.config.json for ${toolId}`, err);
+ }
+ }
+
+ // Validate declared capabilities against the known registry (warn on unknown tags)
+ if (capabilities && capabilities.length > 0) {
+ const knownTags = await this.getKnownCapabilityTags();
+ const knownTagSet = new Set(knownTags.map((t) => t.tag));
+ const unknownCaps = capabilities.filter((c) => !knownTagSet.has(c));
+ if (unknownCaps.length > 0) {
+ logWarn(`[ToolRegistry] Tool ${toolId} declares unrecognised capability tags: ${unknownCaps.join(", ")}. Ensure these tags exist in the capability registry or check for typos.`);
+ }
+ }
+
const tool: Tool = {
id: toolId,
name: packageJson.displayName || packageJson.name,
@@ -561,6 +704,7 @@ export class ToolManager extends EventEmitter {
repository: typeof packageJson.repository === "string" ? packageJson.repository : packageJson.repository?.url,
website: packageJson.homepage,
readmeUrl: packageJson.readme,
+ capabilities, // Invocation capability tags from pptb.config.json
};
this.tools.set(toolId, tool);
@@ -736,6 +880,32 @@ export class ToolManager extends EventEmitter {
// Create a tool object with local path metadata
const toolId = `local-${sanitizedToolId}`;
+
+ // Read optional pptb.config.json for invocation capabilities
+ let capabilities: string[] | undefined;
+ const pptbConfigPath = path.join(localPath, "pptb.config.json");
+ if (fs.existsSync(pptbConfigPath)) {
+ try {
+ const pptbConfig = JSON.parse(fs.readFileSync(pptbConfigPath, "utf-8"));
+ const caps = pptbConfig?.invocation?.capabilities;
+ if (Array.isArray(caps) && caps.length > 0) {
+ capabilities = (caps as unknown[]).filter((c): c is string => typeof c === "string" && c.trim().length > 0);
+ }
+ } catch (err) {
+ logWarn(`[ToolRegistry] Could not read pptb.config.json for ${toolId}`, err);
+ }
+ }
+
+ // Validate declared capabilities against the known registry (warn on unknown tags)
+ if (capabilities && capabilities.length > 0) {
+ const knownTags = await this.getKnownCapabilityTags();
+ const knownTagSet = new Set(knownTags.map((t) => t.tag));
+ const unknownCaps = capabilities.filter((c) => !knownTagSet.has(c));
+ if (unknownCaps.length > 0) {
+ logWarn(`[ToolRegistry] Tool ${toolId} declares unrecognised capability tags: ${unknownCaps.join(", ")}. Ensure these tags exist in the capability registry or check for typos.`);
+ }
+ }
+
const tool: Tool = {
id: toolId,
name: packageJson.displayName || packageJson.name,
@@ -749,6 +919,7 @@ export class ToolManager extends EventEmitter {
repository: typeof packageJson.repository === "string" ? packageJson.repository : packageJson.repository?.url,
website: packageJson.homepage,
readmeUrl: packageJson.readme,
+ capabilities, // Invocation capability tags from pptb.config.json
};
this.tools.set(toolId, tool);
diff --git a/src/main/preload.ts b/src/main/preload.ts
index 0d383fa7..7ebb6323 100644
--- a/src/main/preload.ts
+++ b/src/main/preload.ts
@@ -61,7 +61,8 @@ contextBridge.exposeInMainWorld("toolboxAPI", {
primaryConnectionId: string | null,
secondaryConnectionId: string | null,
prefillData: Record,
- ) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData),
+ noReturn?: boolean,
+ ) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData, noReturn),
switchToolWindow: (instanceId: string) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.SWITCH, instanceId),
closeToolWindow: (instanceId: string) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.CLOSE, instanceId),
hideToolWindows: () => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.HIDE_ALL),
@@ -69,6 +70,35 @@ contextBridge.exposeInMainWorld("toolboxAPI", {
getOpenToolWindows: () => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.GET_OPEN_TOOLS),
updateToolConnection: (instanceId: string, primaryConnectionId: string | null, secondaryConnectionId?: string | null) =>
ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION, instanceId, primaryConnectionId, secondaryConnectionId),
+ findToolsByCapability: (tag: string) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY, tag),
+ /** Trigger banner "Return to Caller" — resolves the currently active callee's invocation with null and auto-closes it. */
+ returnToCallerBanner: () =>
+ ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA, null, null),
+ onInvocationBannerState: (callback: (state: { visible: boolean; callerToolName?: string }) => void) => {
+ ipcRenderer.on(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, (_event, state) => callback(state));
+ },
+ /** Listen for multi-connection prompts triggered by an invocation that requires a secondary connection. */
+ onInvocationConnectionsPrompt: (callback: (prompt: { requestId: string; toolName: string; isSecondaryRequired: boolean; inheritedPrimaryConnectionId: string | null }) => void) => {
+ ipcRenderer.on(TOOL_WINDOW_CHANNELS.INVOCATION_PROMPT_CONNECTIONS, (_event, prompt) => callback(prompt));
+ },
+ /** Reply to a multi-connection prompt with the selected connection IDs (or null to cancel). */
+ provideInvocationConnections: (requestId: string, result: { primaryConnectionId: string | null; secondaryConnectionId: string | null } | null) =>
+ ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.PROVIDE_INVOCATION_CONNECTIONS, requestId, result),
+ /**
+ * Listen for inter-tool callee launch notifications. Fired once the callee BrowserView
+ * is ready so the renderer can create a dedicated tab for the callee instance.
+ */
+ onCalleeToolOpened: (callback: (data: { calleeInstanceId: string; callerInstanceId: string; tool: unknown; primaryConnectionId: string | null; secondaryConnectionId: string | null }) => void) => {
+ ipcRenderer.on(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_OPENED, (_event, data) => callback(data));
+ },
+ /**
+ * Listen for inter-tool callee auto-close notifications. Fired after the callee is
+ * auto-closed by the main process so the renderer can remove the tab and return focus
+ * to the caller.
+ */
+ onCalleeToolClosed: (callback: (data: { calleeInstanceId: string; callerInstanceId: string }) => void) => {
+ ipcRenderer.on(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_CLOSED, (_event, data) => callback(data));
+ },
// Favorite tools - Only for PPTB UI
addFavoriteTool: (toolId: string) => ipcRenderer.invoke(SETTINGS_CHANNELS.ADD_FAVORITE_TOOL, toolId),
@@ -85,10 +115,13 @@ contextBridge.exposeInMainWorld("toolboxAPI", {
// Registry-based tools (new primary method)
fetchRegistryTools: () => ipcRenderer.invoke(TOOL_CHANNELS.FETCH_REGISTRY_TOOLS),
fetchCommunityLinks: () => ipcRenderer.invoke(TOOL_CHANNELS.FETCH_COMMUNITY_LINKS),
+ getKnownCapabilityTags: () => ipcRenderer.invoke(TOOL_CHANNELS.GET_KNOWN_CAPABILITY_TAGS),
installToolFromRegistry: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.INSTALL_TOOL_FROM_REGISTRY, toolId),
checkToolUpdates: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.CHECK_TOOL_UPDATES, toolId),
updateTool: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.UPDATE_TOOL, toolId),
isToolUpdating: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.IS_TOOL_UPDATING, toolId),
+ checkBetaPackage: (npmPackageName: string) => ipcRenderer.invoke(TOOL_CHANNELS.CHECK_BETA_PACKAGE, npmPackageName),
+ installPrereleaseToolFromNpm: (npmPackageName: string) => ipcRenderer.invoke(TOOL_CHANNELS.INSTALL_PRERELEASE_TOOL, npmPackageName),
// Tool Settings - Only for PPTB UI
getToolSettings: (toolId: string) => ipcRenderer.invoke(SETTINGS_CHANNELS.GET_TOOL_SETTINGS, toolId),
diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts
index ca665665..8914a9d5 100644
--- a/src/main/toolPreloadBridge.ts
+++ b/src/main/toolPreloadBridge.ts
@@ -385,7 +385,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", {
launchTool: async (
targetToolId: string,
prefillData: Record = {},
- options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null },
+ options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null; noReturn?: boolean },
): Promise => {
const { instanceId: callerInstanceId } = await getToolIdentifiers();
if (!callerInstanceId) {
@@ -401,10 +401,33 @@ contextBridge.exposeInMainWorld("toolboxAPI", {
// Generate a unique instanceId for the callee (mirrors the pattern used in the renderer)
const calleeInstanceId = `${targetToolId}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
- const primaryConnectionId = options?.primaryConnectionId !== undefined ? options.primaryConnectionId : null;
+ const primaryConnectionId = options?.primaryConnectionId !== undefined
+ ? options.primaryConnectionId
+ : (toolContext?.connectionId ?? null); // FXS auto-inherit: use caller's active connection
const secondaryConnectionId = options?.secondaryConnectionId !== undefined ? options.secondaryConnectionId : null;
+ const noReturn = options?.noReturn ?? false;
- return ipcInvoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData);
+ return ipcInvoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData, noReturn);
+ },
+
+ /**
+ * Find installed tools that declare a given capability tag in their pptb.config.json.
+ *
+ * @param tag The capability tag to search for (e.g. "entity-picker")
+ * @returns A list of matching installed tools
+ */
+ findToolsByCapability: async (tag: string): Promise => {
+ return ipcInvoke(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY, tag) as Promise;
+ },
+
+ /**
+ * Returns the list of known (registered) capability tags from the capability registry.
+ * Backed by Supabase with a built-in fallback for offline use.
+ *
+ * @returns Array of { tag, description } entries
+ */
+ getKnownCapabilityTags: async (): Promise> => {
+ return ipcInvoke(TOOL_CHANNELS.GET_KNOWN_CAPABILITY_TAGS) as Promise>;
},
},
});
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 4978a1f0..b3cf5380 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -365,6 +365,14 @@
DEBUG
+
+
+
+
+
+
+
+
diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts
index 816b3fcd..e24245ad 100644
--- a/src/renderer/modules/initialization.ts
+++ b/src/renderer/modules/initialization.ts
@@ -34,7 +34,7 @@ import { openSettingsTab } from "./settingsManagement";
import { switchSidebar } from "./sidebarManagement";
import { handleTerminalClosed, handleTerminalCommandCompleted, handleTerminalCreated, handleTerminalError, handleTerminalOutput, setupTerminalPanel } from "./terminalManagement";
import { applyDebugMenuVisibility, applyTerminalFont, applyTheme } from "./themeManagement";
-import { applyAppearanceSettings, closeAllTools, initializeTabScrollButtons, launchTool, restoreSession, setupKeyboardShortcuts, showHomePage } from "./toolManagement";
+import { applyAppearanceSettings, closeAllTools, initializeCalleeToolListeners, initializeInvocationBanner, initializeInvocationConnectionsPrompt, initializeTabScrollButtons, launchTool, restoreSession, setupKeyboardShortcuts, showHomePage } from "./toolManagement";
import { clearInstalledToolsDropdownFilters, loadSidebarTools } from "./toolsSidebarManagement";
/**
@@ -244,6 +244,17 @@ function setupToolbarButtons(): void {
// Initialize tab scroll buttons
initializeTabScrollButtons();
+
+ // Initialize shell-level "Return to [CallerToolName]" banner for inter-tool invocations
+ initializeInvocationBanner();
+
+ // Handle multi-connection prompts triggered by invoked callee tools that require
+ // a secondary connection not present on the caller (e.g. FXS "Send To" → DMS)
+ initializeInvocationConnectionsPrompt();
+
+ // Create/remove tabs for callee tools launched via inter-tool invocation so they
+ // open in their own tab instead of replacing the caller's view.
+ initializeCalleeToolListeners();
}
/**
@@ -751,11 +762,25 @@ function setupToolPanelBoundsListener(): void {
if (toolPanelContent) {
const rect = toolPanelContent.getBoundingClientRect();
+ let adjustedY = Math.round(rect.top);
+ let adjustedHeight = Math.round(rect.height);
+
+ // If the invocation banner is visible it floats over the content area via
+ // position:absolute, but Electron BrowserViews are native OS views that CSS
+ // cannot push around. Shrink the BrowserView bounds from the top so the
+ // tool content starts below the banner and is never hidden behind it.
+ const invocationBanner = document.getElementById("invocation-banner");
+ if (invocationBanner && invocationBanner.style.display !== "none") {
+ const bannerHeight = Math.round(invocationBanner.getBoundingClientRect().height);
+ adjustedY += bannerHeight;
+ adjustedHeight = Math.max(1, adjustedHeight - bannerHeight);
+ }
+
const bounds = {
x: Math.round(rect.left),
- y: Math.round(rect.top),
+ y: adjustedY,
width: Math.round(rect.width),
- height: Math.round(rect.height),
+ height: adjustedHeight,
};
logInfo("[Renderer] Sending tool panel bounds:", bounds);
window.api.send("get-tool-panel-bounds-response", bounds);
diff --git a/src/renderer/modules/marketplaceManagement.ts b/src/renderer/modules/marketplaceManagement.ts
index a47cdc49..a2c689f0 100644
--- a/src/renderer/modules/marketplaceManagement.ts
+++ b/src/renderer/modules/marketplaceManagement.ts
@@ -58,6 +58,7 @@ export async function loadToolsLibrary(): Promise {
minAPI: tool.minAPI, // Include min API version
maxAPI: tool.maxAPI, // Include max API version
isSupported: tool.isSupported, // Include compatibility status
+ npmPackageName: tool.npmPackageName, // Include npm package name for pre-release detection
}) as ToolDetail,
);
@@ -514,6 +515,7 @@ function renderToolDetailContent(panel: HTMLElement, tool: ToolDetail, isInstall
${badgeMarkup || ratingsHtml ? `
${badgeMarkup}${ratingsHtml}
` : ""}
+
✓ Installed
${linksMarkup}
@@ -543,6 +545,7 @@ function renderToolDetailContent(panel: HTMLElement, tool: ToolDetail, isInstall
// Wire up install button
const installBtn = panel.querySelector("#tool-detail-install-btn");
+ const prereleaseBtn = panel.querySelector("#tool-detail-prerelease-btn");
const installedBadge = panel.querySelector("#tool-detail-installed-badge");
installBtn?.addEventListener("click", async () => {
if (!installBtn || installBtn.disabled) return;
@@ -551,6 +554,7 @@ function renderToolDetailContent(panel: HTMLElement, tool: ToolDetail, isInstall
try {
await window.toolboxAPI.installToolFromRegistry(tool.id);
installBtn.style.display = "none";
+ if (prereleaseBtn) prereleaseBtn.style.display = "none";
if (installedBadge) installedBadge.style.display = "inline-flex";
window.toolboxAPI.utils.showNotification({
title: "Tool Installed",
@@ -570,12 +574,71 @@ function renderToolDetailContent(panel: HTMLElement, tool: ToolDetail, isInstall
}
});
+ // Wire up pre-release install button
+ prereleaseBtn?.addEventListener("click", async () => {
+ if (!prereleaseBtn || prereleaseBtn.disabled) return;
+ if (!tool.npmPackageName) return;
+ prereleaseBtn.disabled = true;
+ prereleaseBtn.textContent = "Installing Pre-Release...";
+ try {
+ await window.toolboxAPI.installPrereleaseToolFromNpm(tool.npmPackageName);
+ if (installBtn) installBtn.style.display = "none";
+ prereleaseBtn.style.display = "none";
+ if (installedBadge) installedBadge.style.display = "inline-flex";
+ window.toolboxAPI.utils.showNotification({
+ title: "Pre-Release Tool Installed",
+ body: `Pre-release version of ${tool.name} has been installed successfully`,
+ type: "success",
+ });
+ await loadSidebarTools();
+ } catch (error) {
+ prereleaseBtn.disabled = false;
+ prereleaseBtn.textContent = "Install Pre-Release Version";
+ window.toolboxAPI.utils.showNotification({
+ title: "Installation Failed",
+ body: `Failed to install pre-release version: ${formatError(error)}`,
+ type: "error",
+ });
+ }
+ });
+
// Apply icon masks for SVG icons
applyToolIconMasks(panel);
// Async README loading — pass the tabId so stale fetches are discarded
const tabId = `tool-detail-${tool.id}`;
void loadToolReadme(panel, tool.readmeUrl, tabId);
+
+ // Async pre-release check — show the button only when a beta version is available
+ if (tool.npmPackageName && !isInstalled) {
+ void checkAndShowPrereleaseButton(panel, tool.npmPackageName, tabId);
+ }
+}
+
+/**
+ * Async helper: query the npm registry for a beta dist-tag and, if found, show
+ * the "Install Pre-Release Version" button in the tool detail panel.
+ * Silently does nothing when no beta exists or the tab has already been navigated away from.
+ */
+async function checkAndShowPrereleaseButton(panel: HTMLElement, npmPackageName: string, tabId: string): Promise {
+ try {
+ const result = await window.toolboxAPI.checkBetaPackage(npmPackageName);
+ if (!result?.hasBeta) return;
+
+ // Discard if the user switched away from this detail tab while the request was in flight
+ const detailPanel = document.getElementById("tool-detail-content-panel");
+ if (!detailPanel || detailPanel.getAttribute("data-tab-id") !== tabId) return;
+
+ const btn = panel.querySelector("#tool-detail-prerelease-btn");
+ if (!btn) return;
+
+ if (result.betaVersion) {
+ btn.textContent = `Install Pre-Release Version (v${result.betaVersion})`;
+ }
+ btn.style.display = "inline-flex";
+ } catch (error) {
+ logWarn("Failed to check for pre-release version", error);
+ }
}
async function loadToolReadme(panel: HTMLElement, readmeUrl: string | undefined, tabId: string): Promise {
diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts
index c48239c8..de41e3ed 100644
--- a/src/renderer/modules/toolManagement.ts
+++ b/src/renderer/modules/toolManagement.ts
@@ -1756,3 +1756,184 @@ export function initializeTabScrollButtons(): void {
// Initial update
updateTabScrollButtons();
}
+
+/**
+ * Initialise the shell-level invocation banner shown above tool content when a callee tool is active.
+ *
+ * The main process pushes INVOCATION_BANNER_STATE whenever the active tool changes.
+ * - visible: true → show the banner with the caller's display name
+ * - visible: false → hide the banner
+ *
+ * The banner is only shown when the invocation was launched without `noReturn: true`.
+ * For one-way "Send To" flows (`noReturn: true`) no banner is shown.
+ *
+ * The banner text reads "Launched from [CallerToolName]" and the return button reads
+ * "Return to [CallerToolName]".
+ *
+ * Clicking "Return to …" triggers RETURN_INVOCATION_DATA with a null payload (banner early-return path):
+ * - The active invocation resolves with null on the caller side
+ * - PPTB auto-closes the callee window
+ *
+ * Clicking "✕" (dismiss) hides the banner for the session but does NOT cancel the invocation.
+ */
+export function initializeInvocationBanner(): void {
+ const banner = document.getElementById("invocation-banner");
+ const bannerText = document.getElementById("invocation-banner-text");
+ const returnBtn = document.getElementById("invocation-banner-return");
+ const dismissBtn = document.getElementById("invocation-banner-dismiss");
+
+ if (!banner || !bannerText || !returnBtn || !dismissBtn) return;
+
+ // Listen for banner state pushes from the main process
+ window.toolboxAPI.onInvocationBannerState((state) => {
+ if (state.visible && state.callerToolName) {
+ bannerText.textContent = `Launched from ${state.callerToolName}`;
+ returnBtn.textContent = `Return to ${state.callerToolName}`;
+ banner.style.display = "flex";
+ } else {
+ banner.style.display = "none";
+ }
+ // Notify the main process so it can re-request BrowserView bounds that
+ // account for the banner height (or restore full-height when hidden).
+ window.api.send("invocation-banner-visibility-changed");
+ });
+
+ // "Return" button: trigger banner early-return path
+ returnBtn.addEventListener("click", () => {
+ void window.toolboxAPI.returnToCallerBanner();
+ banner.style.display = "none";
+ });
+
+ // "Dismiss" button: hide banner only — does NOT end the invocation.
+ // After hiding, notify the main process to restore full-height BrowserView bounds.
+ dismissBtn.addEventListener("click", () => {
+ banner.style.display = "none";
+ window.api.send("invocation-banner-visibility-changed");
+ });
+}
+
+/**
+ * Listen for invocation connection prompts from the main process.
+ *
+ * When an invoked callee tool requires a secondary connection that was not inherited from
+ * the caller (e.g. DMS requires both primary + secondary but FXS only has primary), the
+ * main process sends INVOCATION_PROMPT_CONNECTIONS. This handler shows the
+ * multi-connection selector and returns the chosen IDs back to the main process via
+ * PROVIDE_INVOCATION_CONNECTIONS.
+ */
+export function initializeInvocationConnectionsPrompt(): void {
+ window.toolboxAPI.onInvocationConnectionsPrompt(async ({ requestId, toolName, isSecondaryRequired, inheritedPrimaryConnectionId }) => {
+ try {
+ const result = await openSelectMultiConnectionModal(isSecondaryRequired, toolName);
+ await window.toolboxAPI.provideInvocationConnections(requestId, {
+ primaryConnectionId: result.primaryConnectionId ?? inheritedPrimaryConnectionId,
+ secondaryConnectionId: result.secondaryConnectionId,
+ });
+ } catch (err) {
+ // User cancelled or modal failed – notify main process so it can reject the launch
+ logWarn(`[invocationConnectionsPrompt] Connection modal cancelled or failed: ${err instanceof Error ? err.message : String(err)}`);
+ await window.toolboxAPI.provideInvocationConnections(requestId, null);
+ }
+ });
+}
+
+/**
+ * Listen for callee tool lifecycle events pushed by the main process when a tool is
+ * launched via inter-tool invocation (invocation.launchTool()).
+ *
+ * CALLEE_TOOL_OPENED: the callee BrowserView was successfully created. The renderer
+ * registers a new entry in openTools and creates a dedicated tab so the callee
+ * appears as its own independent instance rather than replacing the caller's tab.
+ *
+ * CALLEE_TOOL_CLOSED: the callee was auto-closed by the main process after it returned
+ * data (resolveInvocation path). The renderer removes the callee tab and switches
+ * back to the caller tool.
+ */
+export function initializeCalleeToolListeners(): void {
+ window.toolboxAPI.onCalleeToolOpened(({ calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId }) => {
+ // Ensure the tool panel is visible (it may already be open via the caller, but
+ // guard against edge cases where the caller launched without the panel shown).
+ hideHomePage();
+ const toolPanel = document.getElementById("tool-panel");
+ if (toolPanel) {
+ toolPanel.style.display = "flex";
+ }
+
+ // Avoid double-registration if the event fires more than once.
+ if (openTools.has(calleeInstanceId)) {
+ return;
+ }
+
+ // Determine the instance number for the display name.
+ const existingInstances = Array.from(openTools.values()).filter((t) => !t.isDetailTab && t.toolId === tool.id);
+ const instanceNumber = existingInstances.length + 1;
+
+ // Register the callee in the open-tools map so all tab management
+ // functions (close, pin, context menu, session save, etc.) work correctly.
+ openTools.set(calleeInstanceId, {
+ instanceId: calleeInstanceId,
+ toolId: tool.id,
+ tool: tool,
+ isPinned: false,
+ connectionId: primaryConnectionId,
+ secondaryConnectionId: secondaryConnectionId,
+ });
+
+ // Create the visual tab for the callee.
+ createTab(calleeInstanceId, tool, instanceNumber);
+
+ // Set the callee as the active tab in the renderer (the main process has already
+ // switched the BrowserView — we only update renderer state here to stay in sync).
+ activeToolId = calleeInstanceId;
+ document.querySelectorAll(".tool-tab").forEach((tab) => {
+ tab.classList.remove("active");
+ });
+ const calleeTab = document.getElementById(`tool-tab-${calleeInstanceId}`);
+ if (calleeTab) {
+ calleeTab.classList.add("active");
+ }
+
+ updateToolbarButtonVisibility();
+ updateTabScrollButtons();
+ saveSession();
+
+ // Refresh the connection status strip for the newly active callee tab.
+ updateActiveToolConnectionStatus().catch((err) => {
+ logError(err instanceof Error ? err : new Error(String(err)));
+ });
+ });
+
+ window.toolboxAPI.onCalleeToolClosed(({ calleeInstanceId, callerInstanceId }) => {
+ // Remove the callee tab element from the DOM.
+ const calleeTab = document.getElementById(`tool-tab-${calleeInstanceId}`);
+ if (calleeTab) {
+ calleeTab.remove();
+ }
+
+ // Clean up any registered close guard for this instance.
+ closeGuards.delete(calleeInstanceId);
+
+ // Remove from the open-tools map.
+ openTools.delete(calleeInstanceId);
+
+ updateToolbarButtonVisibility();
+ updateTabScrollButtons();
+ saveSession();
+
+ // Switch back to the caller tool if it is still open, otherwise fall back to
+ // the most-recently-opened tool, or show the home page when no tools remain.
+ if (openTools.has(callerInstanceId)) {
+ void switchToTool(callerInstanceId);
+ } else if (openTools.size > 0) {
+ const lastInstanceId = Array.from(openTools.keys())[openTools.size - 1];
+ void switchToTool(lastInstanceId);
+ } else {
+ activeToolId = null;
+ const toolPanel = document.getElementById("tool-panel");
+ if (toolPanel) {
+ toolPanel.style.display = "none";
+ }
+ showHomePage();
+ }
+ });
+}
diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss
index 65bc9811..9abbc619 100644
--- a/src/renderer/styles.scss
+++ b/src/renderer/styles.scss
@@ -2202,6 +2202,8 @@ body.dark-theme .settings-vscode-item:hover {
bottom: 0;
background: var(--bg-color);
z-index: 10;
+ justify-content: end;
+ flex-direction: row-reverse;
}
.tool-detail-tab-header {
@@ -2895,6 +2897,7 @@ img.tool-item-icon-img {
.tool-item-update-btn {
margin-top: 10px;
+ text-align: right;
}
.tool-item-delete-btn {
@@ -5738,3 +5741,71 @@ body.dark-theme .global-search-item-badge.badge-settings {
.connection-group-items.collapsed {
display: none;
}
+
+/* ===========================
+ Inter-Tool Invocation Banner
+ Injected by PPTB shell when a callee tool is active.
+ Sits above tool content but below PPTB modals.
+ =========================== */
+.invocation-banner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 900;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ background: var(--primary-color, #0078d4);
+ color: #fff;
+ font-size: 13px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.invocation-banner-text {
+ flex: 1;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.invocation-banner-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.invocation-banner-return-btn {
+ padding: 3px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.6);
+ border-radius: 3px;
+ background: rgba(255, 255, 255, 0.15);
+ color: #fff;
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.invocation-banner-return-btn:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.invocation-banner-dismiss-btn {
+ padding: 2px 6px;
+ border: none;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 14px;
+ cursor: pointer;
+ line-height: 1;
+ border-radius: 3px;
+ transition: background 0.15s ease;
+}
+
+.invocation-banner-dismiss-btn:hover {
+ background: rgba(255, 255, 255, 0.2);
+ color: #fff;
+}
diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts
index c68c215c..3cdc94ba 100644
--- a/src/renderer/types/index.ts
+++ b/src/renderer/types/index.ts
@@ -102,4 +102,5 @@ export interface ToolDetail {
minAPI?: string; // Minimum ToolBox API version required
maxAPI?: string; // Maximum ToolBox API version tested
isSupported?: boolean; // Whether this tool is compatible with current ToolBox version
+ npmPackageName?: string; // npm package name used for pre-release version detection
}