ping-a-human-pi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # ping-a-human-pi
2
+
3
+ Human-in-the-loop and notifications for the [pi](https://pi.dev) coding agent, powered by [ping-a-human](https://www.npmjs.com/package/ping-a-human).
4
+
5
+ Pi has no built-in MCP support, so this package embeds a tiny MCP client and connects pi's hooks to ping-a-human. You get:
6
+
7
+ - **`notify_human` / `ask_human` as native pi tools** the model can call directly.
8
+ - **Notifications** — a Telegram ping every time pi finishes a task.
9
+ - **Approval** — pi asks you for the green light before risky tool calls (e.g. `rm -rf`, `sudo`, `git push --force`) and blocks them unless you say yes.
10
+ - **`/ping` command** — manually send yourself a notification: `/ping` or `/ping your message here`.
11
+ - **`pah` CLI** — a global terminal command, independent of pi: `pah ping "build done"`, `pah ask "deploy?"`.
12
+
13
+ ## Install
14
+
15
+ One command:
16
+
17
+ ```bash
18
+ npx ping-a-human-pi
19
+ ```
20
+
21
+ That's it. It:
22
+
23
+ - installs the pi extension into `~/.pi/agent/extensions/ping-a-human-pi/` (pi auto-discovers it),
24
+ - installs the `pah` CLI onto your PATH,
25
+ - seeds `~/.pi/agent/ping-a-human.json` with sensible defaults (never overwrites an existing one).
26
+
27
+ Run `/reload` in pi (or restart it). To remove everything: `npx ping-a-human-pi uninstall`.
28
+
29
+ First time with ping-a-human? Configure Telegram once: `npx ping-a-human setup`.
30
+
31
+ ## `pah` CLI
32
+
33
+ A standalone command (no pi required) for pinging a human from any terminal:
34
+
35
+ ```bash
36
+ pah ping "build finished" # fire-and-forget notification
37
+ pah notify "deploy started" # alias for ping
38
+ pah ask "ship to prod?" # waits and prints the human's reply
39
+ pah help
40
+ ```
41
+
42
+ It reads the same `~/.pi/agent/ping-a-human.json` for the server `command`/`args`/`env` (defaults to `npx -y ping-a-human`). The installer drops it in the first writable PATH dir among `~/bin`, `~/.local/bin`, `/usr/local/bin`; if none are on PATH it creates `~/.local/bin` and tells you to add it.
43
+
44
+ Alternatives:
45
+
46
+ ```bash
47
+ npm i -g ping-a-human-pi # global install of the pah CLI + installer
48
+ pi install npm:ping-a-human-pi # load as a pi package
49
+ sh install.sh # from a cloned checkout
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ All optional. Edit `~/.pi/agent/ping-a-human.json` (see `ping-a-human-pi.example.json`):
55
+
56
+ | Key | Default | Meaning |
57
+ |-----|---------|---------|
58
+ | `command` / `args` | `npx -y ping-a-human` | how to launch the server |
59
+ | `exposeTools` | `true` | register `notify_human` / `ask_human` as model-callable tools |
60
+ | `notify.enabled` | `true` | ping you when a task finishes |
61
+ | `notify.template` | `✅ pi finished a task in {cwd}:\n\n{summary}` | message text; `{summary}` `{cwd}` substituted |
62
+ | `approval.enabled` | `false` | ask before risky tool calls |
63
+ | `approval.tools` | `["bash"]` | which pi tools to gate |
64
+ | `approval.patterns` | dangerous-command regexes | only gate calls whose args match one; empty = gate all calls of those tools |
65
+ | `approval.template` | `🔐 pi wants to run {tool}:\n\n{details}\n\nApprove? (reply yes/no)` | prompt; `{tool}` `{details}` substituted |
66
+
67
+ A reply matching `yes/ok/approve/allow/sure/go/lgtm/...` approves; anything else blocks the call (and the reason is sent back to the model).
68
+
69
+ ## Uninstall
70
+
71
+ ```bash
72
+ rm -rf ~/.pi/agent/extensions/ping-a-human-pi
73
+ rm -f ~/.pi/agent/ping-a-human.json # optional
74
+ rm -f ~/bin/pah ~/.local/bin/pah /usr/local/bin/pah 2>/dev/null # the CLI
75
+ ```
package/bin/cli.mjs ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ping-a-human-pi installer.
4
+ *
5
+ * npx ping-a-human-pi # install everything
6
+ * npx ping-a-human-pi uninstall # remove it
7
+ *
8
+ * Installs:
9
+ * 1. the pi extension -> ~/.pi/agent/extensions/ping-a-human-pi/index.ts
10
+ * 2. the `pah` CLI -> first writable dir on PATH (~/bin, ~/.local/bin, /usr/local/bin)
11
+ * 3. a default config -> ~/.pi/agent/ping-a-human.json (only if missing)
12
+ */
13
+
14
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const PKG = join(HERE, "..");
21
+ const PI_HOME = process.env.PI_HOME || join(homedir(), ".pi", "agent");
22
+ const EXT_DIR = join(PI_HOME, "extensions", "ping-a-human-pi");
23
+ const CONFIG = join(PI_HOME, "ping-a-human.json");
24
+
25
+ const c = {
26
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
27
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
28
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
29
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
30
+ };
31
+ const say = (s = "") => process.stdout.write(`${s}\n`);
32
+
33
+ function pathDirs() {
34
+ return (process.env.PATH || "").split(":").filter(Boolean);
35
+ }
36
+
37
+ function pickBinDir() {
38
+ const onPath = new Set(pathDirs());
39
+ for (const d of [join(homedir(), "bin"), join(homedir(), ".local", "bin"), "/usr/local/bin"]) {
40
+ if (onPath.has(d)) {
41
+ try {
42
+ mkdirSync(d, { recursive: true });
43
+ return { dir: d, onPath: true };
44
+ } catch {
45
+ /* not writable */
46
+ }
47
+ }
48
+ }
49
+ const fallback = join(homedir(), ".local", "bin");
50
+ try {
51
+ mkdirSync(fallback, { recursive: true });
52
+ return { dir: fallback, onPath: onPath.has(fallback) };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function pahTargets() {
59
+ return [join(homedir(), "bin", "pah"), join(homedir(), ".local", "bin", "pah"), "/usr/local/bin/pah"];
60
+ }
61
+
62
+ function install() {
63
+ say(c.bold("→ Installing ping-a-human-pi"));
64
+
65
+ // 1. pi extension
66
+ mkdirSync(EXT_DIR, { recursive: true });
67
+ copyFileSync(join(PKG, "extension", "index.ts"), join(EXT_DIR, "index.ts"));
68
+ say(` ${c.green("✓")} pi extension ${c.dim("→ " + join(EXT_DIR, "index.ts"))}`);
69
+
70
+ // 2. pah CLI
71
+ const picked = pickBinDir();
72
+ if (picked) {
73
+ const dest = join(picked.dir, "pah");
74
+ copyFileSync(join(PKG, "bin", "pah"), dest);
75
+ chmodSync(dest, 0o755);
76
+ say(` ${c.green("✓")} pah CLI ${c.dim("→ " + dest)}`);
77
+ if (!picked.onPath) {
78
+ say(` ${c.yellow("!")} ${picked.dir} is not on your PATH. Add to your shell rc:`);
79
+ say(` export PATH="${picked.dir}:$PATH"`);
80
+ }
81
+ } else {
82
+ say(` ${c.yellow("!")} could not install the pah CLI (no writable bin dir)`);
83
+ }
84
+
85
+ // 3. config (never overwrite)
86
+ mkdirSync(PI_HOME, { recursive: true });
87
+ if (existsSync(CONFIG)) {
88
+ say(` ${c.green("✓")} config ${c.dim("→ " + CONFIG + " (kept)")}`);
89
+ } else {
90
+ const example = join(PKG, "ping-a-human-pi.example.json");
91
+ if (existsSync(example)) copyFileSync(example, CONFIG);
92
+ else writeFileSync(CONFIG, JSON.stringify({ notify: { enabled: true }, approval: { enabled: false } }, null, 2) + "\n");
93
+ say(` ${c.green("✓")} config ${c.dim("→ " + CONFIG)}`);
94
+ }
95
+
96
+ say("");
97
+ say(c.green(c.bold("✅ Installed.")) + " In pi (or after /reload) you now have:");
98
+ say(" • notify_human + ask_human as tools");
99
+ say(" • a Telegram ping when each task finishes");
100
+ say(" • /ping inside pi, and the pah CLI in your terminal");
101
+ say(" • optional approval prompts (set approval.enabled=true in the config)");
102
+ say("");
103
+ say(c.bold("Next:"));
104
+ say(" 1. Configure Telegram (one time): " + c.bold("npx ping-a-human setup"));
105
+ say(' 2. Try the CLI: ' + c.bold('pah ping "hello from my terminal"'));
106
+ }
107
+
108
+ function uninstall() {
109
+ say(c.bold("→ Uninstalling ping-a-human-pi"));
110
+ rmSync(EXT_DIR, { recursive: true, force: true });
111
+ say(` ${c.green("✓")} removed extension`);
112
+ for (const t of pahTargets()) {
113
+ if (existsSync(t)) {
114
+ try {
115
+ rmSync(t, { force: true });
116
+ say(` ${c.green("✓")} removed ${t}`);
117
+ } catch {
118
+ /* ignore */
119
+ }
120
+ }
121
+ }
122
+ say(` ${c.dim("• left config untouched: " + CONFIG)}`);
123
+ say(c.green("✅ Done."));
124
+ }
125
+
126
+ const cmd = process.argv[2];
127
+ if (cmd === "uninstall" || cmd === "remove") uninstall();
128
+ else if (!cmd || cmd === "install") install();
129
+ else {
130
+ say("Usage: npx ping-a-human-pi [install|uninstall]");
131
+ process.exit(1);
132
+ }
package/bin/pah ADDED
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pah — a tiny CLI for ping-a-human.
4
+ *
5
+ * pah ping [message...] send a Telegram notification (fire-and-forget)
6
+ * pah notify [message...] alias for `ping`
7
+ * pah ask <question...> ask a human and print their reply (waits)
8
+ * pah help show usage
9
+ *
10
+ * Reads server config from ~/.pi/agent/ping-a-human.json if present
11
+ * (keys: command, args, env), otherwise defaults to `npx -y ping-a-human`.
12
+ */
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { readFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const PROTOCOL_VERSION = "2024-11-05";
20
+ const TIMEOUT_MS = 600_000;
21
+
22
+ function loadServer() {
23
+ const candidates = [process.env.PING_A_HUMAN_PI_CONFIG, join(homedir(), ".pi", "agent", "ping-a-human.json")].filter(Boolean);
24
+ for (const path of candidates) {
25
+ try {
26
+ const cfg = JSON.parse(readFileSync(path, "utf8"));
27
+ return {
28
+ command: cfg.command ?? "npx",
29
+ args: cfg.args ?? ["-y", "ping-a-human"],
30
+ env: cfg.env ?? undefined,
31
+ };
32
+ } catch {
33
+ /* try next */
34
+ }
35
+ }
36
+ return { command: "npx", args: ["-y", "ping-a-human"], env: undefined };
37
+ }
38
+
39
+ class McpClient {
40
+ #proc = null;
41
+ #buf = "";
42
+ #id = 1;
43
+ #pending = new Map();
44
+
45
+ constructor({ command, args, env }) {
46
+ this.command = command;
47
+ this.args = args;
48
+ this.env = env;
49
+ }
50
+
51
+ async start() {
52
+ this.#proc = spawn(this.command, this.args, { env: { ...process.env, ...(this.env ?? {}) }, stdio: ["pipe", "pipe", "pipe"] });
53
+ this.#proc.on("error", (e) => this.#failAll(new Error(`ping-a-human: ${e.message}`)));
54
+ this.#proc.on("exit", (c) => this.#failAll(new Error(`ping-a-human exited (code ${c})`)));
55
+ this.#proc.stdout.setEncoding("utf8");
56
+ this.#proc.stdout.on("data", (c) => this.#onData(c));
57
+ this.#proc.stderr.setEncoding("utf8");
58
+ this.#proc.stderr.on("data", () => {});
59
+ await this.#req("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "pah-cli", version: "1.0.0" } });
60
+ this.#notify("notifications/initialized");
61
+ }
62
+
63
+ async call(name, args) {
64
+ const res = await this.#req("tools/call", { name, arguments: args ?? {} });
65
+ if (res?.isError) throw new Error((res.content ?? []).map((b) => b.text ?? "").join("\n") || "ping-a-human error");
66
+ return (res?.content ?? []).map((b) => b.text ?? "").join("\n").trim();
67
+ }
68
+
69
+ stop() {
70
+ this.#proc?.kill();
71
+ this.#proc = null;
72
+ }
73
+
74
+ #onData(chunk) {
75
+ this.#buf += chunk;
76
+ let i;
77
+ while ((i = this.#buf.indexOf("\n")) !== -1) {
78
+ const line = this.#buf.slice(0, i).trim();
79
+ this.#buf = this.#buf.slice(i + 1);
80
+ if (!line) continue;
81
+ let m;
82
+ try {
83
+ m = JSON.parse(line);
84
+ } catch {
85
+ continue;
86
+ }
87
+ if (m.id == null) continue;
88
+ const e = this.#pending.get(m.id);
89
+ if (!e) continue;
90
+ this.#pending.delete(m.id);
91
+ clearTimeout(e.timer);
92
+ if (m.error) e.reject(new Error(m.error.message));
93
+ else e.resolve(m.result);
94
+ }
95
+ }
96
+
97
+ #req(method, params) {
98
+ if (!this.#proc) return Promise.reject(new Error("ping-a-human not running"));
99
+ const id = this.#id++;
100
+ this.#proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
101
+ return new Promise((resolve, reject) => {
102
+ const timer = setTimeout(() => {
103
+ this.#pending.delete(id);
104
+ reject(new Error(`"${method}" timed out`));
105
+ }, TIMEOUT_MS);
106
+ this.#pending.set(id, { resolve, reject, timer });
107
+ });
108
+ }
109
+
110
+ #notify(method, params) {
111
+ this.#proc?.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
112
+ }
113
+
114
+ #failAll(err) {
115
+ for (const [, e] of this.#pending) {
116
+ clearTimeout(e.timer);
117
+ e.reject(err);
118
+ }
119
+ this.#pending.clear();
120
+ }
121
+ }
122
+
123
+ function usage() {
124
+ process.stdout.write(
125
+ [
126
+ "pah — ping a human from the command line",
127
+ "",
128
+ "Usage:",
129
+ ' pah ping [message...] send a notification (fire-and-forget)',
130
+ ' pah notify [message...] alias for ping',
131
+ ' pah ask <question...> ask a human and print their reply (waits)',
132
+ " pah help show this help",
133
+ "",
134
+ "Examples:",
135
+ ' pah ping "build finished"',
136
+ ' pah ask "deploy to prod?"',
137
+ "",
138
+ ].join("\n"),
139
+ );
140
+ }
141
+
142
+ async function main() {
143
+ const [cmd, ...rest] = process.argv.slice(2);
144
+ const text = rest.join(" ").trim();
145
+
146
+ if (!cmd || cmd === "help" || cmd === "-h" || cmd === "--help") {
147
+ usage();
148
+ process.exit(cmd ? 0 : 1);
149
+ }
150
+
151
+ const client = new McpClient(loadServer());
152
+ try {
153
+ await client.start();
154
+ if (cmd === "ping" || cmd === "notify") {
155
+ const message = text || `👋 Ping from ${process.cwd()}`;
156
+ const reply = await client.call("notify_human", { message });
157
+ process.stdout.write(`${reply || "Notification sent."}\n`);
158
+ } else if (cmd === "ask") {
159
+ if (!text) {
160
+ process.stderr.write('pah ask: a question is required, e.g. pah ask "ship it?"\n');
161
+ process.exitCode = 2;
162
+ } else {
163
+ const reply = await client.call("ask_human", { question: text });
164
+ process.stdout.write(`${reply}\n`);
165
+ }
166
+ } else {
167
+ process.stderr.write(`pah: unknown command "${cmd}"\n\n`);
168
+ usage();
169
+ process.exitCode = 2;
170
+ }
171
+ } catch (err) {
172
+ process.stderr.write(`pah: ${err.message}\n`);
173
+ process.exitCode = 1;
174
+ } finally {
175
+ client.stop();
176
+ }
177
+ }
178
+
179
+ main();
@@ -0,0 +1,366 @@
1
+ /**
2
+ * ping-a-human-pi — human-in-the-loop for pi, powered by ping-a-human.
3
+ *
4
+ * Pi has no built-in MCP support, so this extension embeds a tiny MCP stdio
5
+ * client and connects pi to the `ping-a-human` MCP server. It gives you:
6
+ *
7
+ * 1. Tools: `notify_human` and `ask_human` become native pi tools the
8
+ * model can call directly.
9
+ * 2. Notifications: when a task finishes (`agent_end`) you get a Telegram ping
10
+ * via `notify_human`.
11
+ * 3. Approval: before risky tool calls (`tool_call`) pi asks you via
12
+ * `ask_human` and blocks the call unless you approve.
13
+ *
14
+ * Zero config required. To tune behavior, drop a JSON file at
15
+ * ~/.pi/agent/ping-a-human.json (or set $PING_A_HUMAN_PI_CONFIG)
16
+ * See ping-a-human-pi.example.json for all options.
17
+ */
18
+
19
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
20
+ import { readFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import { Type } from "typebox";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Config (all optional)
28
+ // ---------------------------------------------------------------------------
29
+ interface Config {
30
+ /** How to launch the ping-a-human MCP server. */
31
+ command?: string;
32
+ args?: string[];
33
+ env?: Record<string, string>;
34
+ /** Register notify_human / ask_human as tools the model can call. Default true. */
35
+ exposeTools?: boolean;
36
+ notify?: {
37
+ enabled?: boolean; // default true
38
+ template?: string; // {summary} {cwd}
39
+ };
40
+ approval?: {
41
+ enabled?: boolean; // default false
42
+ tools?: string[]; // pi tool names to gate (default ["bash"])
43
+ patterns?: string[]; // regex/substrings; if set, only matching calls are gated
44
+ template?: string; // {tool} {details}
45
+ };
46
+ }
47
+
48
+ const DEFAULTS = {
49
+ command: "npx",
50
+ args: ["-y", "ping-a-human"],
51
+ exposeTools: true,
52
+ notify: {
53
+ enabled: true,
54
+ template: "✅ pi finished a task in {cwd}:\n\n{summary}",
55
+ },
56
+ approval: {
57
+ enabled: false,
58
+ tools: ["bash"],
59
+ patterns: [
60
+ "rm\\s+-rf",
61
+ "\\bsudo\\b",
62
+ "git\\s+push",
63
+ "--force",
64
+ "\\bdd\\b",
65
+ "mkfs",
66
+ ">\\s*/dev/",
67
+ "chmod\\s+-R",
68
+ "chown\\s+-R",
69
+ ":\\(\\)\\s*\\{",
70
+ "\\bshutdown\\b",
71
+ "\\breboot\\b",
72
+ ],
73
+ template: "🔐 pi wants to run {tool}:\n\n{details}\n\nApprove? (reply yes/no)",
74
+ },
75
+ };
76
+
77
+ const PROTOCOL_VERSION = "2024-11-05";
78
+ const REQUEST_TIMEOUT_MS = 600_000; // human-in-the-loop calls can wait a while
79
+
80
+ const NOTIFY_TOOL = "notify_human";
81
+ const ASK_TOOL = "ask_human";
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Minimal MCP stdio client (newline-delimited JSON-RPC)
85
+ // ---------------------------------------------------------------------------
86
+ interface JsonRpcResponse {
87
+ id?: number | string;
88
+ result?: unknown;
89
+ error?: { code: number; message: string };
90
+ }
91
+ interface McpTool {
92
+ name: string;
93
+ description?: string;
94
+ inputSchema?: Record<string, unknown>;
95
+ }
96
+ interface McpContentBlock {
97
+ type: string;
98
+ text?: string;
99
+ data?: string;
100
+ mimeType?: string;
101
+ [k: string]: unknown;
102
+ }
103
+
104
+ class McpClient {
105
+ private proc: ChildProcessWithoutNullStreams | null = null;
106
+ private buffer = "";
107
+ private nextId = 1;
108
+ private pending = new Map<number | string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>();
109
+
110
+ constructor(private readonly command: string, private readonly args: string[], private readonly env?: Record<string, string>) {}
111
+
112
+ async start(): Promise<McpTool[]> {
113
+ this.proc = spawn(this.command, this.args, {
114
+ env: { ...process.env, ...(this.env ?? {}) },
115
+ stdio: ["pipe", "pipe", "pipe"],
116
+ });
117
+ this.proc.on("error", (e) => this.failAll(new Error(`[ping-a-human] ${e.message}`)));
118
+ this.proc.on("exit", (c) => this.failAll(new Error(`[ping-a-human] exited (code ${c})`)));
119
+ this.proc.stdout.setEncoding("utf8");
120
+ this.proc.stdout.on("data", (c: string) => this.onData(c));
121
+ this.proc.stderr.setEncoding("utf8");
122
+ this.proc.stderr.on("data", () => {});
123
+
124
+ await this.request("initialize", {
125
+ protocolVersion: PROTOCOL_VERSION,
126
+ capabilities: {},
127
+ clientInfo: { name: "ping-a-human-pi", version: "1.0.0" },
128
+ });
129
+ this.notify("notifications/initialized");
130
+ const listed = (await this.request("tools/list", {})) as { tools?: McpTool[] };
131
+ return listed.tools ?? [];
132
+ }
133
+
134
+ async callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpContentBlock[]> {
135
+ const res = (await this.request("tools/call", { name, arguments: args ?? {} }, signal)) as {
136
+ content?: McpContentBlock[];
137
+ isError?: boolean;
138
+ };
139
+ if (res.isError) throw new Error((res.content ?? []).map((c) => c.text ?? "").join("\n") || "ping-a-human error");
140
+ return res.content ?? [];
141
+ }
142
+
143
+ stop(): void {
144
+ this.failAll(new Error("[ping-a-human] stopped"));
145
+ this.proc?.kill();
146
+ this.proc = null;
147
+ }
148
+
149
+ private onData(chunk: string): void {
150
+ this.buffer += chunk;
151
+ let i: number;
152
+ while ((i = this.buffer.indexOf("\n")) !== -1) {
153
+ const line = this.buffer.slice(0, i).trim();
154
+ this.buffer = this.buffer.slice(i + 1);
155
+ if (!line) continue;
156
+ let m: JsonRpcResponse;
157
+ try {
158
+ m = JSON.parse(line);
159
+ } catch {
160
+ continue;
161
+ }
162
+ if (m.id === undefined || m.id === null) continue;
163
+ const e = this.pending.get(m.id);
164
+ if (!e) continue;
165
+ this.pending.delete(m.id);
166
+ clearTimeout(e.timer);
167
+ if (m.error) e.reject(new Error(m.error.message));
168
+ else e.resolve(m.result);
169
+ }
170
+ }
171
+
172
+ private request(method: string, params: unknown, signal?: AbortSignal): Promise<unknown> {
173
+ if (!this.proc) return Promise.reject(new Error("[ping-a-human] not running"));
174
+ const id = this.nextId++;
175
+ const payload = `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
176
+ return new Promise((resolve, reject) => {
177
+ const timer = setTimeout(() => {
178
+ this.pending.delete(id);
179
+ reject(new Error(`[ping-a-human] "${method}" timed out`));
180
+ }, REQUEST_TIMEOUT_MS);
181
+ this.pending.set(id, { resolve, reject, timer });
182
+ signal?.addEventListener(
183
+ "abort",
184
+ () => {
185
+ const e = this.pending.get(id);
186
+ if (e) {
187
+ this.pending.delete(id);
188
+ clearTimeout(e.timer);
189
+ reject(new Error("[ping-a-human] aborted"));
190
+ }
191
+ },
192
+ { once: true },
193
+ );
194
+ this.proc!.stdin.write(payload);
195
+ });
196
+ }
197
+
198
+ private notify(method: string, params?: unknown): void {
199
+ this.proc?.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
200
+ }
201
+
202
+ private failAll(err: Error): void {
203
+ for (const [, e] of this.pending) {
204
+ clearTimeout(e.timer);
205
+ e.reject(err);
206
+ }
207
+ this.pending.clear();
208
+ }
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Helpers
213
+ // ---------------------------------------------------------------------------
214
+ function loadConfig(): Config {
215
+ const candidates = [process.env.PING_A_HUMAN_PI_CONFIG, join(homedir(), ".pi", "agent", "ping-a-human.json")].filter(Boolean) as string[];
216
+ for (const path of candidates) {
217
+ try {
218
+ return JSON.parse(readFileSync(path, "utf8")) as Config;
219
+ } catch {
220
+ // try next / fall back to defaults
221
+ }
222
+ }
223
+ return {};
224
+ }
225
+
226
+ function toPiContent(blocks: McpContentBlock[]): Array<Record<string, unknown>> {
227
+ const out: Array<Record<string, unknown>> = [];
228
+ for (const b of blocks) {
229
+ if (b.type === "image" && b.data) out.push({ type: "image", source: { type: "base64", mediaType: b.mimeType ?? "image/png", data: b.data } });
230
+ else if (typeof b.text === "string") out.push({ type: "text", text: b.text });
231
+ else out.push({ type: "text", text: JSON.stringify(b) });
232
+ }
233
+ if (out.length === 0) out.push({ type: "text", text: "(no content)" });
234
+ return out;
235
+ }
236
+
237
+ function blocksToText(blocks: McpContentBlock[]): string {
238
+ return blocks.map((b) => b.text ?? "").join("\n").trim();
239
+ }
240
+
241
+ function lastAssistantText(messages: Array<{ role?: string; content?: unknown }>): string {
242
+ for (let i = messages.length - 1; i >= 0; i--) {
243
+ const m = messages[i];
244
+ if (m.role !== "assistant") continue;
245
+ if (typeof m.content === "string") return m.content;
246
+ if (Array.isArray(m.content)) {
247
+ const t = m.content
248
+ .filter((c) => c && typeof c === "object" && (c as { type?: string }).type === "text")
249
+ .map((c) => (c as { text?: string }).text ?? "")
250
+ .join("")
251
+ .trim();
252
+ if (t) return t;
253
+ }
254
+ }
255
+ return "";
256
+ }
257
+
258
+ const APPROVED = /^(y|yes|ok|okay|approve|approved|allow|allowed|sure|go|go ahead|lgtm|do it|proceed)\b/i;
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Extension entry point
262
+ // ---------------------------------------------------------------------------
263
+ export default async function pingAHumanPi(pi: ExtensionAPI) {
264
+ const cfg = loadConfig();
265
+ const command = cfg.command ?? DEFAULTS.command;
266
+ const args = cfg.args ?? DEFAULTS.args;
267
+ const exposeTools = cfg.exposeTools ?? DEFAULTS.exposeTools;
268
+ const notify = { ...DEFAULTS.notify, ...(cfg.notify ?? {}) };
269
+ const approval = { ...DEFAULTS.approval, ...(cfg.approval ?? {}) };
270
+
271
+ const client = new McpClient(command, args, cfg.env);
272
+ let tools: McpTool[];
273
+ try {
274
+ tools = await client.start();
275
+ } catch (err) {
276
+ client.stop();
277
+ // eslint-disable-next-line no-console
278
+ console.error(`[ping-a-human-pi] could not start ping-a-human: ${(err as Error).message}`);
279
+ return;
280
+ }
281
+
282
+ // 1. Expose ping-a-human tools to the model.
283
+ if (exposeTools) {
284
+ for (const tool of tools) {
285
+ pi.registerTool({
286
+ name: tool.name,
287
+ label: tool.name === ASK_TOOL ? "Ask a human" : tool.name === NOTIFY_TOOL ? "Notify a human" : tool.name,
288
+ description: tool.description ?? `ping-a-human tool "${tool.name}"`,
289
+ promptSnippet: tool.description ?? `ping-a-human ${tool.name}`,
290
+ parameters: Type.Unsafe(tool.inputSchema ?? { type: "object", properties: {} }),
291
+ async execute(_id, params, signal) {
292
+ const content = await client.callTool(tool.name, params, signal);
293
+ return { content: toPiContent(content), details: { tool: tool.name } };
294
+ },
295
+ });
296
+ }
297
+ }
298
+
299
+ const hasNotify = tools.some((t) => t.name === NOTIFY_TOOL);
300
+ const hasAsk = tools.some((t) => t.name === ASK_TOOL);
301
+
302
+ // 2. Notify on task completion.
303
+ if (notify.enabled && hasNotify) {
304
+ pi.on("agent_end", async (event, ctx) => {
305
+ const messages = (event as { messages?: Array<{ role?: string; content?: unknown }> }).messages ?? [];
306
+ let summary = lastAssistantText(messages) || "Task finished.";
307
+ if (summary.length > 1500) summary = `${summary.slice(0, 1500)}…`;
308
+ const message = notify.template.replace("{summary}", summary).replace("{cwd}", ctx.cwd);
309
+ try {
310
+ await client.callTool(NOTIFY_TOOL, { message });
311
+ } catch {
312
+ // never let a notification failure affect the agent
313
+ }
314
+ });
315
+ }
316
+
317
+ // 3. Human-in-the-loop approval before risky tool calls.
318
+ if (approval.enabled && hasAsk) {
319
+ const gated = new Set(approval.tools);
320
+ const patterns = approval.patterns.map((p) => {
321
+ try {
322
+ return new RegExp(p, "i");
323
+ } catch {
324
+ return new RegExp(p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
325
+ }
326
+ });
327
+
328
+ pi.on("tool_call", async (event: { toolName: string; input: unknown }, ctx: ExtensionContext) => {
329
+ if (!gated.has(event.toolName)) return;
330
+ const serialized = JSON.stringify(event.input ?? {});
331
+ if (patterns.length > 0 && !patterns.some((re) => re.test(serialized))) return;
332
+
333
+ const details = serialized.length > 1200 ? `${serialized.slice(0, 1200)}…` : serialized;
334
+ const question = approval.template.replace("{tool}", event.toolName).replace("{details}", details);
335
+ try {
336
+ const reply = blocksToText(await client.callTool(ASK_TOOL, { question }, ctx.signal));
337
+ if (APPROVED.test(reply.trim())) return; // approved
338
+ return { block: true, reason: `Blocked by human: ${reply || "declined"}` };
339
+ } catch (err) {
340
+ return { block: true, reason: `ping-a-human approval unavailable: ${(err as Error).message}` };
341
+ }
342
+ });
343
+ }
344
+
345
+ // 4. /ping command — manually notify a human (optionally with a message).
346
+ // /ping -> sends a default "pinging you from pi" message
347
+ // /ping <message> -> sends your message
348
+ if (hasNotify) {
349
+ pi.registerCommand("ping", {
350
+ description: "Send a Telegram notification via ping-a-human (/ping [message])",
351
+ handler: async (args, ctx) => {
352
+ const message = (args ?? "").trim() || `\uD83D\uDC4B Ping from pi (${ctx.cwd})`;
353
+ try {
354
+ const reply = blocksToText(await client.callTool(NOTIFY_TOOL, { message }, ctx.signal));
355
+ ctx.ui.notify(reply || "Notification sent.", "info");
356
+ } catch (err) {
357
+ ctx.ui.notify(`Ping failed: ${(err as Error).message}`, "error");
358
+ }
359
+ },
360
+ });
361
+ }
362
+
363
+ pi.on("session_shutdown", async () => {
364
+ client.stop();
365
+ });
366
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "ping-a-human-pi",
3
+ "version": "0.1.0",
4
+ "description": "Human-in-the-loop and notifications for the pi coding agent, powered by ping-a-human. One command: npx ping-a-human-pi.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ping-a-human-pi": "bin/cli.mjs",
8
+ "pah": "bin/pah"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "extension",
13
+ "ping-a-human-pi.example.json",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "keywords": ["pi-package", "pi", "mcp", "ping-a-human", "human-in-the-loop", "notifications", "cli"],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/startriseio/ping-a-human.git",
23
+ "directory": "ping-a-human-pi"
24
+ },
25
+ "homepage": "https://github.com/startriseio/ping-a-human#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/startriseio/ping-a-human/issues"
28
+ },
29
+ "license": "MIT",
30
+ "pi": {
31
+ "extensions": ["./extension"]
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "typebox": "*"
36
+ }
37
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "//": "Optional config for ping-a-human-pi. Copy to ~/.pi/agent/ping-a-human.json and edit. Everything here is optional; omit keys to use defaults.",
3
+
4
+ "command": "npx",
5
+ "args": ["-y", "ping-a-human"],
6
+
7
+ "exposeTools": true,
8
+
9
+ "notify": {
10
+ "enabled": true,
11
+ "template": "✅ pi finished a task in {cwd}:\n\n{summary}"
12
+ },
13
+
14
+ "approval": {
15
+ "enabled": false,
16
+ "tools": ["bash"],
17
+ "patterns": [
18
+ "rm\\s+-rf",
19
+ "\\bsudo\\b",
20
+ "git\\s+push",
21
+ "--force",
22
+ "\\bdd\\b",
23
+ "mkfs",
24
+ ">\\s*/dev/",
25
+ "chmod\\s+-R",
26
+ "chown\\s+-R",
27
+ "\\bshutdown\\b",
28
+ "\\breboot\\b"
29
+ ],
30
+ "template": "🔐 pi wants to run {tool}:\n\n{details}\n\nApprove? (reply yes/no)"
31
+ }
32
+ }