Skip to content

Commit 941ff01

Browse files
committed
replace pi with opencode
1 parent bf9dd1f commit 941ff01

21 files changed

Lines changed: 706 additions & 411 deletions

File tree

.agents/skills/pi-config/SKILL.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
name: pi-config
3+
description: How Pi coding agent is configured in this repo — settings, extensions, themes, and skills locations.
4+
---
5+
6+
# Pi Configuration
7+
8+
`PI_CODING_AGENT_DIR` is set to `$XDG_CONFIG_HOME/.pi/agent`.
9+
10+
This repo uses GNU Stow with target `~/.config`, so `.pi/` stows to `~/.config/.pi/` — which resolves to `PI_CODING_AGENT_DIR`.
11+
12+
## File Locations (in repo)
13+
14+
| File | Purpose |
15+
| ------------------- | ------------------ |
16+
| `.pi/agent/settings.json` | Global Pi settings |
17+
| `.pi/agent/keybindings.json` | Keybindings |
18+
| `.pi/extensions/` | Extensions |
19+
| `.pi/themes/` | Themes |
20+
21+
Note: Pi only reads files under `.pi/agent/` (since `PI_CODING_AGENT_DIR` points there). Do not create a `.pi/settings.json` at the root — it will be ignored.
22+
23+
## Skills
24+
25+
Skills live in `.agents/skills/` — auto-discovered by Pi for any session inside this repo.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
history.txt
22
.zcompdump
33
.zsh_history
4+
.pi/agent/auth.json
5+
.pi/agent/sessions/

.pi/agent/keybindings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"app.session.new": "ctrl+alt+l"
3+
}

.pi/agent/settings.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"lastChangelogVersion": "0.74.0",
3+
"defaultProvider": "anthropic",
4+
"defaultModel": "claude-opus-4-7",
5+
"defaultThinkingLevel": "medium",
6+
"theme": "catppuccin-mocha",
7+
"quietStartup": true,
8+
"packages": ["npm:@gotgenes/pi-anthropic-auth"],
9+
"warnings": {
10+
"anthropicExtraUsage": false
11+
}
12+
}

.pi/extensions/node_modules

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../npm/node_modules

.pi/extensions/notifier.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// notifier — macOS desktop notifications (via osascript) when pi finishes a
2+
// turn or hits an error, and the user is not currently looking at this pane.
3+
//
4+
// We always run inside tmux. Focus detection mirrors opencode/plugins/notifier.ts.
5+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6+
import { exec } from "node:child_process";
7+
import * as path from "node:path";
8+
9+
const execP = (cmd: string, timeoutMs = 1000): Promise<string> =>
10+
new Promise((resolve, reject) => {
11+
exec(cmd, { timeout: timeoutMs }, (err, stdout) => {
12+
if (err) reject(err);
13+
else resolve((stdout ?? "").trim());
14+
});
15+
});
16+
17+
// --- Focus detection (ported from opencode/plugins/notifier.ts) -----------
18+
19+
const getFrontmostPid = async (): Promise<number | null> => {
20+
try {
21+
const out = await execP(
22+
`osascript -e 'tell application "System Events" to get unix id of first application process whose frontmost is true'`,
23+
);
24+
const pid = parseInt(out, 10);
25+
return Number.isFinite(pid) ? pid : null;
26+
} catch {
27+
return null;
28+
}
29+
};
30+
31+
const getAncestorPids = async (startPid: number): Promise<Set<number>> => {
32+
const ancestors = new Set<number>();
33+
try {
34+
const out = await execP("ps -eo pid=,ppid=", 1500);
35+
const parents = new Map<number, number>();
36+
for (const line of out.split("\n")) {
37+
const parts = line.trim().split(/\s+/);
38+
if (parts.length === 2) {
39+
parents.set(parseInt(parts[0], 10), parseInt(parts[1], 10));
40+
}
41+
}
42+
let pid = startPid;
43+
while (pid > 1) {
44+
ancestors.add(pid);
45+
const ppid = parents.get(pid);
46+
if (ppid === undefined || ppid === pid) break;
47+
pid = ppid;
48+
}
49+
} catch {
50+
/* ignore */
51+
}
52+
return ancestors;
53+
};
54+
55+
const getTmuxClientPid = async (): Promise<number | null> => {
56+
try {
57+
const target = process.env.TMUX_PANE
58+
? `-t ${process.env.TMUX_PANE} `
59+
: "";
60+
const out = await execP(`tmux display-message ${target}-p '#{client_pid}'`);
61+
const pid = parseInt(out, 10);
62+
return Number.isFinite(pid) ? pid : null;
63+
} catch {
64+
return null;
65+
}
66+
};
67+
68+
const isTmuxPaneActive = async (): Promise<boolean> => {
69+
const pane = process.env.TMUX_PANE;
70+
if (!pane) return true;
71+
try {
72+
const out = await execP(
73+
`tmux display-message -t ${pane} -p '#{session_attached} #{window_active} #{pane_active}'`,
74+
);
75+
const [attached, win, p] = out.split(" ");
76+
return attached === "1" && win === "1" && p === "1";
77+
} catch {
78+
return true;
79+
}
80+
};
81+
82+
const isTerminalFocused = async (): Promise<boolean> => {
83+
try {
84+
const front = await getFrontmostPid();
85+
if (front === null) return false;
86+
const clientPid = await getTmuxClientPid();
87+
if (clientPid === null) return false;
88+
const ancestors = await getAncestorPids(clientPid);
89+
if (!ancestors.has(front)) return false;
90+
return isTmuxPaneActive();
91+
} catch {
92+
return false;
93+
}
94+
};
95+
96+
// --- Notification delivery -------------------------------------------------
97+
98+
const debounce = new Map<string, number>();
99+
100+
const escapeAS = (s: string) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
101+
102+
const sendNotification = async (
103+
title: string,
104+
message: string,
105+
): Promise<void> => {
106+
const key = `${title}\x00${message}`;
107+
const now = Date.now();
108+
if ((debounce.get(key) ?? 0) > now - 1000) return;
109+
debounce.set(key, now);
110+
111+
try {
112+
await execP(
113+
`osascript -e 'display notification "${escapeAS(message)}" with title "${escapeAS(title)}"'`,
114+
3000,
115+
);
116+
} catch {
117+
/* ignore */
118+
}
119+
};
120+
121+
const playSound = (): void => {
122+
exec("afplay /System/Library/Sounds/Blow.aiff", { timeout: 5000 }, () => {});
123+
};
124+
125+
const notify = async (
126+
projectName: string,
127+
message: string,
128+
): Promise<void> => {
129+
if (await isTerminalFocused()) return;
130+
await sendNotification(`pi - ${projectName}`, message);
131+
playSound();
132+
};
133+
134+
// --- Extension entry -------------------------------------------------------
135+
136+
export default function (pi: ExtensionAPI) {
137+
let projectName = path.basename(process.cwd());
138+
139+
pi.on("session_start", async (_event, ctx) => {
140+
projectName = path.basename(ctx.cwd ?? process.cwd());
141+
});
142+
143+
pi.on("agent_end", async () => {
144+
await notify(projectName, "Turn complete");
145+
});
146+
}

.pi/extensions/padding.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Adds horizontal padding to entire pi TUI by monkey-patching TUI.render.
2+
import { TUI } from "@earendil-works/pi-tui";
3+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4+
5+
const PAD = 5;
6+
7+
const proto = TUI.prototype as unknown as {
8+
render(width: number): string[];
9+
__padded?: boolean;
10+
};
11+
12+
if (PAD > 0 && !proto.__padded) {
13+
proto.__padded = true;
14+
const origRender = proto.render; // resolves to Container.render via chain
15+
proto.render = function (width: number): string[] {
16+
const inner = Math.max(1, width - 2 * PAD);
17+
const lines = origRender.call(this, inner);
18+
const pad = " ".repeat(PAD);
19+
return lines.map((l: string) => pad + l);
20+
};
21+
}
22+
23+
export default function (_pi: ExtensionAPI) {}

.pi/extensions/tmux-bridge.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// tmux-bridge — exposes a Unix socket so other tmux panes can push
2+
// user messages into this running pi session.
3+
//
4+
// Socket path: $TMPDIR/pi-tmux-<sanitized-session-id>.sock
5+
// Companion CLI: dotfiles/tmux/pi-send (writes one JSON line per message).
6+
//
7+
// Wire format: one JSON object per line, e.g.
8+
// {"text": "hello"}
9+
// {"text": "...", "mode": "steer" | "followUp"} // mode used while streaming
10+
import type {
11+
ExtensionAPI,
12+
ExtensionContext,
13+
} from "@earendil-works/pi-coding-agent";
14+
import { execSync } from "node:child_process";
15+
import * as fs from "node:fs";
16+
import * as net from "node:net";
17+
import * as os from "node:os";
18+
import * as path from "node:path";
19+
20+
function getTmuxSessionId(): string | null {
21+
if (!process.env.TMUX) return null;
22+
try {
23+
const id = execSync("tmux display-message -p '#{session_id}'", {
24+
encoding: "utf8",
25+
stdio: ["ignore", "pipe", "ignore"],
26+
}).trim();
27+
return id || null;
28+
} catch {
29+
return null;
30+
}
31+
}
32+
33+
function socketPathFor(sessionId: string): string {
34+
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
35+
return path.join(os.tmpdir(), `pi-tmux-${safe}.sock`);
36+
}
37+
38+
export default function (pi: ExtensionAPI) {
39+
const sessionId = getTmuxSessionId();
40+
if (!sessionId) return; // not in tmux — nothing to do
41+
const sockPath = socketPathFor(sessionId);
42+
43+
let server: net.Server | undefined;
44+
let currentCtx: ExtensionContext | undefined;
45+
46+
const handleLine = (line: string) => {
47+
const trimmed = line.trim();
48+
if (!trimmed) return;
49+
let payload: { text?: string; mode?: "steer" | "followUp" };
50+
try {
51+
payload = JSON.parse(trimmed);
52+
} catch {
53+
currentCtx?.ui?.notify?.(
54+
"tmux-bridge: dropped malformed JSON line",
55+
"warning",
56+
);
57+
return;
58+
}
59+
const text = payload.text;
60+
if (!text || typeof text !== "string") return;
61+
62+
const idle = currentCtx?.isIdle?.() ?? true;
63+
// When idle, sendUserMessage triggers a turn immediately.
64+
// When streaming, deliverAs is required.
65+
const opts = idle ? undefined : { deliverAs: payload.mode ?? "steer" };
66+
67+
try {
68+
pi.sendUserMessage(text, opts as never);
69+
} catch (e) {
70+
const msg = e instanceof Error ? e.message : String(e);
71+
currentCtx?.ui?.notify?.(`tmux-bridge: ${msg}`, "error");
72+
}
73+
};
74+
75+
const probeExistingListener = (): Promise<boolean> =>
76+
new Promise((resolve) => {
77+
if (!fs.existsSync(sockPath)) return resolve(false);
78+
const probe = net.createConnection(sockPath);
79+
let decided = false;
80+
const done = (alive: boolean) => {
81+
if (decided) return;
82+
decided = true;
83+
probe.destroy();
84+
resolve(alive);
85+
};
86+
probe.once("connect", () => done(true));
87+
probe.once("error", () => done(false));
88+
setTimeout(() => done(false), 200);
89+
});
90+
91+
const start = async (ctx: ExtensionContext) => {
92+
currentCtx = ctx;
93+
if (server) return;
94+
if (await probeExistingListener()) {
95+
ctx.ui?.notify?.(
96+
`tmux-bridge: another pi is already listening on ${sockPath}; ` +
97+
"this instance will not receive sends from pi-send / neovim.",
98+
"warning",
99+
);
100+
return;
101+
}
102+
try {
103+
fs.unlinkSync(sockPath); // stale file from a crashed pi
104+
} catch {
105+
/* not present */
106+
}
107+
server = net.createServer((socket) => {
108+
let buf = "";
109+
socket.setEncoding("utf8");
110+
socket.on("data", (chunk) => {
111+
buf += chunk;
112+
let idx = buf.indexOf("\n");
113+
while (idx !== -1) {
114+
const line = buf.slice(0, idx);
115+
buf = buf.slice(idx + 1);
116+
handleLine(line);
117+
idx = buf.indexOf("\n");
118+
}
119+
});
120+
socket.on("error", () => {
121+
/* ignore peer disconnects */
122+
});
123+
});
124+
server.on("error", (err) => {
125+
ctx.ui?.notify?.(`tmux-bridge: ${err.message}`, "error");
126+
});
127+
server.listen(sockPath, () => {
128+
try {
129+
fs.chmodSync(sockPath, 0o600);
130+
} catch {
131+
/* best effort */
132+
}
133+
});
134+
};
135+
136+
const stop = () => {
137+
if (!server) return; // we never owned the socket; don't unlink another pi's file
138+
server.close();
139+
server = undefined;
140+
try {
141+
fs.unlinkSync(sockPath);
142+
} catch {
143+
/* ignore */
144+
}
145+
};
146+
147+
pi.on("session_start", async (_event, ctx) => {
148+
start(ctx);
149+
});
150+
pi.on("session_shutdown", async () => {
151+
stop();
152+
});
153+
}

.pi/extensions/tsconfig.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "ESNext",
5+
"moduleResolution": "bundler",
6+
"strict": true,
7+
"noEmit": true,
8+
"skipLibCheck": true,
9+
"esModuleInterop": true,
10+
"allowSyntheticDefaultImports": true,
11+
"resolveJsonModule": true,
12+
"types": ["node"]
13+
},
14+
"include": ["**/*.ts"]
15+
}

.pi/npm/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

0 commit comments

Comments
 (0)