pi-better-harness 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/extensions/ssh/index.ts +1 -0
- package/lib/cli.mjs +1 -0
- package/node_modules/pi-better-background-tasks/README.md +1 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/goal-provider.ts +3 -2
- package/node_modules/pi-better-background-tasks/src/maintenance.ts +96 -0
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +4 -2
- package/node_modules/pi-better-background-tasks/src/process-identity.ts +41 -0
- package/node_modules/pi-better-background-tasks/src/registry.ts +194 -2
- package/node_modules/pi-better-background-tasks/src/runtime.ts +10 -0
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +16 -2
- package/node_modules/pi-better-background-tasks/src/tools.ts +5 -3
- package/node_modules/pi-better-background-tasks/src/types.ts +4 -0
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +37 -5
- package/node_modules/pi-better-goal/src/subagents.ts +42 -4
- package/node_modules/pi-better-goal/src/types.ts +1 -0
- package/node_modules/pi-better-ssh/LICENSE +21 -0
- package/node_modules/pi-better-ssh/README.md +99 -0
- package/node_modules/pi-better-ssh/package.json +57 -0
- package/node_modules/pi-better-ssh/src/index.ts +314 -0
- package/node_modules/pi-better-ssh/src/mux-registry.ts +86 -0
- package/node_modules/pi-better-ssh/src/process-runner.ts +166 -0
- package/node_modules/pi-better-ssh/src/profile.ts +132 -0
- package/node_modules/pi-better-ssh/src/remote-bash.ts +184 -0
- package/node_modules/pi-better-ssh/src/shared-ssh-core/index.ts +902 -0
- package/node_modules/pi-better-subagents/cleanup.ts +6 -2
- package/node_modules/pi-better-subagents/index.ts +56 -20
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/registry.ts +207 -3
- package/node_modules/pi-better-subagents/shared-navigator.ts +16 -2
- package/package.json +11 -6
|
@@ -162,6 +162,7 @@ function formatSnapshot(snapshot: ActivitySnapshot): string {
|
|
|
162
162
|
|
|
163
163
|
export default function (pi: ExtensionAPI): void {
|
|
164
164
|
const providers = new Map<string, BackgroundActivityProvider>();
|
|
165
|
+
const providerUnsubscribers = new Map<string, () => void>();
|
|
165
166
|
providers.set("subagents", {
|
|
166
167
|
id: "subagents",
|
|
167
168
|
label: "Subagents",
|
|
@@ -172,6 +173,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
172
173
|
let foregroundRunning = false;
|
|
173
174
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
174
175
|
let collecting = false;
|
|
176
|
+
let collectionPending = false;
|
|
175
177
|
let latestSnapshot: ActivitySnapshot | null = null;
|
|
176
178
|
let backgroundDrainTracker: BackgroundDrainTracker | null = null;
|
|
177
179
|
let lastWakeSignature = "";
|
|
@@ -230,6 +232,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
230
232
|
continuationQueuedFor = null;
|
|
231
233
|
backgroundDrainTracker = null;
|
|
232
234
|
clearIdleContinuation();
|
|
235
|
+
syncPollingState();
|
|
233
236
|
// Force a full redraw when the dock height changes (absent ↔ visible clock).
|
|
234
237
|
refreshGoalWidget?.(!wasVisible || !isGoalClockVisible(goal));
|
|
235
238
|
};
|
|
@@ -241,6 +244,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
241
244
|
continuationQueuedFor = null;
|
|
242
245
|
backgroundDrainTracker = null;
|
|
243
246
|
clearIdleContinuation();
|
|
247
|
+
syncPollingState();
|
|
244
248
|
refreshGoalWidget?.(wasVisible);
|
|
245
249
|
};
|
|
246
250
|
|
|
@@ -409,12 +413,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
409
413
|
|
|
410
414
|
const collectIfPossible = (): void => {
|
|
411
415
|
const ctx = currentCtx;
|
|
412
|
-
if (!ctx
|
|
416
|
+
if (!ctx) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (collecting) {
|
|
420
|
+
collectionPending = true;
|
|
413
421
|
return;
|
|
414
422
|
}
|
|
415
423
|
collecting = true;
|
|
416
424
|
void publishSnapshot(ctx).finally(() => {
|
|
417
425
|
collecting = false;
|
|
426
|
+
if (collectionPending) {
|
|
427
|
+
collectionPending = false;
|
|
428
|
+
collectIfPossible();
|
|
429
|
+
} else {
|
|
430
|
+
syncPollingState();
|
|
431
|
+
}
|
|
418
432
|
});
|
|
419
433
|
};
|
|
420
434
|
|
|
@@ -424,7 +438,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
424
438
|
}
|
|
425
439
|
pollTimer = setInterval(collectIfPossible, POLL_INTERVAL_MS);
|
|
426
440
|
pollTimer.unref?.();
|
|
427
|
-
collectIfPossible();
|
|
428
441
|
};
|
|
429
442
|
|
|
430
443
|
const stopPolling = (): void => {
|
|
@@ -435,6 +448,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
435
448
|
pollTimer = undefined;
|
|
436
449
|
};
|
|
437
450
|
|
|
451
|
+
const syncPollingState = (): void => {
|
|
452
|
+
const ctx = currentCtx;
|
|
453
|
+
const goal = ctx ? currentGoalSnapshot(ctx) : null;
|
|
454
|
+
if (ctx && (isPokeable(goal) || latestSnapshot?.backgroundRunning === true)) {
|
|
455
|
+
startPolling();
|
|
456
|
+
} else {
|
|
457
|
+
stopPolling();
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
438
461
|
const installGoalWidget = (ctx: ExtensionContext): void => {
|
|
439
462
|
if (!ctx.hasUI) {
|
|
440
463
|
return;
|
|
@@ -494,8 +517,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
494
517
|
if (!candidate || typeof candidate.id !== "string" || typeof candidate.getActivity !== "function") {
|
|
495
518
|
return;
|
|
496
519
|
}
|
|
497
|
-
|
|
498
|
-
|
|
520
|
+
const accepted = candidate as BackgroundActivityProvider;
|
|
521
|
+
providerUnsubscribers.get(accepted.id)?.();
|
|
522
|
+
providerUnsubscribers.delete(accepted.id);
|
|
523
|
+
providers.set(accepted.id, accepted);
|
|
524
|
+
if (typeof accepted.onActivityChanged === "function") {
|
|
525
|
+
providerUnsubscribers.set(accepted.id, accepted.onActivityChanged(collectIfPossible));
|
|
526
|
+
}
|
|
527
|
+
if (latestSnapshot) collectIfPossible();
|
|
499
528
|
});
|
|
500
529
|
|
|
501
530
|
pi.registerCommand("goal", {
|
|
@@ -661,7 +690,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
661
690
|
foregroundRunning = !ctx.isIdle();
|
|
662
691
|
pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
|
|
663
692
|
installGoalWidget(ctx);
|
|
664
|
-
|
|
693
|
+
latestSnapshot = await publishSnapshot(ctx);
|
|
694
|
+
syncPollingState();
|
|
665
695
|
});
|
|
666
696
|
|
|
667
697
|
pi.on("input", async (event, ctx) => {
|
|
@@ -762,6 +792,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
762
792
|
foregroundRunning = false;
|
|
763
793
|
clearIdleContinuation();
|
|
764
794
|
currentCtx = undefined;
|
|
795
|
+
latestSnapshot = null;
|
|
796
|
+
collectionPending = false;
|
|
765
797
|
try {
|
|
766
798
|
ctx.ui.setStatus(EXTENSION_NAME, undefined);
|
|
767
799
|
ctx.ui.setWidget(EXTENSION_NAME, undefined);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
@@ -82,6 +82,46 @@ export function readSubagentMetas(baseDir = defaultSubagentBaseDir()): SubagentR
|
|
|
82
82
|
return metas.sort((left, right) => right.startedAt - left.startedAt);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
export function readParentSubagentMetas(
|
|
86
|
+
baseDir = defaultSubagentBaseDir(),
|
|
87
|
+
parentPid = process.pid,
|
|
88
|
+
): SubagentRunMeta[] {
|
|
89
|
+
const directory = join(baseDir, "by-parent", String(parentPid));
|
|
90
|
+
ensureParentIndex(baseDir, directory, parentPid);
|
|
91
|
+
let ids: string[];
|
|
92
|
+
try {
|
|
93
|
+
ids = readdirSync(directory).filter((id) => id !== ".initialized");
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
return ids
|
|
98
|
+
.map((id) => readSubagentMeta(baseDir, id))
|
|
99
|
+
.filter((meta): meta is SubagentRunMeta => meta !== undefined && meta.spawnPid === parentPid)
|
|
100
|
+
.sort((left, right) => right.startedAt - left.startedAt);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readSubagentMeta(baseDir: string, id: string): SubagentRunMeta | undefined {
|
|
104
|
+
try {
|
|
105
|
+
const meta = JSON.parse(readFileSync(join(baseDir, "runs", id, "meta.json"), "utf8")) as SubagentRunMeta;
|
|
106
|
+
return typeof meta.id === "string" && typeof meta.status === "string" ? meta : undefined;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ensureParentIndex(baseDir: string, directory: string, parentPid: number): void {
|
|
113
|
+
try {
|
|
114
|
+
readFileSync(join(directory, ".initialized"));
|
|
115
|
+
return;
|
|
116
|
+
} catch {
|
|
117
|
+
// Existing registries are backfilled once for this parent process.
|
|
118
|
+
}
|
|
119
|
+
const owned = readSubagentMetas(baseDir).filter((meta) => meta.spawnPid === parentPid);
|
|
120
|
+
mkdirSync(directory, { recursive: true });
|
|
121
|
+
for (const meta of owned) writeFileSync(join(directory, meta.id), "");
|
|
122
|
+
writeFileSync(join(directory, ".initialized"), "1");
|
|
123
|
+
}
|
|
124
|
+
|
|
85
125
|
export function subagentStatusToItem(
|
|
86
126
|
meta: SubagentRunMeta,
|
|
87
127
|
status: SubagentEffectiveStatus,
|
|
@@ -122,9 +162,7 @@ export function collectSubagentActivity(
|
|
|
122
162
|
): BackgroundProviderSnapshot {
|
|
123
163
|
const parentPid = options.parentPid ?? process.pid;
|
|
124
164
|
const processExists = options.processExists ?? defaultProcessExists;
|
|
125
|
-
const metas =
|
|
126
|
-
isCurrentParentSubagent(meta, parentPid),
|
|
127
|
-
);
|
|
165
|
+
const metas = readParentSubagentMetas(options.baseDir, parentPid);
|
|
128
166
|
|
|
129
167
|
return {
|
|
130
168
|
providerId: "subagents",
|
|
@@ -47,6 +47,7 @@ export interface BackgroundActivityProvider {
|
|
|
47
47
|
id: string;
|
|
48
48
|
label?: string;
|
|
49
49
|
getActivity(ctx: ExtensionContext): BackgroundProviderSnapshot | Promise<BackgroundProviderSnapshot>;
|
|
50
|
+
onActivityChanged?(notify: () => void): () => void;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export interface ActivitySnapshot {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_MAX_BYTES,
|
|
4
|
+
DEFAULT_MAX_LINES,
|
|
5
|
+
formatSize,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { createSshMuxRegistry } from "./mux-registry.js";
|
|
9
|
+
import type { SshMuxEntryMap, SshMuxRegistry } from "./mux-registry.js";
|
|
10
|
+
import { createProcessRemoteRunner } from "./process-runner.js";
|
|
11
|
+
import {
|
|
12
|
+
createSshProfile,
|
|
13
|
+
formatSshProfileChip,
|
|
14
|
+
listSshHostAliases,
|
|
15
|
+
profileStateEntry,
|
|
16
|
+
restoreSshProfile,
|
|
17
|
+
SSH_PROFILE_ENTRY_TYPE,
|
|
18
|
+
} from "./profile.js";
|
|
19
|
+
import type { SshProfile } from "./profile.js";
|
|
20
|
+
import { executeRemoteBash, resolveRemoteBashConnection } from "./remote-bash.js";
|
|
21
|
+
import type { RemoteBashDependencies, RemoteBashResult } from "./remote-bash.js";
|
|
22
|
+
import { resolveSshCommand } from "./shared-ssh-core/index.js";
|
|
23
|
+
|
|
24
|
+
const STATUS_KEY = "pi-better-ssh";
|
|
25
|
+
|
|
26
|
+
const RemoteBashParams = Type.Object({
|
|
27
|
+
command: Type.String({ description: "Bash command string to execute remotely." }),
|
|
28
|
+
host: Type.Optional(Type.String({ description: "SSH Host alias or user@host target. Optional when an active SSH profile supplies the host." })),
|
|
29
|
+
workdir: Type.Optional(Type.String({ description: "Remote working directory applied before the command." })),
|
|
30
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout). Cancels only this SSH slave command." })),
|
|
31
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Remote environment variables exported before the command." })),
|
|
32
|
+
user: Type.Optional(Type.String({ description: "Structured SSH user override." })),
|
|
33
|
+
port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535, description: "Structured SSH port override." })),
|
|
34
|
+
identity_file: Type.Optional(Type.String({ description: "Structured SSH identity file path." })),
|
|
35
|
+
jump: Type.Optional(Type.String({ description: "Structured SSH jump host passed with -J." })),
|
|
36
|
+
options: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Additional SSH -o key/value options. Callers cannot disable required BatchMode, connect-timeout, no-TTY, or mux safety options." })),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const SshMuxParams = Type.Object({
|
|
40
|
+
action: Type.Union([Type.Literal("status"), Type.Literal("stop")]),
|
|
41
|
+
all: Type.Optional(Type.Boolean({ description: "Apply to every ControlMaster known in this Pi process for the current session." })),
|
|
42
|
+
host: Type.Optional(Type.String({ description: "SSH Host alias or user@host target. Defaults to the active profile." })),
|
|
43
|
+
user: Type.Optional(Type.String({ description: "Structured SSH user override." })),
|
|
44
|
+
port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535 })),
|
|
45
|
+
identity_file: Type.Optional(Type.String()),
|
|
46
|
+
jump: Type.Optional(Type.String()),
|
|
47
|
+
options: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const SshProfileParams = Type.Object({
|
|
51
|
+
action: Type.Union([
|
|
52
|
+
Type.Literal("list"),
|
|
53
|
+
Type.Literal("use"),
|
|
54
|
+
Type.Literal("status"),
|
|
55
|
+
Type.Literal("clear"),
|
|
56
|
+
]),
|
|
57
|
+
host: Type.Optional(Type.String({ description: "SSH config Host alias or user@host. Required for use." })),
|
|
58
|
+
workdir: Type.Optional(Type.String({ description: "Default remote working directory." })),
|
|
59
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Default remote environment variables." })),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export interface RegisterRemoteBashDependencies {
|
|
63
|
+
runner: RemoteBashDependencies["runner"];
|
|
64
|
+
controlPathRoot?: string;
|
|
65
|
+
getActiveProfile?: () => SshProfile | undefined;
|
|
66
|
+
muxRegistry?: SshMuxRegistry;
|
|
67
|
+
onResult?: (result: RemoteBashResult, params: { host?: string }, ctx: ExtensionContext) => void | Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RegisterSshExtensionDependencies extends RegisterRemoteBashDependencies {
|
|
71
|
+
sshConfigPath?: string;
|
|
72
|
+
muxEntries?: SshMuxEntryMap;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function registerRemoteBashTool(pi: ExtensionAPI, dependencies: RegisterRemoteBashDependencies): void {
|
|
76
|
+
pi.registerTool({
|
|
77
|
+
name: "remote_bash",
|
|
78
|
+
label: "Remote Bash",
|
|
79
|
+
description: `Run one short remote bash command synchronously over a reusable SSH ControlMaster. Pass an SSH Host alias or user@host, or omit host when an active SSH profile supplies it; optional workdir, env, timeout, and structured identity fields are supported. Required SSH safety options cannot be disabled. Returns remote output and exitCode without turning a non-zero remote exit into a tool failure. Output is truncated to the last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first), with full output saved locally. For long-running remote work, use bg_task_spawn (or bg_task_* lifecycle tools) with structured ssh instead. Pi's built-in bash remains local.`,
|
|
80
|
+
promptSnippet: "Run a short synchronous remote command through safe reusable SSH",
|
|
81
|
+
promptGuidelines: [
|
|
82
|
+
"Use remote_bash for short synchronous remote commands instead of writing ssh commands inside local bash.",
|
|
83
|
+
"Use bg_task_spawn with structured ssh for long-running or durable remote work instead of remote_bash.",
|
|
84
|
+
],
|
|
85
|
+
parameters: RemoteBashParams,
|
|
86
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
87
|
+
const sessionScope = ctx.sessionManager.getSessionId();
|
|
88
|
+
const activeProfile = dependencies.getActiveProfile?.();
|
|
89
|
+
const result = await executeRemoteBash(params, {
|
|
90
|
+
runner: dependencies.runner,
|
|
91
|
+
sessionScope,
|
|
92
|
+
...(dependencies.controlPathRoot ? { controlPathRoot: dependencies.controlPathRoot } : {}),
|
|
93
|
+
...(activeProfile ? { activeProfile } : {}),
|
|
94
|
+
...(dependencies.muxRegistry ? { muxRegistry: dependencies.muxRegistry } : {}),
|
|
95
|
+
}, signal);
|
|
96
|
+
await dependencies.onResult?.(result, params, ctx);
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text" as const, text: formatToolResult(result, params.timeout) }],
|
|
99
|
+
details: result,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSshExtensionDependencies): void {
|
|
106
|
+
let activeProfile: SshProfile | undefined;
|
|
107
|
+
const muxRegistry = dependencies.muxRegistry ?? createSshMuxRegistry({
|
|
108
|
+
runner: dependencies.runner,
|
|
109
|
+
...(dependencies.controlPathRoot ? { controlPathRoot: dependencies.controlPathRoot } : {}),
|
|
110
|
+
...(dependencies.muxEntries ? { entries: dependencies.muxEntries } : {}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
registerRemoteBashTool(pi, {
|
|
114
|
+
...dependencies,
|
|
115
|
+
muxRegistry,
|
|
116
|
+
getActiveProfile: () => activeProfile,
|
|
117
|
+
onResult: (result, params, ctx) => {
|
|
118
|
+
if (activeProfile && params.host === undefined) {
|
|
119
|
+
setFooter(ctx, activeProfile, result.mux.state);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const resolveMuxTarget = (params: {
|
|
125
|
+
host?: string;
|
|
126
|
+
user?: string;
|
|
127
|
+
port?: number;
|
|
128
|
+
identity_file?: string;
|
|
129
|
+
jump?: string;
|
|
130
|
+
options?: Record<string, string>;
|
|
131
|
+
}) => {
|
|
132
|
+
const host = params.host ?? activeProfile?.host;
|
|
133
|
+
if (!host) {
|
|
134
|
+
throw new Error("ssh_mux requires host (an SSH Host alias or user@host) or an active SSH profile");
|
|
135
|
+
}
|
|
136
|
+
const ssh = resolveRemoteBashConnection({
|
|
137
|
+
command: "true",
|
|
138
|
+
host,
|
|
139
|
+
...(params.user !== undefined ? { user: params.user } : {}),
|
|
140
|
+
...(params.port !== undefined ? { port: params.port } : {}),
|
|
141
|
+
...(params.identity_file !== undefined ? { identity_file: params.identity_file } : {}),
|
|
142
|
+
...(params.jump !== undefined ? { jump: params.jump } : {}),
|
|
143
|
+
...(params.options !== undefined ? { options: params.options } : {}),
|
|
144
|
+
});
|
|
145
|
+
return resolveSshCommand({ command: "true", ssh });
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const queryMux = async (profile: SshProfile, sessionScope: string) => {
|
|
149
|
+
try {
|
|
150
|
+
const status = await muxRegistry.statusTarget(resolveMuxTarget({ host: profile.host }), sessionScope);
|
|
151
|
+
return { state: status.state, target: status.target, detail: status.detail };
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
state: "down" as const,
|
|
155
|
+
target: profile.host,
|
|
156
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const setFooter = (ctx: ExtensionContext, profile: SshProfile | undefined, muxState?: "up" | "down"): void => {
|
|
162
|
+
try {
|
|
163
|
+
ctx.ui.setStatus(STATUS_KEY, profile ? formatSshProfileChip(profile, muxState ?? "down") : undefined);
|
|
164
|
+
} catch {
|
|
165
|
+
// Footer status is best-effort in non-interactive and partial harness contexts.
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const refreshFooter = async (ctx: ExtensionContext) => {
|
|
170
|
+
if (!activeProfile) {
|
|
171
|
+
setFooter(ctx, undefined);
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
const mux = await queryMux(activeProfile, ctx.sessionManager.getSessionId());
|
|
175
|
+
setFooter(ctx, activeProfile, mux.state);
|
|
176
|
+
return mux;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
pi.registerTool({
|
|
180
|
+
name: "ssh_profile",
|
|
181
|
+
label: "SSH Profile",
|
|
182
|
+
description: "List SSH config Host aliases and manage the active session default host, workdir, and environment. Profile state is stored in the Pi session and survives /reload; it does not create a separate host inventory.",
|
|
183
|
+
promptSnippet: "List or manage the active SSH default profile",
|
|
184
|
+
parameters: SshProfileParams,
|
|
185
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
186
|
+
if (params.action === "list") {
|
|
187
|
+
const aliases = listSshHostAliases(dependencies.sshConfigPath);
|
|
188
|
+
const details = { action: "list" as const, aliases, active: activeProfile ?? null };
|
|
189
|
+
return { content: [{ type: "text" as const, text: formatProfileList(aliases, activeProfile) }], details };
|
|
190
|
+
}
|
|
191
|
+
if (params.action === "use") {
|
|
192
|
+
const aliases = listSshHostAliases(dependencies.sshConfigPath);
|
|
193
|
+
activeProfile = createSshProfile(params, aliases, dependencies.sshConfigPath);
|
|
194
|
+
pi.appendEntry(SSH_PROFILE_ENTRY_TYPE, profileStateEntry(activeProfile));
|
|
195
|
+
const mux = await refreshFooter(ctx);
|
|
196
|
+
const details = { action: "use" as const, active: activeProfile, mux };
|
|
197
|
+
return { content: [{ type: "text" as const, text: `Active SSH profile: ${formatSshProfileChip(activeProfile, mux?.state ?? "down")}` }], details };
|
|
198
|
+
}
|
|
199
|
+
if (params.action === "clear") {
|
|
200
|
+
activeProfile = undefined;
|
|
201
|
+
pi.appendEntry(SSH_PROFILE_ENTRY_TYPE, profileStateEntry(undefined));
|
|
202
|
+
setFooter(ctx, undefined);
|
|
203
|
+
const details = { action: "clear" as const, active: null };
|
|
204
|
+
return { content: [{ type: "text" as const, text: "Active SSH profile cleared." }], details };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const mux = activeProfile ? await refreshFooter(ctx) : undefined;
|
|
208
|
+
const details = { action: "status" as const, active: activeProfile ?? null, ...(mux ? { mux } : {}) };
|
|
209
|
+
return {
|
|
210
|
+
content: [{
|
|
211
|
+
type: "text" as const,
|
|
212
|
+
text: activeProfile
|
|
213
|
+
? formatSshProfileChip(activeProfile, mux?.state ?? "down")
|
|
214
|
+
: "No active SSH profile.",
|
|
215
|
+
}],
|
|
216
|
+
details,
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
pi.registerTool({
|
|
222
|
+
name: "ssh_mux",
|
|
223
|
+
label: "SSH Mux",
|
|
224
|
+
description: "Inspect or stop the SSH ControlMaster for one target, the active profile, or all masters observed in this Pi process for the current session.",
|
|
225
|
+
promptSnippet: "Inspect or stop reusable SSH ControlMaster connections",
|
|
226
|
+
parameters: SshMuxParams,
|
|
227
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
228
|
+
const identityFields = [params.host, params.user, params.port, params.identity_file, params.jump, params.options];
|
|
229
|
+
if (params.all && identityFields.some((value) => value !== undefined)) {
|
|
230
|
+
throw new Error("ssh_mux all cannot be combined with target identity fields");
|
|
231
|
+
}
|
|
232
|
+
const sessionScope = ctx.sessionManager.getSessionId();
|
|
233
|
+
if (params.all) {
|
|
234
|
+
const results = params.action === "status"
|
|
235
|
+
? await muxRegistry.statusAll(sessionScope)
|
|
236
|
+
: await muxRegistry.stopAll(sessionScope);
|
|
237
|
+
const masters = results.map(muxResultDetails);
|
|
238
|
+
if (activeProfile) {
|
|
239
|
+
const activeTarget = resolveMuxTarget({ host: activeProfile.host }).identity.target;
|
|
240
|
+
const activeMux = masters.find((master) => master.target === activeTarget);
|
|
241
|
+
if (params.action === "stop") setFooter(ctx, activeProfile, "down");
|
|
242
|
+
else if (activeMux) setFooter(ctx, activeProfile, activeMux.state === "up" ? "up" : "down");
|
|
243
|
+
}
|
|
244
|
+
const details = { action: params.action, scope: "all" as const, masters };
|
|
245
|
+
return { content: [{ type: "text" as const, text: formatMuxResults(masters) }], details };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const resolved = resolveMuxTarget(params);
|
|
249
|
+
const result = params.action === "status"
|
|
250
|
+
? await muxRegistry.statusTarget(resolved, sessionScope)
|
|
251
|
+
: await muxRegistry.stopTarget(resolved, sessionScope);
|
|
252
|
+
const masters = [muxResultDetails(result)];
|
|
253
|
+
if (activeProfile) {
|
|
254
|
+
const activeTarget = resolveMuxTarget({ host: activeProfile.host }).identity.target;
|
|
255
|
+
if (resolved.identity.target === activeTarget) {
|
|
256
|
+
setFooter(ctx, activeProfile, result.state === "up" ? "up" : "down");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const details = { action: params.action, scope: "target" as const, masters };
|
|
260
|
+
return { content: [{ type: "text" as const, text: formatMuxResults(masters) }], details };
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const restore = async (ctx: ExtensionContext): Promise<void> => {
|
|
265
|
+
activeProfile = restoreSshProfile(ctx.sessionManager.getBranch());
|
|
266
|
+
await refreshFooter(ctx);
|
|
267
|
+
};
|
|
268
|
+
pi.on("session_start", async (_event, ctx) => restore(ctx));
|
|
269
|
+
pi.on("session_tree", async (_event, ctx) => restore(ctx));
|
|
270
|
+
pi.on("session_shutdown", async (_event, ctx) => setFooter(ctx, undefined));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export default function sshExtension(pi: ExtensionAPI): void {
|
|
274
|
+
registerSshExtension(pi, { runner: createProcessRemoteRunner() });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function formatProfileList(aliases: string[], active: SshProfile | undefined): string {
|
|
278
|
+
const lines = aliases.length > 0
|
|
279
|
+
? aliases.map((alias) => `${active?.host === alias ? "*" : "-"} ${alias}`)
|
|
280
|
+
: ["(no literal Host aliases found in ~/.ssh/config)"];
|
|
281
|
+
if (active && !aliases.includes(active.host)) lines.unshift(`* ${active.host} (active user@host profile)`);
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function muxResultDetails(result: { state: string; target: string; controlPath: string; detail: string }) {
|
|
286
|
+
return {
|
|
287
|
+
target: result.target,
|
|
288
|
+
state: result.state,
|
|
289
|
+
controlPath: result.controlPath,
|
|
290
|
+
detail: result.detail,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function formatMuxResults(results: Array<{ target: string; state: string; detail: string }>): string {
|
|
295
|
+
if (results.length === 0) return "No SSH ControlMasters are known for this session.";
|
|
296
|
+
return results.map((result) => `${result.target}: mux ${result.state} - ${result.detail}`).join("\n");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatToolResult(result: RemoteBashResult, timeoutSeconds?: number): string {
|
|
300
|
+
let text = result.output || "(no output)";
|
|
301
|
+
if (result.truncated && result.truncation && result.fullOutputPath) {
|
|
302
|
+
const startLine = result.truncation.totalLines - result.truncation.outputLines + 1;
|
|
303
|
+
const endLine = result.truncation.totalLines;
|
|
304
|
+
text += result.truncation.truncatedBy === "lines"
|
|
305
|
+
? `\n\n[Showing lines ${startLine}-${endLine} of ${result.truncation.totalLines}. Full output: ${result.fullOutputPath}]`
|
|
306
|
+
: `\n\n[Showing lines ${startLine}-${endLine} of ${result.truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
|
|
307
|
+
}
|
|
308
|
+
if (result.timedOut) return `${text}\n\nCommand timed out after ${timeoutSeconds} seconds`;
|
|
309
|
+
if (result.cancelled) return `${text}\n\nCommand cancelled`;
|
|
310
|
+
if (result.exitCode !== 0 && result.exitCode !== undefined) {
|
|
311
|
+
return `${text}\n\nCommand exited with code ${result.exitCode}`;
|
|
312
|
+
}
|
|
313
|
+
return text;
|
|
314
|
+
}
|