pi-robot-hand 0.3.1 → 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 +3 -1
- package/package.json +2 -2
- package/skills/secret-drop/SKILL.md +3 -3
- package/src/dialog.ts +7 -1
- package/src/hand.ts +14 -3
- package/src/herdr.ts +7 -2
- package/src/index.ts +1 -1
- package/src/secret-drop/index.ts +1 -1
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
|
+
"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": {
|
|
@@ -10,8 +10,8 @@ Secrets reach files and commands through the `secret_drop` tool, so the value ne
|
|
|
10
10
|
## Writing a secret
|
|
11
11
|
|
|
12
12
|
1. Pick the destination the consuming program expects. For a `regex` placement in a file that is not yet protected, read its structure first.
|
|
13
|
-
2. When the value is created
|
|
14
|
-
3. Call `secret_drop` with a `label` naming exactly what to enter (service, account, environment), `instructions` the user can follow without asking (the exact page or menu path, every field to fill in such as name, scope, permissions, and expiration, and what to copy), the page as `url` when there is one, both in the user's language, and one format:
|
|
13
|
+
2. When the value is newly created on a web page and a browser session on the user's signed-in profile is already open to you, ask the user with your question tool whether you should prepare that page; starting a browser only to qualify does not count. When they agree, open it, fill in every field (name, scope, permissions, expiration), and stop at the button that creates the value. From then on make no browser tool call in that session, whatever `secret_drop` returns, until the user has closed that tab: browser tools return page content, and the value is on that page once the button is pressed. Learn that the tab is closed from the user, through your question tool, rather than from a browser call. A page that shows an existing secret already holds the value in its DOM, so give its `url` and steps instead of opening it. When the user declines or no such session is open, write the full steps.
|
|
14
|
+
3. Call `secret_drop` with a `label` naming exactly what to enter (service, account, environment), `instructions` the user can follow without asking (the exact page or menu path, every field to fill in such as name, scope, permissions, and expiration, and what to copy; for a prepared page, the tab, the button to press, what to copy, and closing the tab afterwards), the page as `url` when there is one, both in the user's language, and one format:
|
|
15
15
|
- `key` + `destination` (format `env`): sets `KEY=value`, replacing existing `KEY` / `export KEY` lines or appending one. Other lines and trailing comments stay. For a file read by `docker --env-file`, pass `quote: "none"`, since Docker keeps quotes as part of the value.
|
|
16
16
|
- `format: "regex"` + `destination` + `regex`: replaces capture group 1, or the whole match, in an existing file. Make the group cover only the value, e.g. `password: "([^"]*)"`.
|
|
17
17
|
- `format: "file"` + `destination`: the whole file is the secret, for key files and single-value files. An existing destination is refused unless `overwrite: true` is passed; pass it only to rotate a single-value file, never for a multi-value file such as `.env`.
|
|
@@ -22,7 +22,7 @@ Secrets reach files and commands through the `secret_drop` tool, so the value ne
|
|
|
22
22
|
4. Call it once per secret and wait for the result:
|
|
23
23
|
- **Applied**: the report states the change and `length check passed (N chars)`. That report is the verification; the value is in place.
|
|
24
24
|
- **Question**: the user pressed Tab and asked instead; nothing was staged. Answer it, then call `secret_drop` again.
|
|
25
|
-
- **Cancelled or aborted**: nothing was applied. Ask the user how to proceed.
|
|
25
|
+
- **Cancelled or aborted**: nothing was applied. Ask the user how to proceed; have the user revoke a token created on a prepared page that was not applied.
|
|
26
26
|
- **Apply failed**: the staged value was discarded. Fix the parameters from the error (a regex that does not match, a failing command) and call again.
|
|
27
27
|
|
|
28
28
|
When the user offers to paste a secret into chat, call `secret_drop` instead.
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
/**
|
|
79
|
-
|
|
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
|
}
|
package/src/secret-drop/index.ts
CHANGED
|
@@ -197,7 +197,7 @@ export default function secretDrop(pi: ExtensionAPI) {
|
|
|
197
197
|
"Use secret_drop whenever a password, token, key, or other credential must land in a file or be fed to a command; never ask the user to paste secrets into chat.",
|
|
198
198
|
"Files that hold secrets are consumed by programs, never read or printed by the agent.",
|
|
199
199
|
"Give secret_drop instructions the user can follow without asking: the exact page or menu, every field to fill in, and what to copy; write them and the label in the user's language.",
|
|
200
|
-
"When the secret is created
|
|
200
|
+
"When the secret is newly created on a web page and a browser session on the user's signed-in profile is already open to you, ask with your question tool whether to prepare that page up to its final create button, so the user only presses it and copies the value; then make no browser call in that session until the user tells you, when you ask, that the tab is closed, whatever secret_drop returns. Give the URL and steps instead of opening a page that shows an existing secret, and full steps when the user declines.",
|
|
201
201
|
"When secret_drop returns a question from the user, answer it, then call secret_drop again.",
|
|
202
202
|
],
|
|
203
203
|
parameters: Params,
|