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 +75 -0
- package/bin/cli.mjs +132 -0
- package/bin/pah +179 -0
- package/extension/index.ts +366 -0
- package/package.json +37 -0
- package/ping-a-human-pi.example.json +32 -0
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
|
+
}
|