@sreetej510/pi-hpc-tools 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 omega
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @sreetej510/pi-hpc-tools
2
+
3
+ A [pi](https://github.com/earendil-works/pi) coding agent extension for exploring a remote
4
+ HPC/SSH host through [`plink`](https://www.chiark.greenend.org.uk/~sgtatham/putty/) without
5
+ ever leaving your local project.
6
+
7
+ ## What it does
8
+
9
+ - Adds three read-only tools — `ls_hpc`, `read_file_hpc`, `grep_hpc` — that shell out to a
10
+ remote host via `plink -batch -pw "<password>" <user>@<host> "<command>"`.
11
+ - Tools are only active in projects where you've explicitly run `/hpc:on`, so the model never
12
+ gets silent access to a remote machine.
13
+ - On/off state is stored per-project in `hpc-config.json` (`enabledProjects: ["/path/to/project", ...]`),
14
+ and restored automatically on resume/session switch.
15
+
16
+ ## Commands
17
+
18
+ | Command | Effect |
19
+ |---|---|
20
+ | `/hpc:on` | Enable the HPC tools for the current project |
21
+ | `/hpc:off` | Disable the HPC tools for the current project |
22
+ | `/hpc:config` | Configure host/user/password/plink path |
23
+
24
+ ## Configuration
25
+
26
+ Credentials are resolved in this order:
27
+
28
+ 1. Environment variables: `HPC_USERNAME` (or `HPC_USER`), `HPC_HOST`, `HPC_PASSWORD`.
29
+ 2. `hpc-config.json` (in `~/.pi/agent/.pi/` or `~/.pi/`), written by `/hpc:config`:
30
+
31
+ ```json
32
+ {
33
+ "username": "...",
34
+ "host": "...",
35
+ "password": "...",
36
+ "plinkPath": "C:/path/to/plink.exe",
37
+ "enabledProjects": ["/path/to/project"]
38
+ }
39
+ ```
40
+
41
+ 3. `PLINK_PATH` env var, or `plinkPath` in the config file, or the default Windows/Git-Bash
42
+ `plink.exe` path — falls back to `plink` on `PATH` otherwise.
43
+
44
+ The shell used to invoke `plink` is read from your pi `settings.json` (`shellPath`), defaulting
45
+ to Git Bash on Windows and `bash` elsewhere.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ npm install -g @sreetej510/pi-hpc-tools
51
+ ```
52
+
53
+ Then add it to your pi `settings.json`:
54
+
55
+ ```json
56
+ {
57
+ "packages": ["npm:@sreetej510/pi-hpc-tools"]
58
+ }
59
+ ```
60
+
61
+ Or, for local development, point at the file directly:
62
+
63
+ ```json
64
+ {
65
+ "extensions": ["/absolute/path/to/pi-extensions/extensions/pi-hpc-tools/src/index.ts"]
66
+ }
67
+ ```
68
+
69
+ ## File layout
70
+
71
+ | File | Responsibility |
72
+ |---|---|
73
+ | `src/index.ts` | Extension entry point: commands (`/hpc:on`, `/hpc:off`, `/hpc:config`) + lifecycle events |
74
+ | `src/constants.ts` | Tool names, timeouts, config file paths |
75
+ | `src/types.ts` | Shared TypeScript types |
76
+ | `src/state.ts` | Shared mutable module state (enabled flag, config cache, sync flags) |
77
+ | `src/config.ts` | `hpc-config.json` load/save, per-project enable state, shell/plink resolution |
78
+ | `src/exec.ts` | `plink` invocation + shell quoting helpers |
79
+ | `src/grep-options.ts` | `grep_hpc` option-string building heuristics |
80
+ | `src/render.ts` | Tool call/result rendering |
81
+ | `src/tool-sync.ts` | Keeps the HPC tools' active/inactive state in sync with `/hpc:on`/`/hpc:off` |
82
+ | `src/tools.ts` | Registers `ls_hpc` / `read_file_hpc` / `grep_hpc` |
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ npm install
88
+ npm run --workspace @sreetej510/pi-hpc-tools check # biome + typecheck
89
+ npm run --workspace @sreetej510/pi-hpc-tools format
90
+ ```
91
+
92
+ ## Security notes
93
+
94
+ - Credentials are stored in plaintext JSON on disk (matching plink's own `-pw` usage) — treat
95
+ `hpc-config.json` like any other secret file and keep it out of version control.
96
+ - Tools are strictly read-only (`ls`, `read`, `grep`); there is no remote write/execute tool.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@sreetej510/pi-hpc-tools",
3
+ "version": "0.1.1",
4
+ "description": "Pi extension for exploring a remote HPC/SSH host (via plink) with ls/read/grep tools, gated per-project by /hpc:on and /hpc:off.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "hpc",
13
+ "ssh",
14
+ "plink",
15
+ "remote"
16
+ ],
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "pi": {
23
+ "extensions": [
24
+ "./src/index.ts"
25
+ ]
26
+ },
27
+ "scripts": {
28
+ "check": "biome check . && npm run typecheck",
29
+ "format": "biome check --write .",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "typebox": "^1.3.3"
34
+ },
35
+ "devDependencies": {
36
+ "@biomejs/biome": "^2.5.3",
37
+ "@earendil-works/pi-coding-agent": "^0.80.3",
38
+ "@earendil-works/pi-tui": "^0.80.3",
39
+ "typescript": "^5.7.3"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/Sreetej510/pi-extensions",
44
+ "directory": "extensions/pi-hpc-tools"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
package/src/config.ts ADDED
@@ -0,0 +1,97 @@
1
+ /** hpc-config.json load/save + per-project enable state + shell/plink resolution. */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { AGENT_DIR, CONFIG_PATHS, DEFAULT_PLINK } from "./constants.js";
5
+ import { getCurrentConfig, setCurrentConfig } from "./state.js";
6
+ import type { HPCConfig } from "./types.js";
7
+
8
+ function ensureDir(filePath: string): void {
9
+ const dir = dirname(filePath);
10
+ if (!existsSync(dir)) {
11
+ mkdirSync(dir, { recursive: true });
12
+ }
13
+ }
14
+
15
+ export function isProjectEnabled(config: HPCConfig, cwd: string): boolean {
16
+ return (config.enabledProjects ?? []).includes(cwd);
17
+ }
18
+
19
+ export function setProjectEnabled(cwd: string, enabled: boolean): void {
20
+ const config = getHPCConfig();
21
+ if (!config) return;
22
+ const projects = new Set(config.enabledProjects ?? []);
23
+ if (enabled) {
24
+ projects.add(cwd);
25
+ } else {
26
+ projects.delete(cwd);
27
+ }
28
+ saveConfig({ ...config, enabledProjects: [...projects] });
29
+ }
30
+
31
+ export function loadConfig(): HPCConfig | null {
32
+ const username = process.env.HPC_USERNAME || process.env.HPC_USER;
33
+ const host = process.env.HPC_HOST;
34
+ const password = process.env.HPC_PASSWORD;
35
+ if (username && host && password) {
36
+ return { username, host, password };
37
+ }
38
+
39
+ for (const configPath of CONFIG_PATHS) {
40
+ try {
41
+ if (!existsSync(configPath)) continue;
42
+ const config = JSON.parse(readFileSync(configPath, "utf-8")) as HPCConfig;
43
+ if (config.username && config.host && config.password) {
44
+ return config;
45
+ }
46
+ } catch {
47
+ // try next path
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export function saveConfig(config: HPCConfig): void {
54
+ const configPath = CONFIG_PATHS[0];
55
+ ensureDir(configPath);
56
+ writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
57
+ setCurrentConfig(config);
58
+ }
59
+
60
+ export function getHPCConfig(): HPCConfig | null {
61
+ if (!getCurrentConfig()) {
62
+ setCurrentConfig(loadConfig());
63
+ }
64
+ return getCurrentConfig();
65
+ }
66
+
67
+ /** Forward-slash path for Git Bash (C:/Users/...). */
68
+ export function toSlashPath(p: string): string {
69
+ return p.replace(/\\/g, "/");
70
+ }
71
+
72
+ function getAgentSettings(): { shellPath?: string } {
73
+ try {
74
+ const settingsPath = join(AGENT_DIR, "settings.json");
75
+ if (existsSync(settingsPath)) {
76
+ return JSON.parse(readFileSync(settingsPath, "utf-8")) as { shellPath?: string };
77
+ }
78
+ } catch {
79
+ // ignore
80
+ }
81
+ return {};
82
+ }
83
+
84
+ /** Shell from settings.json shellPath — must match user bash (Git Bash on Windows). */
85
+ export function getShellExecutable(): string {
86
+ const fromSettings = getAgentSettings().shellPath;
87
+ if (fromSettings) return fromSettings;
88
+ return process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "bash";
89
+ }
90
+
91
+ export function getPlinkCommand(): string {
92
+ if (process.env.PLINK_PATH) return toSlashPath(process.env.PLINK_PATH);
93
+ const cfg = getHPCConfig();
94
+ if (cfg?.plinkPath) return toSlashPath(cfg.plinkPath);
95
+ if (existsSync(DEFAULT_PLINK)) return DEFAULT_PLINK;
96
+ return "plink";
97
+ }
@@ -0,0 +1,13 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export const HPC_TOOL_NAMES = ["ls_hpc", "read_file_hpc", "grep_hpc"] as const;
5
+ export const MAX_READ_LINES = 1000;
6
+ export const GREP_TIMEOUT_MS = 120_000;
7
+ export const UI_PREVIEW_LINES = 10;
8
+
9
+ export const AGENT_DIR = join(homedir(), ".pi", "agent");
10
+ export const CONFIG_PATHS = [join(AGENT_DIR, ".pi", "hpc-config.json"), join(homedir(), ".pi", "hpc-config.json")];
11
+
12
+ /** Default plink on this machine (Git bash path). Override via PLINK_PATH or hpc-config.json plinkPath. */
13
+ export const DEFAULT_PLINK = "C:/Users/sreet/plink/plink.exe";
package/src/exec.ts ADDED
@@ -0,0 +1,49 @@
1
+ /** Remote command execution over plink + shell quoting helpers. */
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { getHPCConfig, getPlinkCommand, getShellExecutable } from "./config.js";
4
+ import type { HpcExecResult } from "./types.js";
5
+
6
+ /** Single-quote escape for remote shell command strings. */
7
+ export function shQuote(value: string): string {
8
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
9
+ }
10
+
11
+ export function bashQuote(value: string): string {
12
+ return `'${value.replace(/'/g, `'\\''`)}'`;
13
+ }
14
+
15
+ export async function execOnHPC(
16
+ pi: ExtensionAPI,
17
+ remoteCommand: string,
18
+ opts?: { timeout?: number; signal?: AbortSignal },
19
+ ): Promise<HpcExecResult> {
20
+ const config = getHPCConfig();
21
+ if (!config) {
22
+ throw new Error("HPC not configured. Use /hpc:config username@host password");
23
+ }
24
+
25
+ const plink = bashQuote(getPlinkCommand());
26
+ const localCmd = `${plink} -batch -pw ${bashQuote(config.password)} ${config.username}@${config.host} ${bashQuote(remoteCommand)}`;
27
+ const connectTimeoutMs = opts?.timeout ?? 60_000;
28
+ const result = await pi.exec(getShellExecutable(), ["-c", localCmd], {
29
+ signal: opts?.signal,
30
+ timeout: connectTimeoutMs,
31
+ });
32
+
33
+ return {
34
+ stdout: result.stdout ?? "",
35
+ stderr: result.stderr ?? "",
36
+ exitCode: result.code ?? 0,
37
+ killed: result.killed,
38
+ };
39
+ }
40
+
41
+ export function isHpcFailure(result: HpcExecResult): boolean {
42
+ const text = `${result.stdout}\n${result.stderr}`;
43
+ return result.exitCode !== 0 || /FATAL ERROR/i.test(text);
44
+ }
45
+
46
+ export function formatHpcFailure(result: HpcExecResult): string {
47
+ if (result.killed) return "HPC command timed out.";
48
+ return (result.stderr || result.stdout).trim() || `HPC command failed (exit ${result.exitCode})`;
49
+ }
@@ -0,0 +1,57 @@
1
+ /** grep_hpc option-string building heuristics. */
2
+
3
+ function grepUsesFixedStrings(options: string): boolean {
4
+ return /(?:^|\s)-F(?:\s|$)/.test(options) || /--fixed-strings/.test(options);
5
+ }
6
+
7
+ function grepUsesExtendedRegex(options: string): boolean {
8
+ return /(?:^|\s)-E(?:\s|$)/.test(options) || /--extended-regexp/.test(options);
9
+ }
10
+
11
+ /** Heuristic: | alternation and common ERE syntax need grep -E. */
12
+ function patternNeedsExtendedRegex(pattern: string): boolean {
13
+ if (pattern.includes("|")) return true;
14
+ if (/[()[\]+]/.test(pattern)) return true;
15
+ if (/(?:^|[^\\])\?/.test(pattern)) return true;
16
+ return false;
17
+ }
18
+
19
+ function isLikelyFilePath(path: string): boolean {
20
+ const base = path.split("/").pop() ?? path;
21
+ return base.includes(".") && !path.endsWith("/");
22
+ }
23
+
24
+ export function buildGrepOptions(
25
+ baseOptions: string,
26
+ pattern: string,
27
+ searchPath: string,
28
+ params: {
29
+ recursive?: boolean;
30
+ context?: number;
31
+ after?: number;
32
+ before?: number;
33
+ },
34
+ ): string {
35
+ let opts = baseOptions.trim();
36
+ opts = opts.replace(/-(?:A|B|C)\s*\d+/g, "").trim();
37
+
38
+ if (!grepUsesFixedStrings(opts) && !grepUsesExtendedRegex(opts) && patternNeedsExtendedRegex(pattern)) {
39
+ opts += " -E";
40
+ }
41
+
42
+ if (params.context !== undefined && params.context > 0) {
43
+ opts += ` -C ${params.context}`;
44
+ } else {
45
+ if (params.after && params.after > 0) opts += ` -A ${params.after}`;
46
+ if (params.before && params.before > 0) opts += ` -B ${params.before}`;
47
+ }
48
+
49
+ const useRecursive = params.recursive !== false && !isLikelyFilePath(searchPath);
50
+ if (!useRecursive) {
51
+ opts = opts.replace(/\s*-r\b/g, "").trim();
52
+ } else if (!/\s-r\b/.test(opts)) {
53
+ opts += " -r";
54
+ }
55
+
56
+ return opts.replace(/\s+/g, " ").trim();
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * HPC Tools Extension for pi
3
+ *
4
+ * Remote file exploration via plink:
5
+ * plink -batch -pw "password" username@host "command"
6
+ *
7
+ * Commands: /hpc:on, /hpc:off, /hpc:config
8
+ * Tools (active only after /hpc:on in this project): ls_hpc, read_file_hpc, grep_hpc
9
+ * On/off state is stored in hpc-config.json as enabledProjects: ["/path/to/project", ...]
10
+ *
11
+ * File layout:
12
+ * index.ts extension entry point (this file) — commands + lifecycle events
13
+ * constants.ts tool names, timeouts, config file paths
14
+ * types.ts shared TypeScript types
15
+ * state.ts shared mutable module state (enabled flag, config cache, sync flags)
16
+ * config.ts hpc-config.json load/save, per-project enable state, shell/plink resolution
17
+ * exec.ts plink invocation + shell quoting helpers
18
+ * grep-options.ts grep_hpc option-string building heuristics
19
+ * render.ts tool call/result rendering
20
+ * tool-sync.ts keeps HPC tools' active/inactive state in sync with /hpc:on|off
21
+ * tools.ts registers ls_hpc / read_file_hpc / grep_hpc
22
+ */
23
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+ import { getHPCConfig, loadConfig, saveConfig } from "./config.js";
25
+ import { getHpcEnabled, getPendingToolSync, setCurrentConfig } from "./state.js";
26
+ import { isHpcSyncNeeded, onProjectContext, scheduleDelayedHpcSync, setHpcEnabled, syncHpcTools } from "./tool-sync.js";
27
+
28
+ export default function hpcToolsExtension(pi: ExtensionAPI) {
29
+ setCurrentConfig(loadConfig());
30
+
31
+ pi.registerCommand("hpc:on", {
32
+ description: "Enable HPC tools for this project folder",
33
+ handler: async (_args, ctx) => {
34
+ const config = getHPCConfig();
35
+ if (!config) {
36
+ ctx.ui.notify("HPC not configured. Use /hpc:config username@host password", "error");
37
+ return;
38
+ }
39
+ setHpcEnabled(pi, ctx.cwd, true);
40
+ ctx.ui.notify(`HPC tools ON in this project (${config.username}@${config.host})`, "info");
41
+ ctx.ui.setStatus("hpc", `HPC: ON`);
42
+ },
43
+ });
44
+
45
+ pi.registerCommand("hpc:off", {
46
+ description: "Disable HPC tools for this project folder (other tools unchanged)",
47
+ handler: async (_args, ctx) => {
48
+ setHpcEnabled(pi, ctx.cwd, false);
49
+ ctx.ui.notify("HPC tools OFF in this project", "info");
50
+ ctx.ui.setStatus("hpc", `HPC: OFF`);
51
+ },
52
+ });
53
+
54
+ pi.registerCommand("hpc:config", {
55
+ description: "Set HPC credentials: /hpc:config username@host password",
56
+ handler: async (args, ctx) => {
57
+ if (!args?.trim()) {
58
+ ctx.ui.notify("Usage: /hpc:config username@host password", "info");
59
+ ctx.ui.notify(" or: /hpc:config username host password", "info");
60
+ return;
61
+ }
62
+
63
+ const parts = args.trim().split(/\s+/);
64
+ let username: string;
65
+ let host: string;
66
+ let password: string;
67
+
68
+ if (parts.length === 2) {
69
+ const at = parts[0].indexOf("@");
70
+ if (at === -1) {
71
+ ctx.ui.notify("Use username@host password", "error");
72
+ return;
73
+ }
74
+ username = parts[0].slice(0, at);
75
+ host = parts[0].slice(at + 1);
76
+ password = parts[1];
77
+ } else if (parts.length >= 3) {
78
+ username = parts[0];
79
+ host = parts[1];
80
+ password = parts.slice(2).join(" ");
81
+ } else {
82
+ ctx.ui.notify("Usage: /hpc:config username@host password", "error");
83
+ return;
84
+ }
85
+
86
+ saveConfig({ username, host, password });
87
+ ctx.ui.notify(`HPC configured: ${username}@${host}`, "info");
88
+ ctx.ui.setStatus("hpc", `HPC: ${getHpcEnabled() ? "ON" : "OFF"}`);
89
+ },
90
+ });
91
+
92
+ pi.on("session_start", async (event, ctx) => {
93
+ // New, resume, reload, fork — re-apply per-project hpc-state.json
94
+ onProjectContext(pi, ctx.cwd);
95
+ if (event.reason === "resume" || event.reason === "reload" || event.reason === "startup") {
96
+ scheduleDelayedHpcSync(pi);
97
+ }
98
+
99
+ const config = getHPCConfig();
100
+ if (config) {
101
+ ctx.ui.setStatus("hpc", `HPC: ${getHpcEnabled() ? "ON" : "OFF"}`);
102
+ } else if (getHpcEnabled()) {
103
+ ctx.ui.setStatus("hpc", `HPC: ON (not configured)`);
104
+ }
105
+ });
106
+
107
+ pi.on("session_tree", async (_event, ctx) => {
108
+ // Navigating branches in an existing session — active tools may be restored from history
109
+ onProjectContext(pi, ctx.cwd);
110
+ });
111
+
112
+ pi.on("before_agent_start", async () => {
113
+ if (!getPendingToolSync() && !isHpcSyncNeeded(pi)) return;
114
+ syncHpcTools(pi);
115
+ });
116
+ }
package/src/render.ts ADDED
@@ -0,0 +1,68 @@
1
+ /** Tool call/result rendering — matches built-in ls/grep/read formatting. */
2
+ import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { UI_PREVIEW_LINES } from "./constants.js";
5
+ import type { HpcToolRenderResult } from "./types.js";
6
+
7
+ function getToolText(result: HpcToolRenderResult): string {
8
+ const block = result.content.find((c) => c.type === "text");
9
+ return block?.type === "text" ? (block.text ?? "") : "";
10
+ }
11
+
12
+ /** Match built-in ls/grep/read renderResult formatting (in-place expand via Ctrl+O). */
13
+ export function formatHpcResult(
14
+ result: HpcToolRenderResult,
15
+ options: { expanded: boolean; isPartial: boolean },
16
+ theme: import("@earendil-works/pi-coding-agent").Theme,
17
+ ): string {
18
+ if (options.isPartial) {
19
+ return theme.fg("warning", "Connecting to HPC...");
20
+ }
21
+
22
+ const details = result.details ?? {};
23
+ const output = getToolText(result).trim();
24
+ const exitCode = details.exitCode as number | undefined;
25
+
26
+ if (details.error || (exitCode !== undefined && exitCode !== 0)) {
27
+ return theme.fg("error", output || `HPC command failed (exit ${exitCode ?? "?"})`);
28
+ }
29
+
30
+ if (!output) {
31
+ return theme.fg("dim", "(no output)");
32
+ }
33
+
34
+ const lines = output.split("\n");
35
+ const maxLines = options.expanded ? lines.length : UI_PREVIEW_LINES;
36
+ const displayLines = lines.slice(0, maxLines);
37
+ const remaining = lines.length - maxLines;
38
+
39
+ let text = `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
40
+ if (remaining > 0) {
41
+ text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
42
+ }
43
+ if (details.truncatedForAgent) {
44
+ text += `\n${theme.fg("warning", "[Truncated for agent — narrow path or pattern]")}`;
45
+ }
46
+ return text;
47
+ }
48
+
49
+ export function hpcRenderCall(
50
+ callText: string,
51
+ theme: import("@earendil-works/pi-coding-agent").Theme,
52
+ context: { lastComponent?: unknown },
53
+ ): Text {
54
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
55
+ text.setText(callText);
56
+ return text;
57
+ }
58
+
59
+ export function hpcRenderResult(
60
+ result: HpcToolRenderResult,
61
+ options: { expanded: boolean; isPartial: boolean },
62
+ theme: import("@earendil-works/pi-coding-agent").Theme,
63
+ context: { lastComponent?: unknown },
64
+ ): Text {
65
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
66
+ text.setText(formatHpcResult(result, options, theme));
67
+ return text;
68
+ }
package/src/state.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared mutable module state, centralized here so every other file mutates
3
+ * it through explicit getters/setters instead of each holding its own copy.
4
+ */
5
+ import type { HPCConfig } from "./types.js";
6
+
7
+ let hpcEnabled = false;
8
+ let currentConfig: HPCConfig | null = null;
9
+ let toolsRegistered = false;
10
+ /** Sync HPC tool visibility after built-ins are in the active set. */
11
+ let pendingToolSync = false;
12
+
13
+ export function getHpcEnabled(): boolean {
14
+ return hpcEnabled;
15
+ }
16
+
17
+ export function setHpcEnabledFlag(enabled: boolean): void {
18
+ hpcEnabled = enabled;
19
+ }
20
+
21
+ export function getCurrentConfig(): HPCConfig | null {
22
+ return currentConfig;
23
+ }
24
+
25
+ export function setCurrentConfig(config: HPCConfig | null): void {
26
+ currentConfig = config;
27
+ }
28
+
29
+ export function getToolsRegistered(): boolean {
30
+ return toolsRegistered;
31
+ }
32
+
33
+ export function setToolsRegistered(registered: boolean): void {
34
+ toolsRegistered = registered;
35
+ }
36
+
37
+ export function getPendingToolSync(): boolean {
38
+ return pendingToolSync;
39
+ }
40
+
41
+ export function setPendingToolSync(pending: boolean): void {
42
+ pendingToolSync = pending;
43
+ }
@@ -0,0 +1,114 @@
1
+ /** Keeps the ls_hpc/read_file_hpc/grep_hpc tools' active/inactive state in sync with /hpc:on|off. */
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { getHPCConfig, isProjectEnabled, loadConfig, setProjectEnabled } from "./config.js";
4
+ import { HPC_TOOL_NAMES } from "./constants.js";
5
+ import {
6
+ getHpcEnabled,
7
+ getPendingToolSync,
8
+ getToolsRegistered,
9
+ setCurrentConfig,
10
+ setHpcEnabledFlag,
11
+ setPendingToolSync,
12
+ } from "./state.js";
13
+ import { registerHPCTools } from "./tools.js";
14
+
15
+ const HPC_TOOL_SET = new Set<string>(HPC_TOOL_NAMES);
16
+
17
+ function isHpcToolName(name: string): boolean {
18
+ return HPC_TOOL_SET.has(name);
19
+ }
20
+
21
+ /** pi.getActiveTools() returns tool name strings (not { name } objects). */
22
+ function getActiveToolNames(pi: ExtensionAPI): string[] {
23
+ const active = pi.getActiveTools() as Array<string | { name: string }>;
24
+ return active.map((t) => (typeof t === "string" ? t : t.name));
25
+ }
26
+
27
+ function wantHpcEnabled(): boolean {
28
+ return getHpcEnabled() && Boolean(getHPCConfig());
29
+ }
30
+
31
+ function hasHpcInActive(pi: ExtensionAPI): boolean {
32
+ return HPC_TOOL_NAMES.some((n) => getActiveToolNames(pi).includes(n));
33
+ }
34
+
35
+ export function isHpcSyncNeeded(pi: ExtensionAPI): boolean {
36
+ return wantHpcEnabled() !== hasHpcInActive(pi);
37
+ }
38
+
39
+ function ensureHpcToolsRegistered(pi: ExtensionAPI): void {
40
+ if (getToolsRegistered()) return;
41
+ registerHPCTools(pi);
42
+ }
43
+
44
+ /**
45
+ * Toggle only the three HPC tools on the current active set.
46
+ * ON: add HPC tools to whatever is already active (keeps all other tools).
47
+ * OFF: remove HPC tools from whatever is active (keeps all other tools).
48
+ */
49
+ function applyHpcToolVisibility(pi: ExtensionAPI, enabled: boolean): void {
50
+ const current = getActiveToolNames(pi);
51
+
52
+ if (enabled) {
53
+ if (current.length === 0) {
54
+ setPendingToolSync(true);
55
+ return;
56
+ }
57
+ const next = new Set(current);
58
+ for (const name of HPC_TOOL_NAMES) next.add(name);
59
+ pi.setActiveTools([...next]);
60
+ } else {
61
+ const withoutHpc = current.filter((n) => !isHpcToolName(n));
62
+ if (withoutHpc.length !== current.length) {
63
+ pi.setActiveTools(withoutHpc);
64
+ }
65
+ }
66
+
67
+ setPendingToolSync(isHpcSyncNeeded(pi));
68
+ }
69
+
70
+ /** Apply project hpc-state.json to the active tool list (resume / session switch). */
71
+ export function syncHpcTools(pi: ExtensionAPI): void {
72
+ const want = wantHpcEnabled();
73
+ if (want) {
74
+ ensureHpcToolsRegistered(pi);
75
+ }
76
+ applyHpcToolVisibility(pi, want);
77
+ }
78
+
79
+ export function scheduleHpcSync(pi: ExtensionAPI, attempt = 0): void {
80
+ const want = wantHpcEnabled();
81
+ const active = getActiveToolNames(pi);
82
+
83
+ if (want && active.length === 0 && attempt < 8) {
84
+ queueMicrotask(() => scheduleHpcSync(pi, attempt + 1));
85
+ return;
86
+ }
87
+
88
+ syncHpcTools(pi);
89
+ }
90
+
91
+ /** Resume/reload restores active tools from the session file after session_start. */
92
+ export function scheduleDelayedHpcSync(pi: ExtensionAPI): void {
93
+ for (const delayMs of [0, 50, 150, 400]) {
94
+ setTimeout(() => {
95
+ if (getPendingToolSync() || isHpcSyncNeeded(pi)) {
96
+ syncHpcTools(pi);
97
+ }
98
+ }, delayMs);
99
+ }
100
+ }
101
+
102
+ export function setHpcEnabled(pi: ExtensionAPI, cwd: string, enabled: boolean): void {
103
+ setHpcEnabledFlag(enabled);
104
+ setProjectEnabled(cwd, enabled);
105
+ syncHpcTools(pi);
106
+ }
107
+
108
+ export function onProjectContext(pi: ExtensionAPI, cwd: string): void {
109
+ const config = loadConfig();
110
+ setCurrentConfig(config);
111
+ setHpcEnabledFlag(config ? isProjectEnabled(config, cwd) : false);
112
+ setPendingToolSync(true);
113
+ scheduleHpcSync(pi);
114
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,295 @@
1
+ /** Registers the ls_hpc / read_file_hpc / grep_hpc tools. */
2
+
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
5
+ import { Type } from "typebox";
6
+ import { GREP_TIMEOUT_MS, MAX_READ_LINES } from "./constants.js";
7
+ import { execOnHPC, formatHpcFailure, isHpcFailure, shQuote } from "./exec.js";
8
+ import { buildGrepOptions } from "./grep-options.js";
9
+ import { hpcRenderCall, hpcRenderResult } from "./render.js";
10
+ import { getToolsRegistered, setToolsRegistered } from "./state.js";
11
+
12
+ export function registerHPCTools(pi: ExtensionAPI): void {
13
+ if (getToolsRegistered()) return;
14
+ setToolsRegistered(true);
15
+
16
+ const sharedRender = hpcRenderResult;
17
+
18
+ pi.registerTool({
19
+ name: "ls_hpc",
20
+ label: "List HPC Directory",
21
+ description:
22
+ "List files and directories on the remote HPC system. Use recursive=true to list all files under a directory.",
23
+ promptSnippet: "List or browse files on remote HPC",
24
+ promptGuidelines: [
25
+ "Use ls_hpc when the user wants to explore directories on the remote HPC filesystem.",
26
+ "Use ls_hpc with recursive=true to enumerate files under a directory tree.",
27
+ ],
28
+ parameters: Type.Object({
29
+ path: Type.Optional(Type.String({ description: "Directory on HPC. Defaults to home (~)." })),
30
+ options: Type.Optional(
31
+ Type.String({ description: "Extra ls flags (e.g. -lh). Default: -la. Ignored when recursive=true." }),
32
+ ),
33
+ recursive: Type.Optional(Type.Boolean({ description: "List all files recursively under path. Default: false." })),
34
+ }),
35
+ renderCall(args, theme, context) {
36
+ const path = (args.path as string) || "~";
37
+ const rec = args.recursive ? " (recursive)" : "";
38
+ const callText = theme.fg("toolTitle", theme.bold("ls_hpc ")) + theme.fg("accent", path) + theme.fg("muted", rec);
39
+ return hpcRenderCall(callText, theme, context);
40
+ },
41
+ renderResult: sharedRender,
42
+ async execute(_toolCallId, params, signal, onUpdate) {
43
+ onUpdate?.({ content: [{ type: "text", text: "Listing on HPC..." }], details: undefined });
44
+ const path = params.path || "~";
45
+ const recursive = params.recursive ?? false;
46
+ const options = params.options || "-la";
47
+
48
+ const command = recursive
49
+ ? `find ${shQuote(path)} -type f 2>/dev/null | head -n 50000 | sort`
50
+ : `ls ${options} ${shQuote(path)} 2>&1`;
51
+
52
+ const result = await execOnHPC(pi, command, { signal });
53
+
54
+ if (isHpcFailure(result)) {
55
+ return {
56
+ content: [{ type: "text", text: formatHpcFailure(result) }],
57
+ details: { exitCode: result.exitCode, path, recursive, error: true },
58
+ isError: true,
59
+ };
60
+ }
61
+
62
+ const text = (result.stdout || "").trim() || "(empty)";
63
+ return {
64
+ content: [{ type: "text", text }],
65
+ details: { exitCode: result.exitCode, path, recursive, lineCount: text.split("\n").length },
66
+ };
67
+ },
68
+ });
69
+
70
+ pi.registerTool({
71
+ name: "read_file_hpc",
72
+ label: "Read HPC File",
73
+ description: `Read a file from remote HPC. Supports offset/limit line range (max ${MAX_READ_LINES} lines per call).`,
74
+ promptSnippet: "Read file contents from remote HPC",
75
+ promptGuidelines: [
76
+ "Use read_file_hpc to view source or config files stored on HPC.",
77
+ `Use offset and limit to paginate large files (max ${MAX_READ_LINES} lines per read).`,
78
+ ],
79
+ parameters: Type.Object({
80
+ path: Type.String({ description: "Absolute or relative file path on HPC." }),
81
+ offset: Type.Optional(Type.Integer({ description: "0-based starting line. Default: 0.", minimum: 0 })),
82
+ limit: Type.Optional(
83
+ Type.Integer({
84
+ description: `Max lines to read (capped at ${MAX_READ_LINES}). Default: ${MAX_READ_LINES}.`,
85
+ minimum: 1,
86
+ maximum: MAX_READ_LINES,
87
+ }),
88
+ ),
89
+ }),
90
+ renderCall(args, theme, context) {
91
+ const path = args.path as string;
92
+ const offset = (args.offset as number | undefined) ?? 0;
93
+ const limit = (args.limit as number | undefined) ?? MAX_READ_LINES;
94
+ const callText =
95
+ theme.fg("toolTitle", theme.bold("read_file_hpc ")) +
96
+ theme.fg("accent", path) +
97
+ theme.fg("warning", `:${offset + 1}${limit ? `-${offset + limit}` : ""}`);
98
+ return hpcRenderCall(callText, theme, context);
99
+ },
100
+ renderResult: sharedRender,
101
+ async execute(_toolCallId, params, signal, onUpdate) {
102
+ onUpdate?.({ content: [{ type: "text", text: "Reading from HPC..." }], details: undefined });
103
+ const filePath = params.path;
104
+ const offset = Math.max(0, params.offset ?? 0);
105
+ let limit = params.limit ?? MAX_READ_LINES;
106
+ if (limit > MAX_READ_LINES) limit = MAX_READ_LINES;
107
+
108
+ const startLine = offset + 1;
109
+ const endLine = offset + limit;
110
+ const command = `sed -n '${startLine},${endLine}p' ${shQuote(filePath)} 2>&1`;
111
+
112
+ const result = await execOnHPC(pi, command, { signal });
113
+ const output = result.stdout ?? "";
114
+ const err = result.stderr ?? "";
115
+
116
+ if (result.exitCode !== 0) {
117
+ return {
118
+ content: [{ type: "text", text: `Error reading ${filePath}:\n${err || output || "(no output)"}` }],
119
+ details: { exitCode: result.exitCode, path: filePath, error: true },
120
+ };
121
+ }
122
+
123
+ if (!output.trim()) {
124
+ return {
125
+ content: [{ type: "text", text: `No content in lines ${startLine}-${endLine} of ${filePath}` }],
126
+ details: { path: filePath, offset, limit, empty: true },
127
+ };
128
+ }
129
+
130
+ const lineCount = output.split("\n").length;
131
+ const header = `${filePath} (lines ${startLine}-${offset + lineCount}):\n`;
132
+ return {
133
+ content: [{ type: "text", text: header + output }],
134
+ details: { path: filePath, offset, limit, lines: lineCount },
135
+ };
136
+ },
137
+ });
138
+
139
+ pi.registerTool({
140
+ name: "grep_hpc",
141
+ label: "Search HPC Files",
142
+ description:
143
+ "Search file contents on HPC with grep. Patterns with | (alternation) auto-use grep -E. Use options '-F' for literal/fixed-string search. Max runtime 2 minutes.",
144
+ promptSnippet: "Grep/search text in remote HPC files",
145
+ promptGuidelines: [
146
+ "Use grep_hpc to find strings or regex patterns in HPC files.",
147
+ "For alternation (foo|bar), use one pattern with | — grep -E is added automatically.",
148
+ "Use options '-F' when the pattern must be matched literally (no regex).",
149
+ "Use after/before/context for surrounding lines (like grep -A/-B/-C).",
150
+ ],
151
+ parameters: Type.Object({
152
+ pattern: Type.String({ description: "Search pattern. Use | for alternation (e.g. foo|bar)." }),
153
+ path: Type.Optional(Type.String({ description: "File or directory on HPC. Default: ." })),
154
+ options: Type.Optional(
155
+ Type.String({
156
+ description: "Extra grep flags (e.g. -i, -l, -E). Default base: -rn.",
157
+ }),
158
+ ),
159
+ file_pattern: Type.Optional(
160
+ Type.String({
161
+ description:
162
+ "Glob for filenames to search, e.g. '*.csv' or '*.py' (uses grep --include, case-insensitive via find when non-recursive).",
163
+ }),
164
+ ),
165
+ after: Type.Optional(Type.Integer({ description: "Lines after match (grep -A).", minimum: 0 })),
166
+ before: Type.Optional(Type.Integer({ description: "Lines before match (grep -B).", minimum: 0 })),
167
+ context: Type.Optional(
168
+ Type.Integer({ description: "Lines before and after (grep -C). Overrides after/before.", minimum: 0 }),
169
+ ),
170
+ recursive: Type.Optional(Type.Boolean({ description: "Search recursively (-r). Default true for directories." })),
171
+ }),
172
+ renderCall(args, theme, context) {
173
+ let callText = theme.fg("toolTitle", theme.bold("grep_hpc "));
174
+ callText += theme.fg("accent", `"${args.pattern as string}"`);
175
+ if (args.path) callText += theme.fg("toolOutput", ` in ${args.path as string}`);
176
+ if (args.file_pattern) callText += theme.fg("toolOutput", ` (${args.file_pattern as string})`);
177
+ return hpcRenderCall(callText, theme, context);
178
+ },
179
+ renderResult: sharedRender,
180
+ async execute(_toolCallId, params, signal, onUpdate) {
181
+ onUpdate?.({ content: [{ type: "text", text: "Searching on HPC..." }], details: undefined });
182
+
183
+ const pattern = params.pattern;
184
+ const searchPath = params.path || ".";
185
+ const grepOpts = buildGrepOptions(params.options || "-n", pattern, searchPath, {
186
+ recursive: params.recursive,
187
+ context: params.context,
188
+ after: params.after,
189
+ before: params.before,
190
+ });
191
+ const quotedPattern = shQuote(pattern);
192
+ const quotedPath = shQuote(searchPath);
193
+
194
+ let command: string;
195
+ if (params.file_pattern) {
196
+ const glob = params.file_pattern.trim();
197
+ const quotedGlob = shQuote(glob);
198
+ let fileGrepOpts = grepOpts.replace(/--include(?:=\S+|\s+\S+)?/g, "").trim();
199
+
200
+ if (params.recursive === false) {
201
+ const namePred = glob.includes("*") || glob.includes("?") ? `-iname ${quotedGlob}` : `-name ${quotedGlob}`;
202
+ command = `find ${quotedPath} -maxdepth 1 -type f ${namePred} -exec grep -Hn ${fileGrepOpts} -e ${quotedPattern} {} + 2>&1`;
203
+ } else {
204
+ if (!/\s-r\b/.test(fileGrepOpts)) fileGrepOpts += " -r";
205
+ command = `grep ${fileGrepOpts} --include=${quotedGlob} -e ${quotedPattern} ${quotedPath} 2>&1`;
206
+ }
207
+ } else {
208
+ command = `grep ${grepOpts} -e ${quotedPattern} ${quotedPath} 2>&1`;
209
+ }
210
+ command += " || true";
211
+
212
+ const result = await execOnHPC(pi, command, { signal, timeout: GREP_TIMEOUT_MS });
213
+
214
+ if (result.killed) {
215
+ return {
216
+ content: [
217
+ {
218
+ type: "text",
219
+ text: `grep_hpc timed out after ${GREP_TIMEOUT_MS / 1000}s. Narrow the path, use file_pattern, or refine the pattern.`,
220
+ },
221
+ ],
222
+ details: { path: searchPath, pattern, timedOut: true, error: true },
223
+ };
224
+ }
225
+
226
+ if (isHpcFailure(result)) {
227
+ return {
228
+ content: [{ type: "text", text: formatHpcFailure(result) }],
229
+ details: {
230
+ path: searchPath,
231
+ pattern,
232
+ file_pattern: params.file_pattern,
233
+ exitCode: result.exitCode,
234
+ error: true,
235
+ },
236
+ isError: true,
237
+ };
238
+ }
239
+
240
+ const output = (result.stdout || "").trim();
241
+ const stderr = (result.stderr || "").trim();
242
+ if (!output) {
243
+ const scope = params.file_pattern ? `${searchPath} (files: ${params.file_pattern})` : searchPath;
244
+ if (stderr && /no such file|not found|cannot access/i.test(stderr)) {
245
+ return {
246
+ content: [{ type: "text", text: `grep_hpc failed:\n${stderr}` }],
247
+ details: { path: searchPath, pattern, error: true },
248
+ isError: true,
249
+ };
250
+ }
251
+ return {
252
+ content: [
253
+ {
254
+ type: "text",
255
+ text: `No matches for ${JSON.stringify(pattern)} in ${scope}${grepOpts.includes("-E") ? " (extended regex)" : ""}`,
256
+ },
257
+ ],
258
+ details: {
259
+ path: searchPath,
260
+ pattern,
261
+ file_pattern: params.file_pattern,
262
+ matches: 0,
263
+ grepOptions: grepOpts,
264
+ },
265
+ };
266
+ }
267
+
268
+ const truncation = truncateHead(output, {
269
+ maxLines: DEFAULT_MAX_LINES,
270
+ maxBytes: DEFAULT_MAX_BYTES,
271
+ });
272
+
273
+ let agentText = truncation.content;
274
+ const details: Record<string, unknown> = {
275
+ path: searchPath,
276
+ pattern,
277
+ grepOptions: grepOpts,
278
+ matches: output.split("\n").filter((l) => l.trim()).length,
279
+ exitCode: result.exitCode,
280
+ };
281
+
282
+ if (truncation.truncated) {
283
+ details.truncatedForAgent = true;
284
+ const omittedLines = truncation.totalLines - truncation.outputLines;
285
+ const omittedBytes = truncation.totalBytes - truncation.outputBytes;
286
+ agentText += ` | [Output truncated for agent: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). ${omittedLines} lines (${formatSize(omittedBytes)}) omitted. Narrow search path or pattern.]`;
287
+ }
288
+
289
+ return {
290
+ content: [{ type: "text", text: agentText }],
291
+ details,
292
+ };
293
+ },
294
+ });
295
+ }
package/src/types.ts ADDED
@@ -0,0 +1,19 @@
1
+ export interface HPCConfig {
2
+ username: string;
3
+ host: string;
4
+ password: string;
5
+ plinkPath?: string;
6
+ enabledProjects?: string[];
7
+ }
8
+
9
+ export type HpcToolRenderResult = {
10
+ content: Array<{ type: string; text?: string }>;
11
+ details?: Record<string, unknown>;
12
+ };
13
+
14
+ export type HpcExecResult = {
15
+ stdout: string;
16
+ stderr: string;
17
+ exitCode: number;
18
+ killed?: boolean;
19
+ };