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:
ace-pm 2026-04-15 14:34:53 +02:00
parent a81fa3ae4a
commit d501ca7d6d
69 changed files with 105 additions and 2459 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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", () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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", () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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