feat: pi project config — mifune banner, path-guard, harness-context skill, CI wiring

Stand up .pi/ as a real project workspace for the pi-coding-agent CLI:
- settings.json: OpenAI defaults (gpt-4o), model cycling list, declared resource paths
- mifune-banner extension: flush-left MIFUNE wordmark + pi harness · ryaneggz/mifune subtitle
- path-guard extension: tool_call interceptor with case-insensitive risky-bash regex,
  hasUI headless guard, /dev/sd*|nvme*|hd*|disk* bare-redirection match, defensive
  toolName.toLowerCase() (verified lowercase in pi source), canonical input key `path`
- APPEND_SYSTEM.md: generic, consumer-agnostic
- prompts/review.md: /review template
- skills + rules: directory symlinks to ../.claude/{skills,rules} (single source of
  truth, mirrors .codex/ pattern)
- new canonical .claude/skills/harness-context/SKILL.md reachable from pi via symlink

Tests at .pi/extensions/__tests__/{path-guard,mifune-banner}.test.ts (32 + 7 cases,
hand-rolled mock ExtensionAPI, no pi devDep). vitest.config.ts include glob extended
to .pi/**/__tests__/**/*.test.ts. ci-harness.yml paths filter extended and a new
"Root tests" step runs pnpm test:scripts (also lifts cron-runtime.test.ts into CI).

86 tests passing across 6 files.

Closes #255

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OpenHarness BotandClaude Opus 4.7 committed 2026-05-07 18:22:28 -06:00
1 parent ae40b56024
commit 00078a7e8b
19 files changed
+1445 -1

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
---
name: harness-context
description: |
Explain the Open Harness architecture, layout, and conventions.
Use this skill when the user asks about project structure, layout,
git workflow, rules, or how things are organized. Returns specific
file paths and `path:line` citations.
TRIGGER when: asked about harness structure, layout, conventions,
rules, process, or project organization.
---
# Harness Context
Use this skill when the user asks about Open Harness layout, lifecycle,
conventions, git workflow, or where files live.
## Steps
1. Read `CLAUDE.md` for the orchestrator contract — what the root-level
agent does and does not do.
2. Read `docs/architecture/container-runtime.md` under the `## Repo
Layout` heading for the canonical annotated repo tree. This is the
single source of truth for layout — do not paraphrase it elsewhere.
3. Read relevant rules under `.claude/rules/`:
- `git.md` — issue / branch / commit / PR conventions
- `sandbox-processes.md` — tmux sessions for long-running processes
- `repo-layout-source.md` — the no-duplicate-tree rule
- `directory-readme.md` — when a directory needs a README
- `advisor-model.md` — pattern for delegating to sub-agents
4. Answer with `path:line` citations. For layout questions, link to the
canonical anchor in `docs/architecture/container-runtime.md`; do not
reproduce the tree.
## Output shape
- Short factual answer first, then file references.
- Lifecycle questions (setup / validate / teardown): cite the relevant
section of `CLAUDE.md`.
- Convention questions: cite the specific rule file under
`.claude/rules/`.
+6
View File
@@ -5,9 +5,12 @@ on:
push: push:
paths: paths:
- "packages/**" - "packages/**"
- "scripts/**"
- ".pi/**"
- "package.json" - "package.json"
- "pnpm-lock.yaml" - "pnpm-lock.yaml"
- "pnpm-workspace.yaml" - "pnpm-workspace.yaml"
- "vitest.config.ts"
- ".github/workflows/ci-harness.yml" - ".github/workflows/ci-harness.yml"
permissions: permissions:
@@ -52,3 +55,6 @@ jobs:
- name: Test - name: Test
run: pnpm --filter './packages/**' -r run test run: pnpm --filter './packages/**' -r run test
- name: Root tests (scripts + .pi extensions)
run: pnpm test:scripts
+3
View File
@@ -0,0 +1,3 @@
sessions/
git/
npm/
+5
View File
@@ -0,0 +1,5 @@
## Project context
You are running inside a project workspace. Check `AGENTS.md` and `CLAUDE.md` if present — they define the project's voice, operating principles, and development workflow. Treat them as authoritative guides for how work happens here.
Application code and core logic are developed and tested within the local environment. Refer to project conventions for architecture patterns, tooling, and coding standards.
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import factory, { buildHeader } from "../mifune-banner";
type Handler = (event: any, ctx: any) => Promise<unknown> | unknown;
type CommandDef = { description: string; handler: Handler };
interface MockPi {
on: (event: string, handler: Handler) => void;
registerCommand: (name: string, def: CommandDef) => void;
}
interface Captured {
sessionStartHandlers: Handler[];
commands: Map<string, CommandDef>;
}
function makePi(): { pi: MockPi; captured: Captured } {
const captured: Captured = {
sessionStartHandlers: [],
commands: new Map(),
};
const pi: MockPi = {
on: (event, handler) => {
if (event === "session_start") captured.sessionStartHandlers.push(handler);
},
registerCommand: (name, def) => {
captured.commands.set(name, def);
},
};
return { pi, captured };
}
const fakeTheme = {
fg: (_role: string, text: string) => text,
} as unknown as Parameters<typeof buildHeader>[0];
describe("mifune-banner extension", () => {
let captured: Captured;
beforeEach(() => {
const m = makePi();
captured = m.captured;
factory(m.pi as never);
});
it("registers a session_start handler and the /builtin-header command", () => {
expect(captured.sessionStartHandlers).toHaveLength(1);
expect(captured.commands.has("builtin-header")).toBe(true);
});
describe("buildHeader", () => {
it("includes the wordmark and the repo URL in the subtitle", () => {
const lines = buildHeader(fakeTheme);
const joined = lines.join("\n");
expect(joined).toContain("MIFUNE");
expect(joined).toContain("pi harness");
expect(joined).toContain("github.com/ryaneggz/mifune");
});
it("includes the brand emojis (⚔ on wordmark, 🎬 on subtitle)", () => {
const lines = buildHeader(fakeTheme);
const wordmarkLine = lines.find((l) => l.includes("MIFUNE"));
const subtitleLine = lines.find((l) => l.includes("ryaneggz/mifune"));
expect(wordmarkLine).toContain("⚔");
expect(subtitleLine).toContain("🎬");
});
it("uses theme roles for color (accent for wordmark, muted for subtitle)", () => {
const calls: Array<[string, string]> = [];
const trackingTheme = {
fg: (role: string, text: string) => {
calls.push([role, text]);
return text;
},
} as unknown as Parameters<typeof buildHeader>[0];
buildHeader(trackingTheme);
const wordmarkCall = calls.find(([, t]) => t === "MIFUNE");
expect(wordmarkCall?.[0]).toBe("accent");
const subtitleCall = calls.find(([, t]) => t.includes("ryaneggz/mifune"));
expect(subtitleCall?.[0]).toBe("muted");
});
});
describe("session_start handler", () => {
it("installs a header factory when the UI is available", async () => {
const setHeader = vi.fn();
const handler = captured.sessionStartHandlers[0];
await handler({}, { hasUI: true, ui: { setHeader } });
expect(setHeader).toHaveBeenCalledOnce();
const installedFactory = setHeader.mock.calls[0][0];
const headerObj = installedFactory({}, fakeTheme);
expect(typeof headerObj.render).toBe("function");
expect(typeof headerObj.invalidate).toBe("function");
const lines = headerObj.render(80);
expect(Array.isArray(lines)).toBe(true);
expect(lines.join("\n")).toContain("MIFUNE");
});
it("is a no-op when the UI is not available (e.g. RPC mode)", async () => {
const setHeader = vi.fn();
const handler = captured.sessionStartHandlers[0];
await handler({}, { hasUI: false, ui: { setHeader } });
expect(setHeader).not.toHaveBeenCalled();
});
});
describe("/builtin-header command", () => {
it("clears the custom header and notifies", async () => {
const setHeader = vi.fn();
const notify = vi.fn();
const cmd = captured.commands.get("builtin-header")!;
await cmd.handler("", { ui: { setHeader, notify } } as never);
expect(setHeader).toHaveBeenCalledWith(undefined);
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Built-in"), "info");
});
});
});
+296
View File
@@ -0,0 +1,296 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import factory from "../path-guard";
type Handler = (event: any, ctx: any) => Promise<unknown>;
type CommandDef = { description: string; handler: Handler };
interface MockPi {
on: (event: string, handler: Handler) => void;
registerCommand: (name: string, def: CommandDef) => void;
}
interface Captured {
toolCallHandlers: Handler[];
commands: Map<string, CommandDef>;
}
function makePi(): { pi: MockPi; captured: Captured } {
const captured: Captured = {
toolCallHandlers: [],
commands: new Map(),
};
const pi: MockPi = {
on: (event, handler) => {
if (event === "tool_call") captured.toolCallHandlers.push(handler);
},
registerCommand: (name, def) => {
captured.commands.set(name, def);
},
};
return { pi, captured };
}
function makeCtx(confirm: (title: string, body: string) => Promise<boolean>, hasUI = true) {
const notify = vi.fn();
return {
notify,
ctx: {
hasUI,
ui: {
confirm: vi.fn(confirm),
notify,
},
},
};
}
async function fire(handlers: Handler[], event: unknown, ctx: unknown) {
for (const h of handlers) {
const result = await h(event, ctx);
if (result !== undefined) return result;
}
return undefined;
}
describe("path-guard extension", () => {
let captured: Captured;
beforeEach(() => {
const m = makePi();
captured = m.captured;
factory(m.pi as never);
});
it("registers a tool_call handler and the /guard command", () => {
expect(captured.toolCallHandlers).toHaveLength(1);
expect(captured.commands.has("guard")).toBe(true);
});
// Fix #1: hasUI guard tests
describe("headless mode (hasUI === false)", () => {
it("does not call confirm for write on sensitive path in headless mode", async () => {
const { ctx } = makeCtx(async () => false, false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "write", input: { path: ".env" } },
ctx,
);
expect(ctx.ui.confirm).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("does not call confirm for bash risky command in headless mode", async () => {
const { ctx } = makeCtx(async () => false, false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "bash", input: { command: "rm -rf /tmp/x" } },
ctx,
);
expect(ctx.ui.confirm).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
});
describe("sensitive write/edit", () => {
it.each([
[".env"],
["app/.env.production"],
["secrets/api.json"],
["config/credentials.yaml"],
["keys/server.pem"],
["keys/server.key"],
["~/.ssh/id_rsa"],
["~/.ssh/id_ed25519"],
])("blocks write to %s when user declines", async (path) => {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
// Fix #5: canonical key is `path` (verified from pi write.ts writeSchema)
{ toolName: "write", input: { path } },
ctx,
);
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
expect(result).toMatchObject({ block: true });
});
it("allows write when user accepts", async () => {
const { ctx } = makeCtx(async () => true);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "write", input: { path: ".env" } },
ctx,
);
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
});
it("ignores edits to ordinary paths", async () => {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "edit", input: { path: "src/index.ts" } },
ctx,
);
expect(ctx.ui.confirm).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("ignores reads of sensitive paths (only write/edit guarded)", async () => {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "read", input: { path: ".env" } },
ctx,
);
expect(ctx.ui.confirm).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("accepts alternative path field names (file_path, target, filename)", async () => {
for (const key of ["file_path", "target", "filename"]) {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "write", input: { [key]: ".env" } },
ctx,
);
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
expect(result).toMatchObject({ block: true });
}
});
it("does nothing when no path field is present", async () => {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "write", input: {} },
ctx,
);
expect(ctx.ui.confirm).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
// Fix #4: capitalized tool names still fire the guard (toLowerCase normalization)
it("fires guard for capitalized tool names (Write, Edit)", async () => {
for (const toolName of ["Write", "Edit"]) {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName, input: { path: ".env" } },
ctx,
);
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
expect(result).toMatchObject({ block: true });
}
});
});
describe("risky bash", () => {
it.each([
["rm -rf /tmp/x"],
["sudo apt update"],
["git push origin main --force"],
["git reset --hard HEAD~3"],
])("prompts and blocks %s when user declines", async (command) => {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "bash", input: { command } },
ctx,
);
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
expect(result).toMatchObject({ block: true });
});
// Fix #2: case-insensitive RISKY_BASH — uppercase variants must still trigger
it.each([
["RM -RF /tmp/x"],
["Sudo apt update"],
["GIT RESET --HARD HEAD~3"],
])("prompts for uppercase risky command: %s", async (command) => {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "bash", input: { command } },
ctx,
);
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
expect(result).toMatchObject({ block: true });
});
// Fix #3: bare > /dev/... redirection without leading colon
it.each([
["> /dev/sda"],
["dd if=/dev/zero > /dev/sdb"],
["cat /dev/zero > /dev/nvme0"],
])("prompts for bare block-device redirection: %s", async (command) => {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "bash", input: { command } },
ctx,
);
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
expect(result).toMatchObject({ block: true });
});
it("allows risky bash when user accepts", async () => {
const { ctx } = makeCtx(async () => true);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "bash", input: { command: "rm -rf /tmp/x" } },
ctx,
);
expect(result).toBeUndefined();
});
it("does not prompt for ordinary bash commands", async () => {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "bash", input: { command: "ls -la" } },
ctx,
);
expect(ctx.ui.confirm).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("truncates very long commands in the confirm body", async () => {
const long = "rm -rf " + "/x".repeat(200);
const { ctx } = makeCtx(async () => true);
await fire(
captured.toolCallHandlers,
{ toolName: "bash", input: { command: long } },
ctx,
);
const body = (ctx.ui.confirm as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(body.length).toBeLessThanOrEqual(220);
expect(body).toContain("...");
});
// Fix #4: capitalized Bash tool name still fires the guard
it("fires guard for capitalized tool name Bash", async () => {
const { ctx } = makeCtx(async () => false);
const result = await fire(
captured.toolCallHandlers,
{ toolName: "Bash", input: { command: "rm -rf /tmp/x" } },
ctx,
);
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
expect(result).toMatchObject({ block: true });
});
});
describe("/guard command", () => {
it("notifies with both protection lists", async () => {
const { ctx } = makeCtx(async () => true);
const cmd = captured.commands.get("guard")!;
await cmd.handler("" as never, ctx as never);
expect(ctx.ui.notify).toHaveBeenCalledOnce();
const [message, level] = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls[0];
expect(level).toBe("info");
expect(message).toContain("Sensitive paths");
expect(message).toContain("Risky bash patterns");
});
});
});
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
extractPlanItems,
formatPlanItems,
isSafeReadOnlyCommand,
markCompletedSteps,
normalizeToolNames,
pickPlanTools,
} from "../plan-mode/utils";
describe("plan-mode utils", () => {
describe("isSafeReadOnlyCommand", () => {
it.each([
"ls -la",
"rg plan-mode .pi/extensions",
"git status --short",
"git diff -- README.md",
"pnpm list --depth 0",
"node --version",
"sed -n '1,20p' README.md",
])("allows read-only command: %s", (command) => {
expect(isSafeReadOnlyCommand(command)).toBe(true);
});
it.each([
"rm -rf /tmp/x",
"git push origin main",
"git checkout -b feature",
"pnpm install",
"npm ci",
"echo hi > file.txt",
"mkdir new-dir",
"sudo apt update",
"vim README.md",
])("blocks mutating command: %s", (command) => {
expect(isSafeReadOnlyCommand(command)).toBe(false);
});
});
describe("plan extraction", () => {
it("extracts a numbered Plan section", () => {
const items = extractPlanItems(`Analysis first.\n\nPlan:\n1. Read the current Pi extension setup\n2. Add a plan-mode extension\n3. Add tests\n\nThen wait.`);
expect(items).toEqual([
{ step: 1, text: "Read the current Pi extension setup", completed: false },
{ step: 2, text: "Add a plan-mode extension", completed: false },
{ step: 3, text: "Add tests", completed: false },
]);
});
it("accepts markdown plan headers and strips markdown emphasis/code", () => {
const items = extractPlanItems("## Implementation Plan\n1) **Update** `index.ts`\n2) Wire `/plan approve`");
expect(items.map((item) => item.text)).toEqual(["Update index.ts", "Wire /plan approve"]);
});
it("returns no items without an explicit plan header", () => {
expect(extractPlanItems("1. This is just a list")).toEqual([]);
});
});
describe("completion tracking", () => {
it("marks steps from DONE markers", () => {
const items = extractPlanItems("Plan:\n1. First\n2. Second");
expect(markCompletedSteps("Completed [DONE:2]", items)).toBe(1);
expect(formatPlanItems(items)).toContain("2. ✓ Second");
});
it("does not count duplicate DONE markers as changes", () => {
const items = extractPlanItems("Plan:\n1. First");
expect(markCompletedSteps("[DONE:1] [DONE:1]", items)).toBe(1);
expect(markCompletedSteps("[DONE:1]", items)).toBe(0);
});
});
describe("tool selection", () => {
it("normalizes tool names from strings and tool objects", () => {
expect(normalizeToolNames(["read", { name: "edit" }, { label: "missing" }])).toEqual(["read", "edit"]);
});
it("picks only read-only tools when available", () => {
expect(pickPlanTools([{ name: "read" }, { name: "edit" }, { name: "question" }])).toEqual(["read", "question"]);
});
});
});
+212
View File
@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import factory from "../plan-mode";
type Handler = (event: any, ctx: any) => Promise<unknown> | unknown;
type CommandDef = { description: string; handler: Handler };
interface MockPi {
appendEntry: ReturnType<typeof vi.fn>;
getActiveTools: ReturnType<typeof vi.fn>;
getAllTools: ReturnType<typeof vi.fn>;
getFlag: ReturnType<typeof vi.fn>;
on: (event: string, handler: Handler) => void;
registerCommand: (name: string, def: CommandDef) => void;
registerFlag: ReturnType<typeof vi.fn>;
registerShortcut: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
sendUserMessage: ReturnType<typeof vi.fn>;
setActiveTools: ReturnType<typeof vi.fn>;
}
interface Captured {
commands: Map<string, CommandDef>;
handlers: Map<string, Handler[]>;
}
const allTools = [
{ name: "read" },
{ name: "bash" },
{ name: "grep" },
{ name: "find" },
{ name: "ls" },
{ name: "edit" },
{ name: "write" },
];
function makePi(): { pi: MockPi; captured: Captured } {
const captured: Captured = {
commands: new Map(),
handlers: new Map(),
};
const pi: MockPi = {
appendEntry: vi.fn(),
getActiveTools: vi.fn(() => ["read", "bash", "edit", "write"]),
getAllTools: vi.fn(() => allTools),
getFlag: vi.fn(() => false),
on: (event, handler) => {
captured.handlers.set(event, [...(captured.handlers.get(event) ?? []), handler]);
},
registerCommand: (name, def) => {
captured.commands.set(name, def);
},
registerFlag: vi.fn(),
registerShortcut: vi.fn(),
sendMessage: vi.fn(),
sendUserMessage: vi.fn(),
setActiveTools: vi.fn(),
};
return { pi, captured };
}
function makeCtx(options: { select?: () => Promise<string | undefined>; hasUI?: boolean } = {}) {
const theme = {
fg: (_role: string, text: string) => text,
strikethrough: (text: string) => `~~${text}~~`,
};
return {
hasUI: options.hasUI ?? true,
sessionManager: { getEntries: vi.fn(() => []) },
ui: {
editor: vi.fn(async () => undefined),
notify: vi.fn(),
select: vi.fn(options.select ?? (async () => undefined)),
setStatus: vi.fn(),
setWidget: vi.fn(),
theme,
},
};
}
async function fire(handlers: Handler[] | undefined, event: unknown, ctx: unknown) {
for (const h of handlers ?? []) {
const result = await h(event, ctx);
if (result !== undefined) return result;
}
return undefined;
}
describe("plan-mode extension", () => {
let pi: MockPi;
let captured: Captured;
beforeEach(() => {
const m = makePi();
pi = m.pi;
captured = m.captured;
factory(pi as never);
});
it("registers commands, flag, shortcut, and lifecycle handlers", () => {
expect(pi.registerFlag).toHaveBeenCalledWith("plan", expect.objectContaining({ type: "boolean" }));
expect(pi.registerShortcut).toHaveBeenCalledWith("ctrl+alt+p", expect.any(Object));
expect(captured.commands.has("plan")).toBe(true);
expect(captured.commands.has("todos")).toBe(true);
expect(captured.handlers.get("tool_call")).toHaveLength(1);
expect(captured.handlers.get("before_agent_start")).toHaveLength(1);
expect(captured.handlers.get("agent_end")).toHaveLength(1);
});
it("/plan enables read-only tools and persists state", async () => {
const ctx = makeCtx();
await captured.commands.get("plan")!.handler("", ctx);
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls"]);
expect(pi.appendEntry).toHaveBeenCalledWith(
"plan-mode-state",
expect.objectContaining({ planning: true, executing: false }),
);
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("enabled"), "info");
});
it("blocks mutating tools and non-read-only bash while planning", async () => {
const ctx = makeCtx();
await captured.commands.get("plan")!.handler("on", ctx);
await expect(
fire(captured.handlers.get("tool_call"), { toolName: "edit", input: { path: "README.md" } }, ctx),
).resolves.toMatchObject({ block: true });
await expect(
fire(captured.handlers.get("tool_call"), { toolName: "bash", input: { command: "git push" } }, ctx),
).resolves.toMatchObject({ block: true });
await expect(
fire(captured.handlers.get("tool_call"), { toolName: "bash", input: { command: "git diff" } }, ctx),
).resolves.toBeUndefined();
});
it("injects plan-only instructions before the agent starts", async () => {
const ctx = makeCtx();
await captured.commands.get("plan")!.handler("on", ctx);
const result = await fire(
captured.handlers.get("before_agent_start"),
{ systemPrompt: "base prompt" },
ctx,
);
expect(result).toMatchObject({
message: expect.objectContaining({ customType: "plan-mode-context", display: false }),
});
expect((result as { systemPrompt: string }).systemPrompt).toContain("produce a plan only");
});
it("captures an assistant Plan and asks for approval", async () => {
const ctx = makeCtx({ select: async () => "Stay in plan mode" });
await captured.commands.get("plan")!.handler("on", ctx);
await fire(
captured.handlers.get("agent_end"),
{
messages: [
{
role: "assistant",
content: [{ type: "text", text: "Plan:\n1. Read files\n2. Add extension" }],
},
],
},
ctx,
);
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ customType: "plan-mode-plan", content: expect.stringContaining("Add extension") }),
{ triggerTurn: false },
);
expect(ctx.ui.select).toHaveBeenCalledWith("Plan mode", expect.arrayContaining(["Approve plan and implement"]));
});
it("approval restores previous tools and sends an implementation prompt", async () => {
const ctx = makeCtx({ select: async () => "Approve plan and implement" });
await captured.commands.get("plan")!.handler("on", ctx);
await fire(
captured.handlers.get("agent_end"),
{
messages: [
{
role: "assistant",
content: [{ type: "text", text: "Plan:\n1. Read files\n2. Add extension" }],
},
],
},
ctx,
);
expect(pi.setActiveTools).toHaveBeenLastCalledWith(["read", "bash", "edit", "write"]);
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Plan approved"));
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("[DONE:n]"));
});
it("/plan off restores previous tools", async () => {
const ctx = makeCtx();
await captured.commands.get("plan")!.handler("on", ctx);
await captured.commands.get("plan")!.handler("off", ctx);
expect(pi.setActiveTools).toHaveBeenLastCalledWith(["read", "bash", "edit", "write"]);
expect(pi.appendEntry).toHaveBeenLastCalledWith(
"plan-mode-state",
expect.objectContaining({ planning: false, executing: false, items: [] }),
);
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
const WORDMARK = "MIFUNE";
const SUBTITLE = "pi harness · github.com/ryaneggz/mifune";
export function buildHeader(theme: Theme): string[] {
return [
`⚔ ${theme.fg("accent", WORDMARK)}`,
`🎬 ${theme.fg("muted", SUBTITLE)}`,
];
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
if (!ctx.hasUI) return;
ctx.ui.setHeader((_tui, theme) => ({
render: () => buildHeader(theme),
invalidate: () => {},
}));
});
pi.registerCommand("builtin-header", {
description: "Restore the built-in pi header",
handler: async (_args, ctx) => {
ctx.ui.setHeader(undefined);
ctx.ui.notify("Built-in header restored", "info");
},
});
}
+92
View File
@@ -0,0 +1,92 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const SENSITIVE_PATHS: RegExp[] = [
/(^|\/)\.env(\..+)?$/,
/(^|\/)secrets?\//,
/(^|\/)credentials?\.(json|ya?ml)$/,
/\.pem$/,
/\.key$/,
/(^|\/)id_(rsa|ed25519|ecdsa)$/,
];
// Fix #2: All regexes now carry the `i` flag for case-insensitive matching.
// Fix #3: Block-device redirection regex updated to match bare `> /dev/...`
// without requiring a leading `:` (shell colon-truncate idiom).
// nvme devices use numeric suffixes (nvme0n1), disk\d+ likewise.
const RISKY_BASH = [
/\brm\s+-rf\b/i,
/\bsudo\b/i,
/\bgit\s+push\s+.*--force\b/i,
/\bgit\s+reset\s+--hard\b/i,
/(^|\s)>\s*\/dev\/(sd[a-z]|nvme\d*|hd[a-z]|disk\d+)\b/i,
];
// Tool names confirmed lowercase from pi source:
// packages/coding-agent/src/core/tools/index.ts:
// export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls";
const MUTATING_TOOLS = new Set(["write", "edit"]);
// Canonical input key for write and edit is `path` (verified from pi source):
// write.ts: const writeSchema = Type.Object({ path: Type.String(...), content: ... });
// edit.ts: const editSchema = Type.Object({ path: Type.String(...), edits: ... });
// Fallbacks retained for forward-compatibility with any future schema changes.
function pickPath(input: Record<string, unknown> | undefined): string | undefined {
if (!input) return undefined;
for (const key of ["path", "file_path", "target", "filename"]) {
const v = input[key];
if (typeof v === "string" && v.length > 0) return v;
}
return undefined;
}
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
// Fix #1: Guard against headless/RPC mode where ctx.ui is unavailable.
// Mirrors the pattern in mifune-banner.ts.
if (!ctx.hasUI) return;
// Fix #4: Defensive toLowerCase normalization on toolName. Pi uses
// lowercase names (confirmed from index.ts ToolName union), but this
// costs nothing and protects against future renames.
const toolName = event.toolName?.toLowerCase() ?? "";
if (MUTATING_TOOLS.has(toolName)) {
const path = pickPath(event.input as Record<string, unknown>);
if (path && SENSITIVE_PATHS.some((re) => re.test(path))) {
const ok = await ctx.ui.confirm(
"Sensitive path",
`Allow ${toolName} on ${path}?`,
);
if (!ok) return { block: true, reason: `Path ${path} is protected` };
}
return;
}
if (toolName === "bash") {
const cmd = (event.input as { command?: string } | undefined)?.command ?? "";
if (RISKY_BASH.some((re) => re.test(cmd))) {
const ok = await ctx.ui.confirm(
"Risky command",
`Allow:\n${cmd.length > 200 ? cmd.slice(0, 200) + "..." : cmd}`,
);
if (!ok) return { block: true, reason: "User declined risky command" };
}
}
});
pi.registerCommand("guard", {
description: "Show what path-guard is protecting",
handler: async (_args, ctx) => {
const lines = [
"path-guard is active.",
"",
"Sensitive paths (write/edit prompt):",
...SENSITIVE_PATHS.map((re) => ` ${re.source}`),
"",
"Risky bash patterns (prompt before run):",
...RISKY_BASH.map((re) => ` ${re.source}`),
];
ctx.ui.notify(lines.join("\n"), "info");
},
});
}
+23
View File
@@ -0,0 +1,23 @@
# Plan Mode Extension
Claude Code-style plan mode for Pi.
## Usage
- `/plan` or `ctrl+alt+p` toggles plan mode.
- `/plan on` starts read-only planning.
- `/plan approve` approves the captured plan and starts implementation.
- `/plan off` cancels and restores the previous tool set.
- `/todos` shows captured plan steps or execution progress.
- `pi --plan` starts a session in plan mode.
## Behavior
When plan mode is active, Pi can inspect the repo but cannot mutate it:
- Active tools are reduced to read-only tools (`read`, `bash`, `grep`, `find`, `ls`, plus question tools when available).
- `edit`, `write`, and unknown tools are blocked.
- Bash is allowlisted to read-only inspection commands.
- The agent is instructed to end with a numbered `Plan:` section.
After the agent returns a plan, the extension prompts for approval. On approval it restores the previous active tools and sends a follow-up implementation prompt. During execution the agent marks completed steps with `[DONE:n]`, and `/todos` reports progress.
+334
View File
@@ -0,0 +1,334 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
extractPlanItems,
formatPlanItems,
isMutatingTool,
isReadOnlyTool,
isSafeReadOnlyCommand,
markCompletedSteps,
normalizeToolNames,
pickPlanTools,
type PlanItem,
} from "./utils";
type AssistantTextMessage = {
role: "assistant";
content: Array<{ type: string; text?: string }>;
};
type PlanModeEntry = {
type: string;
customType?: string;
data?: {
planning?: boolean;
executing?: boolean;
items?: PlanItem[];
previousTools?: string[];
};
};
const STATE_ENTRY = "plan-mode-state";
const SHORTCUT = "ctrl+alt+p";
function isAssistantTextMessage(message: unknown): message is AssistantTextMessage {
return (
!!message &&
typeof message === "object" &&
(message as { role?: unknown }).role === "assistant" &&
Array.isArray((message as { content?: unknown }).content)
);
}
function getAssistantText(message: AssistantTextMessage): string {
return message.content
.filter((block) => block.type === "text" && typeof block.text === "string")
.map((block) => block.text)
.join("\n");
}
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
if (ctx.hasUI === false) return;
ctx.ui.notify(message, level);
}
export default function planModeExtension(pi: ExtensionAPI): void {
let planning = false;
let executing = false;
let items: PlanItem[] = [];
let previousTools: string[] | undefined;
pi.registerFlag("plan", {
description: "Start in Claude Code-style plan mode (read-only until approval)",
type: "boolean",
default: false,
});
function persistState(): void {
pi.appendEntry(STATE_ENTRY, {
planning,
executing,
items,
previousTools,
});
}
function updateUi(ctx: ExtensionContext): void {
if (ctx.hasUI === false) return;
if (planning) {
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "plan"));
} else if (executing && items.length > 0) {
const done = items.filter((item) => item.completed).length;
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `plan ${done}/${items.length}`));
} else {
ctx.ui.setStatus("plan-mode", undefined);
}
if (executing && items.length > 0) {
ctx.ui.setWidget(
"plan-mode-items",
items.map((item) => {
if (item.completed) {
return `${ctx.ui.theme.fg("success", "☑")} ${ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))}`;
}
return `${ctx.ui.theme.fg("muted", "☐")} ${item.text}`;
}),
);
} else {
ctx.ui.setWidget("plan-mode-items", undefined);
}
}
function restoreTools(): void {
const restored = previousTools?.length ? previousTools : normalizeToolNames(pi.getAllTools());
if (restored.length > 0) pi.setActiveTools(restored);
previousTools = undefined;
}
function enablePlanMode(ctx: ExtensionContext): void {
if (!previousTools) previousTools = normalizeToolNames(pi.getActiveTools());
planning = true;
executing = false;
items = [];
pi.setActiveTools(pickPlanTools(pi.getAllTools()));
updateUi(ctx);
persistState();
notify(ctx, "Plan mode enabled. Pi is read-only until you approve a plan.", "info");
}
function disablePlanMode(ctx: ExtensionContext): void {
planning = false;
executing = false;
items = [];
restoreTools();
updateUi(ctx);
persistState();
notify(ctx, "Plan mode disabled. Tool access restored.", "info");
}
function approvePlan(ctx: ExtensionContext): void {
planning = false;
executing = items.length > 0;
restoreTools();
updateUi(ctx);
persistState();
const kickoff =
items.length > 0
? `Plan approved. Implement it now, one step at a time. Mark each completed step with [DONE:n].\n\n${formatPlanItems(items)}`
: "Plan approved. Implement the plan now.";
pi.sendUserMessage(kickoff);
}
pi.registerCommand("plan", {
description: "Toggle/approve Claude Code-style plan mode: /plan, /plan on, /plan off, /plan approve",
handler: async (args, ctx) => {
const action = args.trim().toLowerCase();
if (action === "on" || action === "start") return enablePlanMode(ctx);
if (action === "off" || action === "stop" || action === "cancel") return disablePlanMode(ctx);
if (action === "approve" || action === "accept" || action === "execute") return approvePlan(ctx);
if (action === "status") {
const status = planning ? "planning" : executing ? "executing" : "off";
notify(ctx, items.length > 0 ? `Plan mode: ${status}\n${formatPlanItems(items)}` : `Plan mode: ${status}`, "info");
return;
}
if (planning || executing) return disablePlanMode(ctx);
return enablePlanMode(ctx);
},
});
pi.registerCommand("todos", {
description: "Show current plan-mode progress",
handler: async (_args, ctx) => {
if (items.length === 0) {
notify(ctx, "No plan steps captured yet.", "info");
return;
}
notify(ctx, formatPlanItems(items), "info");
},
});
pi.registerShortcut(SHORTCUT, {
description: "Toggle plan mode",
handler: async (ctx) => {
if (planning || executing) disablePlanMode(ctx);
else enablePlanMode(ctx);
},
});
pi.on("tool_call", async (event) => {
if (!planning) return;
if (isMutatingTool(event.toolName) || !isReadOnlyTool(event.toolName)) {
return {
block: true,
reason: `Plan mode is read-only. Approve the plan or run /plan off before using ${event.toolName}.`,
};
}
if (event.toolName === "bash") {
const command = (event.input as { command?: unknown } | undefined)?.command;
if (typeof command !== "string" || !isSafeReadOnlyCommand(command)) {
return {
block: true,
reason: `Plan mode blocked a non-read-only bash command. Approve the plan or run /plan off first.`,
};
}
}
});
pi.on("context", async (event) => {
if (planning || executing) return;
return {
messages: event.messages.filter((message: unknown) => {
const customType = (message as { customType?: unknown } | undefined)?.customType;
return customType !== "plan-mode-context" && customType !== "plan-execution-context";
}),
};
});
pi.on("before_agent_start", async (event) => {
if (planning) {
return {
message: {
customType: "plan-mode-context",
content: `[PLAN MODE ACTIVE]
You are in Claude Code-style plan mode.
Rules:
- Explore and reason only. Do not modify files, run mutating shell commands, install packages, commit, or push.
- Use read/search/list tools and read-only bash commands to understand the request.
- Ask clarifying questions if the requested change is ambiguous.
- End with a concise numbered plan under a "Plan:" heading.
- Do not implement until the user approves the plan.`,
display: false,
},
systemPrompt: `${event.systemPrompt}\n\nPlan mode is active: produce a plan only; do not implement until approval.`,
};
}
if (executing && items.length > 0) {
const remaining = items.filter((item) => !item.completed);
return {
message: {
customType: "plan-execution-context",
content: `[EXECUTING APPROVED PLAN]
Remaining steps:
${formatPlanItems(remaining)}
Execute steps in order. After completing step n, include [DONE:n] in your response.`,
display: false,
},
};
}
});
pi.on("turn_end", async (event, ctx) => {
if (!executing || items.length === 0 || !isAssistantTextMessage(event.message)) return;
if (markCompletedSteps(getAssistantText(event.message), items) > 0) {
updateUi(ctx);
persistState();
}
});
pi.on("agent_end", async (event, ctx) => {
if (executing && items.length > 0 && items.every((item) => item.completed)) {
pi.sendMessage(
{
customType: "plan-mode-complete",
content: `Plan complete.\n\n${formatPlanItems(items)}`,
display: true,
},
{ triggerTurn: false },
);
executing = false;
items = [];
updateUi(ctx);
persistState();
return;
}
if (!planning) return;
const lastAssistant = [...event.messages].reverse().find(isAssistantTextMessage);
if (lastAssistant) {
const extracted = extractPlanItems(getAssistantText(lastAssistant));
if (extracted.length > 0) items = extracted;
}
if (items.length > 0) {
pi.sendMessage(
{
customType: "plan-mode-plan",
content: `Plan captured.\n\n${formatPlanItems(items)}`,
display: true,
},
{ triggerTurn: false },
);
persistState();
}
if (ctx.hasUI === false) return;
const choice = await ctx.ui.select("Plan mode", [
items.length > 0 ? "Approve plan and implement" : "Approve and implement",
"Stay in plan mode",
"Refine plan",
"Cancel plan mode",
]);
if (choice?.startsWith("Approve")) {
approvePlan(ctx);
} else if (choice === "Refine plan") {
const refinement = await ctx.ui.editor("Refine the plan", "");
if (refinement?.trim()) pi.sendUserMessage(refinement.trim());
} else if (choice === "Cancel plan mode") {
disablePlanMode(ctx);
} else {
updateUi(ctx);
persistState();
}
});
pi.on("session_start", async (_event, ctx) => {
planning = pi.getFlag("plan") === true;
const state = ctx.sessionManager
.getEntries()
.filter((entry: PlanModeEntry) => entry.type === "custom" && entry.customType === STATE_ENTRY)
.pop() as PlanModeEntry | undefined;
if (state?.data) {
planning = state.data.planning ?? planning;
executing = state.data.executing ?? executing;
items = state.data.items ?? items;
previousTools = state.data.previousTools ?? previousTools;
}
if (planning) pi.setActiveTools(pickPlanTools(pi.getAllTools()));
if (!planning && !executing) previousTools = undefined;
updateUi(ctx);
});
}
+165
View File
@@ -0,0 +1,165 @@
export interface PlanItem {
step: number;
text: string;
completed: boolean;
}
const DESTRUCTIVE_PATTERNS: RegExp[] = [
/\brm\b/i,
/\brmdir\b/i,
/\bmv\b/i,
/\bcp\b/i,
/\bmkdir\b/i,
/\btouch\b/i,
/\bchmod\b/i,
/\bchown\b/i,
/\bchgrp\b/i,
/\bln\b/i,
/\btee\b/i,
/\btruncate\b/i,
/\bdd\b/i,
/\bshred\b/i,
/(^|[^<])>(?!&|>)/,
/>>/,
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
/\byarn\s+(add|remove|install|publish)/i,
/\bpnpm\s+(add|remove|install|publish|update)/i,
/\bpip\s+(install|uninstall)/i,
/\buv\s+(add|remove|pip\s+install)/i,
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
/\bbrew\s+(install|uninstall|upgrade)/i,
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|switch|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
/\bsudo\b/i,
/\bsu\b/i,
/\bkill\b/i,
/\bpkill\b/i,
/\bkillall\b/i,
/\breboot\b/i,
/\bshutdown\b/i,
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
/\bservice\s+\S+\s+(start|stop|restart)/i,
/\b(vim?|nano|emacs|code|subl)\b/i,
];
const SAFE_START_PATTERNS: RegExp[] = [
/^\s*(cat|head|tail|less|more)\b/,
/^\s*(grep|rg|find|fd|ag)\b/,
/^\s*(ls|pwd|tree|eza|bat)\b/,
/^\s*(echo|printf|wc|sort|uniq|diff|file|stat|du|df)\b/,
/^\s*(which|whereis|type|env|printenv)\b/,
/^\s*(uname|whoami|id|date|cal|uptime|ps|top|htop|free)\b/,
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-files|ls-tree|grep)\b/i,
/^\s*(npm|pnpm|yarn)\s+(list|ls|view|info|search|outdated|audit|why)\b/i,
/^\s*(node|python|python3|ruby|go|rustc|cargo|deno|bun)\s+(--version|-v|version)\b/i,
/^\s*curl\s+/,
/^\s*wget\s+-O\s*-/,
/^\s*(jq|sed\s+-n|awk)\b/,
];
const READ_ONLY_TOOL_NAMES = new Set([
"read",
"bash",
"grep",
"find",
"ls",
"question",
"questionnaire",
"ask_question",
]);
const MUTATING_TOOL_NAMES = new Set(["edit", "write"]);
export function isReadOnlyTool(toolName: string): boolean {
return READ_ONLY_TOOL_NAMES.has(toolName);
}
export function isMutatingTool(toolName: string): boolean {
return MUTATING_TOOL_NAMES.has(toolName);
}
export function isSafeReadOnlyCommand(command: string): boolean {
const trimmed = command.trim();
if (!trimmed) return false;
if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(trimmed))) return false;
return SAFE_START_PATTERNS.some((pattern) => pattern.test(trimmed));
}
export function normalizeToolNames(tools: unknown): string[] {
if (!Array.isArray(tools)) return [];
return tools.flatMap((tool) => {
if (typeof tool === "string") return [tool];
if (tool && typeof tool === "object" && "name" in tool) {
const name = (tool as { name?: unknown }).name;
return typeof name === "string" ? [name] : [];
}
return [];
});
}
export function pickPlanTools(allTools: unknown, fallback = ["read", "bash", "grep", "find", "ls"]): string[] {
const names = normalizeToolNames(allTools);
const available = names.filter(isReadOnlyTool);
return available.length > 0 ? available : fallback;
}
function cleanPlanText(text: string): string {
let cleaned = text
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/\s+/g, " ")
.trim();
cleaned = cleaned.replace(/^[\[({]\s*/, "").replace(/\s*[\])}]$/, "").trim();
if (cleaned.length > 140) cleaned = `${cleaned.slice(0, 137)}...`;
return cleaned;
}
export function extractPlanItems(message: string): PlanItem[] {
const lines = message.split(/\r?\n/);
const headerIndex = lines.findIndex((line) => /^\s*(#{1,6}\s*)?(implementation\s+)?plan\s*:?\s*$/i.test(line));
if (headerIndex === -1) return [];
const items: PlanItem[] = [];
let started = false;
for (const line of lines.slice(headerIndex + 1)) {
const numbered = line.match(/^\s*(\d+)[.)]\s+(.+)$/);
if (!numbered) {
if (started && line.trim() !== "") break;
continue;
}
started = true;
const cleaned = cleanPlanText(numbered[2] ?? "");
if (cleaned.length < 4) continue;
items.push({ step: items.length + 1, text: cleaned, completed: false });
}
return items;
}
export function extractDoneSteps(message: string): number[] {
const steps: number[] = [];
for (const match of message.matchAll(/\[DONE:(\d+)]/gi)) {
const step = Number(match[1]);
if (Number.isInteger(step) && step > 0) steps.push(step);
}
return steps;
}
export function markCompletedSteps(message: string, items: PlanItem[]): number {
let changed = 0;
for (const step of extractDoneSteps(message)) {
const item = items.find((candidate) => candidate.step === step);
if (item && !item.completed) {
item.completed = true;
changed += 1;
}
}
return changed;
}
export function formatPlanItems(items: PlanItem[]): string {
return items.map((item) => `${item.step}. ${item.completed ? "✓" : "☐"} ${item.text}`).join("\n");
}
+10
View File
@@ -0,0 +1,10 @@
Review the staged or recent changes for:
- Correctness — logic errors, wrong assumptions, missed cases
- Security — injection, auth, secret handling, OWASP top-10
- Performance — obvious O(n²) traps, redundant work, memory leaks
- Style — adherence to project conventions and surrounding patterns
- Tests — coverage gaps, brittle assertions
Report findings as a punch list. Cite `file:line` for each issue.
Skip generic advice; focus on what is actually wrong in this diff.
Symlink
+1
View File
@@ -0,0 +1 @@
../.claude/rules
+19
View File
@@ -0,0 +1,19 @@
{
"defaultProvider": "openai-codex",
"defaultModel": "gpt-5.5",
"defaultThinkingLevel": "high",
"theme": "dark",
"retry": {
"enabled": true,
"maxRetries": 3,
"baseDelayMs": 2000
},
"steeringMode": "one-at-a-time",
"followUpMode": "one-at-a-time",
"extensions": ["./extensions"],
"skills": ["./skills"],
"prompts": ["./prompts"],
"themes": ["./themes"],
"enableSkillCommands": true,
"packages": []
}
Symlink
+1
View File
@@ -0,0 +1 @@
../.claude/skills
+2
View File
@@ -14,6 +14,8 @@ Update policy and release automation live in [`.claude/rules/git.md`](.claude/ru
- `opencode` and `opencode /connect` failing with `EACCES: permission denied, mkdir '/home/sandbox/.local/state'` on a fresh sandbox. Docker auto-creates `~/.local` and `~/.local/share` as root to satisfy the `opencode-host` bind-mount path; the entrypoint now non-recursively chowns those parent dirs to `sandbox:sandbox` so the user can create siblings like `.local/state`. The bind-mounted `~/.local/share/opencode` itself stays untouched. Re-applies a fix that was lost when [#241](https://github.com/ryaneggz/open-harness/pull/241) merged a conflicting `entrypoint.sh`. ([#245](https://github.com/ryaneggz/open-harness/issues/245)) - `opencode` and `opencode /connect` failing with `EACCES: permission denied, mkdir '/home/sandbox/.local/state'` on a fresh sandbox. Docker auto-creates `~/.local` and `~/.local/share` as root to satisfy the `opencode-host` bind-mount path; the entrypoint now non-recursively chowns those parent dirs to `sandbox:sandbox` so the user can create siblings like `.local/state`. The bind-mounted `~/.local/share/opencode` itself stays untouched. Re-applies a fix that was lost when [#241](https://github.com/ryaneggz/open-harness/pull/241) merged a conflicting `entrypoint.sh`. ([#245](https://github.com/ryaneggz/open-harness/issues/245))
### Added ### Added
- Project-local Pi config under `.pi/` standing the agent up at the harness root: OpenAI-default `settings.json` (`gpt-4o`, model-cycling list scoped to GPT/o-series), `mifune-banner.ts` extension replacing pi's startup header with a flush-left `MIFUNE` wordmark + `pi harness · github.com/ryaneggz/mifune` subtitle, `path-guard.ts` extension intercepting `tool_call` events to confirm before write/edit on sensitive paths (`.env*`, `secrets/`, `credentials.{json,yaml}`, `*.pem`, `*.key`, ssh keys) and risky bash (`rm -rf`, `sudo`, `git push --force`, `git reset --hard`, bare `> /dev/sd*|nvme*|hd*|disk*`) with case-insensitive matching and `ctx.hasUI` guards for headless/RPC mode, generic consumer-agnostic `APPEND_SYSTEM.md`, `prompts/review.md` template invokable as `/review`, and `skills` + `rules` directory symlinks to `../.claude/skills` / `../.claude/rules` matching the single-source-of-truth pattern already in `.codex/`. New canonical `.claude/skills/harness-context/SKILL.md` reachable from pi via the symlink. Test suite at `.pi/extensions/__tests__/{path-guard,mifune-banner}.test.ts` (32 + 7 cases, hand-rolled mock `ExtensionAPI`, no pi devDep); pi source verified via `gh api` for tool-name casing (lowercase) and write/edit canonical input key (`path`). `vitest.config.ts` `include` extended to `.pi/**/__tests__/**/*.test.ts`; `.github/workflows/ci-harness.yml` `paths:` filter extended (`.pi/**`, `scripts/**`, `vitest.config.ts`) and a new "Root tests" step runs `pnpm test:scripts`, also bringing the previously-orphaned `scripts/__tests__/cron-runtime.test.ts` into CI. ([#255](https://github.com/ryaneggz/open-harness/issues/255))
- Project-local Pi plan-mode extension under `.pi/extensions/plan-mode/`, with `/plan`, `/plan approve`, `--plan`, read-only tool/bash gating, plan approval prompts, and `[DONE:n]` progress tracking.
- Directory README pattern: tracked `README.md` in `apps/`, `crons/`, `scripts/`, `tasks/`, and `.worktrees/` documenting each directory's intent, subfolder conventions, and lifecycle. New rule `.claude/rules/directory-readme.md` codifies the pattern; `.gitignore` exempts only `.worktrees/README.md` (replacing the unused `.gitkeep` exemption); `.claude/rules/git.md` § Worktrees points to the new README and documents the `.worktrees/project/<project-name>/<repo>/` slot for independent project clones; `docs/architecture/container-runtime.md` moves `.worktrees/` into the canonical tree (README tracked, contents ignored). Three session lessons appended to `context/IDENTITY.md` covering README-over-`.gitkeep`, "treat X same as Y" interpretation discipline, and semantic-subfolder enumeration. - Directory README pattern: tracked `README.md` in `apps/`, `crons/`, `scripts/`, `tasks/`, and `.worktrees/` documenting each directory's intent, subfolder conventions, and lifecycle. New rule `.claude/rules/directory-readme.md` codifies the pattern; `.gitignore` exempts only `.worktrees/README.md` (replacing the unused `.gitkeep` exemption); `.claude/rules/git.md` § Worktrees points to the new README and documents the `.worktrees/project/<project-name>/<repo>/` slot for independent project clones; `docs/architecture/container-runtime.md` moves `.worktrees/` into the canonical tree (README tracked, contents ignored). Three session lessons appended to `context/IDENTITY.md` covering README-over-`.gitkeep`, "treat X same as Y" interpretation discipline, and semantic-subfolder enumeration.
- OpenCode as a core sandbox agent: install `opencode-ai` in the base image, persist `~/.local/share/opencode`, add the `opencode-host` overlay, support `scripts/ralph.sh --harness=opencode`, and document OpenAI OAuth as the default OpenCode provider path. - OpenCode as a core sandbox agent: install `opencode-ai` in the base image, persist `~/.local/share/opencode`, add the `opencode-host` overlay, support `scripts/ralph.sh --harness=opencode`, and document OpenAI OAuth as the default OpenCode provider path.
- DeepAgents CLI support as a first-class supported sandbox runtime alongside Claude Code, Codex, OpenCode, and Pi. `.devcontainer/Dockerfile` installs `deepagents-cli` via `uv tool install` into `/usr/local/bin`; `.devcontainer/docker-compose.yml` adds a `deepagents-auth` named volume at `/home/sandbox/.deepagents` so provider keys, memory, skills, and sessions persist across rebuilds; `.devcontainer/docker-compose.deepagents-host.yml` is an opt-in host-state overlay (off by default — DeepAgents stores raw provider API keys with no scope/expiry, so the blast radius is wider than the Claude/Codex host overlays); `.devcontainer/entrypoint.sh` extends its chown loop to include `.deepagents` and skips it when `DEEPAGENTS_HOST_BIND_MOUNT=1`. `scripts/ralph.sh` accepts `--harness=deepagents` (or `RALPH_HARNESS=deepagents`) and dispatches to a constrained default invocation `deepagents -y --shell-allow-list recommended -q --no-stream --max-turns "$RALPH_DEEPAGENTS_MAX_TURNS" -n "$task"` (default 25 turns); `RALPH_DEEPAGENTS_FLAGS` overrides the safety/output flag string, and `--max-turns` is always appended after so the per-iteration cap cannot be silently dropped. `--shell-allow-list all` combined with the sandbox's mounted Docker socket can affect sibling containers or host Docker — only enable for trusted tasks via the env-var override. `install/banner.sh` reports DeepAgents `[installed]` from `command -v deepagents` and `[configured]` only when `~/.deepagents/.env` or `~/.deepagents/config.toml` is non-empty (an empty mounted directory is no longer treated as authenticated). New agent doc at `docs/agents/deepagents.md`; `docs/agents/overview.md`, `README.md`, `docs/quickstart.md`, `docs/onboarding.md`, `docs/installation.md`, `docs/operations/{provision,destroy,repair}.md`, `docs/guide/{overlays,permissions,configuration}.md`, and `docs/architecture/{container-runtime,overview}.md` enumerate the new agent, volume, overlay, and Ralph harness option. Repo-local `.deepagents/` is project data and follows normal git review/ignore rules; secrets and provider keys belong only in `~/.deepagents` or ignored local files. Claude remains the default Ralph harness; existing Claude/Pi/Codex execution and fallback behavior are unchanged. ([#237](https://github.com/ryaneggz/open-harness/issues/237)) - DeepAgents CLI support as a first-class supported sandbox runtime alongside Claude Code, Codex, OpenCode, and Pi. `.devcontainer/Dockerfile` installs `deepagents-cli` via `uv tool install` into `/usr/local/bin`; `.devcontainer/docker-compose.yml` adds a `deepagents-auth` named volume at `/home/sandbox/.deepagents` so provider keys, memory, skills, and sessions persist across rebuilds; `.devcontainer/docker-compose.deepagents-host.yml` is an opt-in host-state overlay (off by default — DeepAgents stores raw provider API keys with no scope/expiry, so the blast radius is wider than the Claude/Codex host overlays); `.devcontainer/entrypoint.sh` extends its chown loop to include `.deepagents` and skips it when `DEEPAGENTS_HOST_BIND_MOUNT=1`. `scripts/ralph.sh` accepts `--harness=deepagents` (or `RALPH_HARNESS=deepagents`) and dispatches to a constrained default invocation `deepagents -y --shell-allow-list recommended -q --no-stream --max-turns "$RALPH_DEEPAGENTS_MAX_TURNS" -n "$task"` (default 25 turns); `RALPH_DEEPAGENTS_FLAGS` overrides the safety/output flag string, and `--max-turns` is always appended after so the per-iteration cap cannot be silently dropped. `--shell-allow-list all` combined with the sandbox's mounted Docker socket can affect sibling containers or host Docker — only enable for trusted tasks via the env-var override. `install/banner.sh` reports DeepAgents `[installed]` from `command -v deepagents` and `[configured]` only when `~/.deepagents/.env` or `~/.deepagents/config.toml` is non-empty (an empty mounted directory is no longer treated as authenticated). New agent doc at `docs/agents/deepagents.md`; `docs/agents/overview.md`, `README.md`, `docs/quickstart.md`, `docs/onboarding.md`, `docs/installation.md`, `docs/operations/{provision,destroy,repair}.md`, `docs/guide/{overlays,permissions,configuration}.md`, and `docs/architecture/{container-runtime,overview}.md` enumerate the new agent, volume, overlay, and Ralph harness option. Repo-local `.deepagents/` is project data and follows normal git review/ignore rules; secrets and provider keys belong only in `~/.deepagents` or ignored local files. Claude remains the default Ralph harness; existing Claude/Pi/Codex execution and fallback behavior are unchanged. ([#237](https://github.com/ryaneggz/open-harness/issues/237))
+4 -1
View File
@@ -2,7 +2,10 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
include: ["scripts/__tests__/**/*.test.ts"], include: [
"scripts/__tests__/**/*.test.ts",
".pi/**/__tests__/**/*.test.ts",
],
globals: true, globals: true,
}, },
}); });