From c05406345b3f6010da60ba3dc4fe8d77e37f60eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 03:37:48 +0000 Subject: [PATCH 1/5] feat(pptb-validate): add pptb.config.json invocation validation with semver enforcement Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/f5d8d8b6-bbf6-473f-a251-979e002c64a3 Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- packages/README.md | 38 +++++++++++ packages/bin/pptb-validate.js | 91 ++++++++++++++++++++----- packages/index.d.ts | 2 + packages/lib/validate.js | 121 +++++++++++++++++++++++++++++++++- packages/package.json | 1 + packages/pptbConfig.d.ts | 82 +++++++++++++++++++++++ 6 files changed, 317 insertions(+), 18 deletions(-) mode change 100755 => 100644 packages/bin/pptb-validate.js create mode 100644 packages/pptbConfig.d.ts diff --git a/packages/README.md b/packages/README.md index 152a18cd..07a594f3 100644 --- a/packages/README.md +++ b/packages/README.md @@ -8,6 +8,7 @@ TypeScript type definitions for Power Platform ToolBox APIs, plus a built-in CLI - [Quick start](#quick-start) - [CLI options](#cli-options) - [What is validated](#what-is-validated) + - [pptb.config.json (optional)](#pptbconfigjson-optional) - [Overview](#overview) - [Usage](#usage) - [Include all type definitions](#include-all-type-definitions) @@ -109,6 +110,43 @@ The validator checks every field that the official review pipeline inspects: > \* Required only when the `features` object is present. +#### pptb.config.json (optional) + +In addition to `package.json`, the validator automatically checks a `pptb.config.json` file if one is present in the same directory. This file declares tool-to-tool communication contracts and other PPTB-specific metadata. + +| 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.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 | +| `invocation.returnTopic.properties` | ❌ | Map of property names to `{ type?, enum?, items? }` descriptors | + +> \*\* Required only when the `invocation` object is present. + +**Example `pptb.config.json`:** + +```json +{ + "invocation": { + "version": "1.0.0", + "prefill": { + "properties": { + "entityName": { "type": "string" }, + "attributes": { "type": "array", "items": { "type": "string" } } + } + }, + "returnTopic": { + "properties": { + "result": { "type": "object" }, + "status": { "type": "string", "enum": ["success", "cancelled", "error"] }, + "error": { "type": "string" } + } + } + } +} +``` + ## Overview The `@pptb/types` package provides TypeScript definitions for two main APIs: diff --git a/packages/bin/pptb-validate.js b/packages/bin/pptb-validate.js old mode 100755 new mode 100644 index cea47887..93c2c614 --- 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 } = require("../lib/validate"); +const { validatePackageJson, validatePptbConfig } = require("../lib/validate"); // ANSI colour helpers – gracefully degrade when colours are unsupported const NO_COLOR = !process.stdout.isTTY || process.env.NO_COLOR; @@ -37,7 +37,8 @@ ${c.bold("USAGE")} pptb-validate [options] [path/to/package.json] When no path is given the tool looks for ${c.cyan("package.json")} in the current - working directory. + working directory. If a ${c.cyan("pptb.config.json")} file exists in the same directory + it is automatically validated as well. ${c.bold("OPTIONS")} ${c.cyan("--skip-url-checks")} Skip URL reachability checks (faster, works offline) @@ -79,6 +80,10 @@ async function main() { packageJsonPath = path.resolve(process.cwd(), packageJsonPath); } + // Derive pptb.config.json path from the same directory as package.json + const toolDir = path.dirname(packageJsonPath); + const pptbConfigPath = path.join(toolDir, "pptb.config.json"); + // --- Load package.json --- if (!fs.existsSync(packageJsonPath)) { if (jsonOutput) { @@ -102,21 +107,35 @@ async function main() { process.exit(1); } + // --- Load pptb.config.json (optional) --- + let pptbConfig = null; + let pptbConfigParseError = null; + if (fs.existsSync(pptbConfigPath)) { + try { + pptbConfig = JSON.parse(fs.readFileSync(pptbConfigPath, "utf8")); + } catch (err) { + pptbConfigParseError = err instanceof Error ? err.message : String(err); + } + } + // --- Run validation --- if (!jsonOutput) { console.log(); console.log(c.bold("Power Platform ToolBox – Tool Validator")); console.log(c.dim("─".repeat(45))); console.log(c.dim(`File: ${packageJsonPath}`)); + if (pptbConfig !== null) { + console.log(c.dim(`Config: ${pptbConfigPath}`)); + } if (skipUrlChecks) { console.log(c.yellow("⚠ URL reachability checks are skipped")); } console.log(); } - let result; + let packageResult; try { - result = await validatePackageJson(packageJson, { skipUrlChecks }); + packageResult = await validatePackageJson(packageJson, { skipUrlChecks }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (jsonOutput) { @@ -127,27 +146,62 @@ async function main() { process.exit(1); } + // --- Validate pptb.config.json if present --- + let configResult = null; + if (pptbConfigParseError !== null) { + configResult = { valid: false, errors: [`Failed to parse pptb.config.json: ${pptbConfigParseError}`], warnings: [] }; + } else if (pptbConfig !== null) { + configResult = validatePptbConfig(pptbConfig); + } + + // Merge results for overall pass/fail + const allErrors = [...packageResult.errors, ...(configResult ? configResult.errors : [])]; + const allWarnings = [...packageResult.warnings, ...(configResult ? configResult.warnings : [])]; + const overallValid = packageResult.valid && (configResult === null || configResult.valid); + // --- Output results --- if (jsonOutput) { - console.log(JSON.stringify(result, null, 2)); - process.exit(result.valid ? 0 : 1); + const output = { + valid: overallValid, + errors: allErrors, + warnings: allWarnings, + packageInfo: packageResult.packageInfo, + configInfo: configResult ? configResult.packageInfo : undefined, + }; + console.log(JSON.stringify(output, null, 2)); + process.exit(overallValid ? 0 : 1); } - // Human-readable output - if (result.errors.length > 0) { - console.log(c.bold(c.red(`Errors (${result.errors.length})`))); - result.errors.forEach((e) => console.log(` ${c.red("✖")} ${e}`)); + // Human-readable output – package.json section + if (packageResult.errors.length > 0) { + console.log(c.bold(c.red(`package.json Errors (${packageResult.errors.length})`))); + packageResult.errors.forEach((e) => console.log(` ${c.red("✖")} ${e}`)); console.log(); } - if (result.warnings.length > 0) { - console.log(c.bold(c.yellow(`Warnings (${result.warnings.length})`))); - result.warnings.forEach((w) => console.log(` ${c.yellow("⚠")} ${w}`)); + if (packageResult.warnings.length > 0) { + console.log(c.bold(c.yellow(`package.json Warnings (${packageResult.warnings.length})`))); + packageResult.warnings.forEach((w) => console.log(` ${c.yellow("⚠")} ${w}`)); console.log(); } - if (result.valid) { - const info = result.packageInfo; + // Human-readable output – pptb.config.json section + if (configResult !== null) { + if (configResult.errors.length > 0) { + console.log(c.bold(c.red(`pptb.config.json Errors (${configResult.errors.length})`))); + configResult.errors.forEach((e) => console.log(` ${c.red("✖")} ${e}`)); + console.log(); + } + + if (configResult.warnings.length > 0) { + console.log(c.bold(c.yellow(`pptb.config.json Warnings (${configResult.warnings.length})`))); + configResult.warnings.forEach((w) => console.log(` ${c.yellow("⚠")} ${w}`)); + console.log(); + } + } + + if (overallValid) { + const info = packageResult.packageInfo; console.log(c.green(c.bold("✔ Validation passed"))); console.log(); console.log(c.bold("Package summary")); @@ -157,13 +211,16 @@ async function main() { console.log(` Display name: ${info.displayName}`); console.log(` Description : ${info.description}`); console.log(` License : ${info.license}`); - console.log(` Contributors: ${info.contributors.map((c) => c.name).join(", ")}`); + console.log(` Contributors: ${info.contributors.map((contributor) => contributor.name).join(", ")}`); if (info.icon) { console.log(` Icon : ${info.icon}`); } if (info.features) { 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}`); + } console.log(); } else { console.log(c.red(c.bold("✖ Validation failed"))); @@ -172,7 +229,7 @@ async function main() { console.log(); } - process.exit(result.valid ? 0 : 1); + process.exit(overallValid ? 0 : 1); } main().catch((err) => { diff --git a/packages/index.d.ts b/packages/index.d.ts index 2ed19414..74025a35 100644 --- a/packages/index.d.ts +++ b/packages/index.d.ts @@ -16,7 +16,9 @@ /// /// +/// // Re-export all namespaces for convenience export * from "./dataverseAPI"; export * from "./toolboxAPI"; +export * from "./pptbConfig"; diff --git a/packages/lib/validate.js b/packages/lib/validate.js index bed83327..a979ad1b 100644 --- a/packages/lib/validate.js +++ b/packages/lib/validate.js @@ -33,6 +33,19 @@ * }} ValidationResult */ +/** + * @typedef {{ type?: string; enum?: string[]; items?: object }} JsonSchemaProperty + * @typedef {{ properties?: Record }} JsonSchemaObject + * @typedef {{ + * version: string; + * prefill?: JsonSchemaObject; + * returnTopic?: JsonSchemaObject; + * }} InvocationConfig + * @typedef {{ + * invocation?: InvocationConfig; + * }} PptbConfig + */ + // List of approved open source licenses const APPROVED_LICENSES = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "GPL-2.0", "GPL-3.0", "LGPL-3.0", "ISC", "AGPL-3.0-only"]; @@ -321,4 +334,110 @@ async function validatePackageJson(packageJson, options = {}) { }; } -module.exports = { validatePackageJson, isValidUrl, APPROVED_LICENSES }; +module.exports = { validatePackageJson, validatePptbConfig, isValidUrl, APPROVED_LICENSES }; + +/** + * Validates a tool's pptb.config.json against the official review criteria. + * + * @param {PptbConfig} config - The parsed pptb.config.json object. + * @returns {ValidationResult} + */ +function validatePptbConfig(config) { + const errors = /** @type {string[]} */ ([]); + const warnings = /** @type {string[]} */ ([]); + + if (config === null || typeof config !== "object" || Array.isArray(config)) { + errors.push("pptb.config.json must be a JSON object"); + return { valid: false, errors, warnings }; + } + + const VALID_ROOT_KEYS = ["invocation"]; + const unknownRootKeys = Object.keys(config).filter((k) => !VALID_ROOT_KEYS.includes(k)); + if (unknownRootKeys.length > 0) { + warnings.push(`pptb.config.json contains unrecognised root keys: ${unknownRootKeys.join(", ")}`); + } + + // Invocation section + if (config.invocation !== undefined) { + const inv = config.invocation; + + if (inv === null || typeof inv !== "object" || Array.isArray(inv)) { + errors.push("invocation must be a non-array object"); + } else { + // invocation.version – required, must be a valid semver string + if (inv.version === undefined || inv.version === null) { + errors.push("invocation.version is required"); + } else if (typeof inv.version !== "string") { + errors.push("invocation.version must be a string"); + } else if (!SEMVER_REGEX.test(inv.version)) { + errors.push(`invocation.version "${inv.version}" is not a valid semantic version string (e.g. "1.0.0")`); + } + + // invocation.prefill – optional JSON-schema-like object + if (inv.prefill !== undefined) { + if (inv.prefill === null || typeof inv.prefill !== "object" || Array.isArray(inv.prefill)) { + errors.push("invocation.prefill must be a non-array object"); + } else if (inv.prefill.properties !== undefined) { + validateJsonSchemaProperties("invocation.prefill", inv.prefill.properties, errors); + } + } + + // invocation.returnTopic – optional JSON-schema-like object + if (inv.returnTopic !== undefined) { + if (inv.returnTopic === null || typeof inv.returnTopic !== "object" || Array.isArray(inv.returnTopic)) { + errors.push("invocation.returnTopic must be a non-array object"); + } else if (inv.returnTopic.properties !== undefined) { + validateJsonSchemaProperties("invocation.returnTopic", inv.returnTopic.properties, errors); + } + } + } + } + + const valid = errors.length === 0; + + return { + valid, + errors, + warnings, + packageInfo: valid + ? { + invocation: config.invocation, + } + : undefined, + }; +} + +/** + * Validates a JSON-schema-style `properties` map used inside invocation sections. + * Only performs basic structural validation; full JSON Schema validation is not required. + * + * @param {string} fieldName + * @param {unknown} properties + * @param {string[]} errors + */ +function validateJsonSchemaProperties(fieldName, properties, errors) { + if (properties === null || typeof properties !== "object" || Array.isArray(properties)) { + errors.push(`${fieldName}.properties must be a non-array object`); + return; + } + + const propsRecord = /** @type {Record} */ (properties); + + for (const [key, value] of Object.entries(propsRecord)) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + errors.push(`${fieldName}.properties.${key} must be an object`); + continue; + } + const prop = /** @type {Record} */ (value); + if (prop.type !== undefined && typeof prop.type !== "string") { + errors.push(`${fieldName}.properties.${key}.type must be a string`); + } + if (prop.enum !== undefined) { + if (!Array.isArray(prop.enum)) { + errors.push(`${fieldName}.properties.${key}.enum must be an array`); + } else if (prop.enum.length === 0) { + errors.push(`${fieldName}.properties.${key}.enum must not be empty`); + } + } + } +} diff --git a/packages/package.json b/packages/package.json index 1d0e822c..e029a570 100644 --- a/packages/package.json +++ b/packages/package.json @@ -26,6 +26,7 @@ "index.d.ts", "toolboxAPI.d.ts", "dataverseAPI.d.ts", + "pptbConfig.d.ts", "bin/", "lib/", "README.md" diff --git a/packages/pptbConfig.d.ts b/packages/pptbConfig.d.ts new file mode 100644 index 00000000..684a0b75 --- /dev/null +++ b/packages/pptbConfig.d.ts @@ -0,0 +1,82 @@ +/** + * Type definitions for pptb.config.json – the optional configuration file that + * tools can place alongside their package.json to declare invocation contracts + * and other Power Platform ToolBox-specific metadata. + * + * Tool developers should place this file in the root of their tool package so + * that `pptb-validate` can automatically discover and validate it. + * + * Example pptb.config.json: + * ```json + * { + * "invocation": { + * "version": "1.0.0", + * "prefill": { + * "properties": { + * "entityName": { "type": "string" }, + * "attributes": { "type": "array", "items": { "type": "string" } } + * } + * }, + * "returnTopic": { + * "properties": { + * "result": { "type": "object" }, + * "status": { "type": "string", "enum": ["success", "cancelled", "error"] }, + * "error": { "type": "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"). */ + type?: string; + /** Restricts the value to one of the listed literals. */ + enum?: string[]; + /** Describes the items of an array property. */ + items?: JsonSchemaProperty; +} + +/** A JSON-schema-style object definition: a map of named property descriptors. */ +export interface JsonSchemaObject { + properties?: Record; +} + +/** + * The invocation contract for a tool. + * + * - `version` controls which revision of this contract is in effect. It must + * follow **semantic versioning** (`MAJOR.MINOR.PATCH[-prerelease][+build]`). + * Tool developers own this version and should bump it whenever the shape of + * `prefill` or `returnTopic` changes in a meaningful way. + * - `prefill` describes the data that callers can pre-populate when opening + * this tool programmatically. + * - `returnTopic` describes the data this tool will resolve back to its caller + * when it finishes. + */ +export interface InvocationConfig { + /** + * Semantic version of this invocation contract (e.g. "1.0.0"). + * Tool developers control this version so they can evolve the prefill or + * return shape independently of the tool's npm package version. + */ + version: string; + /** Schema of the data the caller can pass in when invoking this tool. */ + prefill?: JsonSchemaObject; + /** Schema of the data this tool returns to its caller on completion. */ + returnTopic?: JsonSchemaObject; +} + +/** + * The shape of a tool's `pptb.config.json` file. + * + * This file lives alongside `package.json` in the tool's package root. + * All sections are optional; the file itself is optional. When present it + * is validated by `pptb-validate` in addition to `package.json`. + */ +export interface PptbConfig { + /** Invocation contract – how this tool can be called by other tools. */ + invocation?: InvocationConfig; +} From 39bdd5e12062b5eafdf5bce352992f7f5e0c840e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:12:04 +0000 Subject: [PATCH 2/5] feat(inter-tool-launch): implement all 7 parts of Inter-Tool Launch Context Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/06819ca7-d6e6-42b6-ab39-1eff21e59a5f Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- packages/README.md | 76 ++++++++++++++ packages/toolboxAPI.d.ts | 78 ++++++++++++++ src/common/ipc/channels.ts | 2 + src/common/types/api.ts | 1 + src/main/managers/toolWindowManager.ts | 140 ++++++++++++++++++++++++- src/main/preload.ts | 2 + src/main/toolPreloadBridge.ts | 69 +++++++++++- src/renderer/modules/toolManagement.ts | 15 ++- 8 files changed, 378 insertions(+), 5 deletions(-) diff --git a/packages/README.md b/packages/README.md index 07a594f3..5ad037ba 100644 --- a/packages/README.md +++ b/packages/README.md @@ -18,6 +18,7 @@ TypeScript type definitions for Power Platform ToolBox APIs, plus a built-in CLI - [Utilities](#utilities) - [Terminal Operations](#terminal-operations) - [Events](#events) + - [Inter-Tool Invocation](#inter-tool-invocation) - [Dataverse API Examples](#dataverse-api-examples) - [CRUD Operations](#crud-operations) - [FetchXML Queries](#fetchxml-queries) @@ -30,6 +31,7 @@ TypeScript type definitions for Power Platform ToolBox APIs, plus a built-in CLI - [Utils](#utils) - [Terminal](#terminal) - [Events](#events-1) + - [Invocation](#invocation) - [Dataverse API (`window.dataverseAPI`)](#dataverse-api-windowdataverseapi) - [CRUD Operations](#crud-operations-1) - [Query Operations](#query-operations) @@ -286,6 +288,67 @@ toolboxAPI.events.on((event, payload) => { const history = await toolboxAPI.events.getHistory(10); // Last 10 events ``` +### Inter-Tool Invocation + +Tools can launch one another and pass data between them using the `invocation` namespace. + +#### Caller: launching another tool with prefill data + +```typescript +// Tool A – launches the entity-picker tool and waits for a selection +const result = await toolboxAPI.invocation.launchTool( + "@my-org/entity-picker", + { entityName: "account", allowMultiSelect: false }, +); + +if (result) { + console.log("Selected record id:", (result as { selectedId: string }).selectedId); +} +``` + +#### Callee: reading prefill data and returning a result + +```typescript +// Tool B (@my-org/entity-picker) – reads the context provided by Tool A +const ctx = await toolboxAPI.invocation.getLaunchContext(); +if (ctx) { + const entityName = ctx.entityName as string; // "account" + // … show records from entityName … + + // When the user makes their selection: + await toolboxAPI.invocation.returnData({ selectedId: "a1b2c3...", selectedName: "Contoso" }); +} +``` + +> **Tip:** A tool that was *not* launched by another tool receives `null` from `getLaunchContext()`. +> Use this to show a standalone UI or redirect accordingly. + +#### Declaring your invocation contract + +Add a `pptb.config.json` alongside your `package.json` to tell callers what data you expect and return: + +```json +{ + "invocation": { + "version": "1.0.0", + "prefill": { + "properties": { + "entityName": { "type": "string" }, + "allowMultiSelect": { "type": "boolean" } + } + }, + "returnTopic": { + "properties": { + "selectedId": { "type": "string" }, + "selectedName": { "type": "string" } + } + } + } +} +``` + +Run `pptb-validate` to validate both `package.json` and `pptb.config.json` at once. + ## Dataverse API Examples The Dataverse API provides direct access to Microsoft Dataverse operations: @@ -533,6 +596,19 @@ Core platform features organized into namespaces: - **off(callback: (event: any, payload: ToolBoxEventPayload) => void)**: void - Removes a previously registered event listener +#### Invocation + +- **getLaunchContext()**: Promise\ | null\> + - 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 + +- **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 + ### Dataverse API (`window.dataverseAPI`) Complete HTTP client for interacting with Microsoft Dataverse: diff --git a/packages/toolboxAPI.d.ts b/packages/toolboxAPI.d.ts index 0987ca60..9403c2fd 100644 --- a/packages/toolboxAPI.d.ts +++ b/packages/toolboxAPI.d.ts @@ -397,6 +397,14 @@ declare namespace ToolBoxAPI { */ events: EventsAPI; + /** + * Inter-tool launch context API. + * + * Allows one tool to launch another, pass prefill data to it, and receive + * a return value when the callee finishes. + */ + invocation: InvocationAPI; + /** * Get the current tool context * @internal Used internally by the framework @@ -404,6 +412,76 @@ declare namespace ToolBoxAPI { getToolContext: () => Promise; } + /** + * Inter-tool launch context API. + * + * Tools use this namespace to: + * 1. **Launch another tool** with prefill data (`invocation.launchTool`). + * 2. **Read their own launch context** when they were launched by another tool (`invocation.getLaunchContext`). + * 3. **Return data** back to the tool that launched them (`invocation.returnData`). + * + * @example Caller (Tool A) + * ```ts + * const result = await toolboxAPI.invocation.launchTool( + * "@my-org/entity-picker", + * { entityName: "account" }, + * ); + * console.log(result); // { selectedId: "...", selectedName: "..." } + * ``` + * + * @example Callee (Tool B) + * ```ts + * const ctx = await toolboxAPI.invocation.getLaunchContext(); + * if (ctx) { + * console.log(ctx.entityName); // "account" + * } + * + * // After user picks something... + * await toolboxAPI.invocation.returnData({ selectedId, selectedName }); + * ``` + */ + export interface InvocationAPI { + /** + * Returns the prefill data that was passed by the caller tool when it launched + * this tool, or `null` if this tool was not launched via an inter-tool invocation. + */ + getLaunchContext: () => Promise | null>; + + /** + * 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. + * + * If this tool was not launched by another tool, the call is a no-op. + * + * @param returnData The data to pass back to the caller + */ + returnData: (returnData: Record) => Promise; + + /** + * 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. + * + * The target tool must be installed and its `pptb.config.json` must declare an + * `invocation.prefill` schema that matches the shape of `prefillData`. + * + * @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 + */ + launchTool: ( + targetToolId: string, + prefillData?: Record, + options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null }, + ) => Promise; + } + /** * Auto-update event handlers */ diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 0b36338c..5121325c 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -82,6 +82,7 @@ export const TOOL_CHANNELS = { // Tool Window-related IPC channels export const TOOL_WINDOW_CHANNELS = { LAUNCH: "tool-window:launch", + LAUNCH_WITH_CONTEXT: "tool-window:launch-with-context", SWITCH: "tool-window:switch", CLOSE: "tool-window:close", GET_ACTIVE: "tool-window:get-active", @@ -89,6 +90,7 @@ export const TOOL_WINDOW_CHANNELS = { UPDATE_TOOL_CONNECTION: "tool-window:update-tool-connection", HIDE_ALL: "tool-window:hide-all", RENDERER_INITIALIZED: "tool-window:renderer-initialized", + RETURN_INVOCATION_DATA: "tool-window:return-invocation-data", } as const; // Terminal-related IPC channels diff --git a/src/common/types/api.ts b/src/common/types/api.ts index a3a8f990..ba7e92f2 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -152,6 +152,7 @@ export interface ToolboxAPI { // Tool Window Management launchToolWindow: (instanceId: string, tool: Tool, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => Promise; + launchToolWithContext: (callerInstanceId: string, calleeInstanceId: string, tool: Tool, primaryConnectionId: string | null, secondaryConnectionId: string | null, prefillData: Record) => Promise; switchToolWindow: (toolId: string) => Promise; closeToolWindow: (toolId: string) => Promise; hideToolWindows: () => Promise; diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index ae3067bc..69857903 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -47,6 +47,22 @@ export class ToolWindowManager { */ private toolViews: Map = new Map(); private toolConnectionInfo: Map = new Map(); // Maps instanceId -> connection info + /** + * 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 + */ + private pendingInvocations: Map< + string, // calleeInstanceId + { + callerInstanceId: string; + prefillData: Record; + resolve: (data: unknown) => void; + reject: (reason: unknown) => void; + } + > = 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; @@ -121,12 +137,14 @@ export class ToolWindowManager { */ private removeIpcHandlers(): void { ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.LAUNCH); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.SWITCH); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.CLOSE); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_ACTIVE); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_OPEN_TOOLS); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.HIDE_ALL); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA); } /** @@ -143,6 +161,29 @@ export class ToolWindowManager { return this.launchTool(instanceId, tool, primaryConnectionId, secondaryConnectionId); }); + // Launch a tool with inter-tool context (called by a tool's preload bridge) + // The caller passes its own instanceId, the target tool, connection IDs, and prefill data. + // Returns a Promise that resolves when the callee calls returnInvocationData. + ipcMain.handle( + TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, + async ( + event, + callerInstanceId: string, + calleeInstanceId: string, + tool: Tool, + primaryConnectionId: string | null, + secondaryConnectionId: string | null, + prefillData: Record, + ) => { + return this.launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData); + }, + ); + + // 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); + }); + // Switch to a different tool ipcMain.handle(TOOL_WINDOW_CHANNELS.SWITCH, async (event, instanceId: string) => { return this.switchToTool(instanceId); @@ -213,7 +254,7 @@ export class ToolWindowManager { * @param primaryConnectionId Primary connection ID for this instance (passed from frontend) * @param secondaryConnectionId Secondary connection ID for multi-connection tools (optional) */ - async launchTool(instanceId: string, tool: Tool, primaryConnectionId: string | null, secondaryConnectionId: string | null = null): Promise { + async launchTool(instanceId: string, tool: Tool, primaryConnectionId: string | null, secondaryConnectionId: string | null = null, prefillData?: Record): Promise { try { logInfo(`[ToolWindowManager] Launching tool instance: ${instanceId}`); @@ -295,6 +336,7 @@ export class ToolWindowManager { // Send tool context immediately (don't wait for did-finish-load) // The preload script will receive this before the tool code runs + const pending = this.pendingInvocations.get(instanceId); const toolContext = { toolId: tool.id, instanceId, @@ -304,6 +346,14 @@ export class ToolWindowManager { connectionId: primaryConnectionId, secondaryConnectionUrl: secondaryConnectionUrl, secondaryConnectionId: secondaryConnectionId, + // Inter-tool launch context (only present when launched by another tool) + ...(pending + ? { + callerInstanceId: pending.callerInstanceId, + prefillData: pending.prefillData, + } + : {}), + ...(prefillData && !pending ? { prefillData } : {}), }; toolView.webContents.send("toolbox:context", toolContext); logInfo(`[ToolWindowManager] Sent tool context for ${instanceId} with connection: ${connectionUrl ? "yes" : "no"}, secondary: ${secondaryConnectionUrl ? "yes" : "no"}`); @@ -339,9 +389,74 @@ export class ToolWindowManager { } /** - * Switch to a different tool (show its BrowserView) - * @param instanceId The instance identifier to switch to + * Launch a tool with inter-tool invocation context. + * + * Called when Tool A wants to launch Tool B with prefill data and (optionally) receive + * a return value when Tool B calls returnInvocationData(). + * + * @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 secondaryConnectionId Secondary connection for the callee (optional) + * @param prefillData Arbitrary data to pre-populate the callee's state + * @returns A Promise that resolves with the data returned by the callee, or null if the callee closes without returning data + */ + async launchToolWithContext( + callerInstanceId: string, + calleeInstanceId: string, + tool: Tool, + primaryConnectionId: string | null, + secondaryConnectionId: string | null, + prefillData: Record, + ): Promise { + return new Promise((resolve, reject) => { + this.pendingInvocations.set(calleeInstanceId, { + callerInstanceId, + prefillData, + resolve, + reject, + }); + + this.launchTool(calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData).catch((error) => { + this.pendingInvocations.delete(calleeInstanceId); + reject(error as Error); + }); + }); + } + + /** + * 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. + * + * @param calleeInstanceId The instanceId of the tool returning data + * @param returnData The data to hand back to the caller */ + resolveInvocation(calleeInstanceId: string, returnData: unknown): void { + const pending = this.pendingInvocations.get(calleeInstanceId); + if (!pending) { + logWarn(`[ToolWindowManager] resolveInvocation: no pending invocation for ${calleeInstanceId}`); + return; + } + + this.pendingInvocations.delete(calleeInstanceId); + + // Notify the caller tool (if it is still open) via an IPC push + const callerView = this.toolViews.get(pending.callerInstanceId); + if (callerView && !callerView.webContents.isDestroyed()) { + callerView.webContents.send("toolbox:invocation-result", { + calleeInstanceId, + returnData, + }); + } + + // Resolve the JS Promise held by launchToolWithContext + pending.resolve(returnData); + } + + async switchToTool(instanceId: string): Promise { try { const toolView = this.toolViews.get(instanceId); @@ -409,6 +524,23 @@ export class ToolWindowManager { this.toolViews.delete(instanceId); this.toolConnectionInfo.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. + 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, + }); + } + pending.resolve(null); + } + // Dispose any terminals created by this tool instance this.terminalManager.closeToolInstanceTerminals(instanceId); @@ -707,11 +839,13 @@ export class ToolWindowManager { */ destroy(): void { ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.LAUNCH); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.SWITCH); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.CLOSE); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_ACTIVE); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_OPEN_TOOLS); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA); if (this.boundsResponseListener) ipcMain.removeListener("get-tool-panel-bounds-response", this.boundsResponseListener); if (this.terminalVisibilityListener) ipcMain.removeListener("terminal-visibility-changed", this.terminalVisibilityListener); diff --git a/src/main/preload.ts b/src/main/preload.ts index 3d48c16e..a2d97ebe 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -54,6 +54,8 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // Tool Window Management (NEW - BrowserView based) launchToolWindow: (instanceId: string, tool: unknown, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.LAUNCH, instanceId, tool, primaryConnectionId, secondaryConnectionId), + launchToolWithContext: (callerInstanceId: string, calleeInstanceId: string, tool: unknown, primaryConnectionId: string | null, secondaryConnectionId: string | null, prefillData: Record) => + ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData), 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), diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index 0bdba1b8..416ff667 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -11,7 +11,7 @@ import { contextBridge, ipcRenderer } from "electron"; // Reverted to importing centralized channel definitions from single source file. // Ensure BrowserView preload can resolve this module (see ToolWindowManager sandbox setting). -import { CONNECTION_CHANNELS, DATAVERSE_CHANNELS, EVENT_CHANNELS, FILESYSTEM_CHANNELS, SETTINGS_CHANNELS, TERMINAL_CHANNELS, UTIL_CHANNELS } from "../common/ipc/channels"; +import { CONNECTION_CHANNELS, DATAVERSE_CHANNELS, EVENT_CHANNELS, FILESYSTEM_CHANNELS, SETTINGS_CHANNELS, TERMINAL_CHANNELS, TOOL_CHANNELS, TOOL_WINDOW_CHANNELS, UTIL_CHANNELS } from "../common/ipc/channels"; import { logInfo } from "../common/logger"; import type { EntityRelatedMetadataPath, EntityRelatedMetadataResponse } from "../common/types"; @@ -331,6 +331,73 @@ contextBridge.exposeInMainWorld("toolboxAPI", { return ipcInvoke(SETTINGS_CHANNELS.TOOL_SETTINGS_SET_ALL, toolId, settings); }, }, + + // Invocation API (inter-tool launch context) + invocation: { + /** + * Returns the prefill data that was passed by the caller tool when it launched this tool, + * or null if this tool was not launched via an inter-tool invocation. + */ + getLaunchContext: async (): Promise | null> => { + await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); + if (!toolContext) { + return null; + } + const prefillData = toolContext.prefillData; + if (!prefillData || typeof prefillData !== "object" || Array.isArray(prefillData)) { + return null; + } + return prefillData as Record; + }, + + /** + * Returns data to the caller tool that launched this tool and closes this tool window. + * If this tool was not launched by another tool the call is a no-op. + * + * @param returnData The data to pass back to the caller + */ + returnData: async (returnData: Record): Promise => { + const { instanceId } = await getToolIdentifiers(); + if (!instanceId) { + return; + } + await ipcInvoke(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA, instanceId, returnData); + }, + + /** + * Launch another tool from within this tool, optionally passing prefill data. + * Returns a Promise that resolves with the data the target tool returns via returnData(), + * or null if the target tool closes without returning data. + * + * @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 Connection overrides for the target tool + */ + launchTool: async ( + targetToolId: string, + prefillData: Record = {}, + options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null }, + ): Promise => { + const { instanceId: callerInstanceId } = await getToolIdentifiers(); + if (!callerInstanceId) { + throw new Error("Cannot launch a tool from an uninitialized tool context"); + } + + // Get the target tool manifest + const tool = await ipcInvoke(TOOL_CHANNELS.GET_TOOL, targetToolId); + if (!tool) { + throw new Error(`Tool not found: ${targetToolId}`); + } + + // 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 secondaryConnectionId = options?.secondaryConnectionId !== undefined ? options.secondaryConnectionId : null; + + return ipcInvoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData); + }, + }, }); // Also expose dataverseAPI as a direct alias (for tools that use it directly) diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index 6bed7f92..0d8a7d15 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -29,6 +29,10 @@ export interface LaunchToolOptions { source?: string; primaryConnectionId?: string | null; secondaryConnectionId?: string | null; + /** Prefill data to pass to the tool on launch (inter-tool launch context). */ + prefillData?: Record; + /** The instanceId of the tool initiating this launch (for inter-tool return data). */ + callerInstanceId?: string; } // Tool state - now keyed by instanceId instead of toolId to support multiple instances @@ -490,7 +494,16 @@ export async function launchTool(toolId: string, options?: LaunchToolOptions): P // Launch the tool using BrowserView via IPC with the instance ID and connection IDs // The backend ToolWindowManager will create a BrowserView and load the tool - const launched = await window.toolboxAPI.launchToolWindow(instanceId, tool, primaryConnectionId, secondaryConnectionId); + const launched = options?.callerInstanceId + ? await window.toolboxAPI.launchToolWithContext( + options.callerInstanceId, + instanceId, + tool, + primaryConnectionId, + secondaryConnectionId ?? null, + options.prefillData ?? {}, + ) + : await window.toolboxAPI.launchToolWindow(instanceId, tool, primaryConnectionId, secondaryConnectionId); if (!launched) { window.toolboxAPI.utils.showNotification({ From 9a5d45514676fa334fb5cd1ce6fdee9d84b0eb79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:14:50 +0000 Subject: [PATCH 3/5] fix: address code review comments (explicit null check, blank line, .gitignore) Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/06819ca7-d6e6-42b6-ab39-1eff21e59a5f Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- .gitignore | 3 +++ src/main/managers/toolWindowManager.ts | 1 - src/main/toolPreloadBridge.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d9f00490..d4529d60 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,6 @@ buildScripts/*/latest-mac.yml # Any macOS Certificate files *.cer *.p12 + +# macOS Finder metadata files +.DS_Store diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index 69857903..a4d819ba 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -456,7 +456,6 @@ export class ToolWindowManager { pending.resolve(returnData); } - async switchToTool(instanceId: string): Promise { try { const toolView = this.toolViews.get(instanceId); diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index 416ff667..150be3bc 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -344,7 +344,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { return null; } const prefillData = toolContext.prefillData; - if (!prefillData || typeof prefillData !== "object" || Array.isArray(prefillData)) { + if (prefillData === null || prefillData === undefined || typeof prefillData !== "object" || Array.isArray(prefillData)) { return null; } return prefillData as Record; From 93990368f1b8830b94f7638d6c94cb497fd08b11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:22:07 +0000 Subject: [PATCH 4/5] docs: add plan-inter-tool-launch-context.md with accurate v3 reflecting all 7 implemented parts Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/16f5bfb7-7f3f-4b6c-9829-843603515c18 Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- .../plans/plan-inter-tool-launch-context.md | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 .github/plans/plan-inter-tool-launch-context.md diff --git a/.github/plans/plan-inter-tool-launch-context.md b/.github/plans/plan-inter-tool-launch-context.md new file mode 100644 index 00000000..29100b9b --- /dev/null +++ b/.github/plans/plan-inter-tool-launch-context.md @@ -0,0 +1,303 @@ +# Plan: Inter-Tool Launch Context + +## Request summary + +Enable tools to programmatically launch other installed tools, pass typed "prefill" data into them, +and receive a return value when the callee finishes. The feature follows the VS Code Extension Host +pattern already used for tool isolation, routing all communication through the Electron IPC bridge +so no tool ever has direct access to another tool's process. + +## Goals + +- Tool A can call `toolboxAPI.invocation.launchTool(targetToolId, prefillData?)` and receive back + whatever data the callee returns. +- Tool B reads caller-supplied data via `toolboxAPI.invocation.getLaunchContext()` and signals + completion via `toolboxAPI.invocation.returnData(result)`. +- If Tool B is closed before calling `returnData`, the caller receives `null` (no hang). +- Tools declare their invocation contract in an optional `pptb.config.json` (validated by + `pptb-validate` CLI) so callers know exactly what shape of data to pass and expect back. +- The existing `launchTool()` renderer entry-point is extended non-breakingly (new optional fields). + +## Non-goals + +- Cross-instance communication outside the launch/return lifecycle (use the Events API for that). +- Auto-closing the callee after `returnData` (the callee manages its own lifecycle). +- Schema-level runtime type checking of `prefillData` / `returnData` at IPC boundaries. + +## Assumptions / Open questions + +- Tools must be already installed; `launchTool` in the preload bridge looks up the tool manifest via + the existing `TOOL_CHANNELS.GET_TOOL` IPC channel. +- `callerInstanceId` is derived from the calling tool's own `toolContext.instanceId`; it is always + set when `toolboxAPI.invocation.launchTool` is called from a tool window. +- Connection IDs for the callee default to `null` when not supplied; the caller can override them + via `options.primaryConnectionId` / `options.secondaryConnectionId`. + +## Acceptance criteria + +- [ ] `toolboxAPI.invocation.launchTool("@scope/tool", { key: "value" })` opens the target tool, + passes the data, and returns the data supplied by `returnData()`. +- [ ] If the callee is closed without calling `returnData`, the caller's Promise resolves with `null`. +- [ ] `toolboxAPI.invocation.getLaunchContext()` returns `null` when no inter-tool context is + present (standalone tool launch). +- [ ] `toolboxAPI.invocation.returnData({...})` is a no-op when the tool was not launched by another + tool. +- [ ] `pptb-validate` validates `pptb.config.json` when present alongside `package.json`. +- [ ] `InvocationAPI` is exported from `packages/toolboxAPI.d.ts` with full JSDoc. +- [ ] `packages/README.md` has caller/callee examples and an API reference entry. +- [ ] `pnpm run typecheck`, `pnpm run lint`, and `pnpm run build` pass with 0 errors. + +## Triage + +Type: **High-risk** + +Rationale: + +- Changes span IPC channels (new handlers), the preload bridge (new surface exposed to tool + windows), and the main-process ToolWindowManager (new Promise-based invocation lifecycle). +- Any mistake in the preload bridge or IPC handler could expose unintended cross-tool data access. + +## Participants (mesh) + +- Product Manager (gateway) +- Data Architect (IPC schema, types) +- Tech Designer (preload bridge, ToolWindowManager lifecycle) +- App Developer (implementation) +- Code Reviewer +- Security Reviewer + +## Plan (drafted by agents) + +### Product Manager (Orchestrator) + +- Scope confirmed: 7 implementation parts, high-risk triage, requires explicit APPROVED before coding. +- Acceptance criteria locked above. + +### Data Architect + +**Part 1 — `pptb.config.json` schema & validation** +- Add `packages/pptbConfig.d.ts` — TypeScript types for `PptbConfig`, `InvocationConfig`, + `JsonSchemaObject` and `JsonSchemaProperty`. +- Update `packages/lib/validate.js` — add `validatePptbConfig(config)` function that checks: + - `invocation.version` is required and must be a valid semver string. + - `invocation.prefill` (optional) must be a `JsonSchemaObject` with valid `properties`. + - `invocation.returnTopic` (optional) must be a `JsonSchemaObject` with valid `properties`. + - Unknown root keys emit warnings (not errors). +- Update `packages/bin/pptb-validate.js` — auto-discover `pptb.config.json` in the same directory + as `package.json` and call `validatePptbConfig` when found; report results in the same + human-readable / `--json` output format. + +**Part 2 — IPC Channels** +- Add to `TOOL_WINDOW_CHANNELS` in `src/common/ipc/channels.ts`: + - `LAUNCH_WITH_CONTEXT: "tool-window:launch-with-context"` + - `RETURN_INVOCATION_DATA: "tool-window:return-invocation-data"` + +### Tech Designer + +**Part 3 — Main Process: ToolWindowManager** + +New private field: +```ts +private pendingInvocations: Map< + string, // calleeInstanceId + { + callerInstanceId: string; + prefillData: Record; + resolve: (data: unknown) => void; + reject: (reason: unknown) => void; + } +> = new Map(); +``` + +New / updated methods in `src/main/managers/toolWindowManager.ts`: + +- `launchTool(instanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData?)` — + optional 5th parameter; when `pendingInvocations` has an entry for `instanceId` the context + message (`toolbox:context`) includes `callerInstanceId` and `prefillData`. +- `launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, + secondaryConnectionId, prefillData)` — stores pending invocation entry then delegates to + `launchTool`; returns a Promise resolved by `resolveInvocation`. +- `resolveInvocation(calleeInstanceId, returnData)` — pops the pending entry, sends + `toolbox:invocation-result` IPC push to the caller BrowserView (if still alive), resolves the + Promise. +- `closeTool(instanceId)` — if a pending invocation exists for this instance, resolve it with + `null` and send `toolbox:invocation-result` to the caller (prevents hang). + +IPC handlers to add in `setupIpcHandlers()` / `removeIpcHandlers()` / `destroy()`: +- `LAUNCH_WITH_CONTEXT` → `launchToolWithContext(...)` +- `RETURN_INVOCATION_DATA` → `resolveInvocation(...)` + +**Part 4 — Tool Preload Bridge** + +Import `TOOL_CHANNELS` and `TOOL_WINDOW_CHANNELS` in `src/main/toolPreloadBridge.ts`. + +New `toolboxAPI.invocation` namespace exposed via `contextBridge.exposeInMainWorld`: + +```ts +invocation: { + getLaunchContext(): Promise | null> + returnData(returnData: Record): Promise + launchTool( + targetToolId: string, + prefillData?: Record, + options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null } + ): Promise +} +``` + +- `getLaunchContext` reads `toolContext.prefillData`; returns `null` when the field is absent, + `null`, `undefined`, or not a plain object. Uses explicit `=== null || === undefined` guard + (not truthiness check) to correctly handle empty-object `{}` prefill data. +- `returnData` invokes `RETURN_INVOCATION_DATA` with the caller's `instanceId` and the payload. +- `launchTool` fetches the target tool manifest via `TOOL_CHANNELS.GET_TOOL`, generates a callee + `instanceId` with `targetToolId-Date.now()-randomSuffix`, then calls `LAUNCH_WITH_CONTEXT`. + +**Part 5 — Renderer** + +`src/renderer/modules/toolManagement.ts`: +- Extend `LaunchToolOptions` with two new optional fields: + - `prefillData?: Record` + - `callerInstanceId?: string` +- In `launchTool()`, when `options.callerInstanceId` is set call + `window.toolboxAPI.launchToolWithContext(callerInstanceId, instanceId, tool, + primaryConnectionId, secondaryConnectionId ?? null, options.prefillData ?? {})` instead of the + standard `launchToolWindow` path. + +`src/main/preload.ts`: +- Expose `launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, + secondaryConnectionId, prefillData)` → `ipcRenderer.invoke(LAUNCH_WITH_CONTEXT, ...)`. + +`src/common/types/api.ts`: +- Add `launchToolWithContext(...)` to the renderer `ToolboxAPI` type. + +### App Developer + +*(see Execution log below)* + +### Critic + +- Empty-object `{}` prefill data must not be rejected by `getLaunchContext` — use explicit + `=== null || === undefined` guard, not `!prefillData`. +- Extra blank lines between method definitions in `toolWindowManager.ts` should match the + surrounding code style. +- `.DS_Store` must remain in `.gitignore`. + +## Checkpoint + +Status: **APPROVED** + +- [x] Scope and acceptance criteria confirmed +- [x] Critic reviewed +- [x] User approved plan (required for high-risk) + +--- + +## Execution log (only after GO/APPROVED) + +### App Developer + +**Part 1 — pptb.config.json schema & validation** + +- `packages/pptbConfig.d.ts` — created; exports `JsonSchemaProperty`, `JsonSchemaObject`, + `InvocationConfig`, `PptbConfig`. +- `packages/lib/validate.js` — `validatePptbConfig(config)` added; validates `invocation.version` + (semver), `prefill` and `returnTopic` (JSON-schema objects). +- `packages/bin/pptb-validate.js` — auto-discovers `pptb.config.json` alongside `package.json`; + calls `validatePptbConfig`; results included in human-readable and `--json` output. + +**Part 2 — IPC Channels** + +- `src/common/ipc/channels.ts` — `LAUNCH_WITH_CONTEXT` and `RETURN_INVOCATION_DATA` added to + `TOOL_WINDOW_CHANNELS`. + +**Part 3 — ToolWindowManager** + +- `pendingInvocations` private map added. +- `launchTool` signature extended with optional `prefillData` param; tool context message includes + `callerInstanceId` + `prefillData` when a pending invocation exists. +- `launchToolWithContext` method added (stores pending entry, delegates to `launchTool`, returns + Promise). +- `resolveInvocation` method added (pops pending entry, pushes `toolbox:invocation-result` to + caller BrowserView, resolves Promise). +- `closeTool` extended: if a pending invocation exists for the closing instance, resolve its + Promise with `null` and notify the caller BrowserView. +- `setupIpcHandlers` / `removeIpcHandlers` / `destroy` updated for both new channels. + +**Part 4 — Tool Preload Bridge** + +- `src/main/toolPreloadBridge.ts` imports `TOOL_CHANNELS` and `TOOL_WINDOW_CHANNELS`. +- `toolboxAPI.invocation` namespace added: `getLaunchContext`, `returnData`, `launchTool`. +- `getLaunchContext` uses explicit `=== null || === undefined` guard (Critic fix applied). + +**Part 5 — Renderer** + +- `LaunchToolOptions` extended with `prefillData` and `callerInstanceId`. +- `launchTool()` routes through `launchToolWithContext` when `callerInstanceId` is present. +- `preload.ts` exposes `launchToolWithContext` method. +- `src/common/types/api.ts` — `launchToolWithContext` added to renderer API type. + +**Part 6 — Public type definitions** + +- `packages/toolboxAPI.d.ts` — `InvocationAPI` interface added with full JSDoc including + caller/callee usage examples; `API` interface updated to include `invocation: InvocationAPI`. + +**Part 7 — Documentation** + +- `packages/README.md` — new **Inter-Tool Invocation** section added (table of contents, + caller/callee examples, `pptb.config.json` contract guidance); **Invocation** entry added to + the API Reference section. + +### Code Reviewer + +- Issue: `!prefillData` truthiness check rejects valid empty-object `{}`. + Fix: changed to `prefillData === null || prefillData === undefined`. +- Issue: extra blank line between `resolveInvocation` and `switchToTool` method definitions. + Fix: blank line removed. +- Issue: `.DS_Store` removed from `.gitignore` (pre-existing regression on the branch). + Fix: entry restored. +- All other review comments: no further issues raised. + +### Security Reviewer + +- CodeQL scan: 0 alerts. +- No new attack surface introduced: `prefillData` and `returnData` are plain JSON objects + serialised over Electron's existing IPC bridge; no new Node.js APIs are exposed to tool windows. + +--- + +## Files changed + +| File | Change | +|------|--------| +| `src/common/ipc/channels.ts` | Add `LAUNCH_WITH_CONTEXT`, `RETURN_INVOCATION_DATA` to `TOOL_WINDOW_CHANNELS` | +| `src/main/managers/toolWindowManager.ts` | `pendingInvocations` map; `launchToolWithContext`; `resolveInvocation`; `launchTool` prefill param; `closeTool` null-resolve on close | +| `src/main/toolPreloadBridge.ts` | `toolboxAPI.invocation` namespace; import `TOOL_CHANNELS`, `TOOL_WINDOW_CHANNELS` | +| `src/main/preload.ts` | Expose `launchToolWithContext` to renderer | +| `src/renderer/modules/toolManagement.ts` | Extend `LaunchToolOptions`; route through `launchToolWithContext` | +| `src/common/types/api.ts` | Add `launchToolWithContext` to renderer API type | +| `packages/toolboxAPI.d.ts` | Add `InvocationAPI` interface; update `API` interface | +| `packages/README.md` | Inter-tool invocation section + API reference entry | +| `packages/pptbConfig.d.ts` | New file — TypeScript types for `pptb.config.json` | +| `packages/lib/validate.js` | `validatePptbConfig` function | +| `packages/bin/pptb-validate.js` | Auto-discover and validate `pptb.config.json` | +| `.gitignore` | Restored `.DS_Store` entry | + +## Validation steps + +- `pnpm run typecheck` — 0 errors ✅ +- `pnpm run lint` — 0 errors ✅ +- `pnpm run build` — succeeds ✅ +- CodeQL Security Scan — 0 alerts ✅ + +## Risks & rollback + +- **IPC handler conflicts**: new channels are added to both `setupIpcHandlers`/`removeIpcHandlers` + and `destroy`; duplicate-registration risk is mitigated by always calling `removeHandler` before + `handle`. +- **Hanging caller Promise**: mitigated by the `closeTool` null-resolve path. +- **Prefill data size**: no size limit is enforced at IPC level; very large objects could slow IPC. + Mitigated by documenting that `prefillData` should contain identifiers/configs, not large + payloads. +- **Rollback**: all changes are additive (new channels, new methods, new API namespace). The + existing `LAUNCH` channel and `launchToolWindow` path are untouched. Reverting this PR requires + only removing the new symbols; no migration is needed. From cfd03dc68baa40ad2baf9eb2ef37ceb9a92dc7f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:55:49 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20rename=20PptbConfig=E2=86=92PPTBConf?= =?UTF-8?q?ig=20and=20validatePptbConfig=E2=86=92validatePPTBConfig=20for?= =?UTF-8?q?=20consistent=20casing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/f894c5cf-a937-472f-a7ee-823d77dc6687 Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- .github/plans/plan-inter-tool-launch-context.md | 14 +++++++------- packages/bin/pptb-validate.js | 4 ++-- packages/lib/validate.js | 8 ++++---- packages/pptbConfig.d.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/plans/plan-inter-tool-launch-context.md b/.github/plans/plan-inter-tool-launch-context.md index 29100b9b..087f2e98 100644 --- a/.github/plans/plan-inter-tool-launch-context.md +++ b/.github/plans/plan-inter-tool-launch-context.md @@ -76,15 +76,15 @@ Rationale: ### Data Architect **Part 1 — `pptb.config.json` schema & validation** -- Add `packages/pptbConfig.d.ts` — TypeScript types for `PptbConfig`, `InvocationConfig`, +- Add `packages/pptbConfig.d.ts` — TypeScript types for `PPTBConfig`, `InvocationConfig`, `JsonSchemaObject` and `JsonSchemaProperty`. -- Update `packages/lib/validate.js` — add `validatePptbConfig(config)` function that checks: +- Update `packages/lib/validate.js` — add `validatePPTBConfig(config)` function that checks: - `invocation.version` is required and must be a valid semver string. - `invocation.prefill` (optional) must be a `JsonSchemaObject` with valid `properties`. - `invocation.returnTopic` (optional) must be a `JsonSchemaObject` with valid `properties`. - Unknown root keys emit warnings (not errors). - Update `packages/bin/pptb-validate.js` — auto-discover `pptb.config.json` in the same directory - as `package.json` and call `validatePptbConfig` when found; report results in the same + as `package.json` and call `validatePPTBConfig` when found; report results in the same human-readable / `--json` output format. **Part 2 — IPC Channels** @@ -199,11 +199,11 @@ Status: **APPROVED** **Part 1 — pptb.config.json schema & validation** - `packages/pptbConfig.d.ts` — created; exports `JsonSchemaProperty`, `JsonSchemaObject`, - `InvocationConfig`, `PptbConfig`. -- `packages/lib/validate.js` — `validatePptbConfig(config)` added; validates `invocation.version` + `InvocationConfig`, `PPTBConfig`. +- `packages/lib/validate.js` — `validatePPTBConfig(config)` added; validates `invocation.version` (semver), `prefill` and `returnTopic` (JSON-schema objects). - `packages/bin/pptb-validate.js` — auto-discovers `pptb.config.json` alongside `package.json`; - calls `validatePptbConfig`; results included in human-readable and `--json` output. + calls `validatePPTBConfig`; results included in human-readable and `--json` output. **Part 2 — IPC Channels** @@ -278,7 +278,7 @@ Status: **APPROVED** | `packages/toolboxAPI.d.ts` | Add `InvocationAPI` interface; update `API` interface | | `packages/README.md` | Inter-tool invocation section + API reference entry | | `packages/pptbConfig.d.ts` | New file — TypeScript types for `pptb.config.json` | -| `packages/lib/validate.js` | `validatePptbConfig` function | +| `packages/lib/validate.js` | `validatePPTBConfig` function | | `packages/bin/pptb-validate.js` | Auto-discover and validate `pptb.config.json` | | `.gitignore` | Restored `.DS_Store` entry | diff --git a/packages/bin/pptb-validate.js b/packages/bin/pptb-validate.js index 93c2c614..110f447d 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 } = require("../lib/validate"); // ANSI colour helpers – gracefully degrade when colours are unsupported const NO_COLOR = !process.stdout.isTTY || process.env.NO_COLOR; @@ -151,7 +151,7 @@ async function main() { if (pptbConfigParseError !== null) { configResult = { valid: false, errors: [`Failed to parse pptb.config.json: ${pptbConfigParseError}`], warnings: [] }; } else if (pptbConfig !== null) { - configResult = validatePptbConfig(pptbConfig); + configResult = validatePPTBConfig(pptbConfig); } // Merge results for overall pass/fail diff --git a/packages/lib/validate.js b/packages/lib/validate.js index a979ad1b..79387e6d 100644 --- a/packages/lib/validate.js +++ b/packages/lib/validate.js @@ -43,7 +43,7 @@ * }} InvocationConfig * @typedef {{ * invocation?: InvocationConfig; - * }} PptbConfig + * }} PPTBConfig */ // List of approved open source licenses @@ -334,15 +334,15 @@ async function validatePackageJson(packageJson, options = {}) { }; } -module.exports = { validatePackageJson, validatePptbConfig, isValidUrl, APPROVED_LICENSES }; +module.exports = { validatePackageJson, validatePPTBConfig, isValidUrl, APPROVED_LICENSES }; /** * Validates a tool's pptb.config.json against the official review criteria. * - * @param {PptbConfig} config - The parsed pptb.config.json object. + * @param {PPTBConfig} config - The parsed pptb.config.json object. * @returns {ValidationResult} */ -function validatePptbConfig(config) { +function validatePPTBConfig(config) { const errors = /** @type {string[]} */ ([]); const warnings = /** @type {string[]} */ ([]); diff --git a/packages/pptbConfig.d.ts b/packages/pptbConfig.d.ts index 684a0b75..ef8c85f9 100644 --- a/packages/pptbConfig.d.ts +++ b/packages/pptbConfig.d.ts @@ -76,7 +76,7 @@ export interface InvocationConfig { * All sections are optional; the file itself is optional. When present it * is validated by `pptb-validate` in addition to `package.json`. */ -export interface PptbConfig { +export interface PPTBConfig { /** Invocation contract – how this tool can be called by other tools. */ invocation?: InvocationConfig; }