pi-robot-hand 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wayne930242
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # pi-robot-hand
2
+
3
+ A [pi](https://pi.dev) extension that lets the agent hand the user a command to run themselves.
4
+
5
+ 1. The agent calls `robot_hand` with a command and a one-line reason.
6
+ 2. Pi shows an inline selector in place of the prompt, with the reason and exactly what the user would run.
7
+ 3. The user picks one:
8
+ - **Put it in my prompt**: a shell command lands as `! command`, a pi slash command such as `/reload` lands as-is. Pressing Enter runs it; a `!` command's output returns to the conversation.
9
+ - **Copy it to the clipboard**: the raw shell command, for another terminal.
10
+ - **No, thanks**: nothing is placed.
11
+ 4. The tool returns the choice. After a prompt or clipboard hand-over the agent's turn ends, so the next move is the user's.
12
+
13
+ The agent never runs the command. It suits commands the user has to run: pi slash commands, `sudo` or interactive commands, and commands a safety extension blocks for the agent.
14
+
15
+ ```
16
+ robot_hand 需要 sudo 重啟服務
17
+ ! sudo systemctl restart nginx
18
+ ──────────────────────────────────────────────
19
+ Robot Hand — 需要 sudo 重啟服務
20
+
21
+ ! sudo systemctl restart nginx
22
+
23
+ → Put it in my prompt
24
+ Copy it to the clipboard
25
+ No, thanks
26
+
27
+ ↑↓ navigate enter select escape/ctrl+c cancel
28
+ ──────────────────────────────────────────────
29
+ ```
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pi install npm:pi-robot-hand
35
+ ```
36
+
37
+ ## Tool: `robot_hand`
38
+
39
+ | Parameter | Description |
40
+ |---|---|
41
+ | `command` | The command. Shell commands go without a leading `!`; pi commands start with `/`. |
42
+ | `reason` | One short line telling the user why, in the user's language. |
43
+ | `kind` | `shell` (default) or `pi`. |
44
+
45
+ When the prompt already holds a draft, the prompt option says it replaces the draft. The clipboard option appears only for shell commands in the terminal UI. RPC clients get the prompt option; modes without a UI make the tool fail so the agent shows the command in chat.
46
+
47
+ ## Development
48
+
49
+ ```bash
50
+ npm install
51
+ npm run check # tsc --noEmit
52
+ npm test # node --test
53
+ ```
54
+
55
+ Releases publish from GitHub Actions through npm trusted publishing: bump `version` in `package.json`, commit, and push a matching `v<version>` tag.
56
+
57
+ ## License
58
+
59
+ MIT
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "pi-robot-hand",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: the agent hands the user a command, and after the user agrees it lands in the pi prompt or the clipboard, never run by the agent",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "wayne930242",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/wayne930242/pi-robot-hand.git"
11
+ },
12
+ "homepage": "https://github.com/wayne930242/pi-robot-hand#readme",
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi",
16
+ "pi-coding-agent",
17
+ "extension",
18
+ "clipboard",
19
+ "command",
20
+ "approval"
21
+ ],
22
+ "files": [
23
+ "src",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./src/index.ts"
30
+ ]
31
+ },
32
+ "scripts": {
33
+ "check": "tsc --noEmit",
34
+ "test": "node --test tests/*.test.ts"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-ai": "*",
38
+ "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-tui": "*",
40
+ "typebox": "*"
41
+ },
42
+ "devDependencies": {
43
+ "@earendil-works/pi-ai": "0.87.1",
44
+ "@earendil-works/pi-coding-agent": "0.87.1",
45
+ "@earendil-works/pi-tui": "0.87.1",
46
+ "@types/node": "26.6.3",
47
+ "typebox": "1.3.34",
48
+ "typescript": "7.0.2"
49
+ }
50
+ }
package/src/hand.ts ADDED
@@ -0,0 +1,61 @@
1
+ /** What the agent hands over, and how each of the user's choices turns into editor or clipboard text. */
2
+
3
+ export type Kind = "shell" | "pi";
4
+ export type Choice = "prompt" | "clipboard" | "cancel";
5
+
6
+ export interface Offer {
7
+ command: string;
8
+ kind: Kind;
9
+ }
10
+
11
+ export interface Option {
12
+ choice: Choice;
13
+ label: string;
14
+ }
15
+
16
+ /** Checks the command and fills in the default kind. Throws with a message the agent can act on. */
17
+ export function toOffer(command: string, kind: Kind | undefined): Offer {
18
+ const text = command.trim();
19
+ if (text.length === 0) throw new Error("`command` is empty.");
20
+ const resolved = kind ?? "shell";
21
+ if (resolved === "shell" && text.startsWith("!")) {
22
+ throw new Error("Pass the shell command without a leading `!`; robot_hand adds it when the command goes to the prompt.");
23
+ }
24
+ if (resolved === "pi" && !text.startsWith("/")) {
25
+ throw new Error("kind=pi takes a pi slash command such as `/reload`; use kind=shell for shell commands.");
26
+ }
27
+ return { command: text, kind: resolved };
28
+ }
29
+
30
+ /** The text placed in the pi prompt: shell commands run through pi's `!` prefix, pi commands go as-is. */
31
+ export function promptText(offer: Offer): string {
32
+ return offer.kind === "shell" ? `! ${offer.command}` : offer.command;
33
+ }
34
+
35
+ /**
36
+ * The choices shown to the user. The clipboard needs the local terminal and only makes sense for a
37
+ * shell command run elsewhere; a pi command only runs inside pi.
38
+ */
39
+ export function options(offer: Offer, hasDraft: boolean, canCopy: boolean): Option[] {
40
+ const list: Option[] = [{ choice: "prompt", label: hasDraft ? "Put it in my prompt (replaces my draft)" : "Put it in my prompt" }];
41
+ if (canCopy && offer.kind === "shell") list.push({ choice: "clipboard", label: "Copy it to the clipboard" });
42
+ list.push({ choice: "cancel", label: "No, thanks" });
43
+ return list;
44
+ }
45
+
46
+ /** The selector title: why the agent hands this over, then exactly what the user would run. */
47
+ export function dialogTitle(offer: Offer, reason: string): string {
48
+ return `Robot Hand — ${reason}\n\n ${promptText(offer).split("\n").join("\n ")}`;
49
+ }
50
+
51
+ /** What the tool tells the agent after the user chose. */
52
+ export function resultText(choice: Choice, offer: Offer): string {
53
+ if (choice === "prompt") {
54
+ const output = offer.kind === "shell" ? " Its output appears in the conversation when they run it." : "";
55
+ return `The user put \`${promptText(offer)}\` in their prompt; it runs only when they press Enter.${output} Nothing has run yet; wait for the user.`;
56
+ }
57
+ if (choice === "clipboard") {
58
+ return "The user copied the command to their clipboard to run it themselves. Nothing has run yet; wait for the user to report the result.";
59
+ }
60
+ return "The user declined; nothing was placed or run. Ask how they want to proceed.";
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * pi-robot-hand: the agent hands the user a command. After the user agrees, it lands in the pi
3
+ * prompt (shell commands as `! command`) or the clipboard. The user decides whether to run it;
4
+ * the agent never does.
5
+ */
6
+
7
+ import { StringEnum } from "@earendil-works/pi-ai";
8
+ import { copyToClipboard, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { Text } from "@earendil-works/pi-tui";
10
+ import { Type } from "typebox";
11
+ import { type Choice, dialogTitle, options, promptText, resultText, toOffer } from "./hand.ts";
12
+
13
+ interface HandDetails {
14
+ command: string;
15
+ status: "waiting" | Choice;
16
+ }
17
+
18
+ const Params = Type.Object({
19
+ command: Type.String({
20
+ description: "The command to hand over. kind=shell: a shell command without a leading `!`. kind=pi: a pi slash command such as `/reload`.",
21
+ }),
22
+ reason: Type.String({ description: "One short line telling the user why they should run it, in the user's language." }),
23
+ kind: Type.Optional(
24
+ StringEnum(["shell", "pi"] as const, {
25
+ description:
26
+ "shell: goes to the prompt as `! command` (output returns to the conversation) or to the clipboard for another terminal. pi: a pi slash command, placed in the prompt as-is. Default shell.",
27
+ }),
28
+ ),
29
+ });
30
+
31
+ export function createRobotHand(copy: (text: string) => Promise<void> = copyToClipboard) {
32
+ return function robotHand(pi: ExtensionAPI) {
33
+ pi.registerTool({
34
+ name: "robot_hand",
35
+ label: "Robot Hand",
36
+ description:
37
+ "Hand the user a command to run themselves. The user sees the command and the reason, then puts it in their pi prompt (a shell command becomes `! command`, whose output returns to the conversation when they press Enter), copies it to the clipboard, or declines. The tool returns the user's choice; it never runs the command.",
38
+ promptSnippet: "Hand the user a command to run themselves, via their pi prompt or clipboard",
39
+ promptGuidelines: [
40
+ "Use robot_hand when the user has to run a command themselves: pi slash commands such as /reload, interactive or sudo commands, commands a guard blocked, or anything they asked to run on their own.",
41
+ "Pass one command per robot_hand call and write the reason in the user's language.",
42
+ "After robot_hand places or copies a command, nothing has run yet: wait for the user instead of assuming the result.",
43
+ ],
44
+ parameters: Params,
45
+ executionMode: "sequential",
46
+
47
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
48
+ if (!ctx.hasUI) {
49
+ throw new Error("robot_hand needs an interactive pi UI; show the command to the user in chat instead.");
50
+ }
51
+ const offer = toOffer(params.command, params.kind);
52
+ const text = promptText(offer);
53
+ const choices = options(offer, ctx.ui.getEditorText().trim().length > 0, ctx.mode === "tui");
54
+
55
+ onUpdate?.({ content: [{ type: "text", text: "Waiting for the user to take the command..." }], details: { command: text, status: "waiting" } });
56
+ pi.events.emit("herdr:blocked", { active: true, label: "Waiting for the user to take a command" });
57
+ let picked: string | undefined;
58
+ try {
59
+ picked = await ctx.ui.select(
60
+ dialogTitle(offer, params.reason),
61
+ choices.map((option) => option.label),
62
+ { signal },
63
+ );
64
+ } finally {
65
+ pi.events.emit("herdr:blocked", { active: false });
66
+ }
67
+ const choice = choices.find((option) => option.label === picked)?.choice ?? "cancel";
68
+
69
+ if (choice === "prompt") {
70
+ ctx.ui.setEditorText(text);
71
+ ctx.ui.notify("Review the command in your prompt and press Enter to run it.", "info");
72
+ } else if (choice === "clipboard") {
73
+ await copy(offer.command);
74
+ ctx.ui.notify("Command copied to the clipboard.", "info");
75
+ }
76
+ return {
77
+ content: [{ type: "text", text: resultText(choice, offer) }],
78
+ details: { command: text, status: choice } satisfies HandDetails,
79
+ // The next move is the user's; a follow-up from the agent would only talk over it.
80
+ terminate: choice !== "cancel",
81
+ };
82
+ },
83
+
84
+ renderCall(args, theme) {
85
+ const head = `${theme.fg("toolTitle", theme.bold("robot_hand "))}${theme.fg("dim", args.reason ?? "")}`;
86
+ const command = args.command ? (args.kind === "pi" ? args.command : `! ${args.command}`) : "";
87
+ const lines = command.split("\n").map((line: string) => ` ${theme.fg("accent", line)}`);
88
+ return new Text([head, ...lines].join("\n"), 0, 0);
89
+ },
90
+
91
+ renderResult(result, _options, theme) {
92
+ const details = result.details as HandDetails | undefined;
93
+ if (!details || typeof details.status !== "string") {
94
+ const first = result.content[0];
95
+ return new Text(first?.type === "text" ? first.text : "", 0, 0);
96
+ }
97
+ if (details.status === "waiting") return new Text(theme.fg("dim", "Waiting for you to take the command"), 0, 0);
98
+ if (details.status === "prompt") return new Text(`${theme.fg("success", "✓ ")}In your prompt — press Enter to run it`, 0, 0);
99
+ if (details.status === "clipboard") return new Text(`${theme.fg("success", "✓ ")}Copied to your clipboard`, 0, 0);
100
+ return new Text(theme.fg("warning", "Declined — nothing placed or run"), 0, 0);
101
+ },
102
+ });
103
+ };
104
+ }
105
+
106
+ export default createRobotHand();