pi-better-harness 0.3.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-better-harness
2
2
 
3
- `pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: an opt-in foreground write sandbox, delegated subagents, durable background tasks, and goal tracking.
3
+ `pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: an opt-in foreground write sandbox, delegated subagents, durable background tasks, synchronous SSH commands, and goal tracking.
4
4
 
5
5
  ## Quick Answer
6
6
 
@@ -9,6 +9,7 @@ Use `pi-better-harness` when you want the full working set for Pi. It manages:
9
9
  - `pi-better-sandbox` for an opt-in write sandbox around Pi's foreground tools.
10
10
  - `pi-better-subagents` for detached, sandboxed subagent runs.
11
11
  - `pi-better-background-tasks` for durable shell tasks and watchers.
12
+ - `pi-better-ssh` for short remote commands over reusable SSH connections.
12
13
  - `pi-better-goal` for objective tracking that is aware of background work.
13
14
 
14
15
  `pi-better-plan` and `pi-better-read-aloud` are intentionally not included yet.
@@ -60,7 +61,7 @@ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.c
60
61
 
61
62
  ## When To Use
62
63
 
63
- Use the installer when you want every core extension with standalone package identities. Install an individual package instead when you only need the sandbox, subagents, shell task supervision, or goal tracking.
64
+ Use the installer when you want every core extension with standalone package identities. Install an individual package instead when you only need the sandbox, subagents, shell task supervision, synchronous SSH, or goal tracking.
64
65
 
65
66
  ## Compatibility
66
67
 
@@ -0,0 +1 @@
1
+ export { default } from "../../node_modules/pi-better-ssh/src/index.ts";
package/lib/cli.mjs CHANGED
@@ -12,6 +12,7 @@ export const componentPackages = [
12
12
  "pi-better-sandbox",
13
13
  "pi-better-subagents",
14
14
  "pi-better-background-tasks",
15
+ "pi-better-ssh",
15
16
  "pi-better-goal",
16
17
  ];
17
18
 
@@ -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
+ }
@@ -0,0 +1,86 @@
1
+ import { createSshMuxController } from "./shared-ssh-core/index.js";
2
+ import type {
3
+ RemoteRunner,
4
+ ResolvedSshCommand,
5
+ SshMuxCleanupResult,
6
+ SshMuxController,
7
+ SshMuxStatus,
8
+ } from "./shared-ssh-core/index.js";
9
+
10
+ const GLOBAL_MUX_ENTRIES = Symbol.for("pi-better-ssh.mux-entries.v1");
11
+
12
+ export interface KnownSshMuxEntry {
13
+ sessionScope: string;
14
+ resolved: ResolvedSshCommand;
15
+ }
16
+
17
+ export type SshMuxEntryMap = Map<string, KnownSshMuxEntry>;
18
+
19
+ export interface SshMuxRegistry {
20
+ controllerFor(resolved: ResolvedSshCommand, sessionScope: string): SshMuxController;
21
+ statusTarget(resolved: ResolvedSshCommand, sessionScope: string): Promise<SshMuxStatus>;
22
+ stopTarget(resolved: ResolvedSshCommand, sessionScope: string): Promise<SshMuxCleanupResult>;
23
+ statusAll(sessionScope: string): Promise<SshMuxStatus[]>;
24
+ stopAll(sessionScope: string): Promise<SshMuxCleanupResult[]>;
25
+ }
26
+
27
+ export function createSshMuxRegistry(options: {
28
+ runner: RemoteRunner;
29
+ controlPathRoot?: string;
30
+ entries?: SshMuxEntryMap;
31
+ }): SshMuxRegistry {
32
+ const entries = options.entries ?? processMuxEntries();
33
+
34
+ const makeController = (resolved: ResolvedSshCommand, sessionScope: string): SshMuxController =>
35
+ createSshMuxController({
36
+ ...resolved,
37
+ runner: options.runner,
38
+ sessionScope,
39
+ ...(options.controlPathRoot ? { controlPathRoot: options.controlPathRoot } : {}),
40
+ });
41
+
42
+ const controllerFor = (resolved: ResolvedSshCommand, sessionScope: string): SshMuxController => {
43
+ const controller = makeController(resolved, sessionScope);
44
+ entries.set(controller.controlPath, { sessionScope, resolved });
45
+ return controller;
46
+ };
47
+
48
+ const knownForSession = (sessionScope: string): Array<[string, KnownSshMuxEntry]> =>
49
+ [...entries].filter(([, entry]) => entry.sessionScope === sessionScope);
50
+
51
+ return {
52
+ controllerFor,
53
+ statusTarget: (resolved, sessionScope) => controllerFor(resolved, sessionScope).status(),
54
+ stopTarget: async (resolved, sessionScope) => {
55
+ const controller = controllerFor(resolved, sessionScope);
56
+ try {
57
+ return await controller.cleanup();
58
+ } finally {
59
+ entries.delete(controller.controlPath);
60
+ }
61
+ },
62
+ statusAll: async (sessionScope) => {
63
+ const statuses: SshMuxStatus[] = [];
64
+ for (const [, entry] of knownForSession(sessionScope)) {
65
+ statuses.push(await makeController(entry.resolved, sessionScope).status());
66
+ }
67
+ return statuses;
68
+ },
69
+ stopAll: async (sessionScope) => {
70
+ const results: SshMuxCleanupResult[] = [];
71
+ for (const [controlPath, entry] of knownForSession(sessionScope)) {
72
+ try {
73
+ results.push(await makeController(entry.resolved, sessionScope).cleanup());
74
+ } finally {
75
+ entries.delete(controlPath);
76
+ }
77
+ }
78
+ return results;
79
+ },
80
+ };
81
+ }
82
+
83
+ function processMuxEntries(): SshMuxEntryMap {
84
+ const globalState = globalThis as typeof globalThis & { [GLOBAL_MUX_ENTRIES]?: SshMuxEntryMap };
85
+ return globalState[GLOBAL_MUX_ENTRIES] ??= new Map();
86
+ }