@zeroclover/pi-rtk 0.0.0-stage → 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/CHANGELOG.md +11 -0
- package/LICENSE +661 -0
- package/README.md +99 -2
- package/package.json +34 -3
- package/src/adapters.ts +67 -0
- package/src/command.ts +58 -0
- package/src/config.ts +107 -0
- package/src/controller.ts +213 -0
- package/src/index.ts +67 -0
- package/src/outcome.ts +63 -0
- package/src/run.ts +63 -0
package/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-rtk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Routes the shell commands Pi runs through [RTK](https://github.com/rtk-ai/rtk), so that `git status`, `cargo test`, `rg`, `ls` and the other commands RTK knows return compact, token-efficient output. Before each command runs, pi-rtk asks `rtk rewrite` for RTK's equivalent and substitutes it (`git status` → `rtk git status`). RTK's rule set decides what is rewritten; pi-rtk decides where and when it is safe.
|
|
4
|
+
|
|
5
|
+
pi-rtk replaces the single-file hook that `rtk init --agent pi` installs. It covers Pi's `bash` tool and the `exec_command` tool of [pi-unified-exec](https://www.npmjs.com/package/pi-unified-exec) and compatible exec plugins, including calls made from codemode scripts. Other exec tools can be added in a configuration file. pi-rtk also:
|
|
6
|
+
|
|
7
|
+
- blocks commands that RTK denies with a reason, and never runs a deny message as a command;
|
|
8
|
+
- never rewrites PTY sessions or commands for a non-POSIX shell;
|
|
9
|
+
- leaves the command unchanged whenever rtk is missing, slow, or failing;
|
|
10
|
+
- stops calling a broken rtk for the rest of the session.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Releases are published to npm as `@zeroclover/pi-rtk`, versioned independently of the other packages in this repository. Each release is tagged `@zeroclover/pi-rtk@<version>`, and its notes are the matching section of the package's `CHANGELOG.md`.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pi install npm:@zeroclover/pi-rtk # install a published version for the user
|
|
18
|
+
pi -e ./packages/pi-rtk # try a repository checkout for one session
|
|
19
|
+
pi install /absolute/path/to/packages/pi-rtk # install a repository checkout for the user
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires `rtk` 0.25.0 or newer on `PATH`. Without it, pi-rtk warns once and commands run unchanged.
|
|
23
|
+
|
|
24
|
+
**Coming from `rtk init --agent pi`:** remove `~/.pi/agent/extensions/rtk.ts`, or run `rtk init -g --agent pi --uninstall`. Keeping both does no harm, because pi-rtk skips commands that already start with `rtk`, but the old hook only sees `bash`. The model-facing RTK instructions (`RTK.md`) from `rtk init` are separate, and pi-rtk does not change them.
|
|
25
|
+
|
|
26
|
+
## Provides
|
|
27
|
+
|
|
28
|
+
| Kind | Name | Description |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| event | `tool_call` | Rewrites the command of every tool that has an adapter, whether the model or a codemode script calls it. |
|
|
31
|
+
| command | `/rtk` | Shows the state, the rtk version, every adapter (marked `[not registered]` when no such tool is loaded), the session counts of rewritten, unchanged, skipped, blocked, and failed calls, and the last failure. |
|
|
32
|
+
| command | `/rtk off` / `/rtk on` | Stops or resumes rewriting for this session. `on` also ends a suspension and checks rtk again. Every new session starts on. |
|
|
33
|
+
| status | `rtk` | `RTK off` (`/rtk off` or `RTK_DISABLED=1`), else `RTK unavailable`, else `RTK suspended` while commands are not being rewritten; empty otherwise. |
|
|
34
|
+
|
|
35
|
+
Any other argument to `/rtk` prints the usage.
|
|
36
|
+
|
|
37
|
+
## How a command is handled
|
|
38
|
+
|
|
39
|
+
1. The tool has no adapter, rewriting is off (`/rtk off`, `RTK_DISABLED=1`), or rewriting is suspended: the command runs unchanged.
|
|
40
|
+
2. Skip rules leave the command unchanged without running rtk. They apply when:
|
|
41
|
+
- the command is blank or already starts with `rtk`;
|
|
42
|
+
- the call asks for a PTY (`tty: true`), because RTK filters output, which breaks interactive programs;
|
|
43
|
+
- the `shell` is not bash, sh, zsh, dash, or ksh (for example `pwsh`, `cmd`, `fish`, `nu`), is `false`, or is another non-string value (`true` means the tool's default shell, like an omitted one);
|
|
44
|
+
- on Windows, the tool has a shell field but the call names no shell, because the tool's default there can be PowerShell (pi-unified-exec without Git Bash);
|
|
45
|
+
- the working directory does not exist.
|
|
46
|
+
3. `rtk rewrite <command>` runs without a shell, in the call's working directory, with a 2 s timeout. The command is passed as a single argument after `--`, so one that starts with `-` is never taken for an rtk option.
|
|
47
|
+
|
|
48
|
+
| rtk result | Effect |
|
|
49
|
+
|---|---|
|
|
50
|
+
| exit 0 or 3 with a different command | The tool runs the rewrite. Exit 3 only defers permission to the host, and Pi has no permission rules to apply. |
|
|
51
|
+
| exit 0 or 3 with empty or identical output, or exit 1 | The command runs unchanged (no RTK equivalent). |
|
|
52
|
+
| exit 2 with a message | The call is blocked with `rtk: <message>`. |
|
|
53
|
+
| exit 2 without a message | The command runs unchanged. RTK defers to the host's own deny rules. |
|
|
54
|
+
| timeout, crash, signal, start failure, other exit code | The command runs unchanged and the call counts as a failure. |
|
|
55
|
+
|
|
56
|
+
A cancelled call (Escape) stops rtk and is not counted as a failure.
|
|
57
|
+
|
|
58
|
+
## When rtk is missing or failing
|
|
59
|
+
|
|
60
|
+
- At session start pi-rtk runs `rtk --version` (unless `RTK_DISABLED=1`). If rtk is missing, too old, or fails, it warns once, shows `RTK unavailable`, and runs commands unchanged at no cost. The next call with an adapter, 30 s or more after the last check, checks again, so installing rtk during a session is picked up without `/rtk on`.
|
|
61
|
+
- Three consecutive failures suspend rewriting for the session. pi-rtk warns once with the last failure and shows `RTK suspended`. This keeps a hung rtk from adding 2 s to every command. Any normal answer from rtk resets the count. `/rtk on` or a new session ends the suspension.
|
|
62
|
+
- An internal error in pi-rtk never blocks a command. The command runs unchanged and a warning is shown.
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
`<agent-dir>/pi-rtk.json` (by default `~/.pi/agent/pi-rtk.json`) is read at session start, and changes apply after `/reload` or in a new session. Without the file, pi-rtk uses its built-in adapters:
|
|
67
|
+
|
|
68
|
+
| Tool | Command field | Shell field | PTY field | Working-directory field |
|
|
69
|
+
|---|---|---|---|---|
|
|
70
|
+
| `bash` (Pi, and plugins that replace it under the same name) | `command` | – | – | – |
|
|
71
|
+
| `exec_command` (pi-unified-exec, @chensl/pi-unified-exec, pi-exec-command, @howaboua/pi-codex-conversion) | `cmd` | `shell` | `tty` | `workdir` |
|
|
72
|
+
|
|
73
|
+
The `tools` object adds an adapter for another tool, replaces a built-in one, or removes one with `false`:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"tools": {
|
|
78
|
+
"bg_run": { "commandField": "command" },
|
|
79
|
+
"bash_bg": { "commandField": "command" },
|
|
80
|
+
"bash": false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Adapter key | Required | Meaning |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `commandField` | yes | Input field that holds the shell command. A call without a string in this field is left alone. |
|
|
88
|
+
| `shellField` | no | Input field that names the shell. Only bash, sh, zsh, dash, and ksh are rewritten; `false` (no shell), and on Windows a call that names no shell, are never rewritten. |
|
|
89
|
+
| `ttyField` | no | Boolean input field; `true` (a PTY session) is never rewritten. |
|
|
90
|
+
| `cwdField` | no | Input field with the working directory, resolved against the session directory. |
|
|
91
|
+
|
|
92
|
+
`bg_run` is the tool of pi-background-tasks and `bash_bg` the tool of pi-bg-shell; both run `command` through a POSIX shell without a PTY. Use `"bash": false` when your `bash` tool is not a POSIX shell (for example @code-fixer-23/pi-bash-tooljack-nu, which runs Nushell). Do not add tools that send keystrokes to a running process (`write_stdin`, `terminal_send`), tools whose every call runs in a PTY (pi-smart-terminal's `terminal_exec`), or tools that run commands remotely: an adapter cannot mark them, so their commands would be rewritten. Each problem produces one warning at session start. An entry with an unknown key or an invalid field is ignored as a whole, so a typo can never drop the shell or PTY guard of a built-in adapter. Invalid JSON means the built-in adapters.
|
|
93
|
+
|
|
94
|
+
`RTK_DISABLED=1` in Pi's environment turns rewriting off; this is RTK's own switch. pi-rtk then never runs rtk, not even `rtk --version`, and `/rtk` reports `off (RTK_DISABLED=1)`.
|
|
95
|
+
|
|
96
|
+
## Not covered
|
|
97
|
+
|
|
98
|
+
- Your own `!` and `!!` commands run exactly as typed. Rewriting them would require taking over Pi's `user_bash` execution, and that would bypass sandbox and remote-shell extensions.
|
|
99
|
+
- Permission extensions also handle `tool_call`, in extension load order. One that runs after pi-rtk sees the rewritten command (`rtk git push`), so a rule anchored at the original program (`^git push`) does not match it. Load pi-rtk after such extensions, or write rules that accept an `rtk ` prefix.
|
|
100
|
+
- Output compaction and token statistics belong to RTK. `rtk gain` shows the savings, and `rtk recall <hash>` restores elided output.
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroclover/pi-rtk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Routes shell commands from Pi's bash tool and third-party exec tools through RTK for compact, token-efficient output",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ZeroClover/pi-extensions.git",
|
|
9
|
+
"directory": "packages/pi-rtk"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ZeroClover/pi-extensions/tree/master/packages/pi-rtk#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ZeroClover/pi-extensions/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"rtk"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"CHANGELOG.md"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./src/index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
36
|
+
}
|
|
6
37
|
}
|
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
/** Where a shell-running tool keeps its command and the inputs that decide whether RTK may rewrite it. */
|
|
4
|
+
export interface ToolAdapter {
|
|
5
|
+
/** Input field holding the shell command string. */
|
|
6
|
+
commandField: string;
|
|
7
|
+
/** Input field naming the shell binary, or `false` for no shell; other than a POSIX shell is never rewritten. */
|
|
8
|
+
shellField?: string;
|
|
9
|
+
/** Boolean input field requesting a PTY; interactive sessions are never rewritten. */
|
|
10
|
+
ttyField?: string;
|
|
11
|
+
/** Input field holding the working directory, resolved against the session cwd. */
|
|
12
|
+
cwdField?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pi's built-in `bash` tool and pi-unified-exec's `exec_command`. Tools whose input does not match
|
|
17
|
+
* an adapter (for example another extension's `exec_command` with different fields) are left alone.
|
|
18
|
+
*/
|
|
19
|
+
export const BUILTIN_ADAPTERS: Readonly<Record<string, ToolAdapter>> = {
|
|
20
|
+
bash: { commandField: "command" },
|
|
21
|
+
exec_command: { commandField: "cmd", shellField: "shell", ttyField: "tty", cwdField: "workdir" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Shells whose `-c` syntax RTK's rewrites are written for. */
|
|
25
|
+
const POSIX_SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
26
|
+
|
|
27
|
+
export type SkipReason = "no-command" | "already-rtk" | "non-posix-shell" | "tty";
|
|
28
|
+
|
|
29
|
+
export type Target = { kind: "target"; command: string; cwd: string } | { kind: "skip"; reason: SkipReason };
|
|
30
|
+
|
|
31
|
+
function shellName(shell: string): string {
|
|
32
|
+
const base = shell.split(/[\\/]/).pop() ?? shell;
|
|
33
|
+
return base.replace(/\.exe$/i, "").toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Decides from a tool call's input whether its command may go through `rtk rewrite`, and in which directory. */
|
|
37
|
+
export function resolveTarget(
|
|
38
|
+
adapter: ToolAdapter,
|
|
39
|
+
input: Record<string, unknown>,
|
|
40
|
+
sessionCwd: string,
|
|
41
|
+
platform: NodeJS.Platform = process.platform,
|
|
42
|
+
): Target {
|
|
43
|
+
const command = input[adapter.commandField];
|
|
44
|
+
if (typeof command !== "string" || command.trim() === "") return { kind: "skip", reason: "no-command" };
|
|
45
|
+
if (/^\s*rtk(\s|$)/.test(command)) return { kind: "skip", reason: "already-rtk" };
|
|
46
|
+
if (adapter.ttyField !== undefined && input[adapter.ttyField] === true) return { kind: "skip", reason: "tty" };
|
|
47
|
+
if (adapter.shellField !== undefined) {
|
|
48
|
+
const shell = input[adapter.shellField];
|
|
49
|
+
// `shell: false` means the tool runs the command without a shell, where RTK's rewrites do not parse.
|
|
50
|
+
if (shell === false) return { kind: "skip", reason: "non-posix-shell" };
|
|
51
|
+
// `true` asks for the tool's default shell, like an omitted field; any other non-string value is unknown.
|
|
52
|
+
if (shell !== undefined && shell !== true && typeof shell !== "string")
|
|
53
|
+
return { kind: "skip", reason: "non-posix-shell" };
|
|
54
|
+
if (typeof shell === "string" && shell.trim() !== "") {
|
|
55
|
+
if (!POSIX_SHELLS.has(shellName(shell.trim()))) return { kind: "skip", reason: "non-posix-shell" };
|
|
56
|
+
} else if (platform === "win32") {
|
|
57
|
+
// Exec tools choose their own default shell on Windows, which can be PowerShell (pi-unified-exec without Git Bash).
|
|
58
|
+
return { kind: "skip", reason: "non-posix-shell" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let cwd = sessionCwd;
|
|
62
|
+
if (adapter.cwdField !== undefined) {
|
|
63
|
+
const dir = input[adapter.cwdField];
|
|
64
|
+
if (typeof dir === "string" && dir.trim() !== "") cwd = isAbsolute(dir) ? dir : resolve(sessionCwd, dir);
|
|
65
|
+
}
|
|
66
|
+
return { kind: "target", command, cwd };
|
|
67
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ToolAdapter } from "./adapters.ts";
|
|
2
|
+
import type { RtkController } from "./controller.ts";
|
|
3
|
+
|
|
4
|
+
export const RTK_USAGE = "Usage: /rtk [on|off] (no argument shows the state)";
|
|
5
|
+
|
|
6
|
+
export type RtkAction = { kind: "status" } | { kind: "on" } | { kind: "off" } | { kind: "usage" };
|
|
7
|
+
|
|
8
|
+
export function parseRtkArgs(args: string): RtkAction {
|
|
9
|
+
switch (args.trim().toLowerCase()) {
|
|
10
|
+
case "":
|
|
11
|
+
return { kind: "status" };
|
|
12
|
+
case "on":
|
|
13
|
+
return { kind: "on" };
|
|
14
|
+
case "off":
|
|
15
|
+
return { kind: "off" };
|
|
16
|
+
default:
|
|
17
|
+
return { kind: "usage" };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What `/rtk on` reports after the restart; undefined when the availability check has already warned. */
|
|
22
|
+
export function onNotice(controller: RtkController): { message: string; level: "info" | "warning" } | undefined {
|
|
23
|
+
if (controller.disabledByEnv) {
|
|
24
|
+
return {
|
|
25
|
+
message: "pi-rtk: on for this session, but RTK_DISABLED=1 in Pi's environment keeps rewriting off",
|
|
26
|
+
level: "warning",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (!controller.availability.ok) return undefined;
|
|
30
|
+
return { message: "pi-rtk: on for this session", level: "info" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function describeAdapter(name: string, adapter: ToolAdapter, registered: ReadonlySet<string>): string {
|
|
34
|
+
const fields = [`command: ${adapter.commandField}`];
|
|
35
|
+
if (adapter.shellField !== undefined) fields.push(`shell: ${adapter.shellField}`);
|
|
36
|
+
if (adapter.ttyField !== undefined) fields.push(`tty: ${adapter.ttyField}`);
|
|
37
|
+
if (adapter.cwdField !== undefined) fields.push(`cwd: ${adapter.cwdField}`);
|
|
38
|
+
return `${name} (${fields.join(", ")})${registered.has(name) ? "" : " [not registered]"}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The `/rtk` status report; `registered` holds the names of the tools registered in this session. */
|
|
42
|
+
export function formatStatus(controller: RtkController, registered: ReadonlySet<string>): string {
|
|
43
|
+
const { availability, counts } = controller;
|
|
44
|
+
let state = "on";
|
|
45
|
+
if (controller.disabledByEnv) state = "off (RTK_DISABLED=1)";
|
|
46
|
+
else if (!controller.enabled) state = "off (/rtk off)";
|
|
47
|
+
else if (!availability.ok) state = "unavailable";
|
|
48
|
+
else if (controller.suspended !== undefined) state = `suspended: ${controller.suspended}`;
|
|
49
|
+
const adapters = [...controller.adapters].map(([name, adapter]) => describeAdapter(name, adapter, registered));
|
|
50
|
+
const lines = [
|
|
51
|
+
`state: ${state}`,
|
|
52
|
+
`rtk: ${availability.ok ? availability.version : controller.disabledByEnv ? "not checked (RTK_DISABLED=1)" : availability.reason}`,
|
|
53
|
+
`tools: ${adapters.length === 0 ? "none" : adapters.join("; ")}`,
|
|
54
|
+
`this session: ${counts.rewritten} rewritten, ${counts.unchanged} unchanged, ${counts.skipped} skipped, ${counts.blocked} blocked, ${counts.failed} failed`,
|
|
55
|
+
];
|
|
56
|
+
if (controller.lastFailure !== undefined) lines.push(`last failure: ${controller.lastFailure}`);
|
|
57
|
+
return ["pi-rtk", ...lines.map((line) => ` ${line}`)].join("\n");
|
|
58
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { BUILTIN_ADAPTERS, type ToolAdapter } from "./adapters.ts";
|
|
3
|
+
|
|
4
|
+
export const CONFIG_FILE_NAME = "pi-rtk.json";
|
|
5
|
+
|
|
6
|
+
const ADAPTER_KEYS = ["commandField", "shellField", "ttyField", "cwdField"] as const;
|
|
7
|
+
|
|
8
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function errorMessage(error: unknown): string {
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseAdapter(
|
|
17
|
+
value: Record<string, unknown>,
|
|
18
|
+
path: string,
|
|
19
|
+
warn: (message: string) => void,
|
|
20
|
+
): ToolAdapter | undefined {
|
|
21
|
+
// All or nothing: a partly valid adapter could lose its shell or PTY guard and rewrite what it must not.
|
|
22
|
+
let valid = true;
|
|
23
|
+
for (const key of Object.keys(value)) {
|
|
24
|
+
if ((ADAPTER_KEYS as readonly string[]).includes(key)) continue;
|
|
25
|
+
warn(`${path}.${key}: unknown setting; entry ignored`);
|
|
26
|
+
valid = false;
|
|
27
|
+
}
|
|
28
|
+
const fields: Partial<Record<(typeof ADAPTER_KEYS)[number], string>> = {};
|
|
29
|
+
for (const key of ADAPTER_KEYS) {
|
|
30
|
+
const field = value[key];
|
|
31
|
+
if (field === undefined) continue;
|
|
32
|
+
if (typeof field === "string" && field !== "") {
|
|
33
|
+
fields[key] = field;
|
|
34
|
+
} else {
|
|
35
|
+
warn(`${path}.${key}: expected a non-empty input field name; entry ignored`);
|
|
36
|
+
valid = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const { commandField, ...rest } = fields;
|
|
40
|
+
if (!("commandField" in value)) {
|
|
41
|
+
warn(`${path}: commandField is required; entry ignored`);
|
|
42
|
+
valid = false;
|
|
43
|
+
}
|
|
44
|
+
return valid && commandField !== undefined ? { commandField, ...rest } : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parses the configuration file content into the adapter table: the built-in adapters, with each `tools` entry
|
|
49
|
+
* adding a tool, replacing a built-in adapter, or (`false`) removing one. Every problem produces one warning and
|
|
50
|
+
* leaves the affected entry at its built-in state. `text` is undefined when the file does not exist.
|
|
51
|
+
*/
|
|
52
|
+
export function parseConfig(
|
|
53
|
+
text: string | undefined,
|
|
54
|
+
source: string,
|
|
55
|
+
): { adapters: Map<string, ToolAdapter>; warnings: string[] } {
|
|
56
|
+
const adapters = new Map(Object.entries(BUILTIN_ADAPTERS));
|
|
57
|
+
const warnings: string[] = [];
|
|
58
|
+
const warn = (message: string): void => {
|
|
59
|
+
warnings.push(`pi-rtk: ${source}: ${message}`);
|
|
60
|
+
};
|
|
61
|
+
if (text === undefined) return { adapters, warnings };
|
|
62
|
+
let json: unknown;
|
|
63
|
+
try {
|
|
64
|
+
json = JSON.parse(text);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
warn(`invalid JSON (${errorMessage(error)}); using the built-in tools`);
|
|
67
|
+
return { adapters, warnings };
|
|
68
|
+
}
|
|
69
|
+
if (!isRecord(json)) {
|
|
70
|
+
warn("expected a JSON object; using the built-in tools");
|
|
71
|
+
return { adapters, warnings };
|
|
72
|
+
}
|
|
73
|
+
for (const key of Object.keys(json)) if (key !== "tools") warn(`${key}: unknown setting ignored`);
|
|
74
|
+
|
|
75
|
+
const { tools } = json;
|
|
76
|
+
if (tools === undefined) return { adapters, warnings };
|
|
77
|
+
if (!isRecord(tools)) {
|
|
78
|
+
warn("tools: expected an object mapping tool names to adapters or false; using the built-in tools");
|
|
79
|
+
return { adapters, warnings };
|
|
80
|
+
}
|
|
81
|
+
for (const [name, value] of Object.entries(tools)) {
|
|
82
|
+
if (value === false) {
|
|
83
|
+
adapters.delete(name);
|
|
84
|
+
} else if (isRecord(value)) {
|
|
85
|
+
const adapter = parseAdapter(value, `tools.${name}`, warn);
|
|
86
|
+
if (adapter) adapters.set(name, adapter);
|
|
87
|
+
} else {
|
|
88
|
+
warn(`tools.${name}: expected an adapter object or false; ignored`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { adapters, warnings };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reads and parses the file at `path`; a missing file yields the built-in tools without a warning. */
|
|
95
|
+
export async function loadConfig(path: string): Promise<{ adapters: Map<string, ToolAdapter>; warnings: string[] }> {
|
|
96
|
+
let text: string | undefined;
|
|
97
|
+
try {
|
|
98
|
+
text = await readFile(path, "utf8");
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
|
|
101
|
+
const result = parseConfig(undefined, path);
|
|
102
|
+
result.warnings.push(`pi-rtk: cannot read ${path}: ${errorMessage(error)}; using the built-in tools`);
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return parseConfig(text, path);
|
|
107
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { resolveTarget, type ToolAdapter } from "./adapters.ts";
|
|
3
|
+
import { firstLine, interpretRewrite, runFailure } from "./outcome.ts";
|
|
4
|
+
import type { Run } from "./run.ts";
|
|
5
|
+
|
|
6
|
+
export interface Reporter {
|
|
7
|
+
notify(message: string, level: "info" | "warning"): void;
|
|
8
|
+
setStatus(text: string | undefined): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const STATUS_KEY = "rtk";
|
|
12
|
+
export const RTK_BIN = "rtk";
|
|
13
|
+
export const TIMEOUT_MS = 2000;
|
|
14
|
+
/** Consecutive rtk failures (timeouts, crashes, a vanished binary) that suspend rewriting for the session. */
|
|
15
|
+
export const MAX_CONSECUTIVE_FAILURES = 3;
|
|
16
|
+
/** How long an unavailable rtk is trusted before the next tool call checks again. */
|
|
17
|
+
export const RECHECK_MS = 30_000;
|
|
18
|
+
/** `rtk rewrite` first shipped in 0.25.0 (rtk CHANGELOG, #241). */
|
|
19
|
+
const MIN_VERSION: readonly [number, number, number] = [0, 25, 0];
|
|
20
|
+
|
|
21
|
+
export type Availability = { ok: true; version: string } | { ok: false; reason: string };
|
|
22
|
+
|
|
23
|
+
export interface Counts {
|
|
24
|
+
rewritten: number;
|
|
25
|
+
unchanged: number;
|
|
26
|
+
skipped: number;
|
|
27
|
+
blocked: number;
|
|
28
|
+
failed: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function emptyCounts(): Counts {
|
|
32
|
+
return { rewritten: 0, unchanged: 0, skipped: 0, blocked: 0, failed: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reads `rtk X.Y.Z` and checks it against the minimum; output without a version is accepted as is. */
|
|
36
|
+
export function checkVersion(output: string): Availability {
|
|
37
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(output);
|
|
38
|
+
if (!match) return { ok: true, version: output.trim() || "unknown" };
|
|
39
|
+
const version = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
40
|
+
const text = version.join(".");
|
|
41
|
+
for (let i = 0; i < MIN_VERSION.length; i++) {
|
|
42
|
+
const have = version[i] ?? 0;
|
|
43
|
+
const need = MIN_VERSION[i] ?? 0;
|
|
44
|
+
if (have > need) break;
|
|
45
|
+
if (have < need) return { ok: false, reason: `rtk ${text} is too old (need ${MIN_VERSION.join(".")} or newer)` };
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, version: text };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
return (await stat(path)).isDirectory();
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ControllerOptions {
|
|
59
|
+
run: Run;
|
|
60
|
+
/** Reads an environment variable of Pi's process. */
|
|
61
|
+
getEnv?: (name: string) => string | undefined;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class RtkController {
|
|
66
|
+
adapters = new Map<string, ToolAdapter>();
|
|
67
|
+
availability: Availability = { ok: false, reason: "not checked yet" };
|
|
68
|
+
/** False after `/rtk off`; every session starts on. */
|
|
69
|
+
enabled = true;
|
|
70
|
+
/** Why rewriting stopped after repeated rtk failures, until `/rtk on` or the next session. */
|
|
71
|
+
suspended: string | undefined;
|
|
72
|
+
lastFailure: string | undefined;
|
|
73
|
+
counts = emptyCounts();
|
|
74
|
+
private consecutiveFailures = 0;
|
|
75
|
+
private checkedAt = 0;
|
|
76
|
+
private readonly run: Run;
|
|
77
|
+
private readonly getEnv: (name: string) => string | undefined;
|
|
78
|
+
private readonly now: () => number;
|
|
79
|
+
|
|
80
|
+
constructor(options: ControllerOptions) {
|
|
81
|
+
this.run = options.run;
|
|
82
|
+
this.getEnv = options.getEnv ?? ((name) => process.env[name]);
|
|
83
|
+
this.now = options.now ?? Date.now;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when `RTK_DISABLED=1` is set in Pi's environment, RTK's own off switch. */
|
|
87
|
+
get disabledByEnv(): boolean {
|
|
88
|
+
return this.getEnv("RTK_DISABLED") === "1";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resets the per-session state and checks for a usable rtk binary. */
|
|
92
|
+
async start(adapters: Map<string, ToolAdapter>, reporter: Reporter): Promise<void> {
|
|
93
|
+
this.adapters = adapters;
|
|
94
|
+
this.enabled = true;
|
|
95
|
+
this.counts = emptyCounts();
|
|
96
|
+
await this.restart(reporter);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Clears a suspension and checks rtk again; used by `/rtk on` and at session start. */
|
|
100
|
+
async restart(reporter: Reporter): Promise<void> {
|
|
101
|
+
this.suspended = undefined;
|
|
102
|
+
this.lastFailure = undefined;
|
|
103
|
+
this.consecutiveFailures = 0;
|
|
104
|
+
// RTK's own switch is fixed for the process: nothing will run rtk, so there is nothing to check or warn about.
|
|
105
|
+
if (this.disabledByEnv) {
|
|
106
|
+
reporter.setStatus(this.statusText());
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await this.check(reporter, true);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
setEnabled(enabled: boolean, reporter: Reporter): void {
|
|
113
|
+
this.enabled = enabled;
|
|
114
|
+
reporter.setStatus(this.statusText());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Footer text while commands are not being rewritten; undefined while RTK is active. */
|
|
118
|
+
statusText(): string | undefined {
|
|
119
|
+
if (!this.enabled || this.disabledByEnv) return "RTK off";
|
|
120
|
+
if (!this.availability.ok) return "RTK unavailable";
|
|
121
|
+
if (this.suspended !== undefined) return "RTK suspended";
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async check(reporter: Reporter, announce: boolean): Promise<void> {
|
|
126
|
+
const wasOk = this.availability.ok;
|
|
127
|
+
// Set before the probe so that parallel tool calls do not each start a recheck.
|
|
128
|
+
this.checkedAt = this.now();
|
|
129
|
+
this.availability = await this.probe();
|
|
130
|
+
if (!this.availability.ok && (announce || wasOk)) {
|
|
131
|
+
reporter.notify(`pi-rtk: commands run unchanged: ${this.availability.reason}`, "warning");
|
|
132
|
+
}
|
|
133
|
+
reporter.setStatus(this.statusText());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async probe(): Promise<Availability> {
|
|
137
|
+
const run = await this.run(RTK_BIN, ["--version"], { timeoutMs: TIMEOUT_MS });
|
|
138
|
+
const failure = runFailure("rtk --version", run);
|
|
139
|
+
if (failure !== undefined) return { ok: false, reason: `${failure} (is rtk installed and on PATH?)` };
|
|
140
|
+
if (run.code !== 0) {
|
|
141
|
+
const detail = firstLine(run.stderr);
|
|
142
|
+
return { ok: false, reason: `rtk --version exited with code ${run.code}${detail === "" ? "" : `: ${detail}`}` };
|
|
143
|
+
}
|
|
144
|
+
return checkVersion(run.stdout);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Rewrites the command in `input` in place when the tool has an adapter and RTK has an equivalent. Returns a
|
|
149
|
+
* block when RTK denies the command with a message; every other problem leaves the command unchanged.
|
|
150
|
+
*/
|
|
151
|
+
async handleToolCall(
|
|
152
|
+
toolName: string,
|
|
153
|
+
input: Record<string, unknown>,
|
|
154
|
+
cwd: string,
|
|
155
|
+
signal: AbortSignal | undefined,
|
|
156
|
+
reporter: Reporter,
|
|
157
|
+
): Promise<{ block: true; reason: string } | undefined> {
|
|
158
|
+
const adapter = this.adapters.get(toolName);
|
|
159
|
+
if (!adapter || !this.enabled || this.suspended !== undefined || this.disabledByEnv) return undefined;
|
|
160
|
+
if (!this.availability.ok) {
|
|
161
|
+
if (this.now() - this.checkedAt < RECHECK_MS) return undefined;
|
|
162
|
+
await this.check(reporter, false);
|
|
163
|
+
if (!this.availability.ok) return undefined;
|
|
164
|
+
}
|
|
165
|
+
const target = resolveTarget(adapter, input, cwd);
|
|
166
|
+
// A missing working directory is the tool's error to report, not rtk's.
|
|
167
|
+
if (target.kind === "skip" || !(await isDirectory(target.cwd))) {
|
|
168
|
+
this.counts.skipped++;
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// `--` keeps a command such as `-h` from being parsed as an rtk option, whose help text would become the rewrite.
|
|
173
|
+
const run = await this.run(RTK_BIN, ["rewrite", "--", target.command], {
|
|
174
|
+
cwd: target.cwd,
|
|
175
|
+
timeoutMs: TIMEOUT_MS,
|
|
176
|
+
...(signal ? { signal } : {}),
|
|
177
|
+
});
|
|
178
|
+
// The tool call is being cancelled; that is not a fault of rtk.
|
|
179
|
+
if (signal?.aborted) return undefined;
|
|
180
|
+
|
|
181
|
+
const outcome = interpretRewrite(target.command, run);
|
|
182
|
+
if (outcome.kind === "failure") {
|
|
183
|
+
this.recordFailure(outcome.reason, reporter);
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
this.consecutiveFailures = 0;
|
|
187
|
+
switch (outcome.kind) {
|
|
188
|
+
case "rewrite":
|
|
189
|
+
input[adapter.commandField] = outcome.command;
|
|
190
|
+
this.counts.rewritten++;
|
|
191
|
+
return undefined;
|
|
192
|
+
case "unchanged":
|
|
193
|
+
this.counts.unchanged++;
|
|
194
|
+
return undefined;
|
|
195
|
+
case "block":
|
|
196
|
+
this.counts.blocked++;
|
|
197
|
+
return { block: true, reason: `rtk: ${outcome.reason}` };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private recordFailure(reason: string, reporter: Reporter): void {
|
|
202
|
+
this.counts.failed++;
|
|
203
|
+
this.lastFailure = reason;
|
|
204
|
+
this.consecutiveFailures++;
|
|
205
|
+
if (this.consecutiveFailures < MAX_CONSECUTIVE_FAILURES || this.suspended !== undefined) return;
|
|
206
|
+
this.suspended = `${MAX_CONSECUTIVE_FAILURES} consecutive rtk failures, last: ${reason}`;
|
|
207
|
+
reporter.notify(
|
|
208
|
+
`pi-rtk: suspended for this session after ${this.suspended}; commands run unchanged. /rtk on retries.`,
|
|
209
|
+
"warning",
|
|
210
|
+
);
|
|
211
|
+
reporter.setStatus(this.statusText());
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { formatStatus, onNotice, parseRtkArgs, RTK_USAGE } from "./command.ts";
|
|
4
|
+
import { CONFIG_FILE_NAME, loadConfig } from "./config.ts";
|
|
5
|
+
import { type Reporter, RtkController, STATUS_KEY } from "./controller.ts";
|
|
6
|
+
import { runFile } from "./run.ts";
|
|
7
|
+
|
|
8
|
+
function reporterFor(ctx: ExtensionContext): Reporter {
|
|
9
|
+
return {
|
|
10
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
11
|
+
setStatus: (text) => ctx.ui.setStatus(STATUS_KEY, text),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function errorMessage(error: unknown): string {
|
|
16
|
+
return error instanceof Error ? error.message : String(error);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function (pi: ExtensionAPI): void {
|
|
20
|
+
const controller = new RtkController({ run: runFile });
|
|
21
|
+
|
|
22
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
23
|
+
const reporter = reporterFor(ctx);
|
|
24
|
+
const { adapters, warnings } = await loadConfig(join(getAgentDir(), CONFIG_FILE_NAME));
|
|
25
|
+
for (const warning of warnings) reporter.notify(warning, "warning");
|
|
26
|
+
await controller.start(adapters, reporter);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
30
|
+
try {
|
|
31
|
+
return await controller.handleToolCall(event.toolName, event.input, ctx.cwd, ctx.signal, reporterFor(ctx));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// A throwing tool_call handler blocks the tool; a rewrite problem must never stop a command.
|
|
34
|
+
ctx.ui.notify(`pi-rtk: command left unchanged after an internal error: ${errorMessage(error)}`, "warning");
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
pi.registerCommand("rtk", {
|
|
40
|
+
description: "Show pi-rtk's state, or turn RTK rewriting on or off for this session",
|
|
41
|
+
handler: async (args, ctx) => {
|
|
42
|
+
const reporter = reporterFor(ctx);
|
|
43
|
+
const action = parseRtkArgs(args);
|
|
44
|
+
switch (action.kind) {
|
|
45
|
+
case "status": {
|
|
46
|
+
const registered = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
47
|
+
reporter.notify(formatStatus(controller, registered), "info");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
case "on": {
|
|
51
|
+
controller.setEnabled(true, reporter);
|
|
52
|
+
await controller.restart(reporter);
|
|
53
|
+
const notice = onNotice(controller);
|
|
54
|
+
if (notice) reporter.notify(notice.message, notice.level);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
case "off":
|
|
58
|
+
controller.setEnabled(false, reporter);
|
|
59
|
+
reporter.notify("pi-rtk: off for this session", "info");
|
|
60
|
+
return;
|
|
61
|
+
case "usage":
|
|
62
|
+
reporter.notify(RTK_USAGE, "warning");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|