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.
Files changed (33) hide show
  1. package/README.md +4 -3
  2. package/extensions/ssh/index.ts +1 -0
  3. package/lib/cli.mjs +1 -0
  4. package/node_modules/pi-better-background-tasks/README.md +1 -0
  5. package/node_modules/pi-better-background-tasks/package.json +1 -1
  6. package/node_modules/pi-better-background-tasks/src/goal-provider.ts +3 -2
  7. package/node_modules/pi-better-background-tasks/src/maintenance.ts +96 -0
  8. package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +4 -2
  9. package/node_modules/pi-better-background-tasks/src/process-identity.ts +41 -0
  10. package/node_modules/pi-better-background-tasks/src/registry.ts +194 -2
  11. package/node_modules/pi-better-background-tasks/src/runtime.ts +10 -0
  12. package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +16 -2
  13. package/node_modules/pi-better-background-tasks/src/tools.ts +5 -3
  14. package/node_modules/pi-better-background-tasks/src/types.ts +4 -0
  15. package/node_modules/pi-better-goal/package.json +1 -1
  16. package/node_modules/pi-better-goal/src/index.ts +37 -5
  17. package/node_modules/pi-better-goal/src/subagents.ts +42 -4
  18. package/node_modules/pi-better-goal/src/types.ts +1 -0
  19. package/node_modules/pi-better-ssh/LICENSE +21 -0
  20. package/node_modules/pi-better-ssh/README.md +99 -0
  21. package/node_modules/pi-better-ssh/package.json +57 -0
  22. package/node_modules/pi-better-ssh/src/index.ts +314 -0
  23. package/node_modules/pi-better-ssh/src/mux-registry.ts +86 -0
  24. package/node_modules/pi-better-ssh/src/process-runner.ts +166 -0
  25. package/node_modules/pi-better-ssh/src/profile.ts +132 -0
  26. package/node_modules/pi-better-ssh/src/remote-bash.ts +184 -0
  27. package/node_modules/pi-better-ssh/src/shared-ssh-core/index.ts +902 -0
  28. package/node_modules/pi-better-subagents/cleanup.ts +6 -2
  29. package/node_modules/pi-better-subagents/index.ts +56 -20
  30. package/node_modules/pi-better-subagents/package.json +1 -1
  31. package/node_modules/pi-better-subagents/registry.ts +207 -3
  32. package/node_modules/pi-better-subagents/shared-navigator.ts +16 -2
  33. 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 || collecting) {
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
- providers.set(candidate.id, candidate as BackgroundActivityProvider);
498
- collectIfPossible();
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
- startPolling();
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 = readSubagentMetas(options.baseDir).filter((meta) =>
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
+ }