Improve startup performance with lazy extension loading (#1336)

This commit is contained in:
Jeremy McSpadden 2026-03-19 08:38:50 -05:00 committed by GitHub
parent b67101c51b
commit d7bf3d4e72
22 changed files with 985 additions and 259 deletions

View file

@ -7,6 +7,7 @@ export {
createExtensionRuntime,
discoverAndLoadExtensions,
getUntrustedExtensionPaths,
importExtensionModule,
isProjectTrusted,
loadExtensionFromFactory,
loadExtensions,

View file

@ -67,6 +67,12 @@ const VIRTUAL_MODULES: Record<string, unknown> = {
};
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<string, string> {
return _aliases;
}
function getJitiOptions() {
return isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() };
}
const _moduleImporters = new Map<string, ReturnType<typeof createJiti>>();
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<T = unknown>(parentModuleUrl: string, specifier: string): Promise<T> {
const importer = getModuleImporter(parentModuleUrl);
const resolvedPath = fileURLToPath(new URL(specifier, parentModuleUrl));
return importer.import(resolvedPath) as Promise<T>;
}
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}` };
}
}

View file

@ -127,6 +127,7 @@ export {
createExtensionRuntime,
discoverAndLoadExtensions,
ExtensionRunner,
importExtensionModule,
isBashToolResult,
isEditToolResult,
isFindToolResult,

View file

@ -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');

View file

@ -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()

View file

@ -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).

View file

@ -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<void> | null = null;
async function registerBgShellFeatures(pi: ExtensionAPI, state: BgShellSharedState): Promise<void> {
if (!featuresPromise) {
featuresPromise = (async () => {
const [{ registerBgShellTool }, { registerBgShellCommand }] = await Promise.all([
importExtensionModule<typeof import("./bg-shell-tool.js")>(import.meta.url, "./bg-shell-tool.js"),
importExtensionModule<typeof import("./bg-shell-command.js")>(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);
});
}

View file

@ -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<void> | null = null;
async function registerBrowserTools(pi: ExtensionAPI): Promise<void> {
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<typeof import("./lifecycle.js")>(import.meta.url, "./lifecycle.js"),
importExtensionModule<typeof import("./capture.js")>(import.meta.url, "./capture.js"),
importExtensionModule<typeof import("./settle.js")>(import.meta.url, "./settle.js"),
importExtensionModule<typeof import("./refs.js")>(import.meta.url, "./refs.js"),
importExtensionModule<typeof import("./utils.js")>(import.meta.url, "./utils.js"),
importExtensionModule<typeof import("./tools/navigation.js")>(import.meta.url, "./tools/navigation.js"),
importExtensionModule<typeof import("./tools/screenshot.js")>(import.meta.url, "./tools/screenshot.js"),
importExtensionModule<typeof import("./tools/interaction.js")>(import.meta.url, "./tools/interaction.js"),
importExtensionModule<typeof import("./tools/inspection.js")>(import.meta.url, "./tools/inspection.js"),
importExtensionModule<typeof import("./tools/session.js")>(import.meta.url, "./tools/session.js"),
importExtensionModule<typeof import("./tools/assertions.js")>(import.meta.url, "./tools/assertions.js"),
importExtensionModule<typeof import("./tools/refs.js")>(import.meta.url, "./tools/refs.js"),
importExtensionModule<typeof import("./tools/wait.js")>(import.meta.url, "./tools/wait.js"),
importExtensionModule<typeof import("./tools/pages.js")>(import.meta.url, "./tools/pages.js"),
importExtensionModule<typeof import("./tools/forms.js")>(import.meta.url, "./tools/forms.js"),
importExtensionModule<typeof import("./tools/intent.js")>(import.meta.url, "./tools/intent.js"),
importExtensionModule<typeof import("./tools/pdf.js")>(import.meta.url, "./tools/pdf.js"),
importExtensionModule<typeof import("./tools/state-persistence.js")>(import.meta.url, "./tools/state-persistence.js"),
importExtensionModule<typeof import("./tools/network-mock.js")>(import.meta.url, "./tools/network-mock.js"),
importExtensionModule<typeof import("./tools/device.js")>(import.meta.url, "./tools/device.js"),
importExtensionModule<typeof import("./tools/extract.js")>(import.meta.url, "./tools/extract.js"),
importExtensionModule<typeof import("./tools/visual-diff.js")>(import.meta.url, "./tools/visual-diff.js"),
importExtensionModule<typeof import("./tools/zoom.js")>(import.meta.url, "./tools/zoom.js"),
importExtensionModule<typeof import("./tools/codegen.js")>(import.meta.url, "./tools/codegen.js"),
importExtensionModule<typeof import("./tools/action-cache.js")>(import.meta.url, "./tools/action-cache.js"),
importExtensionModule<typeof import("./tools/injection-detect.js")>(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<typeof import("./lifecycle.js")>(import.meta.url, "./lifecycle.js");
await closeBrowser();
});
}

View file

@ -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<typeof import("./commands.js")>(import.meta.url, "./commands.js");
await handleGSDCommand(args, ctx, pi);
},
});
}

View file

@ -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<void> {
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 {

View file

@ -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<T>(loader: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | null = null;
return () => {
if (!promise) {
promise = loader();
}
return promise;
};
}
const loadAutoModule = memoizeImport(() => importExtensionModule<typeof import("./auto.js")>(import.meta.url, "./auto.js"));
const loadStateModule = memoizeImport(() => importExtensionModule<typeof import("./state.js")>(import.meta.url, "./state.js"));
const loadGuidedFlowModule = memoizeImport(() => importExtensionModule<typeof import("./guided-flow.js")>(import.meta.url, "./guided-flow.js"));
const loadPreferencesModule = memoizeImport(() => importExtensionModule<typeof import("./preferences.js")>(import.meta.url, "./preferences.js"));
const loadDashboardOverlayModule = memoizeImport(() => importExtensionModule<typeof import("./dashboard-overlay.js")>(import.meta.url, "./dashboard-overlay.js"));
const loadWorktreeCommandModule = memoizeImport(() => importExtensionModule<typeof import("./worktree-command.js")>(import.meta.url, "./worktree-command.js"));
const loadAutoWorktreeModule = memoizeImport(() => importExtensionModule<typeof import("./auto-worktree.js")>(import.meta.url, "./auto-worktree.js"));
const loadProviderErrorPauseModule = memoizeImport(() => importExtensionModule<typeof import("./provider-error-pause.js")>(import.meta.url, "./provider-error-pause.js"));
const loadParallelOrchestratorModule = memoizeImport(() => importExtensionModule<typeof import("./parallel-orchestrator.js")>(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<boolean> {
try {
const db = await import("./gsd-db.js");
const db = await importExtensionModule<typeof import("./gsd-db.js")>(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<typeof import("./db-writer.js")>(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<typeof import("./gsd-db.js")>(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<typeof import("./db-writer.js")>(import.meta.url, "./db-writer.js");
const updates: Record<string, string | undefined> = {};
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<typeof import("./db-writer.js")>(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<typeof import("./health-widget.js")>(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<typeof import("../remote-questions/config.js")>(import.meta.url, "../remote-questions/config.js"),
importExtensionModule<typeof import("../remote-questions/status.js")>(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<void>(
(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<typeof import("./commands.js")>(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<typeof import("./memory-store.js")>(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<typeof import("./quick.js")>(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<typeof import("./worktree.js")>(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 &&

View file

@ -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 ──────────────────────────────────────────────────

View file

@ -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<typeof import("./worktree-command.js")>(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 <name> | list | merge | remove");
registerLazyWorktreeAlias(pi, "wt", "Alias for /worktree");
}

View file

@ -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<void> {
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 <name> | 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");
},
});
}

View file

@ -0,0 +1,3 @@
{
"type": "module"
}

View file

@ -18,7 +18,7 @@ import {
setSearchProviderPreference,
resolveSearchProvider,
type SearchProviderPreference,
} from './provider.ts'
} from './provider.js'
const VALID_PREFERENCES: SearchProviderPreference[] = ['tavily', 'brave', 'ollama', 'auto']

View file

@ -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<void> | null = null;
async function registerSearchTools(pi: ExtensionAPI): Promise<void> {
if (!toolsPromise) {
toolsPromise = (async () => {
const [{ registerSearchTool }, { registerFetchPageTool }, { registerLLMContextTool }] = await Promise.all([
importExtensionModule<typeof import("./tool-search.js")>(import.meta.url, "./tool-search.js"),
importExtensionModule<typeof import("./tool-fetch-page.js")>(import.meta.url, "./tool-fetch-page.js"),
importExtensionModule<typeof import("./tool-llm-context.js")>(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);
});
}

View file

@ -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

23
src/startup-timings.ts Normal file
View file

@ -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");
}

View file

@ -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 });
}

View file

@ -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 });
}
});

12
tsconfig.resources.json Normal file
View file

@ -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"]
}