pi-robot-hand 0.2.1 → 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 +47 -123
- package/package.json +1 -1
- package/src/dialog.ts +87 -0
- package/src/hand.ts +14 -0
- package/src/index.ts +45 -30
package/README.md
CHANGED
|
@@ -2,51 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
The lazy kit for [pi](https://pi.dev): too lazy to open an IDE, too lazy to copy and paste, not lazy enough to switch off the safety net.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Some things the agent cannot or should not do itself: `/reload`, a `sudo` command, an `ssh` login, a token that belongs in `.env`.
|
|
6
|
+
pi-robot-hand hands them to you in one keystroke and brings the result back to the agent:
|
|
6
7
|
|
|
7
|
-
- **`robot_hand`**: the agent hands you a command
|
|
8
|
-
- **`!` follow-up**: after a `!command` you type finishes, the agent reads its output and answers
|
|
9
|
-
- **`secret_drop`**: you type a secret into a masked dialog
|
|
10
|
-
|
|
11
|
-
You are the one who runs every command, so a command-guarding extension such as [cc-safety-net](https://github.com/kenryu42/cc-safety-net) keeps checking everything the agent itself runs.
|
|
12
|
-
|
|
13
|
-
## Install
|
|
8
|
+
- **`robot_hand`**: the agent hands you a command for your pi prompt, the clipboard, or a new Herdr pane. The agent never runs it.
|
|
9
|
+
- **`!` follow-up**: after a `!command` you type finishes, the agent reads its output and answers.
|
|
10
|
+
- **`secret_drop`**: you type a secret into a masked dialog. The agent never sees the value.
|
|
14
11
|
|
|
15
12
|
```bash
|
|
16
13
|
pi install npm:pi-robot-hand
|
|
17
14
|
```
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
### Upgrading from pi-secret-drop
|
|
22
|
-
|
|
23
|
-
`secret_drop` used to be the separate `pi-secret-drop` package. Both packages register the same tool, which pi refuses to load, so while `pi-secret-drop` is still in your settings pi-robot-hand leaves `secret_drop` to it and shows a warning with the exact command to remove it:
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
pi remove npm:pi-secret-drop@0.1.6 # the source as listed in your settings
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Protected destinations and their registry in `~/.pi/agent/secret-drop/` carry over unchanged.
|
|
30
|
-
|
|
31
|
-
## `robot_hand`: hand the user a command
|
|
16
|
+
Also on the [pi package gallery](https://pi.dev/packages/pi-robot-hand).
|
|
32
17
|
|
|
33
|
-
|
|
34
|
-
2. Pi shows an inline selector in place of the prompt, with the reason and exactly what you would run.
|
|
35
|
-
3. You pick one:
|
|
36
|
-
- **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 and the agent answers it.
|
|
37
|
-
- **Copy it to the clipboard**: the raw shell command, for another terminal.
|
|
38
|
-
- **Type it into a new Herdr pane (I press Enter)**: when pi runs inside [Herdr](https://herdr.dev), a pane opens below pi with the command on an editable line. Edit it if needed and press Enter, or Ctrl+C to cancel.
|
|
39
|
-
- **Run it in a new Herdr pane**: the same pane, with the command started at once.
|
|
40
|
-
- **No, thanks**: nothing is placed.
|
|
41
|
-
4. The tool returns the choice. After a hand-over the agent's turn ends, so the next move is yours.
|
|
18
|
+
## `robot_hand`
|
|
42
19
|
|
|
43
|
-
|
|
20
|
+
The agent calls `robot_hand` with a command and a one-line reason, and pi shows a selector:
|
|
44
21
|
|
|
45
22
|
```
|
|
46
|
-
|
|
47
|
-
! sudo systemctl restart nginx
|
|
48
|
-
──────────────────────────────────────────────
|
|
49
|
-
Robot Hand — 需要 sudo 重啟服務
|
|
23
|
+
Robot Hand — Restarting nginx needs sudo
|
|
50
24
|
|
|
51
25
|
! sudo systemctl restart nginx
|
|
52
26
|
|
|
@@ -55,115 +29,67 @@ A Herdr pane is a real terminal, so password prompts, `sudo`, confirmations, `ss
|
|
|
55
29
|
Copy it to the clipboard
|
|
56
30
|
Run it in a new Herdr pane
|
|
57
31
|
No, thanks
|
|
58
|
-
|
|
59
|
-
↑↓ navigate enter select escape/ctrl+c cancel
|
|
60
|
-
──────────────────────────────────────────────
|
|
61
32
|
```
|
|
62
33
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
| `reason` | One short line telling the user why, in the user's language. |
|
|
67
|
-
| `kind` | `shell` (default) or `pi`. |
|
|
68
|
-
| `default` | The option the selector starts on: `prompt` (default), `clipboard`, `herdr-type`, or `herdr-run`. The agent picks `herdr-type` for commands that need terminal input. An option this session cannot offer falls back along `herdr-run` → `herdr-type` → `clipboard` → `prompt`. |
|
|
69
|
-
|
|
70
|
-
When the prompt already holds a draft, the prompt option says it replaces the draft. The clipboard and Herdr options appear only for shell commands in the terminal UI, and the Herdr options only inside Herdr. RPC clients get the prompt option; modes without a UI make the tool fail so the agent shows the command in chat.
|
|
34
|
+
- **Prompt**: the command lands in your prompt as `! command`, or as-is for a pi command such as `/reload`. Press Enter to run it.
|
|
35
|
+
- **Clipboard**: the raw command, for another terminal.
|
|
36
|
+
- **Herdr pane**: when pi runs inside [Herdr](https://herdr.dev), a pane opens below pi with the command ready to edit and run, or already running. The pane is a real terminal, so password prompts, `y/N` confirmations, `ssh`, and full-screen programs work. When the command finishes, the pane closes and its output and exit code come back to the agent.
|
|
71
37
|
|
|
72
|
-
The
|
|
38
|
+
The agent sets `default` to choose which option the selector starts on, and picks the Herdr pane for commands that need terminal input.
|
|
39
|
+
Options this session cannot offer are hidden: the clipboard outside the terminal UI, Herdr outside Herdr.
|
|
40
|
+
The pane runs the command with your `PATH` but without your `.zshrc`, so shell aliases are not available.
|
|
73
41
|
|
|
74
42
|
## `!` follow-up
|
|
75
43
|
|
|
76
|
-
Pi records
|
|
44
|
+
Pi records a `!command`'s output but waits for your next message.
|
|
45
|
+
With pi-robot-hand, the agent answers as soon as the command finishes.
|
|
46
|
+
`!!` commands, cancelled commands, and commands typed while the agent is busy keep pi's behavior.
|
|
77
47
|
|
|
78
|
-
## `secret_drop
|
|
48
|
+
## `secret_drop`
|
|
79
49
|
|
|
80
|
-
1. The agent calls `secret_drop` with
|
|
81
|
-
2.
|
|
82
|
-
3.
|
|
83
|
-
|
|
84
|
-
```
|
|
85
|
-
! node ~/.pi/agent/npm/node_modules/pi-robot-hand/dist/apply.js env ./.env DB_PASSWORD --from ~/.pi/agent/secret-drop/staging/64770-948e1cb74b5a
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
4. The user reviews it and presses Enter. The apply script writes the destination, deletes the staged file, and prints only a report:
|
|
50
|
+
1. The agent calls `secret_drop` with where the secret goes and the steps to get it.
|
|
51
|
+
2. You type or paste the secret into a masked dialog, or press Tab to ask the agent a question instead.
|
|
52
|
+
3. Your prompt now holds a `!` command that applies it. Press Enter:
|
|
89
53
|
|
|
90
54
|
```
|
|
91
55
|
✓ secret-drop: updated DB_PASSWORD (1 line) in ./.env (env DB_PASSWORD, mode 644) — length check passed (28 chars)
|
|
92
56
|
```
|
|
93
57
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
```
|
|
99
|
-
╭─ Secret Drop ──────────────────────────────────────────────────────────────╮
|
|
100
|
-
│ Staging database password │
|
|
101
|
-
│ │
|
|
102
|
-
│ How to get it │
|
|
103
|
-
│ 1. Open the staging project in 1Password │
|
|
104
|
-
│ 2. Copy the password of the "db-staging" item │
|
|
105
|
-
│ │
|
|
106
|
-
│ Goes to ./.env (env DB_PASSWORD) │
|
|
107
|
-
│ Next Enter here stages the value. Your prompt then holds a ! command; │
|
|
108
|
-
│ press Enter on it to apply. The agent never sees the value. │
|
|
109
|
-
│ │
|
|
110
|
-
│ › •••••••••••••••••••••••••••• │
|
|
111
|
-
│ 28 chars │
|
|
112
|
-
│ │
|
|
113
|
-
│ Enter stage · Tab ask a question instead · Esc cancel · Ctrl+R show/hide · │
|
|
114
|
-
│ Ctrl+U clear │
|
|
115
|
-
│ Stuck or unsure? Press Tab and ask; the dialog closes and the agent │
|
|
116
|
-
│ answers. │
|
|
117
|
-
╰────────────────────────────────────────────────────────────────────────────╯
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
Tab opens a visible question field. Enter there closes the dialog without staging anything, discards what was typed in the secret field, and returns the question to the agent, which answers and calls `secret_drop` again. When the steps do not fit the terminal, the dialog shortens them; the tool call in the chat shows them in full.
|
|
58
|
+
The agent sees only that report. Afterwards it cannot read the destination, and the value is redacted from tool output.
|
|
59
|
+
It can set a `.env` key, replace a regex match, write a whole file, or feed the secret to a command such as `gh secret set`.
|
|
60
|
+
`/secret-drop` lists protected files.
|
|
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.
|
|
121
62
|
|
|
122
|
-
|
|
63
|
+
## Answering from another extension
|
|
123
64
|
|
|
124
|
-
The
|
|
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.
|
|
125
66
|
|
|
126
|
-
|
|
127
|
-
- **secret_drop gets new secrets in without the agent seeing them.** It protects the destinations it writes, including files outside cc-safety-net's patterns such as `config.yml`, and redacts the values from tool output.
|
|
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`.
|
|
128
68
|
|
|
129
|
-
|
|
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`. |
|
|
130
75
|
|
|
131
|
-
|
|
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`.
|
|
132
77
|
|
|
133
|
-
|
|
134
|
-
|---|---|
|
|
135
|
-
| `label` | What the user should enter, in the user's language. |
|
|
136
|
-
| `instructions` | Required. Numbered steps, in the user's language: where to go (URL or menu path), what to fill in, and what to copy. One step for a value the user already knows. |
|
|
137
|
-
| `url` | The page where the user creates or finds the secret. |
|
|
138
|
-
| `format` | `env`, `regex`, `file`, or `command`. Defaults to `env` when `key` is set, `command` when `command` is set, else `file`. |
|
|
139
|
-
| `destination` | `env` / `regex` / `file`: target file, relative to the working directory. Missing directories are created. |
|
|
140
|
-
| `key`, `quote` | `env`: variable name and quoting (`auto`, `none`, `single`, `double`). Existing `KEY=` / `export KEY=` lines are replaced, keeping trailing `# comments` and CRLF endings; otherwise the line is appended. Use `none` for files read by `docker --env-file`, which keeps quotes as part of the value. |
|
|
141
|
-
| `regex`, `flags` | `regex`: replaces capture group 1, or the whole match, in an existing file. `g` replaces every match. |
|
|
142
|
-
| `fileMode` | Octal permissions. New files default to `600`; existing files keep their mode. |
|
|
143
|
-
| `overwrite` | `file`: allow replacing an existing destination, for rotating single-value files such as keys. Without it an existing destination is refused; with it the dialog warns in red. |
|
|
144
|
-
| `command` | `command`: shell command reading the staged file `{secret}`, e.g. `ansible-vault encrypt_string --stdin-name db_password < {secret} >> vault.yml`. Stdout is discarded; stderr is shown with the value redacted when the command fails. |
|
|
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"`.
|
|
145
79
|
|
|
146
|
-
|
|
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.
|
|
147
81
|
|
|
148
|
-
|
|
82
|
+
## Safety
|
|
149
83
|
|
|
150
|
-
-
|
|
151
|
-
|
|
152
|
-
|
|
84
|
+
pi-robot-hand depends on no other extension.
|
|
85
|
+
You run every command it hands over, so command guards such as [cc-safety-net](https://github.com/kenryu42/cc-safety-net) keep checking everything the agent runs, and the two work side by side.
|
|
86
|
+
A guard that scans tool input may block `robot_hand` when a command names a sensitive path such as `.env`; the agent then writes the command in chat for you to run.
|
|
153
87
|
|
|
154
|
-
|
|
88
|
+
## Upgrading from pi-secret-drop
|
|
155
89
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
- The rename replaces the destination's inode: hard links to it break and the file becomes owned by the user who runs the apply command. Symlinks are resolved and kept.
|
|
160
|
-
- A multi-line quoted `KEY="…` value is replaced on its first line only.
|
|
161
|
-
- Values shorter than 4 characters are not redacted. Values applied with `format: "command"` are redacted for the rest of the session only.
|
|
162
|
-
- The apply command is shown before it runs; review it, since a `command` can send the staged value anywhere.
|
|
163
|
-
|
|
164
|
-
## Other safety extensions
|
|
165
|
-
|
|
166
|
-
pi-robot-hand depends on no other extension and runs alongside command guards such as cc-safety-net. A guard that scans every tool call's input may block `robot_hand` when the text of a command names a sensitive path such as `.env`, even though `robot_hand` only displays it. When that happens, the agent writes the command in chat and you run it yourself.
|
|
90
|
+
`secret_drop` used to be the separate `pi-secret-drop` package.
|
|
91
|
+
While it is still installed, pi-robot-hand leaves `secret_drop` to it and warns you to remove it with `pi remove npm:pi-secret-drop`.
|
|
92
|
+
Protected files carry over.
|
|
167
93
|
|
|
168
94
|
## Development
|
|
169
95
|
|
|
@@ -173,9 +99,7 @@ npm run check # tsc --noEmit
|
|
|
173
99
|
npm test # builds dist/, then node --test
|
|
174
100
|
```
|
|
175
101
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
Releases publish from GitHub Actions through npm trusted publishing: bump `version` in `package.json`, commit, and push a matching `v<version>` tag.
|
|
102
|
+
Push a `v<version>` tag matching `package.json` to publish through GitHub Actions.
|
|
179
103
|
|
|
180
104
|
## License
|
|
181
105
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-robot-hand",
|
|
3
|
-
"version": "0.
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
121
|
-
pi.events
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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) {
|