diff --git a/packages/pi-coding-agent/src/core/extensions/index.ts b/packages/pi-coding-agent/src/core/extensions/index.ts index 1e5938a79..12dc60ed4 100644 --- a/packages/pi-coding-agent/src/core/extensions/index.ts +++ b/packages/pi-coding-agent/src/core/extensions/index.ts @@ -7,6 +7,7 @@ export { createExtensionRuntime, discoverAndLoadExtensions, getUntrustedExtensionPaths, + importExtensionModule, isProjectTrusted, loadExtensionFromFactory, loadExtensions, diff --git a/packages/pi-coding-agent/src/core/extensions/loader.ts b/packages/pi-coding-agent/src/core/extensions/loader.ts index 90adceb59..62e7e08bf 100644 --- a/packages/pi-coding-agent/src/core/extensions/loader.ts +++ b/packages/pi-coding-agent/src/core/extensions/loader.ts @@ -67,6 +67,12 @@ const VIRTUAL_MODULES: Record = { }; const require = createRequire(import.meta.url); +const EXTENSION_TIMING_ENABLED = process.env.GSD_STARTUP_TIMING === "1" || process.env.PI_TIMING === "1"; + +function logExtensionTiming(extensionPath: string, ms: number, outcome: "loaded" | "failed"): void { + if (!EXTENSION_TIMING_ENABLED) return; + console.error(`[startup] extension ${outcome}: ${extensionPath} (${ms}ms)`); +} /** * Get aliases for jiti (used in Node.js/development mode). @@ -118,6 +124,30 @@ function getAliases(): Record { return _aliases; } +function getJitiOptions() { + return isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() }; +} + +const _moduleImporters = new Map>(); + +function getModuleImporter(parentModuleUrl: string) { + let importer = _moduleImporters.get(parentModuleUrl); + if (!importer) { + importer = createJiti(parentModuleUrl, { + moduleCache: true, + ...getJitiOptions(), + }); + _moduleImporters.set(parentModuleUrl, importer); + } + return importer; +} + +export async function importExtensionModule(parentModuleUrl: string, specifier: string): Promise { + const importer = getModuleImporter(parentModuleUrl); + const resolvedPath = fileURLToPath(new URL(specifier, parentModuleUrl)); + return importer.import(resolvedPath) as Promise; +} + const UNICODE_SPACES = /[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g; function normalizeUnicodeSpaces(str: string): string { @@ -325,10 +355,7 @@ function createExtensionAPI( async function loadExtensionModule(extensionPath: string) { const jiti = createJiti(import.meta.url, { moduleCache: false, - // In Bun binary: use virtualModules for bundled packages (no filesystem resolution) - // Also disable tryNative so jiti handles ALL imports (not just the entry point) - // In Node.js/dev: use aliases to resolve to node_modules paths - ...(isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() }), + ...getJitiOptions(), }); const module = await jiti.import(extensionPath, { default: true }); @@ -359,20 +386,24 @@ async function loadExtension( runtime: ExtensionRuntime, ): Promise<{ extension: Extension | null; error: string | null }> { const resolvedPath = resolvePath(extensionPath, cwd); + const start = Date.now(); try { const factory = await loadExtensionModule(resolvedPath); if (!factory) { + logExtensionTiming(extensionPath, Date.now() - start, "failed"); return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; } const extension = createExtension(extensionPath, resolvedPath); const api = createExtensionAPI(extension, runtime, cwd, eventBus); await factory(api); + logExtensionTiming(extensionPath, Date.now() - start, "loaded"); return { extension, error: null }; } catch (err) { const message = err instanceof Error ? err.message : String(err); + logExtensionTiming(extensionPath, Date.now() - start, "failed"); return { extension: null, error: `Failed to load extension: ${message}` }; } } diff --git a/packages/pi-coding-agent/src/index.ts b/packages/pi-coding-agent/src/index.ts index 2cdb0495d..1e947d95c 100644 --- a/packages/pi-coding-agent/src/index.ts +++ b/packages/pi-coding-agent/src/index.ts @@ -127,6 +127,7 @@ export { createExtensionRuntime, discoverAndLoadExtensions, ExtensionRunner, + importExtensionModule, isBashToolResult, isEditToolResult, isFindToolResult, diff --git a/scripts/copy-resources.cjs b/scripts/copy-resources.cjs index 62e2d6812..56d728a40 100644 --- a/scripts/copy-resources.cjs +++ b/scripts/copy-resources.cjs @@ -1,4 +1,36 @@ #!/usr/bin/env node -const { cpSync, rmSync } = require('fs'); +const { spawnSync } = require('child_process'); +const { copyFileSync, mkdirSync, readdirSync, rmSync } = require('fs'); +const { dirname, join } = require('path'); + +function copyNonTsFiles(srcDir, destDir) { + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + const srcPath = join(srcDir, entry.name); + const destPath = join(destDir, entry.name); + + if (entry.isDirectory()) { + copyNonTsFiles(srcPath, destPath); + continue; + } + + if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) { + continue; + } + + mkdirSync(dirname(destPath), { recursive: true }); + copyFileSync(srcPath, destPath); + } +} + rmSync('dist/resources', { recursive: true, force: true }); -cpSync('src/resources', 'dist/resources', { recursive: true, force: true }); + +const tscBin = require.resolve('typescript/bin/tsc'); +const compile = spawnSync(process.execPath, [tscBin, '--project', 'tsconfig.resources.json'], { + stdio: 'inherit', +}); + +if (compile.status !== 0) { + process.exit(compile.status ?? 1); +} + +copyNonTsFiles('src/resources', 'dist/resources'); diff --git a/src/cli.ts b/src/cli.ts index 2ef4b758e..5a42796d2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { shouldRunOnboarding, runOnboarding } from './onboarding.js' import chalk from 'chalk' import { checkForUpdates } from './update-check.js' import { printHelp, printSubcommandHelp } from './help-text.js' +import { markStartup, printStartupTimings } from './startup-timings.js' // --------------------------------------------------------------------------- // Minimal CLI arg parser — detects print/subagent mode flags @@ -210,8 +211,10 @@ if (cliFlags.messages[0] === 'headless') { // because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code. // Provision local managed binaries first so Pi sees them without probing PATH. ensureManagedTools(join(agentDir, 'bin')) +markStartup('ensureManagedTools') const authStorage = AuthStorage.create(authFilePath) +markStartup('AuthStorage.create') loadStoredEnvKeys(authStorage) migratePiCredentials(authStorage) @@ -220,7 +223,9 @@ const { resolveModelsJsonPath } = await import('./models-resolver.js') const modelsJsonPath = resolveModelsJsonPath() const modelRegistry = new ModelRegistry(authStorage, modelsJsonPath) +markStartup('ModelRegistry') const settingsManager = SettingsManager.create(agentDir) +markStartup('SettingsManager.create') // Run onboarding wizard on first launch (no LLM provider configured) if (!isPrintMode && shouldRunOnboarding(authStorage, settingsManager.getDefaultProvider())) { @@ -360,12 +365,14 @@ if (isPrintMode) { exitIfManagedResourcesAreNewer(agentDir) initResources(agentDir) + markStartup('initResources') const resourceLoader = new DefaultResourceLoader({ agentDir, additionalExtensionPaths: cliFlags.extensions.length > 0 ? cliFlags.extensions : undefined, appendSystemPrompt, }) await resourceLoader.reload() + markStartup('resourceLoader.reload') const { session, extensionsResult } = await createAgentSession({ authStorage, @@ -374,6 +381,7 @@ if (isPrintMode) { sessionManager, resourceLoader, }) + markStartup('createAgentSession') if (extensionsResult.errors.length > 0) { for (const err of extensionsResult.errors) { @@ -395,11 +403,13 @@ if (isPrintMode) { const mode = cliFlags.mode || 'text' if (mode === 'rpc') { + printStartupTimings() await runRpcMode(session) process.exit(0) } if (mode === 'mcp') { + printStartupTimings() const { startMcpServer } = await import('./mcp-server.js') await startMcpServer({ tools: session.agent.state.tools ?? [], @@ -409,6 +419,7 @@ if (isPrintMode) { await new Promise(() => {}) } + printStartupTimings() await runPrintMode(session, { mode: mode as 'text' | 'json', messages: cliFlags.messages, @@ -498,8 +509,10 @@ const sessionManager = cliFlags._selectedSessionPath exitIfManagedResourcesAreNewer(agentDir) initResources(agentDir) +markStartup('initResources') const resourceLoader = buildResourceLoader(agentDir) await resourceLoader.reload() +markStartup('resourceLoader.reload') const { session, extensionsResult } = await createAgentSession({ authStorage, @@ -508,6 +521,7 @@ const { session, extensionsResult } = await createAgentSession({ sessionManager, resourceLoader, }) +markStartup('createAgentSession') if (extensionsResult.errors.length > 0) { for (const err of extensionsResult.errors) { @@ -559,4 +573,6 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) { } const interactiveMode = new InteractiveMode(session) +markStartup('InteractiveMode') +printStartupTimings() await interactiveMode.run() diff --git a/src/resource-loader.ts b/src/resource-loader.ts index bcae127d0..d06dd50a7 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -32,9 +32,9 @@ interface ManagedResourceManifest { export { discoverExtensionEntryPaths } from './extension-discovery.js' -function getExtensionKey(entryPath: string, extensionsDir: string): string { +export function getExtensionKey(entryPath: string, extensionsDir: string): string { const relPath = relative(extensionsDir, entryPath) - return relPath.split(/[\\/]/)[0] + return relPath.split(/[\\/]/)[0].replace(/\.(?:ts|js)$/, '') } function getManagedResourceManifestPath(agentDir: string): string { @@ -176,6 +176,7 @@ function makeTreeWritable(dirPath: string): void { function syncResourceDir(srcDir: string, destDir: string): void { makeTreeWritable(destDir) if (existsSync(srcDir)) { + pruneStaleSiblingFiles(srcDir, destDir) for (const entry of readdirSync(srcDir, { withFileTypes: true })) { if (entry.isDirectory()) { const target = join(destDir, entry.name) @@ -193,6 +194,27 @@ function syncResourceDir(srcDir: string, destDir: string): void { } } +function pruneStaleSiblingFiles(srcDir: string, destDir: string): void { + if (!existsSync(destDir)) return + + const sourceFiles = new Set( + readdirSync(srcDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name), + ) + + for (const entry of readdirSync(destDir, { withFileTypes: true })) { + if (!entry.isFile()) continue + if (sourceFiles.has(entry.name)) continue + + const sourceJsName = entry.name.replace(/\.ts$/, '.js') + const sourceTsName = entry.name.replace(/\.js$/, '.ts') + if (sourceFiles.has(sourceJsName) || sourceFiles.has(sourceTsName)) { + rmSync(join(destDir, entry.name), { force: true }) + } + } +} + /** * Recursive directory copy using copyFileSync — workaround for cpSync failures * on Windows paths containing non-ASCII characters (#1178). @@ -236,7 +258,8 @@ export function initResources(agentDir: string): void { if (manifest && manifest.gsdVersion === currentVersion) { // Version matches — check content fingerprint for same-version staleness. const currentHash = computeResourceFingerprint() - if (manifest.contentHash && manifest.contentHash === currentHash) { + const hasStaleExtensionFiles = hasStaleCompiledExtensionSiblings(join(agentDir, 'extensions')) + if (manifest.contentHash && manifest.contentHash === currentHash && !hasStaleExtensionFiles) { return } } @@ -253,6 +276,18 @@ export function initResources(agentDir: string): void { ensureRegistryEntries(join(agentDir, 'extensions')) } +export function hasStaleCompiledExtensionSiblings(extensionsDir: string): boolean { + if (!existsSync(extensionsDir)) return false + for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.ts')) continue + const jsName = entry.name.replace(/\.ts$/, '.js') + if (existsSync(join(extensionsDir, jsName))) { + return true + } + } + return false +} + /** * Constructs a DefaultResourceLoader that loads extensions from both * ~/.gsd/agent/extensions/ (GSD's default) and ~/.pi/agent/extensions/ (pi's default). diff --git a/src/resources/extensions/bg-shell/index.ts b/src/resources/extensions/bg-shell/index.ts index eb61fb8a8..b6fcb179c 100644 --- a/src/resources/extensions/bg-shell/index.ts +++ b/src/resources/extensions/bg-shell/index.ts @@ -1,59 +1,54 @@ /** * Background Shell Extension v2 * - * A next-generation background process manager designed for agentic workflows. - * Provides intelligent process lifecycle management, structured output digests, - * event-driven readiness detection, and context-efficient communication. - * - * Key capabilities: - * - Multi-tier output: digest (30 tokens) → highlights → raw (full context) - * - Readiness detection: port probing, pattern matching, auto-classification - * - Process lifecycle events: starting → ready → error → exited - * - Output diffing & dedup: detect novel errors vs. repeated noise - * - Process groups: manage related processes as a unit - * - Cross-session persistence: survive context resets - * - Expect-style interactions: send_and_wait for interactive CLIs - * - Context injection: proactive alerts for crashes and state changes - * - * Tools: - * bg_shell — start, output, digest, wait_for_ready, send, send_and_wait, run, - * signal, list, kill, restart, group_status - * - * Commands: - * /bg — interactive process manager overlay + * Command/tool registration is deferred in interactive mode so startup does not + * block on the full background-process stack before the TUI paints. */ -import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; - -import { registerBgShellTool } from "./bg-shell-tool.js"; -import { registerBgShellCommand } from "./bg-shell-command.js"; +import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from "@gsd/pi-coding-agent"; import { registerBgShellLifecycle } from "./bg-shell-lifecycle.js"; -// ── Re-exports for consumers ─────────────────────────────────────────────── - -export type { ProcessStatus, ProcessType, BgProcess, BgProcessInfo, OutputDigest, OutputLine, ProcessEvent } from "./types.js"; -export { processes, startProcess, killProcess, restartProcess, cleanupAll, cleanupSessionProcesses } from "./process-manager.js"; -export { generateDigest, getHighlights, getOutput, formatDigestText } from "./output-formatter.js"; -export { waitForReady, probePort } from "./readiness-detector.js"; -export { sendAndWait, runOnSession, queryShellEnv } from "./interaction.js"; -export { BgManagerOverlay } from "./overlay.js"; - -// ── Shared State ──────────────────────────────────────────────────────────── - export interface BgShellSharedState { - latestCtx: ExtensionContext | null; - refreshWidget: () => void; + latestCtx: ExtensionContext | null; + refreshWidget: () => void; } -// ── Extension Entry Point ────────────────────────────────────────────────── +let featuresPromise: Promise | null = null; + +async function registerBgShellFeatures(pi: ExtensionAPI, state: BgShellSharedState): Promise { + if (!featuresPromise) { + featuresPromise = (async () => { + const [{ registerBgShellTool }, { registerBgShellCommand }] = await Promise.all([ + importExtensionModule(import.meta.url, "./bg-shell-tool.js"), + importExtensionModule(import.meta.url, "./bg-shell-command.js"), + ]); + registerBgShellTool(pi, state); + registerBgShellCommand(pi, state); + })().catch((error) => { + featuresPromise = null; + throw error; + }); + } + + return featuresPromise; +} export default function (pi: ExtensionAPI) { - const state: BgShellSharedState = { - latestCtx: null, - refreshWidget: () => {}, - }; + const state: BgShellSharedState = { + latestCtx: null, + refreshWidget: () => {}, + }; - registerBgShellLifecycle(pi, state); - registerBgShellTool(pi, state); - registerBgShellCommand(pi, state); + registerBgShellLifecycle(pi, state); + + pi.on("session_start", async (_event, ctx) => { + if (ctx.hasUI) { + void registerBgShellFeatures(pi, state).catch((error) => { + ctx.ui.notify(`bg-shell failed to load: ${error instanceof Error ? error.message : String(error)}`, "warning"); + }); + return; + } + + await registerBgShellFeatures(pi, state); + }); } diff --git a/src/resources/extensions/browser-tools/index.ts b/src/resources/extensions/browser-tools/index.ts index 79de059c8..236b7b4d4 100644 --- a/src/resources/extensions/browser-tools/index.ts +++ b/src/resources/extensions/browser-tools/index.ts @@ -1,71 +1,160 @@ /** browser-tools — pi extension: full browser interaction via Playwright. */ -import type { ExtensionAPI } from "@gsd/pi-coding-agent"; -import type { ToolDeps } from "./state.js"; -import { ensureBrowser, closeBrowser, getActivePage, getActiveTarget, getActivePageOrNull, attachPageListeners } from "./lifecycle.js"; -import { captureCompactPageState, postActionSummary, constrainScreenshot, captureErrorScreenshot } from "./capture.js"; -import { settleAfterActionAdaptive, ensureMutationCounter } from "./settle.js"; -import { buildRefSnapshot, resolveRefTarget } from "./refs.js"; -import * as u from "./utils.js"; -import { registerNavigationTools } from "./tools/navigation.js"; -import { registerScreenshotTools } from "./tools/screenshot.js"; -import { registerInteractionTools } from "./tools/interaction.js"; -import { registerInspectionTools } from "./tools/inspection.js"; -import { registerSessionTools } from "./tools/session.js"; -import { registerAssertionTools } from "./tools/assertions.js"; -import { registerRefTools } from "./tools/refs.js"; -import { registerWaitTools } from "./tools/wait.js"; -import { registerPageTools } from "./tools/pages.js"; -import { registerFormTools } from "./tools/forms.js"; -import { registerIntentTools } from "./tools/intent.js"; -import { registerPdfTools } from "./tools/pdf.js"; -import { registerStatePersistenceTools } from "./tools/state-persistence.js"; -import { registerNetworkMockTools } from "./tools/network-mock.js"; -import { registerDeviceTools } from "./tools/device.js"; -import { registerExtractTools } from "./tools/extract.js"; -import { registerVisualDiffTools } from "./tools/visual-diff.js"; -import { registerZoomTools } from "./tools/zoom.js"; -import { registerCodegenTools } from "./tools/codegen.js"; -import { registerActionCacheTools } from "./tools/action-cache.js"; -import { registerInjectionDetectionTools } from "./tools/injection-detect.js"; +import { importExtensionModule, type ExtensionAPI } from "@gsd/pi-coding-agent"; + +let registrationPromise: Promise | null = null; + +async function registerBrowserTools(pi: ExtensionAPI): Promise { + if (!registrationPromise) { + registrationPromise = (async () => { + const [ + lifecycle, + capture, + settle, + refs, + utils, + navigation, + screenshot, + interaction, + inspection, + session, + assertions, + refTools, + wait, + pages, + forms, + intent, + pdf, + statePersistence, + networkMock, + device, + extract, + visualDiff, + zoom, + codegen, + actionCache, + injectionDetection, + ] = await Promise.all([ + importExtensionModule(import.meta.url, "./lifecycle.js"), + importExtensionModule(import.meta.url, "./capture.js"), + importExtensionModule(import.meta.url, "./settle.js"), + importExtensionModule(import.meta.url, "./refs.js"), + importExtensionModule(import.meta.url, "./utils.js"), + importExtensionModule(import.meta.url, "./tools/navigation.js"), + importExtensionModule(import.meta.url, "./tools/screenshot.js"), + importExtensionModule(import.meta.url, "./tools/interaction.js"), + importExtensionModule(import.meta.url, "./tools/inspection.js"), + importExtensionModule(import.meta.url, "./tools/session.js"), + importExtensionModule(import.meta.url, "./tools/assertions.js"), + importExtensionModule(import.meta.url, "./tools/refs.js"), + importExtensionModule(import.meta.url, "./tools/wait.js"), + importExtensionModule(import.meta.url, "./tools/pages.js"), + importExtensionModule(import.meta.url, "./tools/forms.js"), + importExtensionModule(import.meta.url, "./tools/intent.js"), + importExtensionModule(import.meta.url, "./tools/pdf.js"), + importExtensionModule(import.meta.url, "./tools/state-persistence.js"), + importExtensionModule(import.meta.url, "./tools/network-mock.js"), + importExtensionModule(import.meta.url, "./tools/device.js"), + importExtensionModule(import.meta.url, "./tools/extract.js"), + importExtensionModule(import.meta.url, "./tools/visual-diff.js"), + importExtensionModule(import.meta.url, "./tools/zoom.js"), + importExtensionModule(import.meta.url, "./tools/codegen.js"), + importExtensionModule(import.meta.url, "./tools/action-cache.js"), + importExtensionModule(import.meta.url, "./tools/injection-detect.js"), + ]); + + const deps = { + ensureBrowser: lifecycle.ensureBrowser, + closeBrowser: lifecycle.closeBrowser, + getActivePage: lifecycle.getActivePage, + getActiveTarget: lifecycle.getActiveTarget, + getActivePageOrNull: lifecycle.getActivePageOrNull, + attachPageListeners: lifecycle.attachPageListeners, + captureCompactPageState: capture.captureCompactPageState, + postActionSummary: capture.postActionSummary, + constrainScreenshot: capture.constrainScreenshot, + captureErrorScreenshot: capture.captureErrorScreenshot, + formatCompactStateSummary: utils.formatCompactStateSummary, + getRecentErrors: utils.getRecentErrors, + settleAfterActionAdaptive: settle.settleAfterActionAdaptive, + ensureMutationCounter: settle.ensureMutationCounter, + buildRefSnapshot: refs.buildRefSnapshot, + resolveRefTarget: refs.resolveRefTarget, + parseRef: utils.parseRef, + formatVersionedRef: utils.formatVersionedRef, + staleRefGuidance: utils.staleRefGuidance, + beginTrackedAction: utils.beginTrackedAction, + finishTrackedAction: utils.finishTrackedAction, + truncateText: utils.truncateText, + verificationFromChecks: utils.verificationFromChecks, + verificationLine: utils.verificationLine, + collectAssertionState: (page: any, checks: any, target?: any) => + utils.collectAssertionState(page, checks, capture.captureCompactPageState, target), + formatAssertionText: utils.formatAssertionText, + formatDiffText: utils.formatDiffText, + getUrlHash: utils.getUrlHash, + captureClickTargetState: utils.captureClickTargetState, + readInputLikeValue: utils.readInputLikeValue, + firstErrorLine: utils.firstErrorLine, + captureAccessibilityMarkdown: (selector?: string) => + utils.captureAccessibilityMarkdown(lifecycle.getActiveTarget(), selector), + resolveAccessibilityScope: utils.resolveAccessibilityScope, + getLivePagesSnapshot: utils.createGetLivePagesSnapshot(lifecycle.ensureBrowser), + getSinceTimestamp: utils.getSinceTimestamp, + getConsoleEntriesSince: utils.getConsoleEntriesSince, + getNetworkEntriesSince: utils.getNetworkEntriesSince, + writeArtifactFile: utils.writeArtifactFile, + copyArtifactFile: utils.copyArtifactFile, + ensureSessionArtifactDir: utils.ensureSessionArtifactDir, + buildSessionArtifactPath: utils.buildSessionArtifactPath, + getSessionArtifactMetadata: utils.getSessionArtifactMetadata, + sanitizeArtifactName: utils.sanitizeArtifactName, + formatArtifactTimestamp: utils.formatArtifactTimestamp, + }; + + navigation.registerNavigationTools(pi, deps); + screenshot.registerScreenshotTools(pi, deps); + interaction.registerInteractionTools(pi, deps); + inspection.registerInspectionTools(pi, deps); + session.registerSessionTools(pi, deps); + assertions.registerAssertionTools(pi, deps); + refTools.registerRefTools(pi, deps); + wait.registerWaitTools(pi, deps); + pages.registerPageTools(pi, deps); + forms.registerFormTools(pi, deps); + intent.registerIntentTools(pi, deps); + pdf.registerPdfTools(pi, deps); + statePersistence.registerStatePersistenceTools(pi, deps); + networkMock.registerNetworkMockTools(pi, deps); + device.registerDeviceTools(pi, deps); + extract.registerExtractTools(pi, deps); + visualDiff.registerVisualDiffTools(pi, deps); + zoom.registerZoomTools(pi, deps); + codegen.registerCodegenTools(pi, deps); + actionCache.registerActionCacheTools(pi, deps); + injectionDetection.registerInjectionDetectionTools(pi, deps); + })().catch((error) => { + registrationPromise = null; + throw error; + }); + } + + return registrationPromise; +} export default function (pi: ExtensionAPI) { - pi.on("session_shutdown", async () => { await closeBrowser(); }); - const deps: ToolDeps = { - ensureBrowser, closeBrowser, getActivePage, getActiveTarget, getActivePageOrNull, attachPageListeners, - captureCompactPageState, postActionSummary, constrainScreenshot, captureErrorScreenshot, - formatCompactStateSummary: u.formatCompactStateSummary, getRecentErrors: u.getRecentErrors, - settleAfterActionAdaptive, ensureMutationCounter, buildRefSnapshot, resolveRefTarget, - parseRef: u.parseRef, formatVersionedRef: u.formatVersionedRef, staleRefGuidance: u.staleRefGuidance, - beginTrackedAction: u.beginTrackedAction, finishTrackedAction: u.finishTrackedAction, - truncateText: u.truncateText, verificationFromChecks: u.verificationFromChecks, - verificationLine: u.verificationLine, collectAssertionState: (p, checks, target?) => u.collectAssertionState(p, checks, captureCompactPageState, target), - formatAssertionText: u.formatAssertionText, formatDiffText: u.formatDiffText, - getUrlHash: u.getUrlHash, - captureClickTargetState: u.captureClickTargetState, readInputLikeValue: u.readInputLikeValue, - firstErrorLine: u.firstErrorLine, captureAccessibilityMarkdown: (selector?) => u.captureAccessibilityMarkdown(getActiveTarget(), selector), - resolveAccessibilityScope: u.resolveAccessibilityScope, - getLivePagesSnapshot: u.createGetLivePagesSnapshot(ensureBrowser), - getSinceTimestamp: u.getSinceTimestamp, getConsoleEntriesSince: u.getConsoleEntriesSince, - getNetworkEntriesSince: u.getNetworkEntriesSince, writeArtifactFile: u.writeArtifactFile, - copyArtifactFile: u.copyArtifactFile, ensureSessionArtifactDir: u.ensureSessionArtifactDir, - buildSessionArtifactPath: u.buildSessionArtifactPath, getSessionArtifactMetadata: u.getSessionArtifactMetadata, - sanitizeArtifactName: u.sanitizeArtifactName, formatArtifactTimestamp: u.formatArtifactTimestamp, - }; - registerNavigationTools(pi, deps); registerScreenshotTools(pi, deps); - registerInteractionTools(pi, deps); registerInspectionTools(pi, deps); - registerSessionTools(pi, deps); registerAssertionTools(pi, deps); - registerRefTools(pi, deps); registerWaitTools(pi, deps); - registerPageTools(pi, deps); - registerFormTools(pi, deps); - registerIntentTools(pi, deps); - registerPdfTools(pi, deps); - registerStatePersistenceTools(pi, deps); - registerNetworkMockTools(pi, deps); - registerDeviceTools(pi, deps); - registerExtractTools(pi, deps); - registerVisualDiffTools(pi, deps); - registerZoomTools(pi, deps); - registerCodegenTools(pi, deps); - registerActionCacheTools(pi, deps); - registerInjectionDetectionTools(pi, deps); + pi.on("session_start", async (_event, ctx) => { + if (ctx.hasUI) { + void registerBrowserTools(pi).catch((error) => { + ctx.ui.notify(`browser-tools failed to load: ${error instanceof Error ? error.message : String(error)}`, "warning"); + }); + return; + } + + await registerBrowserTools(pi); + }); + + pi.on("session_shutdown", async () => { + const { closeBrowser } = await importExtensionModule(import.meta.url, "./lifecycle.js"); + await closeBrowser(); + }); } diff --git a/src/resources/extensions/gsd/commands-bootstrap.ts b/src/resources/extensions/gsd/commands-bootstrap.ts new file mode 100644 index 000000000..8347598a6 --- /dev/null +++ b/src/resources/extensions/gsd/commands-bootstrap.ts @@ -0,0 +1,252 @@ +import { importExtensionModule, type ExtensionAPI, type ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +const TOP_LEVEL_SUBCOMMANDS = [ + { cmd: "help", desc: "Categorized command reference with descriptions" }, + { cmd: "next", desc: "Explicit step mode (same as /gsd)" }, + { cmd: "auto", desc: "Autonomous mode — research, plan, execute, commit, repeat" }, + { cmd: "stop", desc: "Stop auto mode gracefully" }, + { cmd: "pause", desc: "Pause auto-mode (preserves state, /gsd auto to resume)" }, + { cmd: "status", desc: "Progress dashboard" }, + { cmd: "visualize", desc: "Open workflow visualizer" }, + { cmd: "queue", desc: "Queue and reorder future milestones" }, + { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, + { cmd: "discuss", desc: "Discuss architecture and decisions" }, + { cmd: "capture", desc: "Fire-and-forget thought capture" }, + { cmd: "triage", desc: "Manually trigger triage of pending captures" }, + { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, + { cmd: "history", desc: "View execution history" }, + { cmd: "undo", desc: "Revert last completed unit" }, + { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, + { cmd: "export", desc: "Export milestone or slice results" }, + { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, + { cmd: "mode", desc: "Switch workflow mode (solo/team)" }, + { cmd: "prefs", desc: "Manage preferences" }, + { cmd: "config", desc: "Set API keys for external tools" }, + { cmd: "keys", desc: "API key manager" }, + { cmd: "hooks", desc: "Show configured hooks" }, + { cmd: "run-hook", desc: "Manually trigger a specific hook" }, + { cmd: "skill-health", desc: "Skill lifecycle dashboard" }, + { cmd: "doctor", desc: "Runtime health checks with auto-fix" }, + { cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" }, + { cmd: "forensics", desc: "Examine execution logs" }, + { cmd: "init", desc: "Project init wizard" }, + { cmd: "setup", desc: "Global setup status and configuration" }, + { cmd: "migrate", desc: "Migrate a v1 .planning directory to .gsd format" }, + { cmd: "remote", desc: "Control remote auto-mode" }, + { cmd: "steer", desc: "Hard-steer plan documents during execution" }, + { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, + { cmd: "knowledge", desc: "Add persistent project knowledge" }, + { cmd: "new-milestone", desc: "Create a milestone from a specification document" }, + { cmd: "parallel", desc: "Parallel milestone orchestration" }, + { cmd: "park", desc: "Park a milestone" }, + { cmd: "unpark", desc: "Reactivate a parked milestone" }, + { cmd: "update", desc: "Update GSD to the latest version" }, + { cmd: "start", desc: "Start a workflow template" }, + { cmd: "templates", desc: "List available workflow templates" }, + { cmd: "extensions", desc: "Manage extensions" }, +] as const; + +function filterStartsWith( + partial: string, + options: ReadonlyArray<{ cmd: string; desc: string }>, + prefix = "", +) { + const normalizedPrefix = prefix.length > 0 ? `${prefix} ` : ""; + return options + .filter((option) => option.cmd.startsWith(partial)) + .map((option) => ({ + value: `${normalizedPrefix}${option.cmd}`, + label: option.cmd, + description: option.desc, + })); +} + +function getGsdArgumentCompletions(prefix: string) { + const parts = prefix.trim().split(/\s+/); + + if (parts.length <= 1) { + return filterStartsWith(parts[0] ?? "", TOP_LEVEL_SUBCOMMANDS); + } + + const partial = parts[1] ?? ""; + + if (parts[0] === "auto" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "--verbose", desc: "Show detailed execution output" }, + { cmd: "--debug", desc: "Enable debug logging" }, + ], "auto"); + } + + if (parts[0] === "next" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "--verbose", desc: "Show detailed step output" }, + { cmd: "--dry-run", desc: "Preview next step without executing" }, + ], "next"); + } + + if (parts[0] === "mode" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "global", desc: "Edit global workflow mode" }, + { cmd: "project", desc: "Edit project-specific workflow mode" }, + ], "mode"); + } + + if (parts[0] === "parallel" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "start", desc: "Start parallel milestone orchestration" }, + { cmd: "status", desc: "Show parallel worker statuses" }, + { cmd: "stop", desc: "Stop all parallel workers" }, + { cmd: "pause", desc: "Pause a specific worker" }, + { cmd: "resume", desc: "Resume a paused worker" }, + { cmd: "merge", desc: "Merge completed milestone branches" }, + ], "parallel"); + } + + if (parts[0] === "setup" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "llm", desc: "Configure LLM provider settings" }, + { cmd: "search", desc: "Configure web search provider" }, + { cmd: "remote", desc: "Configure remote integrations" }, + { cmd: "keys", desc: "Manage API keys" }, + { cmd: "prefs", desc: "Configure global preferences" }, + ], "setup"); + } + + if (parts[0] === "logs" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "debug", desc: "List or view debug log files" }, + { cmd: "tail", desc: "Show last N activity log summaries" }, + { cmd: "clear", desc: "Remove old activity and debug logs" }, + ], "logs"); + } + + if (parts[0] === "keys" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "list", desc: "Show key status dashboard" }, + { cmd: "add", desc: "Add a key for a provider" }, + { cmd: "remove", desc: "Remove a key" }, + { cmd: "test", desc: "Validate key(s) with API call" }, + { cmd: "rotate", desc: "Replace an existing key" }, + { cmd: "doctor", desc: "Health check all keys" }, + ], "keys"); + } + + if (parts[0] === "prefs" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "global", desc: "Edit global preferences file" }, + { cmd: "project", desc: "Edit project preferences file" }, + { cmd: "status", desc: "Show effective preferences" }, + { cmd: "wizard", desc: "Interactive preferences wizard" }, + { cmd: "setup", desc: "First-time preferences setup" }, + { cmd: "import-claude", desc: "Import settings from Claude Code" }, + ], "prefs"); + } + + if (parts[0] === "remote" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "slack", desc: "Configure Slack integration" }, + { cmd: "discord", desc: "Configure Discord integration" }, + { cmd: "status", desc: "Show remote connection status" }, + { cmd: "disconnect", desc: "Disconnect remote integrations" }, + ], "remote"); + } + + if (parts[0] === "history" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "--cost", desc: "Show cost breakdown per entry" }, + { cmd: "--phase", desc: "Filter by phase type" }, + { cmd: "--model", desc: "Filter by model used" }, + { cmd: "10", desc: "Show last 10 entries" }, + { cmd: "20", desc: "Show last 20 entries" }, + { cmd: "50", desc: "Show last 50 entries" }, + ], "history"); + } + + if (parts[0] === "export" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "--json", desc: "Export as JSON" }, + { cmd: "--markdown", desc: "Export as Markdown" }, + { cmd: "--html", desc: "Export as HTML" }, + { cmd: "--html --all", desc: "Export all milestones as HTML" }, + ], "export"); + } + + if (parts[0] === "cleanup" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "branches", desc: "Remove merged milestone branches" }, + { cmd: "snapshots", desc: "Remove old execution snapshots" }, + ], "cleanup"); + } + + if (parts[0] === "knowledge" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "rule", desc: "Add a project rule" }, + { cmd: "pattern", desc: "Add a code pattern" }, + { cmd: "lesson", desc: "Record a lesson learned" }, + ], "knowledge"); + } + + if (parts[0] === "start" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "bugfix", desc: "Triage, fix, test, and ship a bug fix" }, + { cmd: "small-feature", desc: "Lightweight feature with optional discussion" }, + { cmd: "spike", desc: "Research, prototype, and document findings" }, + { cmd: "hotfix", desc: "Minimal: fix it, test it, ship it" }, + { cmd: "refactor", desc: "Inventory, plan waves, migrate, verify" }, + { cmd: "security-audit", desc: "Scan, triage, remediate, re-scan" }, + { cmd: "dep-upgrade", desc: "Assess, upgrade, fix breaks, verify" }, + { cmd: "full-project", desc: "Complete GSD workflow with full ceremony" }, + { cmd: "resume", desc: "Resume an in-progress workflow" }, + { cmd: "--list", desc: "List all available templates" }, + { cmd: "--dry-run", desc: "Preview workflow without executing" }, + ], "start"); + } + + if (parts[0] === "templates" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "info", desc: "Show detailed template info" }, + ], "templates"); + } + + if (parts[0] === "extensions" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "list", desc: "List all extensions and their status" }, + { cmd: "enable", desc: "Enable a disabled extension" }, + { cmd: "disable", desc: "Disable an extension" }, + { cmd: "info", desc: "Show extension details" }, + ], "extensions"); + } + + if (parts[0] === "doctor" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "fix", desc: "Auto-fix detected issues" }, + { cmd: "heal", desc: "AI-driven deep healing" }, + { cmd: "audit", desc: "Run health audit without fixing" }, + ], "doctor"); + } + + if (parts[0] === "dispatch" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "research", desc: "Run research phase" }, + { cmd: "plan", desc: "Run planning phase" }, + { cmd: "execute", desc: "Run execution phase" }, + { cmd: "complete", desc: "Run completion phase" }, + { cmd: "reassess", desc: "Reassess current progress" }, + { cmd: "uat", desc: "Run user acceptance testing" }, + { cmd: "replan", desc: "Replan the current slice" }, + ], "dispatch"); + } + + return null; +} + +export function registerLazyGSDCommand(pi: ExtensionAPI): void { + pi.registerCommand("gsd", { + description: "GSD — Get Shit Done", + getArgumentCompletions: getGsdArgumentCompletions, + handler: async (args: string, ctx: ExtensionCommandContext) => { + const { handleGSDCommand } = await importExtensionModule(import.meta.url, "./commands.js"); + await handleGSDCommand(args, ctx, pi); + }, + }); +} diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 7e9f73697..342f8e0a1 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -416,36 +416,46 @@ export function registerGSDCommand(pi: ExtensionAPI): void { }, async handler(args: string, ctx: ExtensionCommandContext) { - const trimmed = (typeof args === "string" ? args : "").trim(); + await handleGSDCommand(args, ctx, pi); + }, + }); +} - if (trimmed === "help" || trimmed === "h" || trimmed === "?") { - showHelp(ctx); - return; - } +export async function handleGSDCommand( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise { + const trimmed = (typeof args === "string" ? args : "").trim(); - if (trimmed === "status") { - await handleStatus(ctx); - return; - } + if (trimmed === "help" || trimmed === "h" || trimmed === "?") { + showHelp(ctx); + return; + } - if (trimmed === "visualize") { - await handleVisualize(ctx); - return; - } + if (trimmed === "status") { + await handleStatus(ctx); + return; + } - if (trimmed === "mode" || trimmed.startsWith("mode ")) { - const modeArgs = trimmed.replace(/^mode\s*/, "").trim(); - const scope = modeArgs === "project" ? "project" : "global"; - const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); - await ensurePreferencesFile(path, ctx, scope); - await handlePrefsMode(ctx, scope); - return; - } + if (trimmed === "visualize") { + await handleVisualize(ctx); + return; + } - if (trimmed === "prefs" || trimmed.startsWith("prefs ")) { - await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx); - return; - } + if (trimmed === "mode" || trimmed.startsWith("mode ")) { + const modeArgs = trimmed.replace(/^mode\s*/, "").trim(); + const scope = modeArgs === "project" ? "project" : "global"; + const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); + await ensurePreferencesFile(path, ctx, scope); + await handlePrefsMode(ctx, scope); + return; + } + + if (trimmed === "prefs" || trimmed.startsWith("prefs ")) { + await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx); + return; + } if (trimmed === "init") { const { detectProjectState } = await import("./detection.js"); @@ -893,12 +903,10 @@ Examples: return; } - ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Run /gsd help for available commands.`, - "warning", - ); - }, - }); + ctx.ui.notify( + `Unknown: /gsd ${trimmed}. Run /gsd help for available commands.`, + "warning", + ); } function showHelp(ctx: ExtensionCommandContext): void { diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index fdeec4aa5..39b3a3887 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -23,30 +23,24 @@ import type { ExtensionCommandContext, ExtensionContext, } from "@gsd/pi-coding-agent"; -import { createBashTool, createWriteTool, createReadTool, createEditTool, isToolCallEventType } from "@gsd/pi-coding-agent"; +import { + createBashTool, + createEditTool, + createReadTool, + createWriteTool, + importExtensionModule, + isToolCallEventType, +} from "@gsd/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { debugLog, debugTime } from "./debug-logger.js"; -import { registerGSDCommand } from "./commands.js"; +import { registerLazyGSDCommand } from "./commands-bootstrap.js"; import { loadToolApiKeys } from "./commands-config.js"; import { registerExitCommand } from "./exit-command.js"; -import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js"; -import { getActiveAutoWorktreeContext } from "./auto-worktree.js"; +import { registerLazyWorktreeCommands } from "./worktree-command-bootstrap.js"; import { saveFile, formatContinue, loadFile, parseContinue, parseSummary, loadActiveOverrides, formatOverridesSection } from "./files.js"; import { loadPrompt } from "./prompt-loader.js"; -import { deriveState } from "./state.js"; -import { isAutoActive, isAutoPaused, handleAgentEnd, pauseAuto, getAutoDashboardData, getAutoModeStartModel, markToolStart, markToolEnd } from "./auto.js"; import { saveActivityLog } from "./activity-log.js"; -import { checkAutoStartAfterDiscuss, getDiscussionMilestoneId, findMilestoneIds, nextMilestoneId } from "./guided-flow.js"; -import { GSDDashboardOverlay } from "./dashboard-overlay.js"; -import { - loadEffectiveGSDPreferences, - renderPreferencesForSystemPrompt, - resolveAllSkillReferences, - resolveModelWithFallbacksForUnit, - getNextFallbackModel, - isTransientNetworkError, -} from "./preferences.js"; import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "./skill-discovery.js"; import { resolveSlicePath, resolveSliceFile, resolveTaskFile, resolveTaskFiles, resolveTasksDir, @@ -60,19 +54,37 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { shortcutDesc } from "../shared/mod.js"; import { Text } from "@gsd/pi-tui"; -import { pauseAutoForProviderError, classifyProviderError } from "./provider-error-pause.js"; import { toPosixPath } from "../shared/mod.js"; -import { isParallelActive, shutdownParallel } from "./parallel-orchestrator.js"; import { DEFAULT_BASH_TIMEOUT_SECS } from "./constants.js"; import { getErrorMessage } from "./error-utils.js"; +function memoizeImport(loader: () => Promise): () => Promise { + let promise: Promise | null = null; + return () => { + if (!promise) { + promise = loader(); + } + return promise; + }; +} + +const loadAutoModule = memoizeImport(() => importExtensionModule(import.meta.url, "./auto.js")); +const loadStateModule = memoizeImport(() => importExtensionModule(import.meta.url, "./state.js")); +const loadGuidedFlowModule = memoizeImport(() => importExtensionModule(import.meta.url, "./guided-flow.js")); +const loadPreferencesModule = memoizeImport(() => importExtensionModule(import.meta.url, "./preferences.js")); +const loadDashboardOverlayModule = memoizeImport(() => importExtensionModule(import.meta.url, "./dashboard-overlay.js")); +const loadWorktreeCommandModule = memoizeImport(() => importExtensionModule(import.meta.url, "./worktree-command.js")); +const loadAutoWorktreeModule = memoizeImport(() => importExtensionModule(import.meta.url, "./auto-worktree.js")); +const loadProviderErrorPauseModule = memoizeImport(() => importExtensionModule(import.meta.url, "./provider-error-pause.js")); +const loadParallelOrchestratorModule = memoizeImport(() => importExtensionModule(import.meta.url, "./parallel-orchestrator.js")); + /** * Ensure the GSD database is available, auto-initializing if needed. * Returns true if the DB is ready, false if initialization failed. */ async function ensureDbAvailable(): Promise { try { - const db = await import("./gsd-db.js"); + const db = await importExtensionModule(import.meta.url, "./gsd-db.js"); if (db.isDbAvailable()) return true; // Auto-initialize: open (and create if needed) the DB at the standard path @@ -212,8 +224,8 @@ const GSD_LOGO_LINES = [ ]; export default function (pi: ExtensionAPI) { - registerGSDCommand(pi); - registerWorktreeCommand(pi); + registerLazyGSDCommand(pi); + registerLazyWorktreeCommands(pi); registerExitCommand(pi); // ── EPIPE guard — prevent crash when stdout/stderr pipe closes unexpectedly ── @@ -369,7 +381,7 @@ export default function (pi: ExtensionAPI) { } try { - const { saveDecisionToDb } = await import("./db-writer.js"); + const { saveDecisionToDb } = await importExtensionModule(import.meta.url, "./db-writer.js"); const { id } = await saveDecisionToDb( { scope: params.scope, @@ -431,7 +443,7 @@ export default function (pi: ExtensionAPI) { try { // Verify requirement exists - const db = await import("./gsd-db.js"); + const db = await importExtensionModule(import.meta.url, "./gsd-db.js"); const existing = db.getRequirementById(params.id); if (!existing) { return { @@ -441,7 +453,7 @@ export default function (pi: ExtensionAPI) { }; } - const { updateRequirementInDb } = await import("./db-writer.js"); + const { updateRequirementInDb } = await importExtensionModule(import.meta.url, "./db-writer.js"); const updates: Record = {}; if (params.status !== undefined) updates.status = params.status; if (params.validation !== undefined) updates.validation = params.validation; @@ -519,7 +531,7 @@ export default function (pi: ExtensionAPI) { relativePath = `milestones/${params.milestone_id}/${params.milestone_id}-${params.artifact_type}.md`; } - const { saveArtifactToDb } = await import("./db-writer.js"); + const { saveArtifactToDb } = await importExtensionModule(import.meta.url, "./db-writer.js"); await saveArtifactToDb( { path: relativePath, @@ -574,6 +586,10 @@ export default function (pi: ExtensionAPI) { parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { try { + const [{ findMilestoneIds, nextMilestoneId }, { loadEffectiveGSDPreferences }] = await Promise.all([ + loadGuidedFlowModule(), + loadPreferencesModule(), + ]); const basePath = process.cwd(); const existingIds = findMilestoneIds(basePath); const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; @@ -621,15 +637,15 @@ export default function (pi: ExtensionAPI) { // Always-on health widget — ambient system health signal below the editor try { - const { initHealthWidget } = await import("./health-widget.js"); + const { initHealthWidget } = await importExtensionModule(import.meta.url, "./health-widget.js"); initHealthWidget(ctx); } catch { /* non-fatal — widget is best-effort */ } // Notify remote questions status if configured try { const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([ - import("../remote-questions/config.js"), - import("../remote-questions/status.js"), + importExtensionModule(import.meta.url, "../remote-questions/config.js"), + importExtensionModule(import.meta.url, "../remote-questions/status.js"), ]); const status = getRemoteConfigStatus(); const latest = getLatestPromptSummary(); @@ -652,6 +668,7 @@ export default function (pi: ExtensionAPI) { return; } + const { GSDDashboardOverlay } = await loadDashboardOverlayModule(); const result = await ctx.ui.custom( (tui, theme, _kb, done) => { return new GSDDashboardOverlay(tui, theme, () => done()); @@ -669,7 +686,7 @@ export default function (pi: ExtensionAPI) { // Fallback for RPC mode where ctx.ui.custom() returns undefined. if (result === undefined) { - const { fireStatusViaCommand } = await import("./commands.js"); + const { fireStatusViaCommand } = await importExtensionModule(import.meta.url, "./commands.js"); await fireStatusViaCommand(ctx); } }, @@ -681,6 +698,8 @@ export default function (pi: ExtensionAPI) { const stopContextTimer = debugTime("context-inject"); const systemContent = loadPrompt("system"); + const { loadEffectiveGSDPreferences, resolveAllSkillReferences, renderPreferencesForSystemPrompt } = + await loadPreferencesModule(); const loadedPreferences = loadEffectiveGSDPreferences(); let preferenceBlock = ""; if (loadedPreferences) { @@ -714,7 +733,7 @@ export default function (pi: ExtensionAPI) { // Inject auto-learned project memories let memoryBlock = ""; try { - const { getActiveMemoriesRanked, formatMemoriesForPrompt } = await import("./memory-store.js"); + const { getActiveMemoriesRanked, formatMemoriesForPrompt } = await importExtensionModule(import.meta.url, "./memory-store.js"); const memories = getActiveMemoriesRanked(30); if (memories.length > 0) { const formatted = formatMemoriesForPrompt(memories, 2000); @@ -744,6 +763,10 @@ export default function (pi: ExtensionAPI) { // Worktree context — override the static CWD in the system prompt let worktreeBlock = ""; + const [{ getActiveWorktreeName, getWorktreeOriginalCwd }, { getActiveAutoWorktreeContext }] = await Promise.all([ + loadWorktreeCommandModule(), + loadAutoWorktreeModule(), + ]); const worktreeName = getActiveWorktreeName(); const worktreeMainCwd = getWorktreeOriginalCwd(); const autoWorktree = getActiveAutoWorktreeContext(); @@ -807,9 +830,31 @@ export default function (pi: ExtensionAPI) { // ── agent_end: auto-mode advancement or auto-start after discuss ─────────── pi.on("agent_end", async (event, ctx: ExtensionContext) => { + const [ + { + isAutoActive, + pauseAuto, + getAutoDashboardData, + getAutoModeStartModel, + handleAgentEnd, + }, + { checkAutoStartAfterDiscuss }, + { + isTransientNetworkError, + resolveModelWithFallbacksForUnit, + getNextFallbackModel, + }, + { classifyProviderError, pauseAutoForProviderError }, + ] = await Promise.all([ + loadAutoModule(), + loadGuidedFlowModule(), + loadPreferencesModule(), + loadProviderErrorPauseModule(), + ]); + // Clean up quick-task branch if one just completed (#1269) try { - const { cleanupQuickBranch } = await import("./quick.js"); + const { cleanupQuickBranch } = await importExtensionModule(import.meta.url, "./quick.js"); cleanupQuickBranch(); } catch { /* non-fatal */ } @@ -1020,6 +1065,11 @@ export default function (pi: ExtensionAPI) { // ── session_before_compact ──────────────────────────────────────────────── pi.on("session_before_compact", async (_event, _ctx: ExtensionContext) => { + const [{ isAutoActive, isAutoPaused }, { deriveState }] = await Promise.all([ + loadAutoModule(), + loadStateModule(), + ]); + // Block compaction during auto-mode — each unit is a fresh session // Also block during paused state — context is valuable for the user if (isAutoActive() || isAutoPaused()) { @@ -1066,6 +1116,12 @@ export default function (pi: ExtensionAPI) { // ── session_shutdown: save activity log on Ctrl+C / SIGTERM ───────────── pi.on("session_shutdown", async (_event, ctx: ExtensionContext) => { + const [{ isParallelActive, shutdownParallel }, { isAutoActive, isAutoPaused, getAutoDashboardData }] = + await Promise.all([ + loadParallelOrchestratorModule(), + loadAutoModule(), + ]); + if (isParallelActive()) { try { await shutdownParallel(process.cwd()); @@ -1077,7 +1133,7 @@ export default function (pi: ExtensionAPI) { const cliWorktree = process.env.GSD_CLI_WORKTREE; if (cliWorktree) { try { - const { autoCommitCurrentBranch } = await import("./worktree.js"); + const { autoCommitCurrentBranch } = await importExtensionModule(import.meta.url, "./worktree.js"); const msg = autoCommitCurrentBranch(process.cwd(), "session-end", cliWorktree); if (msg) { ctx.ui.notify(`Auto-committed worktree ${cliWorktree} before exit.`, "info"); @@ -1102,6 +1158,7 @@ export default function (pi: ExtensionAPI) { // CONTEXT.md can be written. pi.on("tool_call", async (event) => { if (!isToolCallEventType("write", event)) return; + const { getDiscussionMilestoneId } = await loadGuidedFlowModule(); const result = shouldBlockContextWrite( event.toolName, event.input.path, @@ -1119,6 +1176,7 @@ export default function (pi: ExtensionAPI) { pi.on("tool_result", async (event) => { if (event.toolName !== "ask_user_questions") return; + const { getDiscussionMilestoneId } = await loadGuidedFlowModule(); const milestoneId = getDiscussionMilestoneId(); // Queue flows don't set pendingAutoStart, so milestoneId may be null. // Depth gate detection still applies — it sets per-milestone flags. @@ -1194,11 +1252,13 @@ export default function (pi: ExtensionAPI) { // ── tool_execution_start/end: track in-flight tools for idle detection ── pi.on("tool_execution_start", async (event) => { + const { isAutoActive, markToolStart } = await loadAutoModule(); if (!isAutoActive()) return; markToolStart(event.toolCallId); }); pi.on("tool_execution_end", async (event) => { + const { markToolEnd } = await loadAutoModule(); markToolEnd(event.toolCallId); }); } @@ -1213,6 +1273,7 @@ async function buildGuidedExecuteContextInjection(prompt: string, basePath: stri const resumeMatch = prompt.match(/Resume interrupted work\.[\s\S]*?slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i); if (resumeMatch) { const [, sliceId, milestoneId] = resumeMatch; + const { deriveState } = await loadStateModule(); const state = await deriveState(basePath); if ( state.activeMilestone?.id === milestoneId && diff --git a/src/resources/extensions/gsd/verification-evidence.ts b/src/resources/extensions/gsd/verification-evidence.ts index 0d801013a..761385f05 100644 --- a/src/resources/extensions/gsd/verification-evidence.ts +++ b/src/resources/extensions/gsd/verification-evidence.ts @@ -11,7 +11,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import type { VerificationResult } from "./types.ts"; +import type { VerificationResult } from "./types.js"; // ─── JSON Evidence Artifact ────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/worktree-command-bootstrap.ts b/src/resources/extensions/gsd/worktree-command-bootstrap.ts new file mode 100644 index 000000000..4aa9e11f6 --- /dev/null +++ b/src/resources/extensions/gsd/worktree-command-bootstrap.ts @@ -0,0 +1,46 @@ +import { importExtensionModule, type ExtensionAPI, type ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +const WORKTREE_SUBCOMMANDS = [ + { cmd: "list", desc: "List existing worktrees" }, + { cmd: "merge", desc: "Merge a worktree into a target branch" }, + { cmd: "remove", desc: "Remove a worktree and its branch" }, + { cmd: "switch", desc: "Switch into an existing worktree" }, + { cmd: "create", desc: "Create and switch into a new worktree" }, + { cmd: "return", desc: "Switch back to the main tree" }, +] as const; + +function getWorktreeCompletions(prefix: string) { + const parts = prefix.trim().split(/\s+/); + if (parts.length <= 1) { + const partial = parts[0] ?? ""; + return WORKTREE_SUBCOMMANDS + .filter((option) => option.cmd.startsWith(partial)) + .map((option) => ({ + value: option.cmd, + label: option.cmd, + description: option.desc, + })); + } + + if (parts[0] === "remove" && parts.length <= 2 && "all".startsWith(parts[1] ?? "")) { + return [{ value: "remove all", label: "all", description: "Remove all worktrees" }]; + } + + return null; +} + +function registerLazyWorktreeAlias(pi: ExtensionAPI, name: "worktree" | "wt", description: string): void { + pi.registerCommand(name, { + description, + getArgumentCompletions: getWorktreeCompletions, + handler: async (args: string, ctx: ExtensionCommandContext) => { + const { handleWorktreeCommand } = await importExtensionModule(import.meta.url, "./worktree-command.js"); + await handleWorktreeCommand(args, ctx, pi, name); + }, + }); +} + +export function registerLazyWorktreeCommands(pi: ExtensionAPI): void { + registerLazyWorktreeAlias(pi, "worktree", "Git worktrees (also /wt): /worktree | list | merge | remove"); + registerLazyWorktreeAlias(pi, "wt", "Alias for /worktree"); +} diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index d89dd3df6..8cb6ee086 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -42,13 +42,25 @@ import { getErrorMessage } from "./error-utils.js"; */ let originalCwd: string | null = null; +function ensureWorktreeStateInitialized(): void { + if (originalCwd) return; + const cwd = process.cwd(); + const marker = `${sep}.gsd${sep}worktrees${sep}`; + const markerIdx = cwd.indexOf(marker); + if (markerIdx !== -1) { + originalCwd = cwd.slice(0, markerIdx); + } +} + /** Get the original project root if currently in a worktree, or null. */ export function getWorktreeOriginalCwd(): string | null { + ensureWorktreeStateInitialized(); return originalCwd; } /** Get the name of the active worktree, or null if not in one. */ export function getActiveWorktreeName(): string | null { + ensureWorktreeStateInitialized(); if (!originalCwd) return null; const cwd = process.cwd(); const wtDir = join(gsdRoot(originalCwd), "worktrees"); @@ -104,12 +116,13 @@ function worktreeCompletions(prefix: string) { return []; } -async function worktreeHandler( +export async function handleWorktreeCommand( args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI, alias: string, ): Promise { + ensureWorktreeStateInitialized(); const trimmed = (typeof args === "string" ? args : "").trim(); const basePath = process.cwd(); @@ -233,21 +246,14 @@ export function registerWorktreeCommand(pi: ExtensionAPI): void { // Restore worktree state after /reload. // The module-level originalCwd resets to null when extensions are re-loaded, // but process.cwd() is still inside the worktree. Detect this and recover. - if (!originalCwd) { - const cwd = process.cwd(); - const marker = `${sep}.gsd${sep}worktrees${sep}`; - const markerIdx = cwd.indexOf(marker); - if (markerIdx !== -1) { - originalCwd = cwd.slice(0, markerIdx); - } - } + ensureWorktreeStateInitialized(); pi.registerCommand("worktree", { description: "Git worktrees (also /wt): /worktree | list | merge | remove", getArgumentCompletions: worktreeCompletions, async handler(args: string, ctx: ExtensionCommandContext) { - await worktreeHandler(args, ctx, pi, "worktree"); + await handleWorktreeCommand(args, ctx, pi, "worktree"); }, }); @@ -256,7 +262,7 @@ export function registerWorktreeCommand(pi: ExtensionAPI): void { description: "Alias for /worktree", getArgumentCompletions: worktreeCompletions, async handler(args: string, ctx: ExtensionCommandContext) { - await worktreeHandler(args, ctx, pi, "wt"); + await handleWorktreeCommand(args, ctx, pi, "wt"); }, }); } diff --git a/src/resources/extensions/package.json b/src/resources/extensions/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/src/resources/extensions/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/src/resources/extensions/search-the-web/command-search-provider.ts b/src/resources/extensions/search-the-web/command-search-provider.ts index ee6520e7d..0e5d6627e 100644 --- a/src/resources/extensions/search-the-web/command-search-provider.ts +++ b/src/resources/extensions/search-the-web/command-search-provider.ts @@ -18,7 +18,7 @@ import { setSearchProviderPreference, resolveSearchProvider, type SearchProviderPreference, -} from './provider.ts' +} from './provider.js' const VALID_PREFERENCES: SearchProviderPreference[] = ['tavily', 'brave', 'ollama', 'auto'] diff --git a/src/resources/extensions/search-the-web/index.ts b/src/resources/extensions/search-the-web/index.ts index af33c3058..da809375f 100644 --- a/src/resources/extensions/search-the-web/index.ts +++ b/src/resources/extensions/search-the-web/index.ts @@ -1,65 +1,48 @@ /** * Web Search Extension v4 * - * Provides three tools for grounding the agent in real-world web content: - * - * search-the-web — Rich web search with extra snippets, freshness filtering, - * domain scoping, AI summarizer, and compact output format. - * Returns links and snippets for selective browsing. - * - * fetch_page — Extract clean markdown from any URL via Jina Reader. - * Supports offset-based continuation, CSS selector targeting, - * and content-type-aware extraction. - * - * search_and_read — Single-call search + content extraction via Brave LLM Context API. - * Returns pre-extracted, relevance-scored page content. - * Best when you need content, not just links. - * - * v4: Native Anthropic web search - * - When using an Anthropic provider, injects the native `web_search_20250305` - * server-side tool via `before_provider_request`. This eliminates the need for - * a BRAVE_API_KEY when using Anthropic models — search is billed through the - * existing Anthropic API key ($0.01/search). - * - Custom Brave-based tools (search-the-web, search_and_read) are disabled when - * Anthropic + no BRAVE_API_KEY to avoid confusing the LLM with broken tools. - * - fetch_page (Jina) remains available — it works without a key at lower rate limits. - * - * v3 improvements over v2: - * - search_and_read: New tool — Brave LLM Context API (search + read in one call) - * - Structured error taxonomy: auth_error, rate_limited, network_error, etc. - * - Spellcheck surfacing: query corrections from Brave shown to agent - * - Latency tracking: API call timing in details for observability - * - Rate limit info: remaining quota surfaced when available - * - more_results_available: pagination hints from Brave - * - Adaptive snippet budget: snippet count adapts to result count - * - fetch_page offset: continuation reading for long pages - * - fetch_page selector: CSS selector targeting via Jina X-Target-Selector - * - fetch_page diagnostics: Jina failure reasons surfaced in details - * - Content-type awareness: JSON passthrough, PDF detection - * - Cache timer cleanup: purge timers use unref() to not block process exit - * - * Environment variables: - * BRAVE_API_KEY — Optional with Anthropic models (built-in search available). - * Required for non-Anthropic providers. Get one at brave.com/search/api - * JINA_API_KEY — Optional. Higher rate limits for page extraction. + * Native Anthropic hooks stay eager. Heavy tool registration is deferred in + * interactive mode so startup is not blocked on the full search tool stack. */ -import type { ExtensionAPI } from "@gsd/pi-coding-agent"; -import { registerSearchTool } from "./tool-search.js"; -import { registerFetchPageTool } from "./tool-fetch-page.js"; -import { registerLLMContextTool } from "./tool-llm-context.js"; +import { importExtensionModule, type ExtensionAPI } from "@gsd/pi-coding-agent"; import { registerSearchProviderCommand } from "./command-search-provider.js"; import { registerNativeSearchHooks } from "./native-search.js"; -export default function (pi: ExtensionAPI) { - registerSearchTool(pi); - registerFetchPageTool(pi); - registerLLMContextTool(pi); +let toolsPromise: Promise | null = null; +async function registerSearchTools(pi: ExtensionAPI): Promise { + if (!toolsPromise) { + toolsPromise = (async () => { + const [{ registerSearchTool }, { registerFetchPageTool }, { registerLLMContextTool }] = await Promise.all([ + importExtensionModule(import.meta.url, "./tool-search.js"), + importExtensionModule(import.meta.url, "./tool-fetch-page.js"), + importExtensionModule(import.meta.url, "./tool-llm-context.js"), + ]); + registerSearchTool(pi); + registerFetchPageTool(pi); + registerLLMContextTool(pi); + })().catch((error) => { + toolsPromise = null; + throw error; + }); + } - // Register slash commands - registerSearchProviderCommand(pi); - - // Register native Anthropic web search hooks - registerNativeSearchHooks(pi); + return toolsPromise; +} + +export default function (pi: ExtensionAPI) { + registerSearchProviderCommand(pi); + registerNativeSearchHooks(pi); + + pi.on("session_start", async (_event, ctx) => { + if (ctx.hasUI) { + void registerSearchTools(pi).catch((error) => { + ctx.ui.notify(`search-the-web failed to load: ${error instanceof Error ? error.message : String(error)}`, "warning"); + }); + return; + } + + await registerSearchTools(pi); + }); } diff --git a/src/resources/extensions/search-the-web/tavily.ts b/src/resources/extensions/search-the-web/tavily.ts index 391cdab30..fa50cfc84 100644 --- a/src/resources/extensions/search-the-web/tavily.ts +++ b/src/resources/extensions/search-the-web/tavily.ts @@ -6,7 +6,7 @@ * All exports are pure functions with no side effects. */ -import type { SearchResultFormatted } from "./format.ts"; +import type { SearchResultFormatted } from "./format.js"; // ============================================================================= // Tavily API Types diff --git a/src/startup-timings.ts b/src/startup-timings.ts new file mode 100644 index 000000000..0279b360c --- /dev/null +++ b/src/startup-timings.ts @@ -0,0 +1,23 @@ +const flag = (process.env.GSD_STARTUP_TIMING ?? process.env.PI_TIMING ?? "").toLowerCase(); +const ENABLED = flag === "1" || flag === "true" || flag === "yes"; + +const timings: Array<{ label: string; ms: number }> = []; +let lastTime = Date.now(); + +export function markStartup(label: string): void { + if (!ENABLED) return; + const now = Date.now(); + timings.push({ label, ms: now - lastTime }); + lastTime = now; +} + +export function printStartupTimings(): void { + if (!ENABLED || timings.length === 0) return; + const total = timings.reduce((sum, timing) => sum + timing.ms, 0); + process.stderr.write("\n--- GSD Startup Timings ---\n"); + for (const timing of timings) { + process.stderr.write(` ${timing.label}: ${timing.ms}ms\n`); + } + process.stderr.write(` TOTAL: ${total}ms\n`); + process.stderr.write("----------------------------\n\n"); +} diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index b0306f04b..abf1b582e 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -20,6 +20,14 @@ import { fileURLToPath } from "node:url"; const projectRoot = join(fileURLToPath(import.meta.url), "..", "..", ".."); +function assertExtensionIndexExists(agentDir: string, extensionName: string): void { + assert.ok( + existsSync(join(agentDir, "extensions", extensionName, "index.js")) + || existsSync(join(agentDir, "extensions", extensionName, "index.ts")), + `${extensionName} extension synced`, + ); +} + // ═══════════════════════════════════════════════════════════════════════════ // 1. app-paths // ═══════════════════════════════════════════════════════════════════════════ @@ -111,7 +119,7 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => { // Spot-check that core extensions are discoverable const discoveredNames = discovered.map(p => { const rel = p.slice(bundledExtensionsDir.length + 1); - return rel.split(/[\\/]/)[0].replace(/\.ts$/, ""); + return rel.split(/[\\/]/)[0].replace(/\.(?:ts|js)$/, ""); }); for (const core of ["gsd", "bg-shell", "browser-tools", "subagent", "search-the-web"]) { assert.ok(discoveredNames.includes(core), `core extension '${core}' is discoverable`); @@ -133,11 +141,11 @@ test("initResources syncs extensions, agents, and skills to target dir", async ( initResources(fakeAgentDir); // Extensions synced - assert.ok(existsSync(join(fakeAgentDir, "extensions", "gsd", "index.ts")), "gsd extension synced"); - assert.ok(existsSync(join(fakeAgentDir, "extensions", "browser-tools", "index.ts")), "browser-tools synced"); - assert.ok(existsSync(join(fakeAgentDir, "extensions", "search-the-web", "index.ts")), "search-the-web synced"); - assert.ok(existsSync(join(fakeAgentDir, "extensions", "context7", "index.ts")), "context7 synced"); - assert.ok(existsSync(join(fakeAgentDir, "extensions", "subagent", "index.ts")), "subagent synced"); + assertExtensionIndexExists(fakeAgentDir, "gsd"); + assertExtensionIndexExists(fakeAgentDir, "browser-tools"); + assertExtensionIndexExists(fakeAgentDir, "search-the-web"); + assertExtensionIndexExists(fakeAgentDir, "context7"); + assertExtensionIndexExists(fakeAgentDir, "subagent"); // Agents synced assert.ok(existsSync(join(fakeAgentDir, "agents", "scout.md")), "scout agent synced"); @@ -151,7 +159,7 @@ test("initResources syncs extensions, agents, and skills to target dir", async ( // Idempotent: run again, no crash initResources(fakeAgentDir); - assert.ok(existsSync(join(fakeAgentDir, "extensions", "gsd", "index.ts")), "idempotent re-sync works"); + assertExtensionIndexExists(fakeAgentDir, "gsd"); } finally { rmSync(tmp, { recursive: true, force: true }); } diff --git a/src/tests/resource-loader.test.ts b/src/tests/resource-loader.test.ts new file mode 100644 index 000000000..6f65870bb --- /dev/null +++ b/src/tests/resource-loader.test.ts @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join, parse } from "node:path"; +import { tmpdir } from "node:os"; + +function overrideHomeEnv(homeDir: string): () => void { + const original = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + HOMEDRIVE: process.env.HOMEDRIVE, + HOMEPATH: process.env.HOMEPATH, + }; + + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + + if (process.platform === "win32") { + const parsedHome = parse(homeDir); + process.env.HOMEDRIVE = parsedHome.root.replace(/[\\/]+$/, ""); + + const homePath = homeDir.slice(parsedHome.root.length).replace(/\//g, "\\"); + process.env.HOMEPATH = homePath.startsWith("\\") ? homePath : `\\${homePath}`; + } + + return () => { + if (original.HOME === undefined) delete process.env.HOME; else process.env.HOME = original.HOME; + if (original.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = original.USERPROFILE; + if (original.HOMEDRIVE === undefined) delete process.env.HOMEDRIVE; else process.env.HOMEDRIVE = original.HOMEDRIVE; + if (original.HOMEPATH === undefined) delete process.env.HOMEPATH; else process.env.HOMEPATH = original.HOMEPATH; + }; +} + +test("getExtensionKey normalizes top-level .ts and .js entry names to the same key", async () => { + const { getExtensionKey } = await import("../resource-loader.ts"); + const extensionsDir = "/tmp/extensions"; + + assert.equal( + getExtensionKey("/tmp/extensions/ask-user-questions.ts", extensionsDir), + "ask-user-questions", + ); + assert.equal( + getExtensionKey("/tmp/extensions/ask-user-questions.js", extensionsDir), + "ask-user-questions", + ); + assert.equal( + getExtensionKey("/tmp/extensions/gsd/index.js", extensionsDir), + "gsd", + ); +}); + +test("hasStaleCompiledExtensionSiblings only flags top-level .ts/.js sibling pairs", async () => { + const { hasStaleCompiledExtensionSiblings } = await import("../resource-loader.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-resource-loader-")); + const extensionsDir = join(tmp, "extensions"); + + try { + mkdirSync(join(extensionsDir, "gsd"), { recursive: true }); + writeFileSync(join(extensionsDir, "gsd", "index.ts"), "export {};\n"); + assert.equal(hasStaleCompiledExtensionSiblings(extensionsDir), false); + + writeFileSync(join(extensionsDir, "ask-user-questions.js"), "export {};\n"); + assert.equal(hasStaleCompiledExtensionSiblings(extensionsDir), false); + + writeFileSync(join(extensionsDir, "ask-user-questions.ts"), "export {};\n"); + assert.equal(hasStaleCompiledExtensionSiblings(extensionsDir), true); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("buildResourceLoader excludes duplicate top-level pi extensions when bundled resources use .js", async () => { + const tmp = mkdtempSync(join(tmpdir(), "gsd-resource-loader-home-")); + const piExtensionsDir = join(tmp, ".pi", "agent", "extensions"); + const fakeAgentDir = join(tmp, ".gsd", "agent"); + const restoreHomeEnv = overrideHomeEnv(tmp); + + try { + mkdirSync(piExtensionsDir, { recursive: true }); + writeFileSync(join(piExtensionsDir, "ask-user-questions.ts"), "export {};\n"); + writeFileSync(join(piExtensionsDir, "custom-extension.ts"), "export {};\n"); + + const { buildResourceLoader } = await import("../resource-loader.ts"); + const loader = buildResourceLoader(fakeAgentDir) as { additionalExtensionPaths?: string[] }; + const additionalExtensionPaths = loader.additionalExtensionPaths ?? []; + + assert.equal( + additionalExtensionPaths.some((entryPath) => entryPath.endsWith("ask-user-questions.ts")), + false, + "bundled compiled extensions should suppress duplicate pi top-level .ts siblings", + ); + assert.equal( + additionalExtensionPaths.some((entryPath) => entryPath.endsWith("custom-extension.ts")), + true, + "non-duplicate pi extensions should still load", + ); + } finally { + restoreHomeEnv(); + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("initResources prunes stale top-level .ts siblings next to bundled compiled extensions", async () => { + const { initResources } = await import("../resource-loader.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-resource-loader-sync-")); + const fakeAgentDir = join(tmp, "agent"); + const staleTsPath = join(fakeAgentDir, "extensions", "ask-user-questions.ts"); + const bundledJsPath = join(fakeAgentDir, "extensions", "ask-user-questions.js"); + + try { + initResources(fakeAgentDir); + assert.equal(existsSync(bundledJsPath), true, "compiled bundled top-level extension should exist"); + + writeFileSync(staleTsPath, "export {};\n"); + assert.equal(existsSync(staleTsPath), true); + + initResources(fakeAgentDir); + + assert.equal(existsSync(staleTsPath), false, "stale .ts sibling should be removed during sync"); + assert.equal(existsSync(bundledJsPath), true, "bundled .js extension should remain after cleanup"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tsconfig.resources.json b/tsconfig.resources.json new file mode 100644 index 000000000..00702c3b5 --- /dev/null +++ b/tsconfig.resources.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src/resources", + "outDir": "dist/resources", + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/resources/extensions/**/*.ts"], + "exclude": ["src/resources/extensions/**/tests/**", "src/resources/extensions/**/*.test.ts", "src/resources/extensions/**/*.test.mts"] +}