pi-robot-hand 0.3.2 → 0.4.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
@@ -68,7 +68,7 @@ The contract is "dialog contract v1", shared by every extension that owns a dial
68
68
 
69
69
  | Event | Direction | Payload |
70
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. |
71
+ | `robot-hand:pending` | owner → bus | `{ version, id, toolCallId, digest, select: { title, choices: [{ label, note?, localOnly?, next?, tagged? }], copy } }`. `id` is a random UUID for this dialog. `note` says where a choice acts; `localOnly` marks the clipboard, which is this computer's; `next` says which terminal opens once the choice is accepted (`"terminal"` for the prompt, `"pane"` for the two Herdr pane choices); `tagged: true` on a pane choice means the pane is marked as described below. `next` and `tagged` are hints and are not hashed. `copy` is the command, for a page that offers a copy button. |
72
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
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
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`. |
@@ -77,6 +77,8 @@ The digest is the lowercase hex SHA-256 of `JSON.stringify(["pi-dialog/1", "sele
77
77
 
78
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
79
 
80
+ When an accepted answer picks one of the two Herdr pane choices, `robot_hand` marks the pane it just opened so the answerer can tell which pane is the owner's: right after `herdr pane split` and before anything is typed or run there, it runs `herdr pane report-metadata <pane_id> --source robot-hand --token sb_answer=<answerId> --ttl-ms 600000`. The value is exactly the answer's `answerId`. [Straw Boss](https://github.com/wayne930242/pi-straw-boss) grants its terminal keys only to the pane that carries it. The tag is set once, with no retry; if it fails, the command still runs and the answerer grants no pane. A pick in the terminal, and answers for the other choices, tag nothing.
81
+
80
82
  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
83
 
82
84
  ## Safety
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-robot-hand",
3
- "version": "0.3.2",
3
+ "version": "0.4.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",
@@ -43,7 +43,7 @@
43
43
  "scripts": {
44
44
  "build": "tsc -p tsconfig.build.json",
45
45
  "check": "tsc --noEmit",
46
- "test": "npm run build && node --test tests/*.test.ts",
46
+ "test": "npm run build && node --test --test-force-exit tests/*.test.ts",
47
47
  "prepack": "npm run build"
48
48
  },
49
49
  "peerDependencies": {
package/src/dialog.ts CHANGED
@@ -7,7 +7,9 @@ export interface Bus {
7
7
  emit(channel: string, data: unknown): void;
8
8
  on(channel: string, handler: (data: unknown) => void): () => void;
9
9
  }
10
- export interface BusChoice { label: string; note?: string; localOnly?: true }
10
+ // `next` and `tagged` are hints, not hashed: `next` says which terminal opens once the choice is accepted, `tagged` that the
11
+ // owner marks the pane it opened with the answer's own `answerId` (token `sb_answer`).
12
+ export interface BusChoice { label: string; note?: string; localOnly?: true; next?: "terminal" | "pane"; tagged?: true }
11
13
  type By = "local" | "remote" | "cancel" | "timeout" | "abort" | "error";
12
14
  type Reason = "closed" | "digest" | "malformed" | "not-allowed";
13
15
  const PREFIX = "robot-hand";
@@ -42,6 +44,7 @@ export function openDialog(bus: Bus, o: { toolCallId: string; title: string; cho
42
44
  const local = new AbortController();
43
45
  let by: By | undefined;
44
46
  let winner: number | undefined;
47
+ let winnerAnswerId: string | undefined;
45
48
  // The flag is written before anything is closed or emitted, so the other claimant sees "closed".
46
49
  const settle = (how: By, answerId?: string, between?: () => void): void => {
47
50
  if (by) return;
@@ -60,6 +63,7 @@ export function openDialog(bus: Bus, o: { toolCallId: string; title: string; cho
60
63
  if (typeof verdict === "string") return result(false, verdict);
61
64
  settle("remote", answerId, () => {
62
65
  winner = verdict;
66
+ winnerAnswerId = answerId;
63
67
  local.abort();
64
68
  result(true);
65
69
  });
@@ -74,6 +78,8 @@ export function openDialog(bus: Bus, o: { toolCallId: string; title: string; cho
74
78
  signal: AbortSignal.any(o.signal ? [o.signal, local.signal] : [local.signal]),
75
79
  get open() { return by === undefined; },
76
80
  get remote() { return by === "remote"; },
81
+ // The accepted answer's id, once a remote answer has won.
82
+ get answerId() { return by === "remote" ? winnerAnswerId : undefined; },
77
83
  finish(picked: number | undefined): number | undefined {
78
84
  settle(picked === undefined ? "cancel" : "local");
79
85
  return by === "local" ? picked : winner;
package/src/hand.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /** What the agent hands over, and how each of the user's choices turns into editor or clipboard text. */
2
2
 
3
+ import type { BusChoice } from "./dialog.ts";
4
+
3
5
  export type Kind = "shell" | "pi";
4
6
  export type Preferred = "prompt" | "clipboard" | "herdr-type" | "herdr-run";
5
7
  export type Choice = Preferred | "cancel";
@@ -77,9 +79,18 @@ const NOTES: Record<Choice, string | undefined> = {
77
79
  cancel: undefined,
78
80
  };
79
81
 
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 } : {}) }));
82
+ // Which terminal opens once an answer is accepted. A pane choice also marks its pane with the answer's id (see startRun).
83
+ const NEXT: Record<Choice, { next: "terminal" } | { next: "pane"; tagged: true } | undefined> = {
84
+ prompt: { next: "terminal" },
85
+ clipboard: undefined,
86
+ "herdr-type": { next: "pane", tagged: true },
87
+ "herdr-run": { next: "pane", tagged: true },
88
+ cancel: undefined,
89
+ };
90
+
91
+ /** The choices as published on the dialog bus, each with its note and where it leads. */
92
+ export function busChoices(list: Option[]): BusChoice[] {
93
+ return list.map(({ choice, label }) => ({ label, ...(NOTES[choice] ? { note: NOTES[choice] } : {}), ...(choice === "clipboard" ? { localOnly: true as const } : {}), ...NEXT[choice] }));
83
94
  }
84
95
 
85
96
  /** The selector title: why the agent hands this over, then exactly what the user would run. */
package/src/herdr.ts CHANGED
@@ -75,8 +75,11 @@ export function sliceOutput(screen: string, token: string): string {
75
75
  return cut ? `[earlier output omitted]\n${text}` : text;
76
76
  }
77
77
 
78
- /** Opens the pane below pi, focuses it, and starts the wrapper. */
79
- export async function startRun(exec: Exec, opts: { piPane: string; cwd: string; tmp: string; command: string; type: boolean; shell: "zsh" | "bash" }): Promise<HerdrRun> {
78
+ /**
79
+ * Opens the pane below pi, focuses it, and starts the wrapper. With an `answerId` (an accepted answer from another
80
+ * extension), the new pane is marked `sb_answer=<answerId>` first, so Straw Boss can tell it is the pane this answer opened.
81
+ */
82
+ export async function startRun(exec: Exec, opts: { piPane: string; cwd: string; tmp: string; command: string; type: boolean; shell: "zsh" | "bash"; answerId?: string }): Promise<HerdrRun> {
80
83
  const token = randomBytes(4).toString("hex");
81
84
  const dir = join(opts.tmp, "pi-robot-hand", token);
82
85
  await mkdir(dir, { recursive: true, mode: 0o700 });
@@ -91,6 +94,8 @@ export async function startRun(exec: Exec, opts: { piPane: string; cwd: string;
91
94
  await rm(dir, { recursive: true, force: true });
92
95
  throw new Error(`herdr could not open a pane: ${split.stdout.trim() || `exit ${split.code}`}`);
93
96
  }
97
+ // Best effort and once: if the tag fails Straw Boss grants no pane, and the command still runs.
98
+ if (opts.answerId) await exec(["pane", "report-metadata", paneId, "--source", "robot-hand", "--token", `sb_answer=${opts.answerId}`, "--ttl-ms", "600000"]).catch(() => undefined);
94
99
  await exec(["pane", "focus", "--pane", opts.piPane, "--direction", "down"]);
95
100
  const run = await exec(["pane", "run", paneId, `${opts.shell} ${shellQuote(script)}`]);
96
101
  if (run.code !== 0) {
package/src/index.ts CHANGED
@@ -150,7 +150,7 @@ export function createRobotHand(copy: (text: string) => Promise<void> = copyToCl
150
150
  await copy(offer.command);
151
151
  ctx.ui.notify("Command copied to the clipboard.", "info");
152
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) });
153
+ const run = await startRun(exec, { piPane, cwd: ctx.cwd, tmp, command: offer.command, type: choice === "herdr-type", shell: findShell(env.PATH), answerId: dialog.answerId });
154
154
  runs.set(run.token, { run, ticks: 0, busy: false });
155
155
  timer ??= setInterval(watch, WATCH_MS);
156
156
  }