pi-robot-hand 0.2.2 → 0.3.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 CHANGED
@@ -13,12 +13,14 @@ pi-robot-hand hands them to you in one keystroke and brings the result back to t
13
13
  pi install npm:pi-robot-hand
14
14
  ```
15
15
 
16
+ Also on the [pi package gallery](https://pi.dev/packages/pi-robot-hand).
17
+
16
18
  ## `robot_hand`
17
19
 
18
20
  The agent calls `robot_hand` with a command and a one-line reason, and pi shows a selector:
19
21
 
20
22
  ```
21
- Robot Hand — 需要 sudo 重啟服務
23
+ Robot Hand — Restarting nginx needs sudo
22
24
 
23
25
  ! sudo systemctl restart nginx
24
26
 
@@ -58,6 +60,25 @@ It can set a `.env` key, replace a regex match, write a whole file, or feed the
58
60
  `/secret-drop` lists protected files.
59
61
  See [docs/secret-drop.md](https://github.com/wayne930242/pi-robot-hand/blob/main/docs/secret-drop.md) for parameters, protection details, and limits.
60
62
 
63
+ ## Answering from another extension
64
+
65
+ `robot_hand` publishes the selector it opens on pi's in-process event bus, so another extension (a browser page, a remote UI) can answer it. The terminal selector works as before whether or not anyone listens, and an answer runs exactly as if you had picked it there.
66
+
67
+ The contract is "dialog contract v1", shared by every extension that owns a dialog; only the event prefix differs. For `robot_hand` the prefix is `robot-hand`. Every payload carries `version: 1`.
68
+
69
+ | Event | Direction | Payload |
70
+ |---|---|---|
71
+ | `robot-hand:pending` | owner → bus | `{ version, id, toolCallId, digest, select: { title, choices: [{ label, note?, localOnly? }], copy } }`. `id` is a random UUID for this dialog. `note` says where a choice acts; `localOnly` marks the clipboard, which is this computer's. `copy` is the command, for a page that offers a copy button. |
72
+ | `robot-hand:answer` | bus → owner | `{ version, id, answerId, digest, selections: [index] }`. `answerId` is `[A-Za-z0-9_-]{16,64}`, minted by the answerer; `digest` is the one from `pending`; `selections` holds exactly one zero-based index into `choices`. No other keys. |
73
+ | `robot-hand:answer-result` | owner → bus | `{ version, id, answerId, accepted, reason? }`, emitted synchronously inside the `answer` listener. `reason` is `closed`, `digest`, `malformed`, or `not-allowed`. An answer for an unknown `id` gets no result. |
74
+ | `robot-hand:closed` | owner → bus | `{ version, id, by, answerId? }`, once per dialog: `by` is `local` (picked in the terminal), `remote` (with the `answerId`), `cancel` (Escape), `abort` (the agent's turn was aborted), or `error`. |
75
+
76
+ The digest is the lowercase hex SHA-256 of `JSON.stringify(["pi-dialog/1", "select", title, labels])`, where `labels` are the choice labels in order. It binds an answer to what the user saw. For the title `Robot Hand — Deploy needs your sudo password\n\n ! npm run deploy` and the labels `Type it into a new Herdr pane (I press Enter)`, `Put it in my prompt`, `No, thanks`, the digest is `2974924308b3389b43761124fc7cbc4a4feced24e7f83706ae3f568a21b45faf`.
77
+
78
+ An answer for this dialog's `id` is refused whole unless its `version`, `answerId`, `digest`, and `selections` are right and it has no extra keys; an `answerId` that fails the pattern comes back as an empty string in the result. The first of a terminal pick, a valid answer, Escape, or an abort wins: a later answer gets `accepted: false, reason: "closed"`, and an answer sent while `pending` is being emitted wins before the selector opens. A remote answer closes the terminal selector; the tool result is the same text as a terminal pick, with `details.answeredFrom: "browser"`.
79
+
80
+ Any extension in the pi process can read dialog ids from the bus and answer. That adds no authority: such an extension could already run commands itself, so there is no opt-in setting. `secret_drop` never publishes anything and can only be answered in the terminal.
81
+
61
82
  ## Safety
62
83
 
63
84
  pi-robot-hand depends on no other extension.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-robot-hand",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "The lazy kit for pi: no IDE, no copy-paste, safety net still on. The agent hands you commands (prompt, clipboard, or a Herdr pane that reports back), answers your ! commands, and takes secrets through a masked dialog it never sees",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/dialog.ts ADDED
@@ -0,0 +1,87 @@
1
+ // Dialog contract v1, owner side: robot_hand publishes the selector it opens and accepts one valid answer from another
2
+ // extension through the path a terminal pick takes. The first of pick, answer, Escape, or abort wins; answers are checked
3
+ // against this call's own copy of the dialog.
4
+ import { createHash, randomUUID } from "node:crypto";
5
+
6
+ export interface Bus {
7
+ emit(channel: string, data: unknown): void;
8
+ on(channel: string, handler: (data: unknown) => void): () => void;
9
+ }
10
+ export interface BusChoice { label: string; note?: string; localOnly?: true }
11
+ type By = "local" | "remote" | "cancel" | "timeout" | "abort" | "error";
12
+ type Reason = "closed" | "digest" | "malformed" | "not-allowed";
13
+ const PREFIX = "robot-hand";
14
+ const ANSWER_ID = /^[A-Za-z0-9_-]{16,64}$/;
15
+ const KEYS = new Set(["version", "id", "answerId", "digest", "selections", "text", "comment"]);
16
+
17
+ /** SHA-256 of the canonical select form: the title and the choice labels, nothing else. */
18
+ export function digestOf(title: string, labels: string[]): string {
19
+ return createHash("sha256").update(JSON.stringify(["pi-dialog/1", "select", title, labels]), "utf8").digest("hex");
20
+ }
21
+
22
+ // A selector takes exactly one index: no words, no comment. Returns that index, or why the whole answer is refused.
23
+ function check(a: Record<string, unknown>, digest: string, count: number): number | Reason {
24
+ const { selections, text, comment } = a;
25
+ const has = (key: string): boolean => Object.hasOwn(a, key);
26
+ const picks = Array.isArray(selections) ? [...selections] : [];
27
+ const picksOk = picks.length > 0 && new Set(picks).size === picks.length && picks.every((i) => Number.isInteger(i) && i >= 0 && i < count);
28
+ const shaped =
29
+ a.version === 1 && typeof a.answerId === "string" && ANSWER_ID.test(a.answerId) && typeof a.digest === "string" && Object.keys(a).every((key) => KEYS.has(key)) &&
30
+ has("selections") !== has("text") &&
31
+ (!has("selections") || picksOk) &&
32
+ (!has("text") || (typeof text === "string" && text.trim() !== "")) &&
33
+ (!has("comment") || (typeof comment === "string" && has("selections")));
34
+ if (!shaped) return "malformed";
35
+ if (a.digest !== digest) return "digest";
36
+ return !has("text") && !has("comment") && picks.length === 1 ? (picks[0] as number) : "not-allowed";
37
+ }
38
+
39
+ export function openDialog(bus: Bus, o: { toolCallId: string; title: string; choices: BusChoice[]; copy: string; signal?: AbortSignal }) {
40
+ const id = randomUUID();
41
+ const digest = digestOf(o.title, o.choices.map((choice) => choice.label));
42
+ const local = new AbortController();
43
+ let by: By | undefined;
44
+ let winner: number | undefined;
45
+ // The flag is written before anything is closed or emitted, so the other claimant sees "closed".
46
+ const settle = (how: By, answerId?: string, between?: () => void): void => {
47
+ if (by) return;
48
+ by = how;
49
+ between?.();
50
+ bus.emit(`${PREFIX}:closed`, { version: 1, id, by: how, ...(answerId ? { answerId } : {}) });
51
+ };
52
+ const onAbort = (): void => settle("abort");
53
+ const off = bus.on(`${PREFIX}:answer`, (raw) => {
54
+ if (typeof raw !== "object" || raw === null) return;
55
+ const a = { ...raw } as Record<string, unknown>;
56
+ if (a.id !== id) return;
57
+ const answerId = typeof a.answerId === "string" && ANSWER_ID.test(a.answerId) ? a.answerId : "";
58
+ const result = (accepted: boolean, reason?: Reason): void => bus.emit(`${PREFIX}:answer-result`, { version: 1, id, answerId, accepted, ...(reason ? { reason } : {}) });
59
+ const verdict = by ? "closed" : check(a, digest, o.choices.length);
60
+ if (typeof verdict === "string") return result(false, verdict);
61
+ settle("remote", answerId, () => {
62
+ winner = verdict;
63
+ local.abort();
64
+ result(true);
65
+ });
66
+ });
67
+ o.signal?.addEventListener("abort", onAbort, { once: true });
68
+ bus.emit(`${PREFIX}:pending`, { version: 1, id, toolCallId: o.toolCallId, digest, select: { title: o.title, choices: o.choices.map((choice) => ({ ...choice })), copy: o.copy } });
69
+ if (o.signal?.aborted) settle("abort");
70
+ // signal: for `select`, aborts on the tool signal or a remote answer. open: false when an answer arrived during the
71
+ // `pending` emit, so the selector is not opened. finish: settles with the terminal's outcome (index, or undefined for
72
+ // Escape) unless something else won, and returns the winning index. dispose: settles as an error, drops the listeners.
73
+ return {
74
+ signal: AbortSignal.any(o.signal ? [o.signal, local.signal] : [local.signal]),
75
+ get open() { return by === undefined; },
76
+ get remote() { return by === "remote"; },
77
+ finish(picked: number | undefined): number | undefined {
78
+ settle(picked === undefined ? "cancel" : "local");
79
+ return by === "local" ? picked : winner;
80
+ },
81
+ dispose(): void {
82
+ settle("error");
83
+ off();
84
+ o.signal?.removeEventListener("abort", onAbort);
85
+ },
86
+ };
87
+ }
package/src/hand.ts CHANGED
@@ -68,6 +68,20 @@ export function options(offer: Offer, reach: Reach, preferred: Preferred = "prom
68
68
  return [...ordered, { choice: "cancel", label: "No, thanks" }];
69
69
  }
70
70
 
71
+ // Where each choice acts, for an answerer that shows the choices elsewhere. The clipboard is this computer's.
72
+ const NOTES: Record<Choice, string | undefined> = {
73
+ prompt: "Lands in the terminal's prompt; press Enter there",
74
+ clipboard: "Copies on this computer",
75
+ "herdr-type": "Opens a pane with the command typed; press Enter there",
76
+ "herdr-run": "Runs it at once in a new pane",
77
+ cancel: undefined,
78
+ };
79
+
80
+ /** The choices as published on the dialog bus, each with its note. */
81
+ export function busChoices(list: Option[]): { label: string; note?: string; localOnly?: true }[] {
82
+ return list.map(({ choice, label }) => ({ label, ...(NOTES[choice] ? { note: NOTES[choice] } : {}), ...(choice === "clipboard" ? { localOnly: true as const } : {}) }));
83
+ }
84
+
71
85
  /** The selector title: why the agent hands this over, then exactly what the user would run. */
72
86
  export function dialogTitle(offer: Offer, reason: string): string {
73
87
  return `Robot Hand — ${reason}\n\n ${promptText(offer).split("\n").join("\n ")}`;
package/src/index.ts CHANGED
@@ -10,12 +10,15 @@ import { StringEnum } from "@earendil-works/pi-ai";
10
10
  import { copyToClipboard, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { Text } from "@earendil-works/pi-tui";
12
12
  import { Type } from "typebox";
13
- import { type Choice, dialogTitle, options, promptText, resultText, toOffer } from "./hand.ts";
13
+ import { openDialog } from "./dialog.ts";
14
+ import { busChoices, type Choice, dialogTitle, options, promptText, resultText, toOffer } from "./hand.ts";
14
15
  import { checkRun, cleanup, type Exec, findShell, type HerdrRun, outcomeText, startRun } from "./herdr.ts";
15
16
 
16
17
  interface HandDetails {
17
18
  command: string;
18
19
  status: "waiting" | Choice;
20
+ /** Present when another extension answered the selector. */
21
+ answeredFrom?: "browser";
19
22
  }
20
23
 
21
24
  export const HERDR_RESULT_TYPE = "robot-hand-herdr";
@@ -103,7 +106,7 @@ export function createRobotHand(copy: (text: string) => Promise<void> = copyToCl
103
106
  parameters: Params,
104
107
  executionMode: "sequential",
105
108
 
106
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
109
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
107
110
  if (!ctx.hasUI) {
108
111
  throw new Error("robot_hand needs an interactive pi UI; show the command to the user in chat instead.");
109
112
  }
@@ -117,37 +120,49 @@ export function createRobotHand(copy: (text: string) => Promise<void> = copyToCl
117
120
  };
118
121
  const choices = options(offer, reach, params.default);
119
122
 
120
- onUpdate?.({ content: [{ type: "text", text: "Waiting for the user to take the command..." }], details: { command: text, status: "waiting" } });
121
- pi.events.emit("herdr:blocked", { active: true, label: "Waiting for the user to take a command" });
122
- let picked: string | undefined;
123
+ const title = dialogTitle(offer, params.reason);
124
+ const dialog = openDialog(pi.events, { toolCallId, title, choices: busChoices(choices), copy: offer.command, signal });
123
125
  try {
124
- picked = await ctx.ui.select(
125
- dialogTitle(offer, params.reason),
126
- choices.map((option) => option.label),
127
- { signal },
128
- );
129
- } finally {
130
- pi.events.emit("herdr:blocked", { active: false });
131
- }
132
- const choice = choices.find((option) => option.label === picked)?.choice ?? "cancel";
126
+ onUpdate?.({ content: [{ type: "text", text: "Waiting for the user to take the command..." }], details: { command: text, status: "waiting" } });
127
+ pi.events.emit("herdr:blocked", { active: true, label: "Waiting for the user to take a command" });
128
+ let won: number | undefined;
129
+ try {
130
+ let picked: string | undefined;
131
+ if (dialog.open) {
132
+ picked = await ctx.ui.select(
133
+ title,
134
+ choices.map((option) => option.label),
135
+ { signal: dialog.signal },
136
+ );
137
+ }
138
+ // Settle right after the selector resolves, before anything else can run.
139
+ const at = choices.findIndex((option) => option.label === picked);
140
+ won = dialog.finish(at < 0 ? undefined : at);
141
+ } finally {
142
+ pi.events.emit("herdr:blocked", { active: false });
143
+ }
144
+ const choice = (won === undefined ? undefined : choices[won]?.choice) ?? "cancel";
133
145
 
134
- if (choice === "prompt") {
135
- ctx.ui.setEditorText(text);
136
- ctx.ui.notify("Review the command in your prompt and press Enter to run it.", "info");
137
- } else if (choice === "clipboard") {
138
- await copy(offer.command);
139
- ctx.ui.notify("Command copied to the clipboard.", "info");
140
- } else if ((choice === "herdr-type" || choice === "herdr-run") && piPane) {
141
- const run = await startRun(exec, { piPane, cwd: ctx.cwd, tmp, command: offer.command, type: choice === "herdr-type", shell: findShell(env.PATH) });
142
- runs.set(run.token, { run, ticks: 0, busy: false });
143
- timer ??= setInterval(watch, WATCH_MS);
146
+ if (choice === "prompt") {
147
+ ctx.ui.setEditorText(text);
148
+ ctx.ui.notify("Review the command in your prompt and press Enter to run it.", "info");
149
+ } else if (choice === "clipboard") {
150
+ await copy(offer.command);
151
+ ctx.ui.notify("Command copied to the clipboard.", "info");
152
+ } else if ((choice === "herdr-type" || choice === "herdr-run") && piPane) {
153
+ const run = await startRun(exec, { piPane, cwd: ctx.cwd, tmp, command: offer.command, type: choice === "herdr-type", shell: findShell(env.PATH) });
154
+ runs.set(run.token, { run, ticks: 0, busy: false });
155
+ timer ??= setInterval(watch, WATCH_MS);
156
+ }
157
+ return {
158
+ content: [{ type: "text", text: resultText(choice, offer) }],
159
+ details: { command: text, status: choice, ...(dialog.remote ? { answeredFrom: "browser" } : {}) } satisfies HandDetails,
160
+ // The next move is the user's; a follow-up from the agent would only talk over it.
161
+ terminate: choice !== "cancel",
162
+ };
163
+ } finally {
164
+ dialog.dispose();
144
165
  }
145
- return {
146
- content: [{ type: "text", text: resultText(choice, offer) }],
147
- details: { command: text, status: choice } satisfies HandDetails,
148
- // The next move is the user's; a follow-up from the agent would only talk over it.
149
- terminate: choice !== "cancel",
150
- };
151
166
  },
152
167
 
153
168
  renderCall(args, theme) {