fix: clean up git state after directory restoration
- Accept deletion of gsd-phase-state.ts (renamed to forge-phase-state.ts earlier) - Accept deletion of create-gsd-extension/ (renamed to create-forge-extension/ earlier) - These renames were part of the rebrand and are preserved in commit history Stabilize git state after restoration operations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a81fa3ae4a
commit
d501ca7d6d
69 changed files with 105 additions and 2459 deletions
|
|
@ -6,23 +6,23 @@ import { _buildImportCandidates } from "./workflow-tools.js";
|
|||
|
||||
describe("_buildImportCandidates", () => {
|
||||
it("includes dist/ fallback for src/ paths", () => {
|
||||
const candidates = _buildImportCandidates("../../../src/resources/extensions/gsd/db-writer.js");
|
||||
const candidates = _buildImportCandidates("../../../src/resources/extensions/sf/db-writer.js");
|
||||
assert.ok(
|
||||
candidates.some((c) => c.includes("/dist/resources/extensions/gsd/db-writer.js")),
|
||||
candidates.some((c) => c.includes("/dist/resources/extensions/sf/db-writer.js")),
|
||||
"should include dist/ swapped candidate",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes src/ fallback for dist/ paths", () => {
|
||||
const candidates = _buildImportCandidates("../../../dist/resources/extensions/gsd/db-writer.js");
|
||||
const candidates = _buildImportCandidates("../../../dist/resources/extensions/sf/db-writer.js");
|
||||
assert.ok(
|
||||
candidates.some((c) => c.includes("/src/resources/extensions/gsd/db-writer.js")),
|
||||
candidates.some((c) => c.includes("/src/resources/extensions/sf/db-writer.js")),
|
||||
"should include src/ swapped candidate",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes .ts variants for .js paths", () => {
|
||||
const candidates = _buildImportCandidates("../../../src/resources/extensions/gsd/db-writer.js");
|
||||
const candidates = _buildImportCandidates("../../../src/resources/extensions/sf/db-writer.js");
|
||||
assert.ok(
|
||||
candidates.some((c) => c.endsWith("db-writer.ts") && c.includes("/src/")),
|
||||
"should include .ts variant for original src/ path",
|
||||
|
|
@ -34,7 +34,7 @@ describe("_buildImportCandidates", () => {
|
|||
});
|
||||
|
||||
it("returns original path first", () => {
|
||||
const input = "../../../src/resources/extensions/gsd/db-writer.js";
|
||||
const input = "../../../src/resources/extensions/sf/db-writer.js";
|
||||
const candidates = _buildImportCandidates(input);
|
||||
assert.equal(candidates[0], input, "first candidate should be the original path");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { _getAdapter, closeDatabase } from "../../../src/resources/extensions/gsd/gsd-db.ts";
|
||||
import { _getAdapter, closeDatabase } from "../../../src/resources/extensions/sf/gsd-db.ts";
|
||||
import { registerWorkflowTools, WORKFLOW_TOOL_NAMES } from "./workflow-tools.ts";
|
||||
|
||||
function makeTmpBase(): string {
|
||||
|
|
@ -363,7 +363,7 @@ describe("workflow MCP tools", () => {
|
|||
title: "Add planning bridge",
|
||||
description: "Implement the shared executor path.",
|
||||
estimate: "15m",
|
||||
files: ["src/resources/extensions/gsd/tools/workflow-tool-executors.ts"],
|
||||
files: ["src/resources/extensions/sf/tools/workflow-tool-executors.ts"],
|
||||
verify: "node --test",
|
||||
inputs: ["ROADMAP.md"],
|
||||
expectedOutput: ["S01-PLAN.md", "T01-PLAN.md"],
|
||||
|
|
|
|||
|
|
@ -329,9 +329,9 @@ function getWriteGateModuleCandidates(): string[] {
|
|||
}
|
||||
|
||||
candidates.push(
|
||||
new URL("../../../src/resources/extensions/gsd/bootstrap/write-gate.js", import.meta.url).href,
|
||||
new URL("../../../dist/resources/extensions/gsd/bootstrap/write-gate.js", import.meta.url).href,
|
||||
new URL("../../../src/resources/extensions/gsd/bootstrap/write-gate.ts", import.meta.url).href,
|
||||
new URL("../../../src/resources/extensions/sf/bootstrap/write-gate.js", import.meta.url).href,
|
||||
new URL("../../../dist/resources/extensions/sf/bootstrap/write-gate.js", import.meta.url).href,
|
||||
new URL("../../../src/resources/extensions/sf/bootstrap/write-gate.ts", import.meta.url).href,
|
||||
);
|
||||
|
||||
return [...new Set(candidates)];
|
||||
|
|
@ -387,9 +387,9 @@ function getWorkflowExecutorModuleCandidates(env: NodeJS.ProcessEnv = process.en
|
|||
}
|
||||
|
||||
candidates.push(
|
||||
new URL("../../../src/resources/extensions/gsd/tools/workflow-tool-executors.js", import.meta.url).href,
|
||||
new URL("../../../dist/resources/extensions/gsd/tools/workflow-tool-executors.js", import.meta.url).href,
|
||||
new URL("../../../src/resources/extensions/gsd/tools/workflow-tool-executors.ts", import.meta.url).href,
|
||||
new URL("../../../src/resources/extensions/sf/tools/workflow-tool-executors.js", import.meta.url).href,
|
||||
new URL("../../../dist/resources/extensions/sf/tools/workflow-tool-executors.js", import.meta.url).href,
|
||||
new URL("../../../src/resources/extensions/sf/tools/workflow-tool-executors.ts", import.meta.url).href,
|
||||
);
|
||||
|
||||
return [...new Set(candidates)];
|
||||
|
|
@ -414,7 +414,7 @@ async function getWorkflowToolExecutors(): Promise<WorkflowToolExecutors> {
|
|||
throw new Error(
|
||||
"Unable to load GSD workflow executor bridge for MCP mutation tools. " +
|
||||
"Set GSD_WORKFLOW_EXECUTORS_MODULE to an importable workflow-tool-executors module, " +
|
||||
"or run the MCP server from a GSD checkout that includes src/resources/extensions/gsd/tools/workflow-tool-executors.(js|ts). " +
|
||||
"or run the MCP server from a GSD checkout that includes src/resources/extensions/sf/tools/workflow-tool-executors.(js|ts). " +
|
||||
`Attempts: ${attempts.join("; ")}`,
|
||||
);
|
||||
})();
|
||||
|
|
@ -516,7 +516,7 @@ async function runSerializedWorkflowDbOperation<T>(
|
|||
): Promise<T> {
|
||||
return runSerializedWorkflowOperation(async () => {
|
||||
const { ensureDbOpen } = await importLocalModule<WorkflowDbBootstrapModule>(
|
||||
"../../../src/resources/extensions/gsd/bootstrap/dynamic-tools.js",
|
||||
"../../../src/resources/extensions/sf/bootstrap/dynamic-tools.js",
|
||||
);
|
||||
const dbAvailable = await ensureDbOpen(projectDir);
|
||||
if (!dbAvailable) {
|
||||
|
|
@ -657,7 +657,7 @@ async function handleSaveGateResult(
|
|||
|
||||
async function ensureMilestoneDbRow(milestoneId: string): Promise<void> {
|
||||
try {
|
||||
const { insertMilestone } = await importLocalModule<any>("../../../src/resources/extensions/gsd/gsd-db.js");
|
||||
const { insertMilestone } = await importLocalModule<any>("../../../src/resources/extensions/sf/gsd-db.js");
|
||||
insertMilestone({ id: milestoneId, status: "queued" });
|
||||
} catch {
|
||||
// Ignore pre-existing rows or transient DB availability issues.
|
||||
|
|
@ -990,7 +990,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
const { projectDir, ...params } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_decision_save", projectDir);
|
||||
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
|
||||
const { saveDecisionToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
|
||||
const { saveDecisionToDb } = await importLocalModule<any>("../../../src/resources/extensions/sf/db-writer.js");
|
||||
return saveDecisionToDb(params, projectDir);
|
||||
});
|
||||
return { content: [{ type: "text" as const, text: `Saved decision ${result.id}` }] };
|
||||
|
|
@ -1006,7 +1006,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
const { projectDir, ...params } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_decision_save", projectDir);
|
||||
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
|
||||
const { saveDecisionToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
|
||||
const { saveDecisionToDb } = await importLocalModule<any>("../../../src/resources/extensions/sf/db-writer.js");
|
||||
return saveDecisionToDb(params, projectDir);
|
||||
});
|
||||
return { content: [{ type: "text" as const, text: `Saved decision ${result.id}` }] };
|
||||
|
|
@ -1022,7 +1022,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
const { projectDir, id, ...updates } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_requirement_update", projectDir);
|
||||
await runSerializedWorkflowDbOperation(projectDir, async () => {
|
||||
const { updateRequirementInDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
|
||||
const { updateRequirementInDb } = await importLocalModule<any>("../../../src/resources/extensions/sf/db-writer.js");
|
||||
return updateRequirementInDb(id, updates, projectDir);
|
||||
});
|
||||
return { content: [{ type: "text" as const, text: `Updated requirement ${id}` }] };
|
||||
|
|
@ -1038,7 +1038,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
const { projectDir, id, ...updates } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_requirement_update", projectDir);
|
||||
await runSerializedWorkflowDbOperation(projectDir, async () => {
|
||||
const { updateRequirementInDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
|
||||
const { updateRequirementInDb } = await importLocalModule<any>("../../../src/resources/extensions/sf/db-writer.js");
|
||||
return updateRequirementInDb(id, updates, projectDir);
|
||||
});
|
||||
return { content: [{ type: "text" as const, text: `Updated requirement ${id}` }] };
|
||||
|
|
@ -1054,7 +1054,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
const { projectDir, ...params } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_requirement_save", projectDir);
|
||||
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
|
||||
const { saveRequirementToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
|
||||
const { saveRequirementToDb } = await importLocalModule<any>("../../../src/resources/extensions/sf/db-writer.js");
|
||||
return saveRequirementToDb(params, projectDir);
|
||||
});
|
||||
return { content: [{ type: "text" as const, text: `Saved requirement ${result.id}` }] };
|
||||
|
|
@ -1070,7 +1070,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
const { projectDir, ...params } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_requirement_save", projectDir);
|
||||
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
|
||||
const { saveRequirementToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
|
||||
const { saveRequirementToDb } = await importLocalModule<any>("../../../src/resources/extensions/sf/db-writer.js");
|
||||
return saveRequirementToDb(params, projectDir);
|
||||
});
|
||||
return { content: [{ type: "text" as const, text: `Saved requirement ${result.id}` }] };
|
||||
|
|
@ -1090,7 +1090,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
findMilestoneIds,
|
||||
getReservedMilestoneIds,
|
||||
nextMilestoneId,
|
||||
} = await importLocalModule<any>("../../../src/resources/extensions/gsd/milestone-ids.js");
|
||||
} = await importLocalModule<any>("../../../src/resources/extensions/sf/milestone-ids.js");
|
||||
const reserved = claimReservedId();
|
||||
if (reserved) {
|
||||
await ensureMilestoneDbRow(reserved);
|
||||
|
|
@ -1118,7 +1118,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
findMilestoneIds,
|
||||
getReservedMilestoneIds,
|
||||
nextMilestoneId,
|
||||
} = await importLocalModule<any>("../../../src/resources/extensions/gsd/milestone-ids.js");
|
||||
} = await importLocalModule<any>("../../../src/resources/extensions/sf/milestone-ids.js");
|
||||
const reserved = claimReservedId();
|
||||
if (reserved) {
|
||||
await ensureMilestoneDbRow(reserved);
|
||||
|
|
@ -1168,7 +1168,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
const { projectDir, ...params } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_plan_task", projectDir, params.milestoneId);
|
||||
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
|
||||
const { handlePlanTask } = await importLocalModule<any>("../../../src/resources/extensions/gsd/tools/plan-task.js");
|
||||
const { handlePlanTask } = await importLocalModule<any>("../../../src/resources/extensions/sf/tools/plan-task.js");
|
||||
return handlePlanTask(params, projectDir);
|
||||
});
|
||||
if ("error" in result) {
|
||||
|
|
@ -1189,7 +1189,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
const { projectDir, ...params } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_plan_task", projectDir, params.milestoneId);
|
||||
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
|
||||
const { handlePlanTask } = await importLocalModule<any>("../../../src/resources/extensions/gsd/tools/plan-task.js");
|
||||
const { handlePlanTask } = await importLocalModule<any>("../../../src/resources/extensions/sf/tools/plan-task.js");
|
||||
return handlePlanTask(params, projectDir);
|
||||
});
|
||||
if ("error" in result) {
|
||||
|
|
@ -1249,9 +1249,9 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
const { projectDir, milestoneId, sliceId, reason } = parseWorkflowArgs(skipSliceSchema, args);
|
||||
await enforceWorkflowWriteGate("gsd_skip_slice", projectDir, milestoneId);
|
||||
await runSerializedWorkflowDbOperation(projectDir, async () => {
|
||||
const { getSlice, updateSliceStatus } = await importLocalModule<any>("../../../src/resources/extensions/gsd/gsd-db.js");
|
||||
const { invalidateStateCache } = await importLocalModule<any>("../../../src/resources/extensions/gsd/state.js");
|
||||
const { rebuildState } = await importLocalModule<any>("../../../src/resources/extensions/gsd/doctor.js");
|
||||
const { getSlice, updateSliceStatus } = await importLocalModule<any>("../../../src/resources/extensions/sf/gsd-db.js");
|
||||
const { invalidateStateCache } = await importLocalModule<any>("../../../src/resources/extensions/sf/state.js");
|
||||
const { rebuildState } = await importLocalModule<any>("../../../src/resources/extensions/sf/doctor.js");
|
||||
const slice = getSlice(milestoneId, sliceId);
|
||||
if (!slice) {
|
||||
throw new Error(`Slice ${sliceId} not found in milestone ${milestoneId}`);
|
||||
|
|
@ -1402,7 +1402,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
journalQueryParams,
|
||||
async (args: Record<string, unknown>) => {
|
||||
const { projectDir, limit, ...filters } = parseWorkflowArgs(journalQuerySchema, args);
|
||||
const { queryJournal } = await importLocalModule<any>("../../../src/resources/extensions/gsd/journal.js");
|
||||
const { queryJournal } = await importLocalModule<any>("../../../src/resources/extensions/sf/journal.js");
|
||||
const entries = queryJournal(projectDir, filters).slice(0, limit ?? 100);
|
||||
if (entries.length === 0) {
|
||||
return { content: [{ type: "text" as const, text: "No matching journal entries found." }] };
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { stopWebMode } from './web-mode.js'
|
|||
import { getProjectSessionsDir } from './project-sessions.js'
|
||||
import { markStartup, printStartupTimings } from './startup-timings.js'
|
||||
import { bootstrapRtk, GSD_RTK_DISABLED_ENV } from './rtk.js'
|
||||
import { loadEffectiveGSDPreferences } from './resources/extensions/gsd/preferences.js'
|
||||
import { loadEffectiveGSDPreferences } from './resources/extensions/sf/preferences.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V8 compile cache — Node 22+ can cache compiled bytecode across runs,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { createJiti } from '@mariozechner/jiti'
|
|||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import type { GSDState } from './resources/extensions/gsd/types.js'
|
||||
import type { GSDState } from './resources/extensions/sf/types.js'
|
||||
import { resolveBundledSourceResource } from './bundled-resource-path.js'
|
||||
|
||||
const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true, debug: false })
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* GSD Phase State — cross-extension coordination
|
||||
* Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
*
|
||||
* Lightweight module-level state that GSD auto-mode writes to and the
|
||||
* subagent tool reads from. Both extensions run in the same process so
|
||||
* a module variable is sufficient — no file I/O needed.
|
||||
*/
|
||||
|
||||
let _active = false;
|
||||
let _currentPhase: string | null = null;
|
||||
|
||||
/** Mark GSD auto-mode as active. */
|
||||
export function activateGSD(): void {
|
||||
_active = true;
|
||||
}
|
||||
|
||||
/** Mark GSD auto-mode as inactive and clear the current phase. */
|
||||
export function deactivateGSD(): void {
|
||||
_active = false;
|
||||
_currentPhase = null;
|
||||
}
|
||||
|
||||
/** Set the currently dispatched GSD phase (e.g. "plan-milestone"). */
|
||||
export function setCurrentPhase(phase: string): void {
|
||||
_currentPhase = phase;
|
||||
}
|
||||
|
||||
/** Clear the current phase (unit completed or aborted). */
|
||||
export function clearCurrentPhase(): void {
|
||||
_currentPhase = null;
|
||||
}
|
||||
|
||||
/** Returns true if GSD auto-mode is currently active. */
|
||||
export function isGSDActive(): boolean {
|
||||
return _active;
|
||||
}
|
||||
|
||||
/** Returns the current GSD phase, or null if none is active. */
|
||||
export function getCurrentPhase(): string | null {
|
||||
return _active ? _currentPhase : null;
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
---
|
||||
name: create-gsd-extension
|
||||
description: Create, debug, and iterate on GSD extensions (TypeScript modules that add tools, commands, event hooks, custom UI, and providers to GSD). Use when asked to build an extension, add a tool the LLM can call, register a slash command, hook into GSD events, create custom TUI components, or modify GSD behavior. Triggers on "create extension", "build extension", "add a tool", "register command", "hook into gsd", "custom tool", "gsd plugin", "gsd extension".
|
||||
---
|
||||
|
||||
<essential_principles>
|
||||
|
||||
**Extensions are TypeScript modules** that hook into GSD's runtime (built on pi). They export a default function receiving `ExtensionAPI` and use it to subscribe to events, register tools/commands/shortcuts, and interact with the session.
|
||||
|
||||
**GSD extension paths (community/user-installed extensions):**
|
||||
- Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts`
|
||||
- Project-local: `.gsd/extensions/*.ts` or `.gsd/extensions/*/index.ts`
|
||||
|
||||
Note: `~/.gsd/agent/extensions/` is reserved for bundled extensions synced from the gsd-pi package. Community extensions placed there are silently ignored by the loader.
|
||||
|
||||
**The three primitives:**
|
||||
1. **Events** — Listen and react (`pi.on("event", handler)`). Can block tool calls, modify messages, inject context.
|
||||
2. **Tools** — Give the LLM new abilities (`pi.registerTool()`). LLM calls them autonomously.
|
||||
3. **Commands** — Give users slash commands (`pi.registerCommand()`). Users type `/mycommand`.
|
||||
|
||||
**Non-negotiable rules:**
|
||||
- Use `StringEnum` from `@mariozechner/pi-ai` for string enum params (NOT `Type.Union`/`Type.Literal` — breaks Google's API)
|
||||
- Truncate tool output to 50KB / 2000 lines max (use `truncateHead`/`truncateTail` from `@mariozechner/pi-coding-agent`)
|
||||
- Store stateful tool state in `details` for branching support
|
||||
- Check `signal?.aborted` in long-running tool executions
|
||||
- Use `pi.exec()` not `child_process` for shell commands
|
||||
- Check `ctx.hasUI` before dialog methods (non-interactive modes exist)
|
||||
- Session control methods (`waitForIdle`, `newSession`, `fork`, `navigateTree`, `reload`) are ONLY available in command handlers — they deadlock in event handlers
|
||||
- Lines from `render()` must not exceed `width` — use `truncateToWidth()`
|
||||
- Use theme from callback params, never import directly
|
||||
- Strip leading `@` from path params in custom tools (some models add it)
|
||||
|
||||
**Available imports:**
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@mariozechner/pi-coding-agent` | `ExtensionAPI`, `ExtensionContext`, `Theme`, event types, tool utilities, `DynamicBorder`, `BorderedLoader`, `CustomEditor`, `highlightCode` |
|
||||
| `@sinclair/typebox` | `Type.Object`, `Type.String`, `Type.Number`, `Type.Optional`, `Type.Boolean`, `Type.Array` |
|
||||
| `@mariozechner/pi-ai` | `StringEnum` (required for string enums), `Type` re-export |
|
||||
| `@mariozechner/pi-tui` | `Text`, `Box`, `Container`, `Spacer`, `Markdown`, `SelectList`, `Input`, `matchesKey`, `Key`, `truncateToWidth`, `visibleWidth` |
|
||||
| Node.js built-ins | `node:fs`, `node:path`, `node:child_process`, etc. |
|
||||
|
||||
</essential_principles>
|
||||
|
||||
<routing>
|
||||
Based on user intent, route to the appropriate workflow:
|
||||
|
||||
**Building a new extension:**
|
||||
- "Create an extension", "build a tool", "I want to add a command" → `workflows/create-extension.md`
|
||||
|
||||
**Adding capabilities to an existing extension:**
|
||||
- "Add a tool to my extension", "add event hook", "add custom rendering" → `workflows/add-capability.md`
|
||||
|
||||
**Debugging an extension:**
|
||||
- "My extension doesn't work", "tool not showing up", "event not firing" → `workflows/debug-extension.md`
|
||||
|
||||
**If user intent is clear from context, skip the question and go directly to the workflow.**
|
||||
</routing>
|
||||
|
||||
<reference_index>
|
||||
All domain knowledge in `references/`:
|
||||
|
||||
**Core architecture:** extension-lifecycle.md, events-reference.md
|
||||
**API surface:** extensionapi-reference.md, extensioncontext-reference.md
|
||||
**Capabilities:** custom-tools.md, custom-commands.md, custom-ui.md, custom-rendering.md
|
||||
**Patterns:** state-management.md, system-prompt-modification.md, compaction-session-control.md
|
||||
**Infrastructure:** model-provider-management.md, remote-execution-overrides.md, packaging-distribution.md, mode-behavior.md
|
||||
**Gotchas:** key-rules-gotchas.md
|
||||
</reference_index>
|
||||
|
||||
<workflows_index>
|
||||
| Workflow | Purpose |
|
||||
|----------|---------|
|
||||
| create-extension.md | Build a new extension from scratch |
|
||||
| add-capability.md | Add tools, commands, hooks, UI to an existing extension |
|
||||
| debug-extension.md | Diagnose and fix extension issues |
|
||||
</workflows_index>
|
||||
|
||||
<success_criteria>
|
||||
Extension is complete when:
|
||||
- TypeScript compiles without errors (jiti handles this at runtime)
|
||||
- Extension loads on GSD startup or `/reload` without errors
|
||||
- Tools appear in the LLM's system prompt and are callable
|
||||
- Commands respond to `/command` input
|
||||
- Event hooks fire at the expected lifecycle points
|
||||
- Custom UI renders correctly within terminal width
|
||||
- State persists correctly across session restarts (if stateful)
|
||||
- Output is truncated to safe limits (if tools produce variable output)
|
||||
</success_criteria>
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
<overview>
|
||||
Custom compaction hooks, triggering compaction, and session control methods available only in command handlers.
|
||||
</overview>
|
||||
|
||||
<custom_compaction>
|
||||
Override default compaction behavior:
|
||||
|
||||
```typescript
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, branchEntries, customInstructions, signal } = event;
|
||||
|
||||
// Option 1: Cancel
|
||||
return { cancel: true };
|
||||
|
||||
// Option 2: Custom summary
|
||||
return {
|
||||
compaction: {
|
||||
summary: "Custom summary of conversation so far...",
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
}
|
||||
};
|
||||
});
|
||||
```
|
||||
</custom_compaction>
|
||||
|
||||
<trigger_compaction>
|
||||
Trigger compaction programmatically from any handler:
|
||||
|
||||
```typescript
|
||||
ctx.compact({
|
||||
customInstructions: "Focus on the authentication changes",
|
||||
onComplete: (result) => ctx.ui.notify("Compacted!", "info"),
|
||||
onError: (error) => ctx.ui.notify(`Failed: ${error.message}`, "error"),
|
||||
});
|
||||
```
|
||||
</trigger_compaction>
|
||||
|
||||
<session_control>
|
||||
**Only available in command handlers** (deadlocks in event handlers):
|
||||
|
||||
```typescript
|
||||
pi.registerCommand("handoff", {
|
||||
handler: async (args, ctx) => {
|
||||
await ctx.waitForIdle();
|
||||
|
||||
// Create new session with initial context
|
||||
const result = await ctx.newSession({
|
||||
parentSession: ctx.sessionManager.getSessionFile(),
|
||||
setup: async (sm) => {
|
||||
sm.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `Context: ${args}` }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (result.cancelled) { /* extension cancelled via session_before_switch */ }
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `ctx.waitForIdle()` | Wait for agent to finish streaming |
|
||||
| `ctx.newSession(options?)` | Create a new session |
|
||||
| `ctx.fork(entryId)` | Fork from a specific entry |
|
||||
| `ctx.navigateTree(targetId, options?)` | Navigate session tree (with optional summary) |
|
||||
| `ctx.reload()` | Hot-reload everything (treat as terminal — code after runs pre-reload version) |
|
||||
|
||||
`navigateTree` options:
|
||||
- `summarize: boolean` — generate summary of abandoned branch
|
||||
- `customInstructions: string` — instructions for summarizer
|
||||
- `replaceInstructions: boolean` — replace default prompt entirely
|
||||
- `label: string` — label to attach to branch summary
|
||||
</session_control>
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
<overview>
|
||||
Custom slash commands — registration, argument completions, subcommand patterns, and the extended command context.
|
||||
</overview>
|
||||
|
||||
<basic_registration>
|
||||
```typescript
|
||||
pi.registerCommand("deploy", {
|
||||
description: "Deploy to an environment",
|
||||
handler: async (args, ctx) => {
|
||||
// args = everything after "/deploy "
|
||||
// ctx = ExtensionCommandContext (has session control methods)
|
||||
ctx.ui.notify(`Deploying to ${args || "production"}`, "info");
|
||||
},
|
||||
});
|
||||
```
|
||||
</basic_registration>
|
||||
|
||||
<argument_completions>
|
||||
Add tab-completion for command arguments:
|
||||
|
||||
```typescript
|
||||
import type { AutocompleteItem } from "@mariozechner/pi-tui";
|
||||
|
||||
pi.registerCommand("deploy", {
|
||||
description: "Deploy to an environment",
|
||||
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
|
||||
const envs = ["dev", "staging", "prod"];
|
||||
const items = envs.map(e => ({ value: e, label: e }));
|
||||
const filtered = items.filter(i => i.value.startsWith(prefix));
|
||||
return filtered.length > 0 ? filtered : null;
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify(`Deploying to ${args}`, "info");
|
||||
},
|
||||
});
|
||||
```
|
||||
</argument_completions>
|
||||
|
||||
<subcommand_pattern>
|
||||
Fake nested commands via first-argument parsing. Used by `/wt new|ls|switch|merge|rm`.
|
||||
|
||||
```typescript
|
||||
pi.registerCommand("foo", {
|
||||
description: "Manage foo items: /foo new|list|delete [name]",
|
||||
|
||||
getArgumentCompletions: (prefix: string) => {
|
||||
const parts = prefix.trim().split(/\s+/);
|
||||
|
||||
// First arg: subcommand
|
||||
if (parts.length <= 1) {
|
||||
return ["new", "list", "delete"]
|
||||
.filter(cmd => cmd.startsWith(parts[0] ?? ""))
|
||||
.map(cmd => ({ value: cmd, label: cmd }));
|
||||
}
|
||||
|
||||
// Second arg: depends on subcommand
|
||||
if (parts[0] === "delete") {
|
||||
const items = getItemsSomehow();
|
||||
return items
|
||||
.filter(name => name.startsWith(parts[1] ?? ""))
|
||||
.map(name => ({ value: `delete ${name}`, label: name }));
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
handler: async (args, ctx) => {
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const sub = parts[0];
|
||||
|
||||
switch (sub) {
|
||||
case "new": /* ... */ return;
|
||||
case "list": /* ... */ return;
|
||||
case "delete": /* handle parts[1] */ return;
|
||||
default:
|
||||
ctx.ui.notify("Usage: /foo <new|list|delete> [name]", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Gotcha:** `"".trim().split(/\s+/)` produces `['']`, not `[]`. That's why `parts.length <= 1` handles both empty and partial first arg.
|
||||
</subcommand_pattern>
|
||||
|
||||
<command_context>
|
||||
Command handlers get `ExtensionCommandContext` which extends `ExtensionContext` with session control methods:
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `ctx.waitForIdle()` | Wait for agent to finish streaming |
|
||||
| `ctx.newSession(options?)` | Create a new session |
|
||||
| `ctx.fork(entryId)` | Fork from an entry |
|
||||
| `ctx.navigateTree(targetId, options?)` | Navigate session tree |
|
||||
| `ctx.reload()` | Hot-reload everything |
|
||||
|
||||
**⚠️ These methods are ONLY available in command handlers.** Calling them from event handlers causes deadlocks.
|
||||
|
||||
```typescript
|
||||
pi.registerCommand("handoff", {
|
||||
handler: async (args, ctx) => {
|
||||
await ctx.waitForIdle();
|
||||
await ctx.newSession({
|
||||
setup: async (sm) => {
|
||||
sm.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `Context: ${args}` }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
</command_context>
|
||||
|
||||
<reload_pattern>
|
||||
Expose reload as both a command and a tool the LLM can call:
|
||||
|
||||
```typescript
|
||||
pi.registerCommand("reload-runtime", {
|
||||
description: "Reload extensions, skills, prompts, and themes",
|
||||
handler: async (_args, ctx) => {
|
||||
await ctx.reload();
|
||||
return; // Treat reload as terminal
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "reload_runtime",
|
||||
label: "Reload Runtime",
|
||||
description: "Reload extensions, skills, prompts, and themes",
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
|
||||
return { content: [{ type: "text", text: "Queued /reload-runtime as follow-up." }] };
|
||||
},
|
||||
});
|
||||
```
|
||||
</reload_pattern>
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
<overview>
|
||||
Custom rendering for tools and messages — control how they appear in the TUI.
|
||||
</overview>
|
||||
|
||||
<tool_rendering>
|
||||
Tools can provide `renderCall` (how the call looks) and `renderResult` (how the result looks):
|
||||
|
||||
```typescript
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { keyHint } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
pi.registerTool({
|
||||
name: "my_tool",
|
||||
// ...
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("my_tool "));
|
||||
text += theme.fg("muted", args.action);
|
||||
if (args.text) text += " " + theme.fg("dim", `"${args.text}"`);
|
||||
return new Text(text, 0, 0); // 0,0 padding — Box handles it
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
// isPartial = true during streaming (onUpdate was called)
|
||||
if (isPartial) {
|
||||
return new Text(theme.fg("warning", "Processing..."), 0, 0);
|
||||
}
|
||||
|
||||
// expanded = user toggled expand (Ctrl+O)
|
||||
if (result.details?.error) {
|
||||
return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
|
||||
}
|
||||
|
||||
let text = theme.fg("success", "✓ Done");
|
||||
if (!expanded) {
|
||||
text += ` (${keyHint("expandTools", "to expand")})`;
|
||||
}
|
||||
if (expanded && result.details?.items) {
|
||||
for (const item of result.details.items) {
|
||||
text += "\n " + theme.fg("dim", item);
|
||||
}
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you omit `renderCall`/`renderResult`, the built-in renderer is used. Useful for tool overrides where you just wrap logic without reimplementing UI.
|
||||
|
||||
**Fallback:** If render methods throw, `renderCall` shows tool name, `renderResult` shows raw `content` text.
|
||||
</tool_rendering>
|
||||
|
||||
<key_hints>
|
||||
Key hint helpers for showing keybinding info in render output:
|
||||
|
||||
```typescript
|
||||
import { keyHint, appKeyHint, editorKey, rawKeyHint } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Editor action hint (respects user keybinding config)
|
||||
keyHint("expandTools", "to expand") // e.g., "Ctrl+O to expand"
|
||||
keyHint("selectConfirm", "to select")
|
||||
|
||||
// Raw key hint (always shows literal key)
|
||||
rawKeyHint("Ctrl+O", "to expand")
|
||||
```
|
||||
</key_hints>
|
||||
|
||||
<message_rendering>
|
||||
Register a renderer for custom message types:
|
||||
|
||||
```typescript
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
|
||||
pi.registerMessageRenderer("my-extension", (message, options, theme) => {
|
||||
const { expanded } = options;
|
||||
let text = theme.fg("accent", `[${message.customType}] `) + message.content;
|
||||
if (expanded && message.details) {
|
||||
text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2));
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
});
|
||||
|
||||
// Send messages that use this renderer:
|
||||
pi.sendMessage({
|
||||
customType: "my-extension", // Matches renderer name
|
||||
content: "Status update",
|
||||
display: true,
|
||||
details: { foo: "bar" },
|
||||
});
|
||||
```
|
||||
</message_rendering>
|
||||
|
||||
<syntax_highlighting>
|
||||
```typescript
|
||||
import { highlightCode, getLanguageFromPath } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const lang = getLanguageFromPath("/path/to/file.rs"); // "rust"
|
||||
const highlighted = highlightCode(code, lang, theme);
|
||||
```
|
||||
</syntax_highlighting>
|
||||
|
||||
<best_practices>
|
||||
- Return `Text` with padding `(0, 0)` — the wrapping `Box` handles padding
|
||||
- Support `expanded` for detail on demand
|
||||
- Handle `isPartial` for streaming progress
|
||||
- Keep collapsed view compact
|
||||
- Use `\n` for multi-line content within a single `Text`
|
||||
</best_practices>
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
<overview>
|
||||
Complete custom tools reference — registration, parameters, execution, output truncation, overrides, rendering, and dynamic registration.
|
||||
</overview>
|
||||
|
||||
<registration>
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
|
||||
pi.registerTool({
|
||||
name: "my_tool", // Unique identifier (snake_case)
|
||||
label: "My Tool", // Display name in TUI
|
||||
description: "What this does", // Full description shown to LLM
|
||||
|
||||
// Optional: one-liner for system prompt "Available tools" section
|
||||
promptSnippet: "Manage project todo items",
|
||||
|
||||
// Optional: bullets added to system prompt "Guidelines" when tool is active
|
||||
promptGuidelines: [
|
||||
"Use my_tool for task management instead of file edits."
|
||||
],
|
||||
|
||||
// Parameter schema (MUST use TypeBox)
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "add", "remove"] as const),
|
||||
text: Type.Optional(Type.String({ description: "Item text" })),
|
||||
id: Type.Optional(Type.Number({ description: "Item ID" })),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
// 1. Check cancellation
|
||||
if (signal?.aborted) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
|
||||
// 2. Stream progress (optional)
|
||||
onUpdate?.({
|
||||
content: [{ type: "text", text: "Working..." }],
|
||||
details: { progress: 50 },
|
||||
});
|
||||
|
||||
// 3. Do the work
|
||||
const result = await doWork(params);
|
||||
|
||||
// 4. Return result
|
||||
return {
|
||||
content: [{ type: "text", text: "Result text for LLM" }], // Sent to LLM context
|
||||
details: { data: result }, // For rendering & state
|
||||
};
|
||||
},
|
||||
|
||||
// Optional: custom TUI rendering
|
||||
renderCall(args, theme) { ... },
|
||||
renderResult(result, { expanded, isPartial }, theme) { ... },
|
||||
});
|
||||
```
|
||||
</registration>
|
||||
|
||||
<critical_stringenum>
|
||||
**⚠️ MUST use `StringEnum` for string enum parameters:**
|
||||
|
||||
```typescript
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
|
||||
// ✅ Correct — works with all providers including Google
|
||||
action: StringEnum(["list", "add", "remove"] as const)
|
||||
|
||||
// ❌ BROKEN with Google's API
|
||||
action: Type.Union([Type.Literal("list"), Type.Literal("add")])
|
||||
```
|
||||
</critical_stringenum>
|
||||
|
||||
<output_truncation>
|
||||
Tools MUST truncate output to avoid context overflow. Built-in limit: 50KB / 2000 lines.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
truncateHead, truncateTail, formatSize,
|
||||
DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
const output = await runCommand();
|
||||
const truncation = truncateHead(output, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
|
||||
let result = truncation.content;
|
||||
if (truncation.truncated) {
|
||||
const tempFile = writeTempFile(output);
|
||||
result += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines`;
|
||||
result += ` (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}).`;
|
||||
result += ` Full output: ${tempFile}]`;
|
||||
}
|
||||
return { content: [{ type: "text", text: result }] };
|
||||
}
|
||||
```
|
||||
|
||||
Use `truncateHead` when beginning matters (search results, file reads). Use `truncateTail` when end matters (logs, command output).
|
||||
</output_truncation>
|
||||
|
||||
<signaling_errors>
|
||||
Throw to signal an error (sets `isError: true`). Returning a value never sets error flag.
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params) {
|
||||
if (!isValid(params.input)) {
|
||||
throw new Error(`Invalid input: ${params.input}`);
|
||||
}
|
||||
return { content: [{ type: "text", text: "OK" }], details: {} };
|
||||
}
|
||||
```
|
||||
</signaling_errors>
|
||||
|
||||
<dynamic_registration>
|
||||
Tools can be registered at any time — during load, in `session_start`, in command handlers. Available immediately without `/reload`.
|
||||
|
||||
```typescript
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
pi.registerTool({ name: "dynamic_tool", ... });
|
||||
});
|
||||
```
|
||||
|
||||
Use `pi.setActiveTools(names)` to enable/disable tools at runtime.
|
||||
</dynamic_registration>
|
||||
|
||||
<overriding_builtins>
|
||||
Register a tool with the same name as a built-in (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) to override it. **Must match exact result shape including `details` type.**
|
||||
|
||||
```typescript
|
||||
import { createReadTool } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
pi.registerTool({
|
||||
name: "read",
|
||||
label: "Read (Logged)",
|
||||
description: "Read file contents with logging",
|
||||
parameters: Type.Object({
|
||||
path: Type.String(),
|
||||
offset: Type.Optional(Type.Number()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
console.log(`[AUDIT] Reading: ${params.path}`);
|
||||
const builtIn = createReadTool(ctx.cwd);
|
||||
return builtIn.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
// Omit renderCall/renderResult to use built-in renderer
|
||||
});
|
||||
```
|
||||
|
||||
Start with no built-in tools: `gsd --no-tools -e ./my-extension.ts`
|
||||
</overriding_builtins>
|
||||
|
||||
<multiple_tools>
|
||||
One extension can register multiple tools with shared state:
|
||||
|
||||
```typescript
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let connection = null;
|
||||
|
||||
pi.registerTool({ name: "db_connect", ... });
|
||||
pi.registerTool({ name: "db_query", ... });
|
||||
pi.registerTool({ name: "db_close", ... });
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
connection?.close();
|
||||
});
|
||||
}
|
||||
```
|
||||
</multiple_tools>
|
||||
|
||||
<path_normalization>
|
||||
Some models add `@` prefix to path arguments. Strip it:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
let path = params.path;
|
||||
if (path.startsWith("@")) path = path.slice(1);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
</path_normalization>
|
||||
|
|
@ -1,490 +0,0 @@
|
|||
<overview>
|
||||
Complete custom UI reference — dialogs, persistent elements, custom components, overlays, custom editors, built-in components, keyboard input, performance, theming, and common mistakes.
|
||||
</overview>
|
||||
|
||||
<ui_architecture>
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Custom Header (ctx.ui.setHeader) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Message Area │
|
||||
│ - User/assistant messages │
|
||||
│ - Tool calls ◄── renderCall/renderResult │
|
||||
│ - Custom messages ◄── registerMessageRenderer │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Widgets (above editor) ◄── ctx.ui.setWidget │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Editor ◄── ctx.ui.custom() / setEditorComponent│
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Widgets (below editor) ◄── ctx.ui.setWidget │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Footer ◄── ctx.ui.setFooter / setStatus │
|
||||
└─────────────────────────────────────────────────┘
|
||||
┌─────────────────────┐
|
||||
│ Overlay (floating) │ ◄── ctx.ui.custom({ overlay })
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**11 ways to get UI on screen:**
|
||||
|
||||
| Method | Blocks? | Replaces editor? |
|
||||
|--------|---------|-------------------|
|
||||
| `ctx.ui.select/confirm/input/editor` | Yes | Temporarily |
|
||||
| `ctx.ui.notify` | No | No |
|
||||
| `ctx.ui.setStatus` | No | No (footer) |
|
||||
| `ctx.ui.setWidget` | No | No |
|
||||
| `ctx.ui.setFooter` | No | No (replaces footer) |
|
||||
| `ctx.ui.setHeader` | No | No (replaces header) |
|
||||
| `ctx.ui.custom()` | Yes | Temporarily |
|
||||
| `ctx.ui.custom({overlay})` | Yes | No (renders on top) |
|
||||
| `ctx.ui.setEditorComponent` | No | Yes (permanently) |
|
||||
| `renderCall/renderResult` | No | No (inline in messages) |
|
||||
| `registerMessageRenderer` | No | No (inline in messages) |
|
||||
</ui_architecture>
|
||||
|
||||
<component_interface>
|
||||
Every visual element implements:
|
||||
|
||||
```typescript
|
||||
interface Component {
|
||||
render(width: number): string[]; // Required — each line ≤ width visible chars
|
||||
handleInput?(data: string): void; // Optional — receive keyboard input
|
||||
wantsKeyRelease?: boolean; // Optional — receive key release events (Kitty protocol)
|
||||
invalidate(): void; // Required — clear cached render state
|
||||
}
|
||||
```
|
||||
|
||||
**Render contract:**
|
||||
- Return array of strings, one per line
|
||||
- Each string MUST NOT exceed `width` in visible characters
|
||||
- ANSI escape codes don't count toward visible width
|
||||
- **Styles are reset at end of each line** — reapply per line
|
||||
- Return `[]` for zero-height component
|
||||
|
||||
**Invalidation contract:**
|
||||
- Clear ALL cached render output
|
||||
- Clear any pre-baked themed strings
|
||||
- Call `super.invalidate()` if extending a built-in component
|
||||
</component_interface>
|
||||
|
||||
<dialogs>
|
||||
Blocking dialog methods on `ctx.ui`:
|
||||
|
||||
```typescript
|
||||
const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); // string | undefined
|
||||
const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); // boolean
|
||||
const name = await ctx.ui.input("Name:", "placeholder"); // string | undefined
|
||||
const text = await ctx.ui.editor("Edit:", "prefilled text"); // string | undefined
|
||||
|
||||
// Timed auto-dismiss with countdown
|
||||
const ok = await ctx.ui.confirm("Proceed?", "Auto-continues in 5s", { timeout: 5000 });
|
||||
// Returns false on timeout, undefined for select/input
|
||||
|
||||
// Manual dismissal with AbortSignal (distinguish timeout from cancel)
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
const ok = await ctx.ui.confirm("Timed", "Auto-cancels in 5s", { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
if (controller.signal.aborted) { /* timed out */ }
|
||||
```
|
||||
</dialogs>
|
||||
|
||||
<persistent_ui>
|
||||
```typescript
|
||||
// Footer status (multiple extensions can set independent entries)
|
||||
ctx.ui.setStatus("my-ext", "● Active");
|
||||
ctx.ui.setStatus("my-ext", undefined); // Clear
|
||||
|
||||
// Widgets
|
||||
ctx.ui.setWidget("my-id", ["Line 1", "Line 2"]); // Above editor
|
||||
ctx.ui.setWidget("my-id", ["Below"], { placement: "belowEditor" }); // Below editor
|
||||
ctx.ui.setWidget("my-id", (_tui, theme) => ({ // Themed
|
||||
render: () => [theme.fg("accent", "Styled")],
|
||||
invalidate: () => {},
|
||||
}));
|
||||
ctx.ui.setWidget("my-id", undefined); // Clear
|
||||
|
||||
// Working message during streaming
|
||||
ctx.ui.setWorkingMessage("Analyzing code...");
|
||||
ctx.ui.setWorkingMessage(); // Restore default
|
||||
|
||||
// Custom footer (full replacement)
|
||||
ctx.ui.setFooter((tui, theme, footerData) => ({
|
||||
render(width) {
|
||||
const branch = footerData.getGitBranch(); // Only available here
|
||||
const statuses = footerData.getExtensionStatuses(); // All setStatus values
|
||||
return [truncateToWidth(`${branch} | model`, width)];
|
||||
},
|
||||
invalidate() {},
|
||||
dispose: footerData.onBranchChange(() => tui.requestRender()), // Reactive
|
||||
}));
|
||||
ctx.ui.setFooter(undefined); // Restore default
|
||||
|
||||
// Custom header
|
||||
ctx.ui.setHeader((tui, theme) => ({
|
||||
render(width) { return [theme.fg("accent", theme.bold("My Header"))]; },
|
||||
invalidate() {},
|
||||
}));
|
||||
|
||||
// Editor control
|
||||
ctx.ui.setEditorText("Prefill");
|
||||
const current = ctx.ui.getEditorText();
|
||||
ctx.ui.pasteToEditor("pasted content"); // Triggers paste handling
|
||||
|
||||
// Tool expansion
|
||||
ctx.ui.setToolsExpanded(true);
|
||||
const expanded = ctx.ui.getToolsExpanded();
|
||||
|
||||
// Theme management
|
||||
const themes = ctx.ui.getAllThemes();
|
||||
ctx.ui.setTheme("light");
|
||||
ctx.ui.theme.fg("accent", "text"); // Access current theme
|
||||
```
|
||||
</persistent_ui>
|
||||
|
||||
<custom_components>
|
||||
`ctx.ui.custom()` temporarily replaces the editor. Returns a value when `done()` is called.
|
||||
|
||||
**Factory callback args:**
|
||||
|
||||
| Argument | Type | Purpose |
|
||||
|----------|------|---------|
|
||||
| `tui` | `TUI` | `tui.requestRender()` triggers re-render after state changes |
|
||||
| `theme` | `Theme` | Current theme for styling |
|
||||
| `keybindings` | `KeybindingsManager` | App keybinding config |
|
||||
| `done` | `(value: T) => void` | Close component and return value |
|
||||
|
||||
**Inline pattern:**
|
||||
```typescript
|
||||
const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => ({
|
||||
render(width: number): string[] {
|
||||
return [truncateToWidth("Press Enter to confirm, Escape to cancel", width)];
|
||||
},
|
||||
handleInput(data: string) {
|
||||
if (matchesKey(data, Key.enter)) done("confirmed");
|
||||
if (matchesKey(data, Key.escape)) done(null);
|
||||
},
|
||||
invalidate() {},
|
||||
}));
|
||||
```
|
||||
|
||||
**Class-based pattern (recommended for complex UI):**
|
||||
```typescript
|
||||
class MyComponent {
|
||||
private selected = 0;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
constructor(
|
||||
private tui: { requestRender: () => void },
|
||||
private theme: Theme,
|
||||
private items: string[],
|
||||
private done: (value: string | null) => void,
|
||||
) {}
|
||||
|
||||
handleInput(data: string) {
|
||||
if (matchesKey(data, Key.up) && this.selected > 0) this.selected--;
|
||||
else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) this.selected++;
|
||||
else if (matchesKey(data, Key.enter)) { this.done(this.items[this.selected]); return; }
|
||||
else if (matchesKey(data, Key.escape)) { this.done(null); return; }
|
||||
else return;
|
||||
this.invalidate();
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
||||
this.cachedLines = this.items.map((item, i) =>
|
||||
truncateToWidth((i === this.selected ? "> " : " ") + item, width)
|
||||
);
|
||||
this.cachedWidth = width;
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
invalidate() { this.cachedWidth = undefined; this.cachedLines = undefined; }
|
||||
}
|
||||
|
||||
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) =>
|
||||
new MyComponent(tui, theme, ["A", "B", "C"], done)
|
||||
);
|
||||
```
|
||||
|
||||
**Composing with built-in components:**
|
||||
```typescript
|
||||
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));
|
||||
|
||||
const selectList = new SelectList(items, 10, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => theme.fg("accent", t),
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => done(item.value);
|
||||
selectList.onCancel = () => done(null);
|
||||
container.addChild(selectList);
|
||||
|
||||
return {
|
||||
render: (w) => container.render(w),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
|
||||
};
|
||||
});
|
||||
```
|
||||
</custom_components>
|
||||
|
||||
<overlays>
|
||||
Floating modals rendered on top of everything:
|
||||
|
||||
```typescript
|
||||
const result = await ctx.ui.custom<string | null>(
|
||||
(tui, theme, _kb, done) => new MyDialog({ onClose: done }),
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: {
|
||||
anchor: "center", // 9 positions (see below)
|
||||
width: "50%", // number = columns, string = percentage
|
||||
minWidth: 40,
|
||||
maxHeight: "80%",
|
||||
margin: 2, // All sides, or { top, right, bottom, left }
|
||||
offsetX: 0, offsetY: 0, // Fine-tune position
|
||||
visible: (w, h) => w >= 80, // Hide on narrow terminals
|
||||
},
|
||||
onHandle: (handle) => {
|
||||
// handle.setHidden(true/false) — temporarily hide
|
||||
// handle.hide() — permanently remove
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Anchor positions:**
|
||||
```
|
||||
top-left top-center top-right
|
||||
left-center center right-center
|
||||
bottom-left bottom-center bottom-right
|
||||
```
|
||||
|
||||
**Stacked overlays:** Multiple overlays stack (newest on top). Closing one gives focus to the one below.
|
||||
|
||||
**⚠️ Overlay lifecycle:** Components are disposed when closed. Never reuse references — create fresh instances each time.
|
||||
</overlays>
|
||||
|
||||
<custom_editor>
|
||||
Replace the main input editor permanently:
|
||||
|
||||
```typescript
|
||||
import { CustomEditor } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
class VimEditor extends CustomEditor {
|
||||
private mode: "normal" | "insert" = "insert";
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "escape") && this.mode === "insert") {
|
||||
this.mode = "normal"; return;
|
||||
}
|
||||
if (this.mode === "insert") { super.handleInput(data); return; }
|
||||
switch (data) {
|
||||
case "i": this.mode = "insert"; return;
|
||||
case "h": super.handleInput("\x1b[D"); return; // Left
|
||||
case "j": super.handleInput("\x1b[B"); return; // Down
|
||||
case "k": super.handleInput("\x1b[A"); return; // Up
|
||||
case "l": super.handleInput("\x1b[C"); return; // Right
|
||||
}
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) return; // Block printable in normal
|
||||
super.handleInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.ui.setEditorComponent((_tui, theme, keybindings) => new VimEditor(theme, keybindings));
|
||||
ctx.ui.setEditorComponent(undefined); // Restore default
|
||||
```
|
||||
|
||||
**Critical:** Extend `CustomEditor` (NOT `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching).
|
||||
</custom_editor>
|
||||
|
||||
<built_in_components>
|
||||
**From `@mariozechner/pi-tui`:**
|
||||
|
||||
| Component | Constructor | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| `Text` | `new Text(content, paddingX, paddingY, bgFn?)` | Multi-line text with word wrap |
|
||||
| `Box` | `new Box(paddingX, paddingY, bgFn)` | Container with padding+background, `.addChild()` |
|
||||
| `Container` | `new Container()` | Vertical stack, `.addChild()`, `.removeChild()`, `.clear()` |
|
||||
| `Spacer` | `new Spacer(lines)` | Empty vertical space |
|
||||
| `Markdown` | `new Markdown(content, padX, padY, getMarkdownTheme())` | Rendered markdown with syntax highlighting |
|
||||
| `Image` | `new Image(base64, mimeType, theme, opts?)` | Image rendering (Kitty, iTerm2) |
|
||||
| `SelectList` | `new SelectList(items, maxVisible, themeOpts)` | Interactive selection with search and scrolling |
|
||||
| `SettingsList` | `new SettingsList(items, maxVisible, theme, onChange, onClose, opts?)` | Toggle settings with left/right arrows |
|
||||
| `Input` | `new Input()` | Text input field |
|
||||
| `Editor` | `new Editor(tui, editorTheme)` | Multi-line editor with undo |
|
||||
|
||||
**SelectList usage:**
|
||||
```typescript
|
||||
const items: SelectItem[] = [
|
||||
{ value: "opt1", label: "Option 1", description: "First option" },
|
||||
{ value: "opt2", label: "Option 2" },
|
||||
];
|
||||
const selectList = new SelectList(items, 10, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => theme.fg("accent", t),
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => { /* item.value */ };
|
||||
selectList.onCancel = () => { /* escape pressed */ };
|
||||
```
|
||||
|
||||
**SettingsList usage:**
|
||||
```typescript
|
||||
const items: SettingItem[] = [
|
||||
{ id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] },
|
||||
{ id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light", "auto"] },
|
||||
];
|
||||
const settings = new SettingsList(items, 15, getSettingsListTheme(),
|
||||
(id, newValue) => { /* setting changed */ },
|
||||
() => { /* close requested */ },
|
||||
{ enableSearch: true },
|
||||
);
|
||||
```
|
||||
|
||||
**From `@mariozechner/pi-coding-agent`:**
|
||||
|
||||
| Component | Constructor | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| `DynamicBorder` | `new DynamicBorder((s: string) => theme.fg("accent", s))` | Border line |
|
||||
| `BorderedLoader` | — | Spinner with cancel support |
|
||||
| `CustomEditor` | `new CustomEditor(theme, keybindings)` | Base class for custom editors |
|
||||
</built_in_components>
|
||||
|
||||
<keyboard_input>
|
||||
```typescript
|
||||
import { matchesKey, Key } from "@mariozechner/pi-tui";
|
||||
|
||||
handleInput(data: string) {
|
||||
// Basic keys
|
||||
if (matchesKey(data, Key.up)) {}
|
||||
if (matchesKey(data, Key.down)) {}
|
||||
if (matchesKey(data, Key.enter)) {}
|
||||
if (matchesKey(data, Key.escape)) {}
|
||||
if (matchesKey(data, Key.tab)) {}
|
||||
if (matchesKey(data, Key.space)) {}
|
||||
if (matchesKey(data, Key.backspace)) {}
|
||||
if (matchesKey(data, Key.home)) {}
|
||||
if (matchesKey(data, Key.end)) {}
|
||||
|
||||
// With modifiers
|
||||
if (matchesKey(data, Key.ctrl("c"))) {}
|
||||
if (matchesKey(data, Key.shift("tab"))) {}
|
||||
if (matchesKey(data, Key.alt("left"))) {}
|
||||
if (matchesKey(data, Key.ctrlShift("p"))) {}
|
||||
|
||||
// String format also works: "enter", "ctrl+c", "shift+tab"
|
||||
|
||||
// Printable character detection
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
// Letter, number, symbol
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**handleInput contract:**
|
||||
1. Check for your keys
|
||||
2. Update state
|
||||
3. Call `this.invalidate()` if render output changes
|
||||
4. Call `tui.requestRender()` to trigger re-render
|
||||
</keyboard_input>
|
||||
|
||||
<line_width_rule>
|
||||
**Cardinal rule: each line from render() must not exceed `width` visible characters.**
|
||||
|
||||
```typescript
|
||||
import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
||||
|
||||
visibleWidth("\x1b[32mHello\x1b[0m"); // Returns 5 (ignores ANSI codes)
|
||||
truncateToWidth("Very long text here", 10); // "Very lo..."
|
||||
truncateToWidth("Very long text here", 10, ""); // "Very long " (no ellipsis)
|
||||
wrapTextWithAnsi("\x1b[32mLong green text\x1b[0m", 10); // Word wrap preserving ANSI
|
||||
```
|
||||
|
||||
If lines exceed `width`, terminal wraps cause visual corruption.
|
||||
</line_width_rule>
|
||||
|
||||
<performance_caching>
|
||||
Always cache render output:
|
||||
|
||||
```typescript
|
||||
class CachedComponent {
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
||||
const lines = this.computeLines(width);
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate() { this.cachedWidth = undefined; this.cachedLines = undefined; }
|
||||
}
|
||||
```
|
||||
|
||||
**Update cycle:** State changes → `invalidate()` → `tui.requestRender()` → `render(width)` called
|
||||
|
||||
**Game loop pattern** (real-time updates):
|
||||
```typescript
|
||||
this.interval = setInterval(() => {
|
||||
this.tick();
|
||||
this.version++;
|
||||
this.tui.requestRender();
|
||||
}, 100); // 10 FPS
|
||||
|
||||
// Clean up in dispose()
|
||||
clearInterval(this.interval);
|
||||
```
|
||||
</performance_caching>
|
||||
|
||||
<theme_colors>
|
||||
Always use theme from callback params, never import directly.
|
||||
|
||||
**All foreground colors:**
|
||||
|
||||
| Category | Colors |
|
||||
|----------|--------|
|
||||
| General | `text`, `accent`, `muted`, `dim` |
|
||||
| Status | `success`, `error`, `warning` |
|
||||
| Borders | `border`, `borderAccent`, `borderMuted` |
|
||||
| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
|
||||
| Tools | `toolTitle`, `toolOutput` |
|
||||
| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
|
||||
| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
|
||||
| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
|
||||
| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
|
||||
|
||||
**All background colors:** `selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`
|
||||
|
||||
**Syntax highlighting:**
|
||||
```typescript
|
||||
import { highlightCode, getLanguageFromPath } from "@mariozechner/pi-coding-agent";
|
||||
const lang = getLanguageFromPath("/file.rs"); // "rust"
|
||||
const highlighted = highlightCode(code, lang, theme);
|
||||
```
|
||||
</theme_colors>
|
||||
|
||||
<common_mistakes>
|
||||
1. **Lines exceed width** → Visual corruption. Use `truncateToWidth()` on every line.
|
||||
2. **Forgetting `tui.requestRender()`** → UI doesn't update. Call after invalidate().
|
||||
3. **Importing theme directly** → Wrong colors after theme switch. Use theme from callback.
|
||||
4. **Not typing DynamicBorder param** → `new DynamicBorder((s: string) => theme.fg("accent", s))`.
|
||||
5. **Reusing disposed overlay components** → Create fresh instances each time.
|
||||
6. **Styles bleeding across lines** → TUI resets per line. Reapply styles, or use `wrapTextWithAnsi()`.
|
||||
7. **Not implementing invalidate()** → Theme changes don't take effect.
|
||||
8. **Forgetting super.invalidate()** → `override invalidate() { super.invalidate(); /* cleanup */ }`
|
||||
9. **Timer not cleaned up** → Call `clearInterval` before `done()`.
|
||||
10. **Using ctx.ui in non-interactive mode** → Check `ctx.hasUI` first.
|
||||
</common_mistakes>
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
<overview>
|
||||
Complete event reference with handler signatures, return types, and type narrowing utilities.
|
||||
</overview>
|
||||
|
||||
<event_categories>
|
||||
|
||||
**Session events:** `session_start`, `session_before_switch`, `session_switch`, `session_before_fork`, `session_fork`, `session_before_compact`, `session_compact`, `session_before_tree`, `session_tree`, `session_shutdown`
|
||||
|
||||
**Agent events:** `before_agent_start`, `agent_start`, `agent_end`, `turn_start`, `turn_end`, `context`, `before_provider_request`, `message_start`, `message_update`, `message_end`
|
||||
|
||||
**Tool events:** `tool_call`, `tool_execution_start`, `tool_execution_update`, `tool_execution_end`, `tool_result`
|
||||
|
||||
**Input events:** `input`
|
||||
|
||||
**Model events:** `model_select`
|
||||
|
||||
**User bash events:** `user_bash`
|
||||
|
||||
**Special:** `session_directory` (CLI startup only, no `ctx` — receives only event)
|
||||
|
||||
</event_categories>
|
||||
|
||||
<handler_signature>
|
||||
```typescript
|
||||
pi.on("event_name", async (event, ctx: ExtensionContext) => {
|
||||
// event — typed payload for this event
|
||||
// ctx — access to UI, session, model, control flow
|
||||
// Return undefined for no action, or a typed response
|
||||
});
|
||||
```
|
||||
</handler_signature>
|
||||
|
||||
<key_events>
|
||||
|
||||
**before_agent_start** — Fired after user prompt, before agent loop. Primary hook for context injection and system prompt modification.
|
||||
```typescript
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
// event.prompt — user's prompt text
|
||||
// event.images — attached images
|
||||
// event.systemPrompt — current system prompt
|
||||
return {
|
||||
message: { customType: "my-ext", content: "Extra context", display: true },
|
||||
systemPrompt: event.systemPrompt + "\n\nExtra instructions...",
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
**tool_call** — Fired before tool executes. Can block.
|
||||
```typescript
|
||||
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (isToolCallEventType("bash", event)) {
|
||||
// event.input is typed as { command: string; timeout?: number }
|
||||
if (event.input.command.includes("rm -rf")) {
|
||||
return { block: true, reason: "Dangerous command" };
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**tool_result** — Fired after tool executes. Can modify result. Handlers chain like middleware.
|
||||
```typescript
|
||||
import { isToolResultEventType } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
if (isToolResultEventType("bash", event)) {
|
||||
// event.details is typed as BashToolDetails
|
||||
}
|
||||
// Return partial patch: { content, details, isError }
|
||||
// Omitted fields keep current values
|
||||
});
|
||||
```
|
||||
|
||||
**context** — Fired before each LLM call. Modify messages non-destructively.
|
||||
```typescript
|
||||
pi.on("context", async (event, ctx) => {
|
||||
// event.messages is a deep copy — safe to modify
|
||||
const filtered = event.messages.filter(m => !shouldPrune(m));
|
||||
return { messages: filtered };
|
||||
});
|
||||
```
|
||||
|
||||
**input** — Fired when user input is received, before skill/template expansion.
|
||||
```typescript
|
||||
pi.on("input", async (event, ctx) => {
|
||||
// event.text — raw input
|
||||
// event.source — "interactive", "rpc", or "extension"
|
||||
if (event.text.startsWith("?quick "))
|
||||
return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
|
||||
return { action: "continue" };
|
||||
});
|
||||
```
|
||||
|
||||
**model_select** — Fired when model changes.
|
||||
```typescript
|
||||
pi.on("model_select", async (event, ctx) => {
|
||||
// event.model, event.previousModel, event.source ("set"|"cycle"|"restore")
|
||||
});
|
||||
```
|
||||
|
||||
</key_events>
|
||||
|
||||
<type_narrowing>
|
||||
Built-in type guards for tool events:
|
||||
|
||||
```typescript
|
||||
import { isToolCallEventType, isToolResultEventType } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Tool calls — narrows event.input type
|
||||
if (isToolCallEventType("bash", event)) { /* event.input: { command, timeout? } */ }
|
||||
if (isToolCallEventType("read", event)) { /* event.input: { path, offset?, limit? } */ }
|
||||
if (isToolCallEventType("write", event)) { /* event.input: { path, content } */ }
|
||||
if (isToolCallEventType("edit", event)) { /* event.input: { path, oldText, newText } */ }
|
||||
|
||||
// Tool results — narrows event.details type
|
||||
if (isToolResultEventType("bash", event)) { /* event.details: BashToolDetails */ }
|
||||
```
|
||||
|
||||
For custom tools, export your input type and use explicit type params:
|
||||
```typescript
|
||||
if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) {
|
||||
event.input.action; // typed
|
||||
}
|
||||
```
|
||||
</type_narrowing>
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
<overview>
|
||||
The extension lifecycle from load to shutdown, including the full event flow.
|
||||
</overview>
|
||||
|
||||
<loading>
|
||||
Extensions load when GSD starts (or on `/reload`). The default export function runs synchronously — subscribe to events and register tools/commands during this call.
|
||||
|
||||
```
|
||||
GSD starts
|
||||
└─► Extension default function runs
|
||||
├── pi.on("event", handler) ← Subscribe
|
||||
├── pi.registerTool({...}) ← Register tools
|
||||
├── pi.registerCommand(...) ← Register commands
|
||||
└── pi.registerShortcut(...) ← Register shortcuts
|
||||
└─► session_start fires
|
||||
```
|
||||
</loading>
|
||||
|
||||
<event_flow>
|
||||
Full event flow per user prompt:
|
||||
|
||||
```
|
||||
user sends prompt
|
||||
├─► Extension commands checked (bypass if match)
|
||||
├─► input event (can intercept/transform/handle)
|
||||
├─► Skill/template expansion
|
||||
├─► before_agent_start (inject message, modify system prompt)
|
||||
├─► agent_start
|
||||
│
|
||||
│ ┌── Turn loop (repeats while LLM calls tools) ──┐
|
||||
│ │ turn_start │
|
||||
│ │ context (can modify messages sent to LLM) │
|
||||
│ │ before_provider_request (inspect/replace payload)│
|
||||
│ │ LLM responds → may call tools: │
|
||||
│ │ tool_call (can BLOCK) │
|
||||
│ │ tool_execution_start/update/end │
|
||||
│ │ tool_result (can MODIFY) │
|
||||
│ │ turn_end │
|
||||
│ └────────────────────────────────────────────────┘
|
||||
│
|
||||
└─► agent_end
|
||||
```
|
||||
</event_flow>
|
||||
|
||||
<session_events>
|
||||
| Event | When | Can Return |
|
||||
|-------|------|------------|
|
||||
| `session_start` | Session loads | — |
|
||||
| `session_before_switch` | Before `/new` or `/resume` | `{ cancel: true }` |
|
||||
| `session_switch` | After switch | — |
|
||||
| `session_before_fork` | Before `/fork` | `{ cancel: true }`, `{ skipConversationRestore: true }` |
|
||||
| `session_fork` | After fork | — |
|
||||
| `session_before_compact` | Before compaction | `{ cancel: true }`, `{ compaction: {...} }` |
|
||||
| `session_compact` | After compaction | — |
|
||||
| `session_shutdown` | On exit | — |
|
||||
</session_events>
|
||||
|
||||
<hot_reload>
|
||||
Extensions in auto-discovered locations hot-reload with `/reload`:
|
||||
- `session_shutdown` fires for old runtime
|
||||
- Resources re-scanned
|
||||
- `session_start` fires for new runtime
|
||||
- Code after `await ctx.reload()` still runs from the pre-reload version — treat as terminal
|
||||
</hot_reload>
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<overview>
|
||||
ExtensionAPI methods — the `pi` object received in the default export function.
|
||||
</overview>
|
||||
|
||||
<core_registration>
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `pi.on(event, handler)` | Subscribe to events |
|
||||
| `pi.registerTool(definition)` | Register LLM-callable tool |
|
||||
| `pi.registerCommand(name, options)` | Register `/command` |
|
||||
| `pi.registerShortcut(key, options)` | Register keyboard shortcut |
|
||||
| `pi.registerFlag(name, options)` | Register CLI flag |
|
||||
| `pi.registerMessageRenderer(customType, renderer)` | Custom message rendering |
|
||||
| `pi.registerProvider(name, config)` | Register/override model provider |
|
||||
| `pi.unregisterProvider(name)` | Remove a provider |
|
||||
</core_registration>
|
||||
|
||||
<messaging>
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `pi.sendMessage(message, options?)` | Inject custom message into session |
|
||||
| `pi.sendUserMessage(content, options?)` | Send user message (triggers turn) |
|
||||
|
||||
**Delivery modes for `sendMessage`:**
|
||||
- `"steer"` (default) — Interrupts streaming after current tool
|
||||
- `"followUp"` — Waits for agent to finish all tools
|
||||
- `"nextTurn"` — Queued for next user prompt
|
||||
|
||||
```typescript
|
||||
pi.sendMessage({
|
||||
customType: "my-extension",
|
||||
content: "Additional context",
|
||||
display: true,
|
||||
details: { ... },
|
||||
}, { deliverAs: "steer", triggerTurn: true });
|
||||
```
|
||||
</messaging>
|
||||
|
||||
<state_session>
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `pi.appendEntry(customType, data?)` | Persist state (NOT sent to LLM) |
|
||||
| `pi.setSessionName(name)` | Set session display name |
|
||||
| `pi.getSessionName()` | Get session name |
|
||||
| `pi.setLabel(entryId, label)` | Bookmark entry for `/tree` |
|
||||
</state_session>
|
||||
|
||||
<tool_management>
|
||||
```typescript
|
||||
const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"]
|
||||
const all = pi.getAllTools(); // [{ name, description }, ...]
|
||||
pi.setActiveTools(["read", "bash"]); // Enable/disable tools
|
||||
```
|
||||
</tool_management>
|
||||
|
||||
<model_management>
|
||||
```typescript
|
||||
const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");
|
||||
if (model) {
|
||||
const success = await pi.setModel(model); // Returns false if no API key
|
||||
}
|
||||
|
||||
pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
|
||||
pi.setThinkingLevel("high");
|
||||
```
|
||||
</model_management>
|
||||
|
||||
<utilities>
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `pi.exec(cmd, args, opts?)` | Shell command (prefer over child_process) |
|
||||
| `pi.events` | Shared event bus for inter-extension communication |
|
||||
| `pi.getFlag(name)` | Get CLI flag value |
|
||||
| `pi.getCommands()` | All available slash commands |
|
||||
</utilities>
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
<overview>
|
||||
ExtensionContext (`ctx`) — available in all event handlers (except `session_directory`).
|
||||
</overview>
|
||||
|
||||
<ui_methods>
|
||||
**Dialogs (blocking — wait for user response):**
|
||||
```typescript
|
||||
const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
|
||||
const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
|
||||
const name = await ctx.ui.input("Name:", "placeholder");
|
||||
const text = await ctx.ui.editor("Edit:", "prefilled text");
|
||||
|
||||
// Timed dialog — auto-dismiss after timeout
|
||||
const ok = await ctx.ui.confirm("Auto-confirm?", "Proceeds in 5s", { timeout: 5000 });
|
||||
```
|
||||
|
||||
**Non-blocking UI:**
|
||||
```typescript
|
||||
ctx.ui.notify("Done!", "info"); // Toast: "info" | "warning" | "error"
|
||||
ctx.ui.setStatus("my-ext", "● Active"); // Footer status
|
||||
ctx.ui.setStatus("my-ext", undefined); // Clear
|
||||
ctx.ui.setWidget("my-id", ["Line 1", "Line 2"]); // Widget above editor
|
||||
ctx.ui.setWidget("my-id", ["Below!"], { placement: "belowEditor" });
|
||||
ctx.ui.setTitle("gsd - my project"); // Terminal title
|
||||
ctx.ui.setEditorText("Prefill"); // Set editor content
|
||||
ctx.ui.setWorkingMessage("Analyzing..."); // Working message during streaming
|
||||
ctx.ui.setToolsExpanded(true); // Expand tool output
|
||||
```
|
||||
</ui_methods>
|
||||
|
||||
<ctx_properties>
|
||||
| Property/Method | Purpose |
|
||||
|----------------|---------|
|
||||
| `ctx.hasUI` | `false` in print/JSON mode — check before dialogs |
|
||||
| `ctx.cwd` | Current working directory |
|
||||
| `ctx.sessionManager` | Read-only session state |
|
||||
| `ctx.modelRegistry` / `ctx.model` | Model access |
|
||||
| `ctx.isIdle()` / `ctx.abort()` / `ctx.hasPendingMessages()` | Agent state |
|
||||
| `ctx.shutdown()` | Request graceful exit (deferred until idle) |
|
||||
| `ctx.getContextUsage()` | Current context token usage |
|
||||
| `ctx.compact(options?)` | Trigger compaction |
|
||||
| `ctx.getSystemPrompt()` | Current effective system prompt |
|
||||
</ctx_properties>
|
||||
|
||||
<session_manager>
|
||||
```typescript
|
||||
ctx.sessionManager.getEntries() // All entries
|
||||
ctx.sessionManager.getBranch() // Current branch
|
||||
ctx.sessionManager.getLeafId() // Current leaf entry ID
|
||||
ctx.sessionManager.getSessionFile() // Session JSONL path
|
||||
ctx.sessionManager.getLabel(entryId) // Entry label
|
||||
```
|
||||
</session_manager>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<overview>
|
||||
Non-negotiable rules and common gotchas when building GSD extensions.
|
||||
</overview>
|
||||
|
||||
<must_follow>
|
||||
1. **Use `StringEnum` for string enums** — `Type.Union`/`Type.Literal` breaks Google's API.
|
||||
2. **Truncate tool output** — Large output causes context overflow, compaction failures, degraded performance. Limit: 50KB / 2000 lines.
|
||||
3. **Use theme from callback** — Don't import theme directly. Use the `theme` parameter from `ctx.ui.custom()` or render functions.
|
||||
4. **`DynamicBorder` color param** — Type as `(s: string) => theme.fg("accent", s)`.
|
||||
5. **Call `tui.requestRender()` after state changes** in `handleInput`.
|
||||
6. **Return `{ render, invalidate, handleInput }`** from custom components.
|
||||
7. **Lines must not exceed `width`** in `render()` — use `truncateToWidth()`.
|
||||
8. **Session control methods ONLY in commands** — `waitForIdle()`, `newSession()`, `fork()`, `navigateTree()`, `reload()` will **deadlock** in event handlers.
|
||||
9. **Strip leading `@` from path arguments** — some models add it.
|
||||
10. **Store state in tool result `details`** for proper branching support.
|
||||
</must_follow>
|
||||
|
||||
<common_patterns>
|
||||
- Rebuild component on `invalidate()` when pre-baking theme colors
|
||||
- Check `signal?.aborted` in long-running tool executions
|
||||
- Use `pi.exec()` instead of `child_process` for shell commands
|
||||
- Overlay components are **disposed when closed** — create fresh instances each time
|
||||
- Treat `ctx.reload()` as terminal — code after runs from pre-reload version
|
||||
- Check `ctx.hasUI` before dialog methods (false in print/JSON mode)
|
||||
- Extension errors are logged but don't crash GSD — tool_call handler errors fail-safe (block the tool)
|
||||
</common_patterns>
|
||||
|
||||
<gsd_paths>
|
||||
**GSD extension paths (community/user-installed extensions):**
|
||||
- Global: `~/.pi/agent/extensions/*.ts`
|
||||
- Global (subdir): `~/.pi/agent/extensions/*/index.ts`
|
||||
- Project-local: `.gsd/extensions/*.ts`
|
||||
- Project-local (subdir): `.gsd/extensions/*/index.ts`
|
||||
|
||||
Note: `~/.gsd/agent/extensions/` is reserved for bundled extensions synced from the gsd-pi package.
|
||||
Community extensions placed there are silently ignored by the loader.
|
||||
</gsd_paths>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<overview>
|
||||
Mode behavior determines which UI methods work. Extensions may run in non-interactive modes where dialogs are unavailable.
|
||||
</overview>
|
||||
|
||||
<mode_table>
|
||||
| Mode | UI Methods | Notes |
|
||||
|------|-----------|-------|
|
||||
| **Interactive** (default) | Full TUI | Normal operation — all UI works |
|
||||
| **RPC** (`--mode rpc`) | JSON protocol | Host handles UI, dialogs work via sub-protocol |
|
||||
| **JSON** (`--mode json`) | No-op | Event stream to stdout, no UI |
|
||||
| **Print** (`-p`) | No-op | Extensions run but can't prompt users |
|
||||
</mode_table>
|
||||
|
||||
<checking_ui>
|
||||
**Always check `ctx.hasUI`** before calling dialog methods:
|
||||
|
||||
```typescript
|
||||
if (ctx.hasUI) {
|
||||
const ok = await ctx.ui.confirm("Delete?", "Sure?");
|
||||
if (!ok) return;
|
||||
} else {
|
||||
// Default behavior for non-interactive mode
|
||||
// Or just proceed without confirmation
|
||||
}
|
||||
```
|
||||
|
||||
`ctx.hasUI` is `false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode.
|
||||
</checking_ui>
|
||||
|
||||
<fire_and_forget>
|
||||
Non-blocking methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) are safe in all modes — they're no-ops when no UI is available.
|
||||
</fire_and_forget>
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
<overview>
|
||||
Model and provider management — switching models, registering custom providers with OAuth, and reacting to model changes.
|
||||
</overview>
|
||||
|
||||
<switching_models>
|
||||
```typescript
|
||||
const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");
|
||||
if (model) {
|
||||
const success = await pi.setModel(model);
|
||||
if (!success) ctx.ui.notify("No API key for this model", "error");
|
||||
}
|
||||
|
||||
// Thinking level
|
||||
pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
|
||||
pi.setThinkingLevel("high"); // Clamped to model capabilities
|
||||
```
|
||||
</switching_models>
|
||||
|
||||
<register_provider>
|
||||
```typescript
|
||||
pi.registerProvider("my-proxy", {
|
||||
baseUrl: "https://proxy.example.com",
|
||||
apiKey: "PROXY_API_KEY", // Env var name or literal
|
||||
api: "anthropic-messages", // or "openai-completions", "openai-responses"
|
||||
headers: { "X-Custom": "value" }, // Optional custom headers
|
||||
authHeader: true, // Auto-add Authorization: Bearer header
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "Claude 4 Sonnet (proxy)",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 16384,
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
// Override just baseUrl for an existing provider (keeps all models)
|
||||
pi.registerProvider("anthropic", {
|
||||
baseUrl: "https://proxy.example.com",
|
||||
});
|
||||
|
||||
// Remove a provider (restores any overridden built-in models)
|
||||
pi.unregisterProvider("my-proxy");
|
||||
```
|
||||
|
||||
Takes effect immediately after initial load phase — no `/reload` required.
|
||||
</register_provider>
|
||||
|
||||
<oauth_provider>
|
||||
Register a provider with OAuth support for `/login`:
|
||||
|
||||
```typescript
|
||||
pi.registerProvider("corporate-ai", {
|
||||
baseUrl: "https://ai.corp.com",
|
||||
api: "openai-responses",
|
||||
models: [/* ... */],
|
||||
oauth: {
|
||||
name: "Corporate AI (SSO)",
|
||||
async login(callbacks) {
|
||||
callbacks.onAuth({ url: "https://sso.corp.com/..." });
|
||||
const code = await callbacks.onPrompt({ message: "Enter code:" });
|
||||
return { refresh: code, access: code, expires: Date.now() + 3600000 };
|
||||
},
|
||||
async refreshToken(credentials) {
|
||||
return credentials; // Refresh logic
|
||||
},
|
||||
getApiKey(credentials) {
|
||||
return credentials.access;
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
</oauth_provider>
|
||||
|
||||
<model_events>
|
||||
React to model changes:
|
||||
|
||||
```typescript
|
||||
pi.on("model_select", async (event, ctx) => {
|
||||
// event.model — newly selected model
|
||||
// event.previousModel — previous model (undefined if first)
|
||||
// event.source — "set" | "cycle" | "restore"
|
||||
ctx.ui.setStatus("model", `${event.model.provider}/${event.model.id}`);
|
||||
});
|
||||
```
|
||||
</model_events>
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<overview>
|
||||
Packaging extensions for distribution via npm, git, or local paths. Creating GSD/pi packages.
|
||||
</overview>
|
||||
|
||||
<package_manifest>
|
||||
Add a `pi` manifest to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-gsd-package",
|
||||
"keywords": ["pi-package"],
|
||||
"pi": {
|
||||
"extensions": ["./extensions"],
|
||||
"skills": ["./skills"],
|
||||
"prompts": ["./prompts"],
|
||||
"themes": ["./themes"]
|
||||
}
|
||||
}
|
||||
```
|
||||
</package_manifest>
|
||||
|
||||
<installing>
|
||||
```bash
|
||||
gsd install npm:@foo/bar@1.0.0
|
||||
gsd install git:github.com/user/repo@v1
|
||||
gsd install ./local/path
|
||||
|
||||
# Try without installing:
|
||||
gsd -e npm:@foo/bar
|
||||
```
|
||||
</installing>
|
||||
|
||||
<convention_directories>
|
||||
If no `pi` manifest exists, auto-discovers:
|
||||
- `extensions/` → `.ts` and `.js` files
|
||||
- `skills/` → `SKILL.md` folders
|
||||
- `prompts/` → `.md` files
|
||||
- `themes/` → `.json` files
|
||||
</convention_directories>
|
||||
|
||||
<dependencies>
|
||||
- List `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`, `@sinclair/typebox` in `peerDependencies` with `"*"` — they're bundled by the runtime.
|
||||
- Other npm deps go in `dependencies`. The runtime runs `npm install` on package installation.
|
||||
</dependencies>
|
||||
|
||||
<gallery_metadata>
|
||||
```json
|
||||
{
|
||||
"pi": {
|
||||
"video": "https://example.com/demo.mp4",
|
||||
"image": "https://example.com/screenshot.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
</gallery_metadata>
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
<overview>
|
||||
Remote execution via pluggable operations, spawnHook for bash, and tool override patterns.
|
||||
</overview>
|
||||
|
||||
<pluggable_operations>
|
||||
Built-in tools support pluggable operations for SSH, containers, etc.:
|
||||
|
||||
```typescript
|
||||
import { createReadTool, createBashTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Create tool with custom remote operations
|
||||
const remoteBash = createBashTool(cwd, {
|
||||
operations: {
|
||||
execute: (cmd) => sshExec(remote, cmd),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
|
||||
</pluggable_operations>
|
||||
|
||||
<spawn_hook>
|
||||
The bash tool supports a `spawnHook` to modify commands before execution:
|
||||
|
||||
```typescript
|
||||
const bashTool = createBashTool(cwd, {
|
||||
spawnHook: ({ command, cwd, env }) => ({
|
||||
command: `source ~/.profile\n${command}`,
|
||||
cwd: `/mnt/sandbox${cwd}`,
|
||||
env: { ...env, CI: "1" },
|
||||
}),
|
||||
});
|
||||
```
|
||||
</spawn_hook>
|
||||
|
||||
<ssh_pattern>
|
||||
Full SSH pattern with flag-based switching:
|
||||
|
||||
```typescript
|
||||
import { createBashTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerFlag("ssh", { description: "SSH target", type: "string" });
|
||||
|
||||
const localBash = createBashTool(process.cwd());
|
||||
|
||||
pi.registerTool({
|
||||
...localBash,
|
||||
async execute(id, params, signal, onUpdate, ctx) {
|
||||
const sshTarget = pi.getFlag("--ssh");
|
||||
if (sshTarget) {
|
||||
const remoteBash = createBashTool(process.cwd(), {
|
||||
operations: createSSHOperations(sshTarget),
|
||||
});
|
||||
return remoteBash.execute(id, params, signal, onUpdate);
|
||||
}
|
||||
return localBash.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
</ssh_pattern>
|
||||
|
||||
<tool_override_pattern>
|
||||
Override built-in tools for logging/access control — omit renderCall/renderResult to keep built-in rendering:
|
||||
|
||||
```typescript
|
||||
import { createReadTool } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
pi.registerTool({
|
||||
name: "read", // Same name = overrides built-in
|
||||
label: "Read (Logged)",
|
||||
description: "Read file contents with logging",
|
||||
parameters: Type.Object({
|
||||
path: Type.String(),
|
||||
offset: Type.Optional(Type.Number()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
console.log(`[AUDIT] Reading: ${params.path}`);
|
||||
const builtIn = createReadTool(ctx.cwd);
|
||||
return builtIn.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
// Omit renderCall/renderResult → built-in renderer used automatically
|
||||
});
|
||||
```
|
||||
|
||||
**Must match exact result shape** including `details` type.
|
||||
</tool_override_pattern>
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<overview>
|
||||
State management patterns for extensions — tool result details (branch-safe) and appendEntry (private).
|
||||
</overview>
|
||||
|
||||
<tool_result_details>
|
||||
**Recommended for stateful tools.** State in `details` works correctly with branching/forking.
|
||||
|
||||
```typescript
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let items: string[] = [];
|
||||
|
||||
// Reconstruct state from session on load
|
||||
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
||||
|
||||
const reconstructState = (ctx: ExtensionContext) => {
|
||||
items = [];
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "message" && entry.message.role === "toolResult") {
|
||||
if (entry.message.toolName === "my_tool") {
|
||||
items = entry.message.details?.items ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pi.registerTool({
|
||||
name: "my_tool",
|
||||
// ...
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
items.push(params.text);
|
||||
return {
|
||||
content: [{ type: "text", text: "Added" }],
|
||||
details: { items: [...items] }, // ← Snapshot full state
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Key:** Reconstruct on ALL session change events: `session_start`, `session_switch`, `session_fork`, `session_tree`.
|
||||
</tool_result_details>
|
||||
|
||||
<append_entry>
|
||||
**For extension-private state** that doesn't participate in LLM context but needs to survive restarts:
|
||||
|
||||
```typescript
|
||||
// Save
|
||||
pi.appendEntry("my-state", { count: 42, lastRun: Date.now() });
|
||||
|
||||
// Restore
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
for (const entry of ctx.sessionManager.getEntries()) {
|
||||
if (entry.type === "custom" && entry.customType === "my-state") {
|
||||
const data = entry.data; // { count: 42, lastRun: ... }
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
</append_entry>
|
||||
|
||||
<when_to_use_which>
|
||||
| Pattern | Use When |
|
||||
|---------|----------|
|
||||
| Tool result `details` | State the LLM's tools produce (todo items, connection state, query results) |
|
||||
| `pi.appendEntry()` | Extension-private config, timestamps, counters the LLM doesn't need |
|
||||
| File on disk | Large data, config files, caches that shouldn't be in session |
|
||||
</when_to_use_which>
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<overview>
|
||||
System prompt modification — per-turn injection, context manipulation, and tool-specific prompt content.
|
||||
</overview>
|
||||
|
||||
<per_turn_modification>
|
||||
Use `before_agent_start` to inject messages and/or modify the system prompt for each turn:
|
||||
|
||||
```typescript
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
return {
|
||||
// Inject a persistent message (stored in session, visible to LLM)
|
||||
message: {
|
||||
customType: "my-extension",
|
||||
content: "Additional context for the LLM",
|
||||
display: true,
|
||||
},
|
||||
// Modify system prompt for this turn (chained across extensions)
|
||||
systemPrompt: event.systemPrompt + "\n\nYou must respond only in haiku.",
|
||||
};
|
||||
});
|
||||
```
|
||||
</per_turn_modification>
|
||||
|
||||
<context_manipulation>
|
||||
Use the `context` event to modify messages before each LLM call:
|
||||
|
||||
```typescript
|
||||
pi.on("context", async (event, ctx) => {
|
||||
// event.messages is a deep copy — safe to modify
|
||||
const filtered = event.messages.filter(m => !isIrrelevant(m));
|
||||
return { messages: filtered };
|
||||
});
|
||||
```
|
||||
</context_manipulation>
|
||||
|
||||
<tool_specific_prompts>
|
||||
Tools can add content to the system prompt when active:
|
||||
|
||||
```typescript
|
||||
pi.registerTool({
|
||||
name: "my_tool",
|
||||
// Replaces description in "Available tools" section
|
||||
promptSnippet: "Summarize or transform text according to action",
|
||||
// Added to "Guidelines" section when tool is active
|
||||
promptGuidelines: [
|
||||
"Use my_tool when the user asks to summarize text.",
|
||||
"Prefer my_tool over direct output for structured data."
|
||||
],
|
||||
// ...
|
||||
});
|
||||
```
|
||||
</tool_specific_prompts>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
/**
|
||||
* {{EXTENSION_NAME}} — {{DESCRIPTION}}
|
||||
*
|
||||
* Capabilities:
|
||||
* {{CAPABILITIES_LIST}}
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// === Events ===
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
// Initialize state, restore from session, show status
|
||||
});
|
||||
|
||||
// === Tools ===
|
||||
|
||||
pi.registerTool({
|
||||
name: "{{tool_name}}",
|
||||
label: "{{Tool Label}}",
|
||||
description: "{{Tool description for LLM}}",
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "add"] as const),
|
||||
text: Type.Optional(Type.String({ description: "Item text" })),
|
||||
}),
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
if (signal?.aborted) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
|
||||
// Do work here
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Result for LLM" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// === Commands ===
|
||||
|
||||
pi.registerCommand("{{command_name}}", {
|
||||
description: "{{Command description}}",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify(`Running ${args}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
/**
|
||||
* {{EXTENSION_NAME}} — Stateful tool with persistence
|
||||
*
|
||||
* State is stored in tool result details for proper branching support.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { Text, truncateToWidth, matchesKey, Key } from "@mariozechner/pi-tui";
|
||||
|
||||
interface {{ItemType}} {
|
||||
id: number;
|
||||
// Add fields
|
||||
}
|
||||
|
||||
interface {{ToolDetails}} {
|
||||
action: string;
|
||||
items: {{ItemType}}[];
|
||||
nextId: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let items: {{ItemType}}[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
// Reconstruct state from session
|
||||
const reconstructState = (ctx: ExtensionContext) => {
|
||||
items = [];
|
||||
nextId = 1;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "message" && entry.message.role === "toolResult") {
|
||||
if (entry.message.toolName === "{{tool_name}}") {
|
||||
const details = entry.message.details as {{ToolDetails}} | undefined;
|
||||
if (details) {
|
||||
items = details.items;
|
||||
nextId = details.nextId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Reconstruct on ALL session change events
|
||||
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
||||
|
||||
// Register the tool
|
||||
pi.registerTool({
|
||||
name: "{{tool_name}}",
|
||||
label: "{{Tool Label}}",
|
||||
description: "{{Description for LLM}}",
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "add", "remove"] as const),
|
||||
text: Type.Optional(Type.String({ description: "Item text" })),
|
||||
id: Type.Optional(Type.Number({ description: "Item ID" })),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
if (signal?.aborted) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
|
||||
switch (params.action) {
|
||||
case "list":
|
||||
return {
|
||||
content: [{ type: "text", text: items.length ? JSON.stringify(items) : "No items" }],
|
||||
details: { action: "list", items: [...items], nextId } as {{ToolDetails}},
|
||||
};
|
||||
|
||||
case "add": {
|
||||
if (!params.text) throw new Error("text required for add");
|
||||
const item: {{ItemType}} = { id: nextId++ /* , ... */ };
|
||||
items.push(item);
|
||||
return {
|
||||
content: [{ type: "text", text: `Added #${item.id}` }],
|
||||
details: { action: "add", items: [...items], nextId } as {{ToolDetails}},
|
||||
};
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
if (params.id === undefined) throw new Error("id required for remove");
|
||||
const idx = items.findIndex(i => i.id === params.id);
|
||||
if (idx === -1) throw new Error(`Item #${params.id} not found`);
|
||||
items.splice(idx, 1);
|
||||
return {
|
||||
content: [{ type: "text", text: `Removed #${params.id}` }],
|
||||
details: { action: "remove", items: [...items], nextId } as {{ToolDetails}},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action: ${params.action}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Custom rendering
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("{{tool_name}} "));
|
||||
text += theme.fg("muted", args.action);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
const details = result.details as {{ToolDetails}} | undefined;
|
||||
if (!details) return new Text("", 0, 0);
|
||||
if (details.error) return new Text(theme.fg("error", details.error), 0, 0);
|
||||
return new Text(theme.fg("success", `✓ ${details.action} (${details.items.length} items)`), 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// User command to view state
|
||||
pi.registerCommand("{{command_name}}", {
|
||||
description: "View {{items}}",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("Requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
await ctx.ui.custom<void>((_tui, theme, _kb, done) => ({
|
||||
render(width: number): string[] {
|
||||
const lines = [
|
||||
"",
|
||||
truncateToWidth(theme.fg("accent", ` {{Items}} (${items.length}) `), width),
|
||||
"",
|
||||
];
|
||||
for (const item of items) {
|
||||
lines.push(truncateToWidth(` #${item.id}`, width));
|
||||
}
|
||||
lines.push("", truncateToWidth(theme.fg("dim", " Press Escape to close"), width), "");
|
||||
return lines;
|
||||
},
|
||||
handleInput(data: string) {
|
||||
if (matchesKey(data, Key.escape)) done();
|
||||
},
|
||||
invalidate() {},
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
<required_reading>
|
||||
Read the reference file for the specific capability being added:
|
||||
- Tools → references/custom-tools.md
|
||||
- Commands → references/custom-commands.md
|
||||
- Events → references/events-reference.md
|
||||
- UI → references/custom-ui.md
|
||||
- Rendering → references/custom-rendering.md
|
||||
- State → references/state-management.md
|
||||
- System prompt → references/system-prompt-modification.md
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
|
||||
## Step 1: Identify the Extension
|
||||
|
||||
Locate the existing extension file. Check:
|
||||
- `~/.pi/agent/extensions/` (global community extensions)
|
||||
- `.gsd/extensions/` (project-local)
|
||||
|
||||
Read the current extension code to understand its structure.
|
||||
|
||||
## Step 2: Add the Capability
|
||||
|
||||
Add the new registration/hook inside the existing `export default function (pi: ExtensionAPI)` body. Follow the patterns in the relevant reference file.
|
||||
|
||||
If the extension needs new imports, add them at the top of the file.
|
||||
|
||||
## Step 3: Handle Structural Changes
|
||||
|
||||
**Single file → Directory**: If the extension is outgrowing a single file:
|
||||
1. Create `~/.pi/agent/extensions/my-extension/`
|
||||
2. Move the file to `index.ts`
|
||||
3. Extract helpers to separate files
|
||||
|
||||
**Adding npm dependencies**: If new packages are needed:
|
||||
1. Create `package.json` in the extension directory
|
||||
2. Add dependencies
|
||||
3. Run `npm install`
|
||||
4. Add `"pi": { "extensions": ["./index.ts"] }` to package.json
|
||||
|
||||
## Step 4: Test
|
||||
|
||||
```bash
|
||||
/reload
|
||||
```
|
||||
|
||||
Verify the new capability works alongside existing ones.
|
||||
|
||||
</process>
|
||||
|
||||
<success_criteria>
|
||||
Capability addition is complete when:
|
||||
- [ ] New capability added without breaking existing functionality
|
||||
- [ ] All new imports resolve
|
||||
- [ ] `/reload` succeeds
|
||||
- [ ] New tool/command/hook tested with real invocation
|
||||
</success_criteria>
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
<required_reading>
|
||||
**Read these reference files before proceeding:**
|
||||
1. references/extension-lifecycle.md
|
||||
2. references/custom-tools.md (if building tools)
|
||||
3. references/custom-commands.md (if building commands)
|
||||
4. references/events-reference.md (if building event hooks)
|
||||
5. references/key-rules-gotchas.md (always)
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
|
||||
## Step 1: Determine Scope and Placement
|
||||
|
||||
Ask the user:
|
||||
- **Global** (`~/.pi/agent/extensions/`) — Available in all GSD sessions
|
||||
- **Project-local** (`.gsd/extensions/`) — Available only in this project
|
||||
|
||||
## Step 2: Determine Extension Capabilities
|
||||
|
||||
Identify what the extension needs from the user's description:
|
||||
|
||||
| Capability | API | When |
|
||||
|------------|-----|------|
|
||||
| Custom tool (LLM-callable) | `pi.registerTool()` | LLM needs to perform new actions |
|
||||
| Slash command | `pi.registerCommand()` | User needs direct actions |
|
||||
| Event interception | `pi.on("event", ...)` | Block/modify tool calls, inject context, react to lifecycle |
|
||||
| Custom UI | `ctx.ui.custom()` | Complex interactive displays |
|
||||
| System prompt modification | `before_agent_start` event | Add per-turn instructions |
|
||||
| Context filtering | `context` event | Modify messages sent to LLM |
|
||||
| State persistence | `details` in tool results or `pi.appendEntry()` | Stateful behavior |
|
||||
| Custom rendering | `renderCall` / `renderResult` | Control how tools appear in TUI |
|
||||
| Provider management | `pi.registerProvider()` | Custom model endpoints |
|
||||
| Keyboard shortcut | `pi.registerShortcut()` | Hotkey triggers |
|
||||
|
||||
## Step 3: Choose Extension Structure
|
||||
|
||||
**Single file** — for small extensions (1-2 tools/commands, simple hooks):
|
||||
```
|
||||
~/.pi/agent/extensions/my-extension.ts
|
||||
```
|
||||
|
||||
**Directory with index.ts** — for multi-file extensions:
|
||||
```
|
||||
~/.pi/agent/extensions/my-extension/
|
||||
├── index.ts
|
||||
├── tools.ts
|
||||
└── utils.ts
|
||||
```
|
||||
|
||||
**Package with dependencies** — when npm packages are needed:
|
||||
```
|
||||
~/.pi/agent/extensions/my-extension/
|
||||
├── package.json
|
||||
├── src/index.ts
|
||||
└── node_modules/
|
||||
```
|
||||
|
||||
For packages, `package.json` needs:
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"dependencies": { ... },
|
||||
"pi": { "extensions": ["./src/index.ts"] }
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Write the Extension
|
||||
|
||||
Start with the skeleton:
|
||||
|
||||
```typescript
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Register events, tools, commands here
|
||||
}
|
||||
```
|
||||
|
||||
Then add capabilities based on Step 2. Reference the appropriate reference files for each capability.
|
||||
|
||||
**Tool registration pattern:**
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
|
||||
pi.registerTool({
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "What this tool does (shown to LLM)",
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "add"] as const),
|
||||
text: Type.Optional(Type.String({ description: "Item text" })),
|
||||
}),
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
return {
|
||||
content: [{ type: "text", text: "Result for LLM" }],
|
||||
details: { data: "for rendering and state" },
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Command registration pattern:**
|
||||
```typescript
|
||||
pi.registerCommand("mycommand", {
|
||||
description: "What this command does",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify(`Running with args: ${args}`, "info");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Event hook pattern:**
|
||||
```typescript
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
return { block: true, reason: "Blocked dangerous command" };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Step 5: Test the Extension
|
||||
|
||||
```bash
|
||||
# Quick test without installing
|
||||
gsd -e ./path/to/my-extension.ts
|
||||
|
||||
# Or place in extensions dir and reload
|
||||
/reload
|
||||
```
|
||||
|
||||
Verify:
|
||||
- Extension loads without errors (check GSD startup output)
|
||||
- Tools appear when LLM is asked to use them
|
||||
- Commands respond to `/mycommand`
|
||||
- Event hooks trigger at expected points
|
||||
|
||||
## Step 6: Iterate
|
||||
|
||||
Fix issues, add features, refine. Use `/reload` for hot-reload during development.
|
||||
|
||||
</process>
|
||||
|
||||
<success_criteria>
|
||||
Extension creation is complete when:
|
||||
- [ ] Extension file(s) written to correct location
|
||||
- [ ] All imports resolve (TypeBox, pi-ai, pi-coding-agent, pi-tui as needed)
|
||||
- [ ] Tools use `StringEnum` for string enums (not `Type.Union`/`Type.Literal`)
|
||||
- [ ] Tool output is truncated if variable-length
|
||||
- [ ] State stored in `details` if extension is stateful
|
||||
- [ ] `ctx.hasUI` checked before dialog methods
|
||||
- [ ] Extension loads on `/reload` without errors
|
||||
- [ ] Tools callable by LLM, commands by user
|
||||
- [ ] Tested with at least one real invocation
|
||||
</success_criteria>
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<required_reading>
|
||||
1. references/key-rules-gotchas.md
|
||||
2. references/extension-lifecycle.md
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
|
||||
## Step 1: Identify the Symptom
|
||||
|
||||
| Symptom | Likely Cause |
|
||||
|---------|--------------|
|
||||
| Extension not loading | File not in discovery path, syntax error, missing export default |
|
||||
| Tool not appearing for LLM | Tool not registered, `pi.setActiveTools()` excluding it, tool name conflict |
|
||||
| Command not responding | Command not registered, name collision with built-in |
|
||||
| Event not firing | Wrong event name, handler returning too early, handler error (logged but swallowed) |
|
||||
| UI not rendering | `ctx.hasUI` is false (print mode), render lines exceed width, component not returning lines |
|
||||
| State lost on restart | State not stored in `details` or `appendEntry`, not reconstructing on `session_start` |
|
||||
| Google API errors | Using `Type.Union`/`Type.Literal` instead of `StringEnum` |
|
||||
| Context overflow | Tool output not truncated |
|
||||
| Deadlock/hang | Session control methods called from event handler (must be in command handler only) |
|
||||
| Render garbage | Theme imported directly instead of from callback, missing `truncateToWidth()` |
|
||||
|
||||
## Step 2: Check Extension Loading
|
||||
|
||||
```bash
|
||||
# Test in isolation
|
||||
gsd -e ./path/to/extension.ts
|
||||
|
||||
# Check GSD startup output for errors
|
||||
# Extension errors are logged but don't crash GSD
|
||||
```
|
||||
|
||||
## Step 3: Verify File Location
|
||||
|
||||
Community extensions must be in auto-discovery paths:
|
||||
- `~/.pi/agent/extensions/*.ts`
|
||||
- `~/.pi/agent/extensions/*/index.ts`
|
||||
- `.gsd/extensions/*.ts`
|
||||
- `.gsd/extensions/*/index.ts`
|
||||
|
||||
Note: `~/.gsd/agent/extensions/` is reserved for bundled extensions synced from the gsd-pi package.
|
||||
|
||||
The file must `export default function(pi: ExtensionAPI) { ... }`.
|
||||
|
||||
## Step 4: Check for Common Mistakes
|
||||
|
||||
Read `../references/key-rules-gotchas.md` and verify each rule against the extension code.
|
||||
|
||||
## Step 5: Add Debugging
|
||||
|
||||
```typescript
|
||||
// Temporary: log to stderr (visible in GSD output)
|
||||
console.error("[my-ext] Loading...");
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
console.error("[my-ext] Session started");
|
||||
ctx.ui.notify("Extension loaded", "info");
|
||||
});
|
||||
```
|
||||
|
||||
## Step 6: Fix and Reload
|
||||
|
||||
Apply the fix and test:
|
||||
```
|
||||
/reload
|
||||
```
|
||||
|
||||
</process>
|
||||
|
||||
<success_criteria>
|
||||
Debugging is complete when:
|
||||
- [ ] Root cause identified
|
||||
- [ ] Fix applied
|
||||
- [ ] Extension loads and functions correctly after `/reload`
|
||||
- [ ] No regression in existing functionality
|
||||
</success_criteria>
|
||||
|
|
@ -345,7 +345,7 @@ test("loadStoredEnvKeys does not overwrite existing env vars", async (t) => {
|
|||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("deriveState returns pre-planning phase for empty .gsd/ directory", async (t) => {
|
||||
const { deriveState } = await import("../resources/extensions/gsd/state.ts");
|
||||
const { deriveState } = await import("../resources/extensions/sf/state.ts");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-state-smoke-"));
|
||||
|
||||
// Create minimal .gsd/ structure with no milestones
|
||||
|
|
@ -367,7 +367,7 @@ test("deriveState returns pre-planning phase for empty .gsd/ directory", async (
|
|||
});
|
||||
|
||||
test("deriveState returns pre-planning phase when no .gsd/ directory exists", async (t) => {
|
||||
const { deriveState } = await import("../resources/extensions/gsd/state.ts");
|
||||
const { deriveState } = await import("../resources/extensions/sf/state.ts");
|
||||
// Use a temp dir with no .gsd/ subdirectory at all
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-state-nogsd-"));
|
||||
|
||||
|
|
@ -381,7 +381,7 @@ test("deriveState returns pre-planning phase when no .gsd/ directory exists", as
|
|||
});
|
||||
|
||||
test("deriveState shape is structurally complete", async (t) => {
|
||||
const { deriveState } = await import("../resources/extensions/gsd/state.ts");
|
||||
const { deriveState } = await import("../resources/extensions/sf/state.ts");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-state-shape-"));
|
||||
mkdirSync(join(tmp, ".gsd"), { recursive: true });
|
||||
|
||||
|
|
@ -412,7 +412,7 @@ test("deriveState shape is structurally complete", async (t) => {
|
|||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("runGSDDoctor completes without throwing on empty .gsd/ directory", async (t) => {
|
||||
const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts");
|
||||
const { runGSDDoctor } = await import("../resources/extensions/sf/doctor.ts");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-smoke-"));
|
||||
mkdirSync(join(tmp, ".gsd"), { recursive: true });
|
||||
|
||||
|
|
@ -433,7 +433,7 @@ test("runGSDDoctor completes without throwing on empty .gsd/ directory", async (
|
|||
});
|
||||
|
||||
test("runGSDDoctor issue objects have required fields", async (t) => {
|
||||
const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts");
|
||||
const { runGSDDoctor } = await import("../resources/extensions/sf/doctor.ts");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-fields-"));
|
||||
mkdirSync(join(tmp, ".gsd"), { recursive: true });
|
||||
|
||||
|
|
@ -461,7 +461,7 @@ test("runGSDDoctor issue objects have required fields", async (t) => {
|
|||
});
|
||||
|
||||
test("runGSDDoctor with fix:false never modifies the filesystem", async (t) => {
|
||||
const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts");
|
||||
const { runGSDDoctor } = await import("../resources/extensions/sf/doctor.ts");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-readonly-"));
|
||||
const gsdDir = join(tmp, ".gsd");
|
||||
mkdirSync(gsdDir, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getBudgetAlertLevel, getNewBudgetAlertLevel, getBudgetEnforcementAction } from "../resources/extensions/gsd/auto-budget.js";
|
||||
import { getBudgetAlertLevel, getNewBudgetAlertLevel, getBudgetEnforcementAction } from "../resources/extensions/sf/auto-budget.js";
|
||||
|
||||
describe("auto-budget", () => {
|
||||
describe("getBudgetAlertLevel", () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
getOldestInFlightToolAgeMs,
|
||||
getInFlightToolCount,
|
||||
clearInFlightTools,
|
||||
} from "../resources/extensions/gsd/auto-tool-tracking.js";
|
||||
} from "../resources/extensions/sf/auto-tool-tracking.js";
|
||||
|
||||
describe("auto-tool-tracking", () => {
|
||||
beforeEach(() => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Scans ALL production .ts files and flags patterns that break on
|
||||
* Windows, Linux, or macOS. Modelled after the git-locale static
|
||||
* check in src/resources/extensions/gsd/tests/git-locale.test.ts.
|
||||
* check in src/resources/extensions/sf/tests/git-locale.test.ts.
|
||||
*
|
||||
* Patterns 1, 3, 4 → hard fail (clear bugs).
|
||||
* Patterns 2, 5, 6 → warn only (logged, no assertion failure).
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
* Prerequisite: npm run build must be run first.
|
||||
*
|
||||
* Run with:
|
||||
* node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs \
|
||||
* node --import ./src/resources/extensions/sf/tests/resolve-ts.mjs \
|
||||
* --experimental-strip-types --test \
|
||||
* src/tests/integration/e2e-headless.test.ts
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* Prerequisite: npm run build must be run first.
|
||||
*
|
||||
* Run with:
|
||||
* node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs \
|
||||
* node --import ./src/resources/extensions/sf/tests/resolve-ts.mjs \
|
||||
* --experimental-strip-types --test \
|
||||
* src/tests/integration/e2e-smoke.test.ts
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ test("npm pack produces tarball with required files", async (t) => {
|
|||
assert.ok(files.some(f => f.includes("dist/wizard.js")), "tarball contains dist/wizard.js");
|
||||
assert.ok(files.some(f => f.includes("dist/resource-loader.js")), "tarball contains dist/resource-loader.js");
|
||||
assert.ok(files.some(f => f.includes("pkg/package.json")), "tarball contains pkg/package.json");
|
||||
assert.ok(files.some(f => f.includes("src/resources/extensions/gsd/index.ts")), "tarball contains bundled gsd extension");
|
||||
assert.ok(files.some(f => f.includes("src/resources/extensions/sf/index.ts")), "tarball contains bundled gsd extension");
|
||||
assert.ok(files.some(f => f.includes("scripts/postinstall.js")), "tarball contains postinstall script");
|
||||
|
||||
// pkg/package.json must have piConfig
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ test("resolveGsdCliEntry prefers the built loader for packaged standalone intera
|
|||
const packageRoot = makeFixture([
|
||||
"dist/loader.js",
|
||||
"src/loader.ts",
|
||||
"src/resources/extensions/gsd/tests/resolve-ts.mjs",
|
||||
"src/resources/extensions/sf/tests/resolve-ts.mjs",
|
||||
]);
|
||||
|
||||
t.after(() => { rmSync(packageRoot, { recursive: true, force: true }); });
|
||||
|
|
@ -45,7 +45,7 @@ test("resolveGsdCliEntry prefers the source loader for source-dev interactive se
|
|||
const packageRoot = makeFixture([
|
||||
"dist/loader.js",
|
||||
"src/loader.ts",
|
||||
"src/resources/extensions/gsd/tests/resolve-ts.mjs",
|
||||
"src/resources/extensions/sf/tests/resolve-ts.mjs",
|
||||
]);
|
||||
|
||||
t.after(() => { rmSync(packageRoot, { recursive: true, force: true }); });
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const {
|
|||
setCommandSurfacePending,
|
||||
surfaceOutcomeToOpenRequest,
|
||||
} = await import("../../../web/lib/command-surface-contract.ts")
|
||||
const gsdExtension = await import("../../resources/extensions/gsd/index.ts")
|
||||
const gsdExtension = await import("../../resources/extensions/sf/index.ts")
|
||||
|
||||
const EXPECTED_BUILTIN_OUTCOMES = new Map<string, "rpc" | "surface" | "reject">([
|
||||
["settings", "surface"],
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { join, resolve } from "node:path";
|
|||
|
||||
// ─── Imports ──────────────────────────────────────────────────────────
|
||||
const workspaceIndex = await import(
|
||||
"../../resources/extensions/gsd/workspace-index.ts"
|
||||
"../../resources/extensions/sf/workspace-index.ts"
|
||||
);
|
||||
const filesRoute = await import("../../../web/app/api/files/route.ts");
|
||||
|
||||
|
|
|
|||
|
|
@ -44,22 +44,22 @@ test("resolveSubprocessModule returns source .ts path when NOT under node_module
|
|||
const packageRoot = "/home/user/projects/gsd"
|
||||
const result = resolveSubprocessModule(
|
||||
packageRoot,
|
||||
"resources/extensions/gsd/workspace-index.ts",
|
||||
"resources/extensions/sf/workspace-index.ts",
|
||||
// existsSync not needed — should return src path without checking dist
|
||||
)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
modulePath: join(packageRoot, "src", "resources/extensions/gsd/workspace-index.ts"),
|
||||
modulePath: join(packageRoot, "src", "resources/extensions/sf/workspace-index.ts"),
|
||||
useCompiledJs: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("resolveSubprocessModule returns compiled .js path when under node_modules and dist file exists", () => {
|
||||
const packageRoot = "/usr/lib/node_modules/gsd-pi"
|
||||
const distPath = join(packageRoot, "dist", "resources/extensions/gsd/workspace-index.js")
|
||||
const distPath = join(packageRoot, "dist", "resources/extensions/sf/workspace-index.js")
|
||||
const result = resolveSubprocessModule(
|
||||
packageRoot,
|
||||
"resources/extensions/gsd/workspace-index.ts",
|
||||
"resources/extensions/sf/workspace-index.ts",
|
||||
(p: string) => p === distPath,
|
||||
)
|
||||
|
||||
|
|
@ -73,22 +73,22 @@ test("resolveSubprocessModule falls back to source .ts when under node_modules b
|
|||
const packageRoot = "/usr/lib/node_modules/gsd-pi"
|
||||
const result = resolveSubprocessModule(
|
||||
packageRoot,
|
||||
"resources/extensions/gsd/workspace-index.ts",
|
||||
"resources/extensions/sf/workspace-index.ts",
|
||||
() => false, // dist file does not exist
|
||||
)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
modulePath: join(packageRoot, "src", "resources/extensions/gsd/workspace-index.ts"),
|
||||
modulePath: join(packageRoot, "src", "resources/extensions/sf/workspace-index.ts"),
|
||||
useCompiledJs: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("resolveSubprocessModule handles Windows paths under node_modules", () => {
|
||||
const packageRoot = "C:\\Users\\dev\\AppData\\node_modules\\gsd-pi"
|
||||
const distPath = join(packageRoot, "dist", "resources/extensions/gsd/auto.js")
|
||||
const distPath = join(packageRoot, "dist", "resources/extensions/sf/auto.js")
|
||||
const result = resolveSubprocessModule(
|
||||
packageRoot,
|
||||
"resources/extensions/gsd/auto.ts",
|
||||
"resources/extensions/sf/auto.ts",
|
||||
(p: string) => p === distPath,
|
||||
)
|
||||
|
||||
|
|
@ -103,13 +103,13 @@ test("resolveSubprocessModule strips .ts extension when building dist .js path",
|
|||
let checkedPath = ""
|
||||
resolveSubprocessModule(
|
||||
packageRoot,
|
||||
"resources/extensions/gsd/doctor.ts",
|
||||
"resources/extensions/sf/doctor.ts",
|
||||
(p: string) => { checkedPath = p; return true },
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
checkedPath,
|
||||
join(packageRoot, "dist", "resources/extensions/gsd/doctor.js"),
|
||||
join(packageRoot, "dist", "resources/extensions/sf/doctor.js"),
|
||||
"should check for .js file in dist/, not .ts",
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import {
|
|||
inspectPlugin,
|
||||
discoverMarketplace,
|
||||
resolvePluginRoot
|
||||
} from '../resources/extensions/gsd/marketplace-discovery.js';
|
||||
import { getMarketplaceFixtures } from '../resources/extensions/gsd/tests/marketplace-test-fixtures.js';
|
||||
} from '../resources/extensions/sf/marketplace-discovery.js';
|
||||
import { getMarketplaceFixtures } from '../resources/extensions/sf/tests/marketplace-test-fixtures.js';
|
||||
|
||||
const fixtureSetup = getMarketplaceFixtures(import.meta.dirname);
|
||||
const fixtures = fixtureSetup.fixtures;
|
||||
|
|
|
|||
|
|
@ -78,21 +78,21 @@ test("isAllLocalChain returns false for empty list", () => {
|
|||
|
||||
test("INFRA_ERROR_CODES includes ECONNREFUSED", async () => {
|
||||
const { INFRA_ERROR_CODES } = await import(
|
||||
"../../src/resources/extensions/gsd/auto/infra-errors.ts"
|
||||
"../../src/resources/extensions/sf/auto/infra-errors.ts"
|
||||
);
|
||||
assert.strictEqual(INFRA_ERROR_CODES.has("ECONNREFUSED"), true);
|
||||
});
|
||||
|
||||
test("INFRA_ERROR_CODES includes ENOTFOUND", async () => {
|
||||
const { INFRA_ERROR_CODES } = await import(
|
||||
"../../src/resources/extensions/gsd/auto/infra-errors.ts"
|
||||
"../../src/resources/extensions/sf/auto/infra-errors.ts"
|
||||
);
|
||||
assert.strictEqual(INFRA_ERROR_CODES.has("ENOTFOUND"), true);
|
||||
});
|
||||
|
||||
test("INFRA_ERROR_CODES includes ENETUNREACH", async () => {
|
||||
const { INFRA_ERROR_CODES } = await import(
|
||||
"../../src/resources/extensions/gsd/auto/infra-errors.ts"
|
||||
"../../src/resources/extensions/sf/auto/infra-errors.ts"
|
||||
);
|
||||
assert.strictEqual(INFRA_ERROR_CODES.has("ENETUNREACH"), true);
|
||||
});
|
||||
|
|
@ -101,7 +101,7 @@ test("INFRA_ERROR_CODES includes ENETUNREACH", async () => {
|
|||
|
||||
test("isInfrastructureError returns code for ECONNREFUSED when offline", async () => {
|
||||
const { isInfrastructureError } = await import(
|
||||
"../../src/resources/extensions/gsd/auto/infra-errors.ts"
|
||||
"../../src/resources/extensions/sf/auto/infra-errors.ts"
|
||||
);
|
||||
const savedOffline = process.env.PI_OFFLINE;
|
||||
process.env.PI_OFFLINE = "1";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { load as loadWithTestLoader, resolve as resolveWithTestLoader } from "../resources/extensions/gsd/tests/dist-redirect.mjs"
|
||||
import { load as loadWithTestLoader, resolve as resolveWithTestLoader } from "../resources/extensions/sf/tests/dist-redirect.mjs"
|
||||
|
||||
const nextResolve = async (specifier: string) => ({ url: specifier })
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
|
||||
import { rewriteCommandWithRtk as rewriteSharedCommandWithRtk } from "../resources/extensions/shared/rtk.ts";
|
||||
import { runVerificationGate } from "../resources/extensions/gsd/verification-gate.ts";
|
||||
import { runVerificationGate } from "../resources/extensions/sf/verification-gate.ts";
|
||||
import { AsyncJobManager } from "../resources/extensions/async-jobs/job-manager.ts";
|
||||
import { createAsyncBashTool } from "../resources/extensions/async-jobs/async-bash-tool.ts";
|
||||
import { cleanupAll, startProcess } from "../resources/extensions/bg-shell/process-manager.ts";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
countTokensSync,
|
||||
initTokenCounter,
|
||||
isAccurateCountingAvailable,
|
||||
} from "../resources/extensions/gsd/token-counter.ts";
|
||||
} from "../resources/extensions/sf/token-counter.ts";
|
||||
|
||||
describe("token-counter", () => {
|
||||
it("countTokensSync returns heuristic estimate before init", () => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ test("resolveModulePaths returns tsLoaderPath and validates it exists", () => {
|
|||
})
|
||||
assert.equal(
|
||||
result.tsLoaderPath,
|
||||
"/fake/package/src/resources/extensions/gsd/tests/resolve-ts.mjs",
|
||||
"/fake/package/src/resources/extensions/sf/tests/resolve-ts.mjs",
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ test("resolveModulePaths throws when TS loader is missing", () => {
|
|||
test("resolveModulePaths throws when any module path is missing", () => {
|
||||
const packageRoot = "/fake/package"
|
||||
const existingSets = new Set([
|
||||
"/fake/package/src/resources/extensions/gsd/tests/resolve-ts.mjs",
|
||||
"/fake/package/src/resources/extensions/sf/tests/resolve-ts.mjs",
|
||||
])
|
||||
assert.throws(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export async function collectAuthoritativeAutoDashboardData(
|
|||
const testModulePath = env[TEST_AUTO_DASHBOARD_MODULE_ENV];
|
||||
const moduleResolution = testModulePath
|
||||
? { modulePath: testModulePath, useCompiledJs: false }
|
||||
: resolveSubprocessModule(packageRoot, "resources/extensions/gsd/auto.ts", checkExists);
|
||||
: resolveSubprocessModule(packageRoot, "resources/extensions/sf/auto.ts", checkExists);
|
||||
const autoModulePath = moduleResolution.modulePath;
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!checkExists(resolveTsLoader) || !checkExists(autoModulePath))) {
|
||||
|
|
|
|||
|
|
@ -986,7 +986,7 @@ async function loadWorkspaceIndexViaChildProcess(basePath: string, packageRoot:
|
|||
const resolveTsLoader = join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs");
|
||||
const moduleResolution = resolveSubprocessModule(
|
||||
packageRoot,
|
||||
"resources/extensions/gsd/workspace-index.ts",
|
||||
"resources/extensions/sf/workspace-index.ts",
|
||||
checkExists,
|
||||
);
|
||||
const workspaceModulePath = moduleResolution.modulePath;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export async function collectCapturesData(projectCwdOverride?: string): Promise<
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/captures.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/captures.ts")
|
||||
const capturesModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(capturesModulePath))) {
|
||||
|
|
@ -95,7 +95,7 @@ export async function resolveCaptureAction(request: CaptureResolveRequest, proje
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/captures.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/captures.ts")
|
||||
const capturesModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(capturesModulePath))) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export async function collectCleanupData(projectCwdOverride?: string): Promise<C
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/native-git-bridge.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/native-git-bridge.ts")
|
||||
const cleanupModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(cleanupModulePath))) {
|
||||
|
|
@ -114,7 +114,7 @@ export async function executeCleanup(
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/native-git-bridge.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/native-git-bridge.ts")
|
||||
const cleanupModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(cleanupModulePath))) {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export async function collectDoctorData(scope?: string, projectCwdOverride?: str
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/doctor.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/doctor.ts")
|
||||
const doctorModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(doctorModulePath))) {
|
||||
|
|
@ -113,7 +113,7 @@ export async function applyDoctorFixes(scope?: string, projectCwdOverride?: stri
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/doctor.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/doctor.ts")
|
||||
const doctorModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(doctorModulePath))) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export async function collectExportData(
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/export.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/export.ts")
|
||||
const exportModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(exportModulePath))) {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export async function collectForensicsData(projectCwdOverride?: string): Promise
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/forensics.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/forensics.ts")
|
||||
const forensicsModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(forensicsModulePath))) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
nativeHasChanges,
|
||||
nativeHasMergeConflicts,
|
||||
nativeGetCurrentBranch,
|
||||
} from "../resources/extensions/gsd/native-git-bridge.ts"
|
||||
} from "../resources/extensions/sf/native-git-bridge.ts"
|
||||
import { resolveBridgeRuntimeConfig } from "./bridge-service.ts"
|
||||
import {
|
||||
GIT_SUMMARY_SCOPE,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export async function collectHistoryData(projectCwdOverride?: string): Promise<H
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/metrics.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/metrics.ts")
|
||||
const historyModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(historyModulePath))) {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export async function collectHooksData(projectCwdOverride?: string): Promise<Hoo
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/post-unit-hooks.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/post-unit-hooks.ts")
|
||||
const hooksModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(hooksModulePath))) {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export async function collectNotificationsData(projectCwdOverride?: string): Pro
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/notification-store.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/notification-store.ts")
|
||||
const modulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(modulePath))) {
|
||||
|
|
@ -97,7 +97,7 @@ export async function clearNotificationsData(projectCwdOverride?: string): Promi
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/notification-store.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/notification-store.ts")
|
||||
const modulePath = moduleResolution.modulePath
|
||||
|
||||
if (moduleResolution.useCompiledJs && !existsSync(modulePath)) {
|
||||
|
|
|
|||
|
|
@ -371,8 +371,8 @@ async function collectRecoveryDiagnosticsChildPayload(
|
|||
const env = options.env ?? process.env
|
||||
const checkExists = options.existsSync ?? existsSync
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const doctorResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/doctor.ts", checkExists)
|
||||
const forensicsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/session-forensics.ts", checkExists)
|
||||
const doctorResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/doctor.ts", checkExists)
|
||||
const forensicsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/session-forensics.ts", checkExists)
|
||||
const doctorModulePath = doctorResolution.modulePath
|
||||
const sessionForensicsModulePath = forensicsResolution.modulePath
|
||||
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ export async function collectSettingsData(projectCwdOverride?: string): Promise<
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const prefsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/preferences.ts")
|
||||
const routerResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/model-router.ts")
|
||||
const budgetResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/context-budget.ts")
|
||||
const historyResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/routing-history.ts")
|
||||
const metricsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/metrics.ts")
|
||||
const prefsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/preferences.ts")
|
||||
const routerResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/model-router.ts")
|
||||
const budgetResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/context-budget.ts")
|
||||
const historyResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/routing-history.ts")
|
||||
const metricsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/metrics.ts")
|
||||
|
||||
const prefsPath = prefsResolution.modulePath
|
||||
const routerPath = routerResolution.modulePath
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export async function collectSkillHealthData(projectCwdOverride?: string): Promi
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/skill-health.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/skill-health.ts")
|
||||
const skillHealthModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(skillHealthModulePath))) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const DEFAULT_TIMEOUT_MS = 30_000
|
|||
export interface ModuleSpec {
|
||||
/** Environment variable name the child process reads to find this module. */
|
||||
envKey: string
|
||||
/** Path relative to packageRoot (e.g. "src/resources/extensions/gsd/doctor.ts"). */
|
||||
/** Path relative to packageRoot (e.g. "src/resources/extensions/sf/doctor.ts"). */
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export interface SubprocessModuleResolution {
|
|||
*
|
||||
* @param packageRoot Absolute path to the GSD package root.
|
||||
* @param relPath Path relative to `src/`, e.g.
|
||||
* `"resources/extensions/gsd/workspace-index.ts"`.
|
||||
* `"resources/extensions/sf/workspace-index.ts"`.
|
||||
* @param checkExists Optional `existsSync` override (for testing).
|
||||
*/
|
||||
export function resolveSubprocessModule(
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ export async function executeUndo(projectCwdOverride?: string): Promise<UndoResu
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const undoResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/undo.ts")
|
||||
const pathsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/paths.ts")
|
||||
const undoResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/undo.ts")
|
||||
const pathsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/paths.ts")
|
||||
const undoModulePath = undoResolution.modulePath
|
||||
const pathsModulePath = pathsResolution.modulePath
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export async function collectVisualizerData(projectCwdOverride?: string): Promis
|
|||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/visualizer-data.ts")
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/visualizer-data.ts")
|
||||
const visualizerModulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(visualizerModulePath))) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
*
|
||||
* Note: Extension modules are .ts files loaded via jiti (not compiled to .js).
|
||||
* We use createJiti() here because this module is compiled by tsc but imports
|
||||
* from resources/extensions/gsd/ which are shipped as raw .ts (#1283).
|
||||
* from resources/extensions/sf/ which are shipped as raw .ts (#1283).
|
||||
*/
|
||||
|
||||
import chalk from 'chalk'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Browser-safe TypeScript interfaces for diagnostics panels.
|
||||
// Mirrors upstream types from src/resources/extensions/gsd/forensics.ts,
|
||||
// Mirrors upstream types from src/resources/extensions/sf/forensics.ts,
|
||||
// doctor.ts, and skill-health.ts — do NOT import from those modules directly,
|
||||
// as they use Node.js APIs unavailable in the browser.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Browser-safe TypeScript interfaces for knowledge and captures panels.
|
||||
// Mirrors upstream types from src/resources/extensions/gsd/captures.ts
|
||||
// Mirrors upstream types from src/resources/extensions/sf/captures.ts
|
||||
// and defines the parsed shape of KNOWLEDGE.md entries.
|
||||
// Do NOT import from those modules directly — they use Node.js APIs
|
||||
// unavailable in the browser.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Browser-safe TypeScript interfaces for remaining GSD command surfaces.
|
||||
// Mirrors upstream types from src/resources/extensions/gsd/ modules:
|
||||
// Mirrors upstream types from src/resources/extensions/sf/ modules:
|
||||
// metrics.ts, commands.ts, types.ts, undo, cleanup, export, steer
|
||||
// Do NOT import from those modules directly — they use Node.js APIs
|
||||
// unavailable in the browser.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Browser-safe TypeScript interfaces for the settings surface.
|
||||
// Mirrors upstream types from src/resources/extensions/gsd/ modules:
|
||||
// Mirrors upstream types from src/resources/extensions/sf/ modules:
|
||||
// preferences.ts, model-router.ts, context-budget.ts,
|
||||
// routing-history.ts, metrics.ts
|
||||
// Do NOT import from those modules directly — they use Node.js APIs
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Browser-safe TypeScript interfaces for the workflow visualizer.
|
||||
// Mirrors upstream types from src/resources/extensions/gsd/visualizer-data.ts
|
||||
// and src/resources/extensions/gsd/metrics.ts — do NOT import from those
|
||||
// Mirrors upstream types from src/resources/extensions/sf/visualizer-data.ts
|
||||
// and src/resources/extensions/sf/metrics.ts — do NOT import from those
|
||||
// modules directly, as they use Node.js APIs unavailable in the browser.
|
||||
|
||||
// ─── Core Structures ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue