Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 82 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,90 @@ Internal workspace packages use the `@harness` scope and are private to this rep

## Structure

- `extensions/` - Private Pi extensions bundled in this repository
- `commands/` - Slash commands (`/spawn`, `/qq`, `/projects:init`, etc.)
- `hooks/` - Event hooks (session protection, autocomplete, chrome, etc.)
- `tools/` - Agent tools (`bash`, `grep`, `find`, `read_url`, `oracle`, etc.)
- `extensions/` - Monolithic extensions still using their own `package.json` (`breadcrumbs`, `providers`)
- `packages/` - Shared internal workspace packages (`@harness/*`)
- `tests/` - Test setup and docs. Shared test utilities live in `packages/test-utils`.

## Extensions
## Entry point convention

- `breadcrumbs` - Session history tools. Search past sessions, extract information, and hand off context to new sessions.
- `qq` - `/qq` quick-question command with custom message rendering and context filtering.
- `defaults` - Personal sensible defaults and quality-of-life improvements.
- `palette` - Command palette with keyboard-driven UI for running commands and shortcuts.
- `planning` - Turn conversations into implementation plans and manage saved plans.
- `providers` - Register custom providers and show unified rate-limit and usage dashboards.
- `subagents` - Framework for spawning specialized subagents with custom tools, consistent UI rendering, and logging.
Every extension entry point is `export default function(pi: ExtensionAPI)`, calling `pi.registerCommand(...)`, `pi.registerTool(...)`, or `pi.on(...)` directly. No wrapper functions.

Pi discovers extensions from the root `package.json` `pi.extensions` array. Directories in that list are scanned for `index.ts` entry points.

## File layout convention

Within an extension directory:

- `index.ts` - Registration code. All `pi.*` and `ctx.*` calls live here.
- `types.ts` - Type definitions and TypeBox schemas
- `render.ts` / `renderers.ts` - Tool/message render functions
- `helpers.ts` / `fetch.ts` / `sanitize.ts` / `blocked-paths.ts` - Pure functions and utilities (names vary by domain)

No `pi.*` or `ctx.*` calls outside `index.ts`. Other files contain only pure functions, types, components, or utils.

### Subagent-based tools

Tools that spawn a subagent (oracle, reviewer, read-session, librarian) follow a different layout:

- `index.ts` - Registration code. Spawns the subagent via `ctx.newSession()`.
- `types.ts` - Subagent details schema and shared types
- `prompt.ts` - System prompt builder for the subagent
- `models/index.ts` - Model selection (picks provider/model for the subagent)
- `tools/` - Subagent tool definitions (only visible inside the subagent, not the parent)
- `lib/` - Supporting logic (GitHub clients, etc.)

## Commands

| Directory | Commands | Notes |
|---|---|---|
| `continue/` | `/continue` | Continue from a linked parent session |
| `introspection/` | `/introspection` | Inspect extension internals |
| `label/` | `/label <text>` | Label the current session entry |
| `projects/` | `/projects:init`, `/projects:settings` | Multi-step project setup wizard |
| `qq/` | `/qq <question>` | Quick question without interrupting main session |
| `session-copy-id/` | `/session:copy-id` | Copy session ID to clipboard |
| `session-copy-path/` | `/session:copy-path` | Copy session file path to clipboard |
| `spawn/` | `/spawn [note]` | Create a linked child session |
| `theme/` | `/theme` | Cycle color theme |

## Hooks

| Directory | Purpose | Key files |
|---|---|---|
| `chrome/` | Header, footer, terminal title, notifications, auto-naming | `hooks/`, `components/`, `lib/`, `native/` |
| `context-window-overrides/` | Override context window size | `index.ts` |
| `event-compat/` | Backwards-compatible event aliases | `index.ts` |
| `protect-sessions-dir/` | Gate agent access to sessions directory | `gate.ts`, `session-gate-dialog.ts`, `bash-parser.ts` |
| `session-autocomplete/` | `@` autocomplete for session names | `db.ts`, `search.ts`, `provider.ts` |
| `session-title/` | Auto-name sessions | `index.ts` |

## Tools

| Directory | Tool name | Notes |
|---|---|---|
| `ask-user/` | `ask_user` | Passthrough |
| `bash/` | `bash` | Adds `cwd` param, spawn hooks, sanitization |
| `edit/` | `edit` | Passthrough |
| `find/` | `find` | Adds `glob`, blocked paths |
| `get-current-time/` | `get_current_time` | Passthrough |
| `grep/` | `grep` | Adds `literal`, `context`, blocked paths, custom render |
| `librarian/` | `librarian` | File search by description |
| `look-at/` | `look_at` | Image analysis |
| `oracle/` | `oracle` | GPT-5 advisor |
| `read/` | `read` | Passthrough |
| `read-session/` | `read_session` | Subagent session reader |
| `read-url/` | `read_url` | URL fetch with handler chain and preview |
| `reviewer/` | `reviewer` | Diff/code review |

## Monolithic extensions

These extensions still use their own `package.json` with `pi.extensions` and are discovered separately:

- `breadcrumbs/` - Session history tools (`find_sessions`, `list_sessions`, `read_session`)
- `providers/` - Rate-limit alerts, usage widgets, dashboards

## Development

Expand All @@ -40,9 +111,10 @@ Workspace packages:

## Custom header

The startup header (`extensions/defaults/components/header.ts`) shows a curated list of harness shortcuts and commands. When adding a new `registerShortcut` or `registerCommand`, ask the user whether it should be added to the header.
The startup header (`hooks/chrome/components/header.ts`) shows a curated list of harness shortcuts and commands. When adding a new `registerShortcut` or `registerCommand`, ask the user whether it should be added to the header.

## Notes

- This repo is my private Pi harness infrastructure first. Not every package here is intended to be published as a standalone package.
- Keep repository-level docs focused on my Pi harness. Extension-specific details belong in the extension README files.
- Monolithic extensions (`breadcrumbs`, `providers`) may be migrated to the `commands/`/`hooks/`/`tools/` layout in the future.
37 changes: 0 additions & 37 deletions README.md

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import type {
import {
IntrospectPanel,
type IntrospectSnapshot,
} from "../components/introspect-panel";
} from "./components/introspect-panel";

export function registerIntrospectCommand(pi: ExtensionAPI) {
export default function (pi: ExtensionAPI) {
pi.registerCommand("introspect", {
description:
"Show introspection info: system prompt, tools, skills, prompts",
Expand Down
File renamed without changes.
File renamed without changes.
25 changes: 12 additions & 13 deletions extensions/projects/commands/init.ts → commands/projects/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
/**
* /projects:init command.
*
* Shows a multi-step wizard to configure packages, skills, and AGENTS.md
* for the current project.
*/

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { configLoader } from "../config";
import { buildAgentsPrompt } from "./init/agents-prompt";
import { applySelections, getInstalled, readSettings } from "./init/installer";
import { buildNixPrompt } from "./init/nix";
import { showWizard } from "./init/wizard";
import { buildAgentsPrompt } from "./commands/init/agents-prompt";
import {
applySelections,
getInstalled,
readSettings,
} from "./commands/init/installer";
import { buildNixPrompt } from "./commands/init/nix";
import { showWizard } from "./commands/init/wizard";
import { configLoader } from "./config";

export default async function (pi: ExtensionAPI): Promise<void> {
await configLoader.load();

export function registerProjectInitCommand(pi: ExtensionAPI): void {
pi.registerCommand("projects:init", {
description: "Initialize project with skills, packages, and AGENTS.md",
handler: async (_args, ctx) => {
Expand Down
File renamed without changes.
12 changes: 8 additions & 4 deletions extensions/qq/commands/qq.ts → commands/qq/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import {
clearQqWidget,
showLoadingWidget,
showResultWidget,
} from "../components/widget";
import { buildQqPrompt } from "../prompt";
import { runQqSubagent } from "../subagent";
} from "./components/widget";
import { buildQqPrompt } from "./prompt";
import { runQqSubagent } from "./subagent";

export function registerQqCommand(pi: ExtensionAPI): void {
export default async function (pi: ExtensionAPI): Promise<void> {
pi.registerCommand("qq", {
description: "Ask a quick question without interrupting the agent",
handler: async (args, ctx) => {
Expand Down Expand Up @@ -51,4 +51,8 @@ export function registerQqCommand(pi: ExtensionAPI): void {
}
},
});

pi.on("agent_start", async (_event, ctx) => {
clearQqWidget(ctx);
});
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
60 changes: 60 additions & 0 deletions commands/spawn/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Helper functions for the spawn command.
*/

import type { SessionEntry } from "@mariozechner/pi-coding-agent";

/**
* Extract plain text from a message content array or string.
*/
export function messageContentToText(
content: string | Array<{ type: string; text?: string }>,
): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("\n");
}

/**
* Get the text of the last assistant message from a branch's entries.
*/
export function getLastAssistantTextFromEntries(
entries: SessionEntry[],
): string | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry?.type !== "message") continue;

const msg = entry.message;
if (msg.role !== "assistant") continue;

const text = messageContentToText(msg.content).trim();
if (text) return text;
}
return undefined;
}

/**
* Build the content for the source entry in the child session.
*/
export function buildSpawnSourceContent(params: {
parentSessionId: string;
parentLastMessage?: string;
}): string {
const { parentSessionId, parentLastMessage } = params;

if (parentLastMessage) {
return `Session spawned from ${parentSessionId}.

## Last message in parent session

${parentLastMessage}`;
}

return `Session spawned from ${parentSessionId}. Use \`read_session\` to access the parent session context:

read_session({ sessionId: "${parentSessionId}", goal: "Get the last assistant message with context" })`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import {
type CustomMessageEntry,
SessionManager,
} from "@mariozechner/pi-coding-agent";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { assert, beforeEach, describe, expect, it, vi } from "vitest";
import setupSpawnCommand from "./index";
import {
SESSION_LINK_SOURCE_TYPE,
type SessionLinkSourceDetails,
} from "../../lib/session-link";
import setupSpawnCommand from "./index";
} from "./types";

/**
* Seed a real in-memory SessionManager with messages and return it.
Expand Down Expand Up @@ -87,8 +87,7 @@ describe("breadcrumbs /spawn command", () => {
expect(setEditorText).toHaveBeenCalledWith("focus on tests");

const childSm = pi.getChildSessionManager();
expect(childSm).toBeDefined();
if (!childSm) throw new Error("childSm should be defined");
assert(childSm, "childSm should be defined");

const entries = childSm.getEntries();
const sourceEntry = entries.find(
Expand All @@ -97,11 +96,11 @@ describe("breadcrumbs /spawn command", () => {
(e as CustomMessageEntry).customType === SESSION_LINK_SOURCE_TYPE,
);

expect(sourceEntry).toBeDefined();
expect(sourceEntry?.display).toBe(true);
assert(sourceEntry, "sourceEntry should be defined");
expect(sourceEntry.display).toBe(true);

const content =
typeof sourceEntry?.content === "string" ? sourceEntry?.content : "";
typeof sourceEntry.content === "string" ? sourceEntry.content : "";
expect(content).toContain(
`Session spawned from ${parentSm.getSessionId()}.`,
);
Expand All @@ -110,11 +109,9 @@ describe("breadcrumbs /spawn command", () => {
expect(content).not.toContain("read_session(");
expect(content).not.toContain("Role: assistant");

expect(sourceEntry?.details).toEqual({
parentSessionId: parentSm.getSessionId(),
goal: "focus on tests",
linkType: "continue",
});
const details = sourceEntry.details as SessionLinkSourceDetails;
expect(details.goal).toBe("focus on tests");
expect(details.linkType).toBe("continue");
});

it("omits the last-message section when there is no assistant message", async () => {
Expand All @@ -133,8 +130,7 @@ describe("breadcrumbs /spawn command", () => {
await pi.command("spawn").execute("");

const childSm = pi.getChildSessionManager();
expect(childSm).toBeDefined();
if (!childSm) throw new Error("childSm should be defined");
assert(childSm, "childSm should be defined");

const entries = childSm.getEntries();
const sourceEntry = entries.find(
Expand All @@ -143,9 +139,9 @@ describe("breadcrumbs /spawn command", () => {
(e as CustomMessageEntry).customType === SESSION_LINK_SOURCE_TYPE,
);

expect(sourceEntry).toBeDefined();
assert(sourceEntry, "sourceEntry should be defined");
const content =
typeof sourceEntry?.content === "string" ? sourceEntry?.content : "";
typeof sourceEntry.content === "string" ? sourceEntry.content : "";
expect(content).not.toContain("## Last message in parent session");
});

Expand Down
Loading
Loading