pi-better-harness 0.3.2 → 0.3.4

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.
@@ -0,0 +1,97 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ import { planProgress } from "./plan-state.js";
5
+ import type { PlanSnapshot, PlanStep } from "./types.js";
6
+
7
+ export interface PlanRenderTheme {
8
+ fg(color: string, value: string): string;
9
+ }
10
+
11
+ export function renderCompactPlan(
12
+ plan: PlanSnapshot,
13
+ width: number,
14
+ theme: PlanRenderTheme,
15
+ options: { focused?: boolean; selectedIndex?: number } = {},
16
+ ): string[] {
17
+ const progress = planProgress(plan);
18
+ const selected = clampIndex(options.selectedIndex ?? progress.activeIndex ?? firstIncompleteIndex(plan), plan.steps.length);
19
+ const stateLabel = progress.blocked > 0
20
+ ? `${progress.blocked} blocked`
21
+ : progress.state === "complete"
22
+ ? "complete"
23
+ : progress.state.replace("_", " ");
24
+ const lines = [
25
+ theme.fg(progress.blocked > 0 ? "warning" : "accent", `plan ${progress.completed}/${progress.total} steps`) +
26
+ theme.fg("dim", ` · ${stateLabel}`),
27
+ ];
28
+
29
+ for (let index = 0; index < plan.steps.length; index += 1) {
30
+ const item = plan.steps[index]!;
31
+ const selectedPrefix = options.focused && index === selected ? theme.fg("accent", "› ") : " ";
32
+ lines.push(`${selectedPrefix}${stepGlyph(item, theme)} ${index + 1} ${stepText(item, theme)}`);
33
+ }
34
+ lines.push("");
35
+ return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
36
+ }
37
+
38
+ export function renderFullPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, selectedIndex = -1): string[] {
39
+ const progress = planProgress(plan);
40
+ const heading = `Practical Plan · ${progress.completed}/${progress.total} completed`;
41
+ const lines = [theme.fg("accent", rule(heading, width)), ""];
42
+ for (let index = 0; index < plan.steps.length; index += 1) {
43
+ const item = plan.steps[index]!;
44
+ const prefix = index === selectedIndex ? theme.fg("accent", "› ") : " ";
45
+ lines.push(`${prefix}${stepGlyph(item, theme)} ${String(index + 1).padStart(2, " ")} ${stepText(item, theme)}`);
46
+ }
47
+ lines.push("", theme.fg("dim", rule("", width)));
48
+ return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
49
+ }
50
+
51
+ export function createFullPlanComponent(
52
+ plan: PlanSnapshot,
53
+ theme: PlanRenderTheme,
54
+ onClose: () => void,
55
+ ): Component {
56
+ let selected = planProgress(plan).activeIndex ?? firstIncompleteIndex(plan);
57
+ return {
58
+ render: (width) => renderFullPlan(plan, width, theme, selected),
59
+ handleInput(data) {
60
+ if (matchesKey(data, "up")) selected = clampIndex(selected - 1, plan.steps.length);
61
+ else if (matchesKey(data, "down")) selected = clampIndex(selected + 1, plan.steps.length);
62
+ else if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
63
+ },
64
+ invalidate() {},
65
+ };
66
+ }
67
+
68
+ function firstIncompleteIndex(plan: PlanSnapshot): number {
69
+ const index = plan.steps.findIndex((item) => item.status !== "completed");
70
+ return index >= 0 ? index : Math.max(0, plan.steps.length - 1);
71
+ }
72
+
73
+ function clampIndex(index: number, total: number): number {
74
+ return Math.min(Math.max(0, index), Math.max(0, total - 1));
75
+ }
76
+
77
+ function stepGlyph(item: PlanStep, theme: PlanRenderTheme): string {
78
+ switch (item.status) {
79
+ case "completed": return theme.fg("success", "✓");
80
+ case "in_progress": return theme.fg("accent", "●");
81
+ case "blocked": return theme.fg("warning", "!");
82
+ case "pending": return theme.fg("dim", "○");
83
+ }
84
+ }
85
+
86
+ function stepText(item: PlanStep, theme: PlanRenderTheme): string {
87
+ if (item.status === "completed" || item.status === "pending") return theme.fg("muted", item.step);
88
+ if (item.status === "blocked") return theme.fg("warning", `BLOCKED · ${item.step}`);
89
+ return item.step;
90
+ }
91
+
92
+ function rule(label: string, width: number): string {
93
+ const size = Math.max(1, Math.floor(width));
94
+ if (!label) return "━".repeat(size);
95
+ const prefix = `━━ ${label} `;
96
+ return prefix + "━".repeat(Math.max(0, size - prefix.length));
97
+ }
@@ -0,0 +1,154 @@
1
+ import type { PlanDisplayMode, PlanEntry, PlanProgress, PlanSnapshot, PlanStep, PlanStepInput } from "./types.js";
2
+ import { EXTENSION_NAME } from "./types.js";
3
+
4
+ const MAX_STEPS = 50;
5
+ const MAX_STEP_CHARS = 500;
6
+ const MAX_EXPLANATION_CHARS = 2_000;
7
+
8
+ interface SessionEntryLike {
9
+ type: string;
10
+ customType?: string;
11
+ data?: unknown;
12
+ }
13
+
14
+ export interface ReconstructedPlanState {
15
+ plan: PlanSnapshot | null;
16
+ displayMode: PlanDisplayMode;
17
+ }
18
+
19
+ function nowSeconds(): number {
20
+ return Math.floor(Date.now() / 1_000);
21
+ }
22
+
23
+ function nextPlanId(now: number): string {
24
+ return `plan_${now.toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
25
+ }
26
+
27
+ export function validatePlanInput(steps: readonly PlanStepInput[], explanation?: string): string | null {
28
+ if (steps.length === 0) return "A plan must contain at least one step.";
29
+ if (steps.length > MAX_STEPS) return `A plan cannot contain more than ${MAX_STEPS} steps.`;
30
+ if (explanation !== undefined && explanation.length > MAX_EXPLANATION_CHARS) {
31
+ return `Plan explanation cannot exceed ${MAX_EXPLANATION_CHARS} characters.`;
32
+ }
33
+
34
+ let inProgress = 0;
35
+ const normalized = new Set<string>();
36
+ for (let index = 0; index < steps.length; index += 1) {
37
+ const item = steps[index]!;
38
+ const text = item.step.trim();
39
+ if (!text) return `Plan step ${index + 1} cannot be empty.`;
40
+ if (text.length > MAX_STEP_CHARS) {
41
+ return `Plan step ${index + 1} cannot exceed ${MAX_STEP_CHARS} characters.`;
42
+ }
43
+ const key = text.toLocaleLowerCase();
44
+ if (normalized.has(key)) return `Plan step ${index + 1} duplicates an earlier step.`;
45
+ normalized.add(key);
46
+ if (item.status === "in_progress") inProgress += 1;
47
+ }
48
+ if (inProgress > 1) return "A plan can have at most one step in progress.";
49
+ return null;
50
+ }
51
+
52
+ export function replacePlan(
53
+ current: PlanSnapshot | null,
54
+ steps: readonly PlanStepInput[],
55
+ explanation?: string,
56
+ now = nowSeconds(),
57
+ ): PlanSnapshot {
58
+ const error = validatePlanInput(steps, explanation);
59
+ if (error) throw new Error(error);
60
+
61
+ const reusableIds = new Map(current?.steps.map((item) => [item.step.trim().toLocaleLowerCase(), item.id]) ?? []);
62
+ const revision = (current?.revision ?? 0) + 1;
63
+ const normalizedSteps: PlanStep[] = steps.map((item, index) => {
64
+ const step = item.step.trim();
65
+ return {
66
+ id: reusableIds.get(step.toLocaleLowerCase()) ?? `step_${revision}_${index + 1}`,
67
+ step,
68
+ status: item.status,
69
+ };
70
+ });
71
+
72
+ return {
73
+ version: 1,
74
+ planId: current?.planId ?? nextPlanId(now),
75
+ revision,
76
+ ...(explanation?.trim() ? { explanation: explanation.trim() } : {}),
77
+ steps: normalizedSteps,
78
+ createdAt: current?.createdAt ?? now,
79
+ updatedAt: now,
80
+ };
81
+ }
82
+
83
+ export function planProgress(plan: PlanSnapshot): PlanProgress {
84
+ const completed = plan.steps.filter((item) => item.status === "completed").length;
85
+ const pending = plan.steps.filter((item) => item.status === "pending").length;
86
+ const blocked = plan.steps.filter((item) => item.status === "blocked").length;
87
+ const inProgress = plan.steps.filter((item) => item.status === "in_progress").length;
88
+ const activeIndex = plan.steps.findIndex((item) => item.status === "in_progress" || item.status === "blocked");
89
+ return {
90
+ total: plan.steps.length,
91
+ completed,
92
+ pending,
93
+ blocked,
94
+ inProgress,
95
+ activeIndex: activeIndex >= 0 ? activeIndex : null,
96
+ state:
97
+ completed === plan.steps.length
98
+ ? "complete"
99
+ : inProgress > 0
100
+ ? "in_progress"
101
+ : blocked > 0
102
+ ? "blocked"
103
+ : "draft",
104
+ };
105
+ }
106
+
107
+ export function planSetEntry(plan: PlanSnapshot, at = nowSeconds()): PlanEntry {
108
+ return { version: 1, kind: "set", plan, at };
109
+ }
110
+
111
+ export function planClearEntry(at = nowSeconds()): PlanEntry {
112
+ return { version: 1, kind: "clear", at };
113
+ }
114
+
115
+ export function planDisplayEntry(mode: PlanDisplayMode, at = nowSeconds()): PlanEntry {
116
+ return { version: 1, kind: "display", mode, at };
117
+ }
118
+
119
+ export function reconstructPlanState(entries: Iterable<SessionEntryLike>): ReconstructedPlanState {
120
+ let plan: PlanSnapshot | null = null;
121
+ let displayMode: PlanDisplayMode = "auto";
122
+ for (const entry of entries) {
123
+ if (entry.type !== "custom" || entry.customType !== EXTENSION_NAME || !isPlanEntry(entry.data)) continue;
124
+ if (entry.data.kind === "set") plan = entry.data.plan;
125
+ else if (entry.data.kind === "clear") plan = null;
126
+ else displayMode = entry.data.mode;
127
+ }
128
+ return { plan, displayMode };
129
+ }
130
+
131
+ function isPlanEntry(value: unknown): value is PlanEntry {
132
+ if (!value || typeof value !== "object") return false;
133
+ const candidate = value as Partial<PlanEntry>;
134
+ if (candidate.version !== 1) return false;
135
+ if (candidate.kind === "clear") return true;
136
+ if (candidate.kind === "display") {
137
+ return candidate.mode === "auto" || candidate.mode === "on" || candidate.mode === "off" || candidate.mode === "hidden";
138
+ }
139
+ if (candidate.kind !== "set") return false;
140
+ return isPlanSnapshot(candidate.plan);
141
+ }
142
+
143
+ function isPlanSnapshot(value: unknown): value is PlanSnapshot {
144
+ if (!value || typeof value !== "object") return false;
145
+ const candidate = value as Partial<PlanSnapshot>;
146
+ return (
147
+ candidate.version === 1 &&
148
+ typeof candidate.planId === "string" &&
149
+ typeof candidate.revision === "number" &&
150
+ Array.isArray(candidate.steps) &&
151
+ typeof candidate.createdAt === "number" &&
152
+ typeof candidate.updatedAt === "number"
153
+ );
154
+ }
@@ -0,0 +1,38 @@
1
+ export const EXTENSION_NAME = "pi-better-plan";
2
+
3
+ export type PlanStepStatus = "pending" | "in_progress" | "completed" | "blocked";
4
+ export type PlanDisplayMode = "auto" | "on" | "off" | "hidden";
5
+
6
+ export interface PlanStepInput {
7
+ step: string;
8
+ status: PlanStepStatus;
9
+ }
10
+
11
+ export interface PlanStep extends PlanStepInput {
12
+ id: string;
13
+ }
14
+
15
+ export interface PlanSnapshot {
16
+ version: 1;
17
+ planId: string;
18
+ revision: number;
19
+ explanation?: string;
20
+ steps: PlanStep[];
21
+ createdAt: number;
22
+ updatedAt: number;
23
+ }
24
+
25
+ export interface PlanProgress {
26
+ total: number;
27
+ completed: number;
28
+ pending: number;
29
+ blocked: number;
30
+ inProgress: number;
31
+ activeIndex: number | null;
32
+ state: "draft" | "in_progress" | "blocked" | "complete";
33
+ }
34
+
35
+ export type PlanEntry =
36
+ | { version: 1; kind: "set"; plan: PlanSnapshot; at: number }
37
+ | { version: 1; kind: "clear"; at: number }
38
+ | { version: 1; kind: "display"; mode: PlanDisplayMode; at: number };
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1aboveio
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.
@@ -0,0 +1,99 @@
1
+ # pi-better-ssh
2
+
3
+ `pi-better-ssh` runs short synchronous remote commands over safe, reusable SSH connections. It adds explicit remote tools; Pi's built-in `bash` remains local and is never overridden.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:pi-better-ssh
9
+ ```
10
+
11
+ For one session without changing Pi settings:
12
+
13
+ ```sh
14
+ pi -e npm:pi-better-ssh
15
+ ```
16
+
17
+ ## Choose The Right Tool
18
+
19
+ Use `remote_bash` when the foreground turn should wait for a short remote command's output and exit code. Use `bg_task_spawn` and the `bg_task_*` lifecycle tools with structured `ssh` for long-running or durable remote jobs; those jobs use remote tmux for lifecycle control.
20
+
21
+ | Tool | Purpose |
22
+ | --- | --- |
23
+ | `remote_bash` | Run one short remote bash command and wait for its result. |
24
+ | `ssh_profile` | List SSH aliases or manage the active session's host, workdir, and environment defaults. |
25
+ | `ssh_mux` | Inspect or stop reusable ControlMaster connections. |
26
+
27
+ ## SSH Hosts
28
+
29
+ Prefer a literal `Host` alias in `~/.ssh/config`; OpenSSH applies its configured hostname, user, port, key, jump host, and other policy. Wildcard entries are honored by OpenSSH but are not listed by `ssh_profile`.
30
+
31
+ ```sshconfig
32
+ Host airflow-prod
33
+ HostName airflow.internal.example
34
+ User deploy
35
+ IdentityFile ~/.ssh/airflow_ed25519
36
+ ProxyJump bastion.example
37
+ ```
38
+
39
+ Tool calls can use the alias (`airflow-prod`) or an explicit `user@host` target. Structured `user`, `port`, `identity_file`, `jump`, and `options` fields override connection details without constructing a shell command.
40
+
41
+ ## Run A Command
42
+
43
+ `remote_bash` requires `command`. It requires `host` unless an active profile supplies one. `workdir` and `env` apply on the remote host; `timeout` is an optional positive number of seconds.
44
+
45
+ ```json
46
+ {
47
+ "command": "airflow dags list",
48
+ "host": "airflow-prod",
49
+ "workdir": "/opt/airflow",
50
+ "env": { "AIRFLOW_HOME": "/opt/airflow" },
51
+ "timeout": 30
52
+ }
53
+ ```
54
+
55
+ A remote non-zero exit is a normal tool result with output and `exitCode`, so agents can inspect command failures. Output follows Pi bash limits: the last 2,000 lines or 50KB, whichever is reached first. Complete truncated output is retained in a local temporary file.
56
+
57
+ ## Session Profile
58
+
59
+ A profile sets defaults for later `remote_bash` calls. `use` accepts a configured Host alias or `user@host`, plus optional remote `workdir` and `env`. The active profile is stored in the Pi session and survives `/reload`; it is not a separate host inventory.
60
+
61
+ ```json
62
+ { "action": "list" }
63
+ ```
64
+
65
+ ```json
66
+ {
67
+ "action": "use",
68
+ "host": "airflow-prod",
69
+ "workdir": "/opt/airflow",
70
+ "env": { "AIRFLOW_HOME": "/opt/airflow" }
71
+ }
72
+ ```
73
+
74
+ Call `ssh_profile` with `status` to inspect the active profile or `clear` to remove it. A footer chip shows the active target, workdir, and whether its mux is up or down. An explicit `host`, `workdir`, or environment entry on `remote_bash` takes precedence over the corresponding profile default.
75
+
76
+ ## ControlMaster
77
+
78
+ `remote_bash` checks for a reusable ControlMaster and opens one when needed. Its ControlPath lives under the harness-owned `~/.pi/agent/ssh-control` directory, which is forced to mode `0700`; the socket key includes the connection identity and Pi session. A stale socket is removed and reopened once. Repeated commands for the same target and session reuse the master.
79
+
80
+ Use `ssh_mux` with `status` or `stop` and either a target, the active profile, or `all:true` for masters observed in the current Pi process and session:
81
+
82
+ ```json
83
+ { "action": "status", "host": "airflow-prod" }
84
+ ```
85
+
86
+ ```json
87
+ { "action": "stop", "all": true }
88
+ ```
89
+
90
+ Stopping a mux does not stop remote tmux jobs. A `remote_bash` timeout terminates only that slave SSH process; a healthy reusable master remains available.
91
+
92
+ ## Safety Defaults
93
+
94
+ - SSH is launched as argv with `shell:false`; connection fields are never interpolated into a local shell command.
95
+ - `BatchMode=yes`, a bounded connect timeout, and no TTY are always enforced, so password prompts fail fast.
96
+ - Caller `options` cannot disable required SSH or mux safety settings.
97
+ - Remote workdir and environment values are shell-quoted by the shared SSH core before `bash -c` runs.
98
+ - Identity file paths may appear in status details, but key material is never read or displayed.
99
+ - This package provides no interactive shell, PTY attach, remote IDE mode, or local `bash` override.
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "pi-better-ssh",
3
+ "version": "0.1.1",
4
+ "description": "Pi extension for safe synchronous remote bash commands over reusable SSH connections.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "pi",
11
+ "ssh",
12
+ "remote-bash"
13
+ ],
14
+ "pi": {
15
+ "extensions": [
16
+ "./src/index.ts"
17
+ ]
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/1aboveio/pi-better-harness.git",
22
+ "directory": "packages/pi-better-ssh"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/1aboveio/pi-better-harness/issues"
26
+ },
27
+ "homepage": "https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-ssh#readme",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "pretest": "node ../../scripts/sync-shared-ssh-core.mjs",
33
+ "prepack": "node ../../scripts/sync-shared-ssh-core.mjs",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "vitest run"
36
+ },
37
+ "files": [
38
+ "src/**/*.ts",
39
+ "!src/**/*.test.ts",
40
+ "!src/shared-ssh-core/test-support/**",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "typebox": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^25.0.0",
50
+ "typebox": "^1.1.39",
51
+ "typescript": "^6.0.0",
52
+ "vitest": "^3.0.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=22.0.0"
56
+ }
57
+ }