feat: add create-gsd-extension skill (#1229)

Self-contained skill for building GSD extensions (TypeScript modules that add
tools, commands, event hooks, custom UI, and providers).

Structure:
- SKILL.md: Router with essential principles (87 lines)
- 3 workflows: create, add capability, debug
- 16 references: complete domain knowledge extracted from extending-pi and
  pi-ui-tui docs (lifecycle, events, API surface, custom tools, commands, UI,
  rendering, state management, compaction, model/provider management, remote
  execution, packaging, mode behavior, gotchas)
- 2 templates: basic skeleton and stateful tool with rendering

Coverage:
- All 25 extending-pi docs
- All relevant pi-ui-tui docs (architecture, components, overlays, keyboard,
  theming, performance, common mistakes)
- GSD-specific paths (~/.gsd not ~/.pi)
- Zero external references — fully self-contained
This commit is contained in:
TÂCHES 2026-03-18 13:26:28 -06:00 committed by GitHub
parent 1e76459813
commit ec5cf03ae8
22 changed files with 2307 additions and 0 deletions

View file

@ -0,0 +1,87 @@
---
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:**
- Global extensions: `~/.gsd/agent/extensions/*.ts` or `~/.gsd/agent/extensions/*/index.ts`
- Project-local extensions: `.gsd/extensions/*.ts` or `.gsd/extensions/*/index.ts`
**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

@ -0,0 +1,77 @@
<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

@ -0,0 +1,139 @@
<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

@ -0,0 +1,108 @@
<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

@ -0,0 +1,183 @@
<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

@ -0,0 +1,490 @@
<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

@ -0,0 +1,126 @@
<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 { isBashToolResult } from "@mariozechner/pi-coding-agent";
pi.on("tool_result", async (event, ctx) => {
if (isBashToolResult(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, isBashToolResult } 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 (isBashToolResult(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

@ -0,0 +1,64 @@
<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

@ -0,0 +1,75 @@
<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

@ -0,0 +1,53 @@
<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

@ -0,0 +1,36 @@
<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:**
- Global: `~/.gsd/agent/extensions/*.ts`
- Global (subdir): `~/.gsd/agent/extensions/*/index.ts`
- Project-local: `.gsd/extensions/*.ts`
- Project-local (subdir): `.gsd/extensions/*/index.ts`
The upstream pi docs reference `~/.pi` paths — GSD uses `~/.gsd` everywhere instead.
</gsd_paths>

View file

@ -0,0 +1,32 @@
<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

@ -0,0 +1,89 @@
<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

@ -0,0 +1,55 @@
<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

@ -0,0 +1,90 @@
<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

@ -0,0 +1,70 @@
<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

@ -0,0 +1,52 @@
<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

@ -0,0 +1,51 @@
/**
* {{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

@ -0,0 +1,143 @@
/**
* {{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

@ -0,0 +1,57 @@
<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:
- `~/.gsd/agent/extensions/` (global)
- `.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 `~/.gsd/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

@ -0,0 +1,156 @@
<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** (`~/.gsd/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):
```
~/.gsd/agent/extensions/my-extension.ts
```
**Directory with index.ts** — for multi-file extensions:
```
~/.gsd/agent/extensions/my-extension/
├── index.ts
├── tools.ts
└── utils.ts
```
**Package with dependencies** — when npm packages are needed:
```
~/.gsd/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

@ -0,0 +1,74 @@
<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
Extensions must be in auto-discovery paths:
- `~/.gsd/agent/extensions/*.ts`
- `~/.gsd/agent/extensions/*/index.ts`
- `.gsd/extensions/*.ts`
- `.gsd/extensions/*/index.ts`
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>