Improve startup performance with lazy extension loading (#1336)
This commit is contained in:
parent
b67101c51b
commit
d7bf3d4e72
22 changed files with 985 additions and 259 deletions
|
|
@ -7,6 +7,7 @@ export {
|
|||
createExtensionRuntime,
|
||||
discoverAndLoadExtensions,
|
||||
getUntrustedExtensionPaths,
|
||||
importExtensionModule,
|
||||
isProjectTrusted,
|
||||
loadExtensionFromFactory,
|
||||
loadExtensions,
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export {
|
|||
createExtensionRuntime,
|
||||
discoverAndLoadExtensions,
|
||||
ExtensionRunner,
|
||||
importExtensionModule,
|
||||
isBashToolResult,
|
||||
isEditToolResult,
|
||||
isFindToolResult,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
16
src/cli.ts
16
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()
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
252
src/resources/extensions/gsd/commands-bootstrap.ts
Normal file
252
src/resources/extensions/gsd/commands-bootstrap.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
46
src/resources/extensions/gsd/worktree-command-bootstrap.ts
Normal file
46
src/resources/extensions/gsd/worktree-command-bootstrap.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
3
src/resources/extensions/package.json
Normal file
3
src/resources/extensions/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
setSearchProviderPreference,
|
||||
resolveSearchProvider,
|
||||
type SearchProviderPreference,
|
||||
} from './provider.ts'
|
||||
} from './provider.js'
|
||||
|
||||
const VALID_PREFERENCES: SearchProviderPreference[] = ['tavily', 'brave', 'ollama', 'auto']
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
23
src/startup-timings.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
124
src/tests/resource-loader.test.ts
Normal file
124
src/tests/resource-loader.test.ts
Normal 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
12
tsconfig.resources.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue