pi-better-harness 0.41.2 → 0.41.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 +3 -2
- package/extensions/task-context/index.ts +8 -7
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/batch.ts +5 -2
- package/node_modules/pi-better-background-tasks/src/failures.ts +14 -6
- package/node_modules/pi-better-background-tasks/src/index.ts +14 -13
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +37 -10
- package/node_modules/pi-better-background-tasks/src/runtime.ts +99 -89
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +24 -3
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/validate.ts +3 -1
- package/node_modules/pi-better-background-tasks/src/shared-ssh-core/index.ts +30 -1
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/store.mjs +4 -2
- package/node_modules/pi-better-background-tasks/src/supervisor/store.ts +4 -2
- package/node_modules/pi-better-background-tasks/src/tools.ts +32 -24
- package/node_modules/pi-better-ssh/README.md +2 -0
- package/node_modules/pi-better-ssh/package.json +1 -1
- package/node_modules/pi-better-ssh/src/index.ts +4 -1
- package/node_modules/pi-better-ssh/src/mux-registry.ts +3 -1
- package/node_modules/pi-better-ssh/src/remote-bash.ts +4 -2
- package/node_modules/pi-better-ssh/src/shared-ssh-core/index.ts +30 -1
- package/node_modules/pi-better-subagents/index.ts +14 -10
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +24 -3
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/validate.ts +3 -1
- package/package.json +4 -4
package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/validate.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* reference channels (`continuationNote`, `reason`, `summary`, `authorityRef`)
|
|
12
12
|
* that a caller could otherwise use to persist a secret.
|
|
13
13
|
*/
|
|
14
|
+
import { isAbsolute } from "node:path";
|
|
14
15
|
import { StoreError } from "./errors.ts";
|
|
15
16
|
import type { EvidenceKind, OwnerIdentity, OwnerKind } from "./types.ts";
|
|
16
17
|
|
|
@@ -143,7 +144,8 @@ export function requirePath(field: string, value: unknown): string {
|
|
|
143
144
|
/** Workspace binding: an absolute path that may contain spaces. */
|
|
144
145
|
export function requireWorkspace(value: unknown): string {
|
|
145
146
|
const workspace = assertPlainString("owner.workspace", value, TEXT_LIMITS.workspace).trim();
|
|
146
|
-
|
|
147
|
+
// Platform-aware: a Windows workspace is "C:\...", which never starts with "/".
|
|
148
|
+
if (!isAbsolute(workspace)) fail("owner.workspace", "must be an absolute path");
|
|
147
149
|
return workspace;
|
|
148
150
|
}
|
|
149
151
|
|
|
@@ -90,6 +90,7 @@ export interface SshMuxControllerOptions extends ResolvedSshCommand {
|
|
|
90
90
|
runner: RemoteRunner;
|
|
91
91
|
sessionScope: string;
|
|
92
92
|
controlPathRoot?: string;
|
|
93
|
+
platform?: NodeJS.Platform;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
export interface SshMuxStatus {
|
|
@@ -101,7 +102,6 @@ export interface SshMuxStatus {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
export interface SshMuxEnsureResult extends SshMuxStatus {
|
|
104
|
-
state: "up";
|
|
105
105
|
reused: boolean;
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -340,6 +340,35 @@ export function defaultSshControlPathRoot(): string {
|
|
|
340
340
|
*/
|
|
341
341
|
export function createSshMuxController(options: SshMuxControllerOptions): SshMuxController {
|
|
342
342
|
const sessionScope = requireValue(options.sessionScope, "ssh mux sessionScope is required").trim();
|
|
343
|
+
// Native Windows OpenSSH cannot provide a working ControlMaster socket.
|
|
344
|
+
if ((options.platform ?? process.platform) === "win32") {
|
|
345
|
+
const commandResult: CommandResult = {
|
|
346
|
+
exitCode: null, signal: null, stdout: "", stderr: "", startedAt: 0, endedAt: 0,
|
|
347
|
+
};
|
|
348
|
+
const disabled: SshMuxStatus = {
|
|
349
|
+
state: "down",
|
|
350
|
+
target: options.identity.target,
|
|
351
|
+
controlPath: "",
|
|
352
|
+
detail: "SSH multiplexing is disabled on Windows; commands use independent connections.",
|
|
353
|
+
commandResult,
|
|
354
|
+
};
|
|
355
|
+
const unsupportedForward = async (): Promise<SshMuxForwardResult> => {
|
|
356
|
+
throw new SshMuxError("SSH Unix socket forwarding is not supported on Windows without ControlMaster.", commandResult);
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
controlPath: "",
|
|
360
|
+
forwardUnixSocket: unsupportedForward,
|
|
361
|
+
cancelUnixSocketForward: unsupportedForward,
|
|
362
|
+
status: async () => disabled,
|
|
363
|
+
ensure: async () => ({ ...disabled, reused: false }),
|
|
364
|
+
cleanup: async () => ({ ...disabled, state: "not_running" }),
|
|
365
|
+
withMux: (commandSpec) => muxCommandSpec(commandSpec, options.identity.target, [
|
|
366
|
+
"-o", "ControlMaster=no",
|
|
367
|
+
"-o", "ControlPersist=no",
|
|
368
|
+
"-o", "ControlPath=none",
|
|
369
|
+
], true),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
343
372
|
const controlPathRoot = resolve(options.controlPathRoot ?? defaultSshControlPathRoot());
|
|
344
373
|
const controlPath = join(controlPathRoot, `cm-${muxIdentityHash(options.identity, sessionScope)}`);
|
|
345
374
|
if (Buffer.byteLength(controlPath) > MAX_SSH_CONTROL_PATH_BYTES) {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
import { createHash, randomBytes } from "node:crypto";
|
|
36
36
|
import { createRequire } from "node:module";
|
|
37
37
|
import { appendFileSync, closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
|
|
38
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
38
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
39
39
|
import { validateCondition } from "./conditions.mjs";
|
|
40
40
|
import { SUPERVISOR_FENCE_WAIT_MS, SUPERVISOR_RECORD_VERSION, SUPERVISOR_TASK_ID_PATTERN, SUPERVISOR_TASK_ID_PREFIX, } from "./types.mjs";
|
|
41
41
|
/** The task id is derived from the token, so one token can only ever name one execution. */
|
|
@@ -113,7 +113,9 @@ function fsyncDirectoryOf(path) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
function tempPathFor(path) {
|
|
116
|
-
|
|
116
|
+
// `basename`, not `split("/")`: a Windows path has no `/`, so the whole path
|
|
117
|
+
// would become the temp file name.
|
|
118
|
+
return join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
117
119
|
}
|
|
118
120
|
/** Write a durable temp file; the caller publishes it. */
|
|
119
121
|
function writeDurableTemp(path, text) {
|
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
writeFileSync,
|
|
46
46
|
writeSync,
|
|
47
47
|
} from "node:fs";
|
|
48
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
48
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
49
49
|
import { validateCondition } from "../conditions.ts";
|
|
50
50
|
import {
|
|
51
51
|
SUPERVISOR_FENCE_WAIT_MS,
|
|
@@ -171,7 +171,9 @@ function fsyncDirectoryOf(path: string): void {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
function tempPathFor(path: string): string {
|
|
174
|
-
|
|
174
|
+
// `basename`, not `split("/")`: a Windows path has no `/`, so the whole path
|
|
175
|
+
// would become the temp file name.
|
|
176
|
+
return join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
/** Write a durable temp file; the caller publishes it. */
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
type WaitView,
|
|
26
26
|
} from "./observed-waits.js";
|
|
27
27
|
import { inspectMeta, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
|
|
28
|
-
import { awaitFirstWatchCheck, FIRST_WATCH_CHECK_WAIT_MS, resumeRunningTask, spawnTask, startWatchTask, stopTask, type WatchTaskParams } from "./runtime.js";
|
|
28
|
+
import { awaitFirstWatchCheck, FIRST_WATCH_CHECK_WAIT_MS, resumeRunningTask, spawnTask, startWatchTask, stopTask, type WatchTaskParams, type BackgroundTaskRuntime } from "./runtime.js";
|
|
29
29
|
import { runTaskMaintenance } from "./maintenance.js";
|
|
30
30
|
import { backgroundProcessDiagnosticsAccess, ForegroundSandboxBlockedError } from "./sandbox.js";
|
|
31
31
|
import { inspectTaskProcess } from "./task-diagnostics.js";
|
|
@@ -34,6 +34,8 @@ import { isTerminalStatus } from "./types.js";
|
|
|
34
34
|
|
|
35
35
|
export { formatLaunch } from "./output.js";
|
|
36
36
|
|
|
37
|
+
const defaultRuntime = { awaitFirstWatchCheck, resumeRunningTask, spawnTask, startWatchTask, stopTask };
|
|
38
|
+
|
|
37
39
|
const JsonPathSchema = Type.String({
|
|
38
40
|
pattern: "^\\$",
|
|
39
41
|
description: "Root-prefixed JSON path, e.g. $.status, $.terminalFailure, or $.steps[0].status. Bare keys and wildcards are unsupported.",
|
|
@@ -292,7 +294,8 @@ const BACKGROUND_ORCHESTRATION_GUIDELINES = [
|
|
|
292
294
|
"Do not treat launch as completion of the parent milestone; relevant background work must be terminal, inspected, and integrated before verification or completion.",
|
|
293
295
|
];
|
|
294
296
|
|
|
295
|
-
export function registerTools(pi: ExtensionAPI): void {
|
|
297
|
+
export function registerTools(pi: ExtensionAPI, runtime: Pick<BackgroundTaskRuntime, keyof typeof defaultRuntime> = defaultRuntime, refreshNavigator = refreshBackgroundTasksNavigator): void {
|
|
298
|
+
const { resumeRunningTask, spawnTask } = runtime;
|
|
296
299
|
let activeSession: BackgroundTaskCallbackOrigin | undefined;
|
|
297
300
|
const getActiveSession = () => activeSession;
|
|
298
301
|
const schemas = new Map<string, object>();
|
|
@@ -345,11 +348,11 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
345
348
|
activeSession = getCallbackOrigin(ctx);
|
|
346
349
|
const durable = await durableLaunchText(pi, "process", params, ctx);
|
|
347
350
|
if (durable !== undefined) {
|
|
348
|
-
|
|
351
|
+
refreshNavigator(ctx);
|
|
349
352
|
return text(durable);
|
|
350
353
|
}
|
|
351
354
|
const launched = reportLaunch(() => spawnTask(pi, params, ctx.cwd, activeSession, getActiveSession));
|
|
352
|
-
|
|
355
|
+
refreshNavigator(ctx);
|
|
353
356
|
return text(launched);
|
|
354
357
|
},
|
|
355
358
|
});
|
|
@@ -362,8 +365,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
362
365
|
parameters: BatchParams,
|
|
363
366
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
364
367
|
activeSession = getCallbackOrigin(ctx);
|
|
365
|
-
const launched = spawnTaskBatch(pi, params, ctx.cwd, activeSession, getActiveSession);
|
|
366
|
-
|
|
368
|
+
const launched = spawnTaskBatch(pi, params, ctx.cwd, activeSession, getActiveSession, {}, runtime);
|
|
369
|
+
refreshNavigator(ctx);
|
|
367
370
|
return text(formatBatchLaunch(launched), launched);
|
|
368
371
|
},
|
|
369
372
|
});
|
|
@@ -378,11 +381,11 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
378
381
|
activeSession = getCallbackOrigin(ctx);
|
|
379
382
|
const durable = await durableLaunchText(pi, "watch", params, ctx);
|
|
380
383
|
if (durable !== undefined) {
|
|
381
|
-
|
|
384
|
+
refreshNavigator(ctx);
|
|
382
385
|
return text(durable);
|
|
383
386
|
}
|
|
384
|
-
const launched = await launchWatch(pi, params, ctx.cwd, activeSession, getActiveSession, FIRST_WATCH_CHECK_WAIT_MS, signal);
|
|
385
|
-
|
|
387
|
+
const launched = await launchWatch(pi, params, ctx.cwd, activeSession, getActiveSession, FIRST_WATCH_CHECK_WAIT_MS, signal, runtime);
|
|
388
|
+
refreshNavigator(ctx);
|
|
386
389
|
return text(launched);
|
|
387
390
|
},
|
|
388
391
|
});
|
|
@@ -436,11 +439,11 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
436
439
|
activeSession = getCallbackOrigin(ctx);
|
|
437
440
|
const durable = await durableStopText(pi, params.id, ctx);
|
|
438
441
|
if (durable !== undefined) {
|
|
439
|
-
|
|
442
|
+
refreshNavigator(ctx);
|
|
440
443
|
return text(durable);
|
|
441
444
|
}
|
|
442
|
-
const result = await formatStop(pi, params.id, ctx, getActiveSession, statusOptions(params, activeSession));
|
|
443
|
-
|
|
445
|
+
const result = await formatStop(pi, params.id, ctx, getActiveSession, statusOptions(params, activeSession), runtime);
|
|
446
|
+
refreshNavigator(ctx);
|
|
444
447
|
return text(result);
|
|
445
448
|
},
|
|
446
449
|
});
|
|
@@ -456,7 +459,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
456
459
|
},
|
|
457
460
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
458
461
|
activeSession = getCallbackOrigin(ctx);
|
|
459
|
-
return actionText(pi, params, ctx, activeSession, getActiveSession, signal);
|
|
462
|
+
return actionText(pi, params, ctx, activeSession, getActiveSession, signal, runtime, refreshNavigator);
|
|
460
463
|
},
|
|
461
464
|
});
|
|
462
465
|
|
|
@@ -470,7 +473,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
470
473
|
},
|
|
471
474
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
472
475
|
activeSession = getCallbackOrigin(ctx);
|
|
473
|
-
return actionText(pi, params, ctx, activeSession, getActiveSession);
|
|
476
|
+
return actionText(pi, params, ctx, activeSession, getActiveSession, undefined, runtime, refreshNavigator);
|
|
474
477
|
},
|
|
475
478
|
});
|
|
476
479
|
|
|
@@ -646,14 +649,16 @@ async function actionText(
|
|
|
646
649
|
ctx: ExtensionContext,
|
|
647
650
|
callbackOrigin: BackgroundTaskCallbackOrigin,
|
|
648
651
|
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
649
|
-
signal
|
|
652
|
+
signal: AbortSignal | undefined,
|
|
653
|
+
runtime: Pick<BackgroundTaskRuntime, keyof typeof defaultRuntime>,
|
|
654
|
+
refreshNavigator: typeof refreshBackgroundTasksNavigator,
|
|
650
655
|
) {
|
|
651
656
|
if (params.action === "log" && params.id) {
|
|
652
657
|
const durable = durableLogText(pi, String(params.id), params, ctx);
|
|
653
658
|
if (durable !== undefined) return text(durable);
|
|
654
659
|
return logText(String(params.id), logOptions(params, callbackOrigin));
|
|
655
660
|
}
|
|
656
|
-
return text(await runAction(pi, params, ctx, callbackOrigin, getActiveSession, signal));
|
|
661
|
+
return text(await runAction(pi, params, ctx, callbackOrigin, getActiveSession, signal, runtime, refreshNavigator));
|
|
657
662
|
}
|
|
658
663
|
|
|
659
664
|
async function runAction(
|
|
@@ -662,8 +667,12 @@ async function runAction(
|
|
|
662
667
|
ctx: ExtensionContext,
|
|
663
668
|
callbackOrigin: BackgroundTaskCallbackOrigin,
|
|
664
669
|
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
665
|
-
signal
|
|
670
|
+
signal: AbortSignal | undefined,
|
|
671
|
+
runtime: Pick<BackgroundTaskRuntime, keyof typeof defaultRuntime>,
|
|
672
|
+
refreshNavigator: typeof refreshBackgroundTasksNavigator,
|
|
666
673
|
): Promise<string> {
|
|
674
|
+
const { spawnTask } = runtime;
|
|
675
|
+
const withNavigatorRefresh = (ctx: ExtensionContext, result: string) => { refreshNavigator(ctx); return result; };
|
|
667
676
|
switch (params.action) {
|
|
668
677
|
case "spawn": {
|
|
669
678
|
const durable = await durableLaunchText(pi, "process", params, ctx);
|
|
@@ -674,7 +683,7 @@ async function runAction(
|
|
|
674
683
|
if (!params.success_when) return "Invalid parameters: watch requires success_when.";
|
|
675
684
|
const durable = await durableLaunchText(pi, "watch", params, ctx);
|
|
676
685
|
if (durable !== undefined) return withNavigatorRefresh(ctx, durable);
|
|
677
|
-
return withNavigatorRefresh(ctx, await launchWatch(pi, params as unknown as WatchTaskParams, ctx.cwd, callbackOrigin, getActiveSession, FIRST_WATCH_CHECK_WAIT_MS, signal));
|
|
686
|
+
return withNavigatorRefresh(ctx, await launchWatch(pi, params as unknown as WatchTaskParams, ctx.cwd, callbackOrigin, getActiveSession, FIRST_WATCH_CHECK_WAIT_MS, signal, runtime));
|
|
678
687
|
}
|
|
679
688
|
case "list":
|
|
680
689
|
return durableAwareList(pi, params, callbackOrigin);
|
|
@@ -694,7 +703,7 @@ async function runAction(
|
|
|
694
703
|
if (!params.id) return "Invalid parameters: stop requires id.";
|
|
695
704
|
const durable = await durableStopText(pi, String(params.id), ctx);
|
|
696
705
|
if (durable !== undefined) return withNavigatorRefresh(ctx, durable);
|
|
697
|
-
return withNavigatorRefresh(ctx, await formatStop(pi, String(params.id), ctx, getActiveSession, statusOptions(params, callbackOrigin)));
|
|
706
|
+
return withNavigatorRefresh(ctx, await formatStop(pi, String(params.id), ctx, getActiveSession, statusOptions(params, callbackOrigin), runtime));
|
|
698
707
|
}
|
|
699
708
|
case "clear": {
|
|
700
709
|
const host = getObservedWaitHost(pi);
|
|
@@ -872,7 +881,9 @@ export async function launchWatch(
|
|
|
872
881
|
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
873
882
|
waitMs = FIRST_WATCH_CHECK_WAIT_MS,
|
|
874
883
|
signal?: AbortSignal,
|
|
884
|
+
runtime: Pick<BackgroundTaskRuntime, keyof typeof defaultRuntime> = defaultRuntime,
|
|
875
885
|
): Promise<string> {
|
|
886
|
+
const { startWatchTask, awaitFirstWatchCheck } = runtime;
|
|
876
887
|
let meta: BackgroundTaskMeta;
|
|
877
888
|
try {
|
|
878
889
|
meta = startWatchTask(pi, params, cwd, callbackOrigin, getActiveSession);
|
|
@@ -887,11 +898,6 @@ export async function launchWatch(
|
|
|
887
898
|
return formatLaunch(latest);
|
|
888
899
|
}
|
|
889
900
|
|
|
890
|
-
function withNavigatorRefresh(ctx: ExtensionContext, result: string): string {
|
|
891
|
-
refreshBackgroundTasksNavigator(ctx);
|
|
892
|
-
return result;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
901
|
/** Origins whose session identity could not be read (the host threw). */
|
|
896
902
|
const unavailableSessionOrigins = new WeakSet<BackgroundTaskCallbackOrigin>();
|
|
897
903
|
|
|
@@ -1023,7 +1029,9 @@ async function formatStop(
|
|
|
1023
1029
|
_ctx: ExtensionContext,
|
|
1024
1030
|
getActiveSession?: () => BackgroundTaskCallbackOrigin | undefined,
|
|
1025
1031
|
options: OutputOptions = {},
|
|
1032
|
+
runtime: Pick<BackgroundTaskRuntime, "stopTask"> = defaultRuntime,
|
|
1026
1033
|
): Promise<string> {
|
|
1034
|
+
const { stopTask } = runtime;
|
|
1027
1035
|
const existing = inspectMeta(id);
|
|
1028
1036
|
if (!existing.meta && !existing.found) return formatStopResult(existing, options);
|
|
1029
1037
|
if (!existing.meta) return formatMissingTask(existing, options);
|
|
@@ -97,6 +97,8 @@ Call `ssh_profile` with `status` to inspect the active profile or `clear` to rem
|
|
|
97
97
|
|
|
98
98
|
## ControlMaster
|
|
99
99
|
|
|
100
|
+
On Windows, commands use independent SSH connections because native Windows OpenSSH does not support working ControlMaster sockets. Mux options from callers and SSH config are overridden. `ssh_mux status` reports mux down with an explanation, and `stop` has no master to stop. Linux and macOS retain connection reuse.
|
|
101
|
+
|
|
100
102
|
`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.
|
|
101
103
|
|
|
102
104
|
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:
|
|
@@ -60,6 +60,7 @@ const SshProfileParams = Type.Object({
|
|
|
60
60
|
export interface RegisterRemoteBashDependencies {
|
|
61
61
|
runner: RemoteBashDependencies["runner"];
|
|
62
62
|
controlPathRoot?: string;
|
|
63
|
+
platform?: NodeJS.Platform;
|
|
63
64
|
getActiveProfile?: () => SshProfile | undefined;
|
|
64
65
|
muxRegistry?: SshMuxRegistry;
|
|
65
66
|
onResult?: (result: RemoteBashResult, params: { host?: string }, ctx: ExtensionContext) => void | Promise<void>;
|
|
@@ -74,7 +75,7 @@ export function registerRemoteBashTool(pi: ExtensionAPI, dependencies: RegisterR
|
|
|
74
75
|
pi.registerTool({
|
|
75
76
|
name: "remote_bash",
|
|
76
77
|
label: "Remote Bash",
|
|
77
|
-
description: `Run one short remote bash command synchronously over a
|
|
78
|
+
description: `Run one short remote bash command synchronously over SSH. Reuses a ControlMaster on Linux/macOS; uses independent connections on Windows. 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.`,
|
|
78
79
|
promptSnippet: "Run a short synchronous remote command through safe reusable SSH",
|
|
79
80
|
promptGuidelines: [
|
|
80
81
|
"Use remote_bash for short synchronous remote commands instead of writing ssh commands inside local bash.",
|
|
@@ -87,6 +88,7 @@ export function registerRemoteBashTool(pi: ExtensionAPI, dependencies: RegisterR
|
|
|
87
88
|
const result = await executeRemoteBash(params, {
|
|
88
89
|
runner: dependencies.runner,
|
|
89
90
|
sessionScope,
|
|
91
|
+
...(dependencies.platform !== undefined ? { platform: dependencies.platform } : {}),
|
|
90
92
|
...(dependencies.controlPathRoot ? { controlPathRoot: dependencies.controlPathRoot } : {}),
|
|
91
93
|
...(activeProfile ? { activeProfile } : {}),
|
|
92
94
|
...(dependencies.muxRegistry ? { muxRegistry: dependencies.muxRegistry } : {}),
|
|
@@ -104,6 +106,7 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
|
|
|
104
106
|
let activeProfile: SshProfile | undefined;
|
|
105
107
|
const muxRegistry = dependencies.muxRegistry ?? createSshMuxRegistry({
|
|
106
108
|
runner: dependencies.runner,
|
|
109
|
+
...(dependencies.platform !== undefined ? { platform: dependencies.platform } : {}),
|
|
107
110
|
...(dependencies.controlPathRoot ? { controlPathRoot: dependencies.controlPathRoot } : {}),
|
|
108
111
|
...(dependencies.muxEntries ? { entries: dependencies.muxEntries } : {}),
|
|
109
112
|
});
|
|
@@ -27,6 +27,7 @@ export interface SshMuxRegistry {
|
|
|
27
27
|
export function createSshMuxRegistry(options: {
|
|
28
28
|
runner: RemoteRunner;
|
|
29
29
|
controlPathRoot?: string;
|
|
30
|
+
platform?: NodeJS.Platform;
|
|
30
31
|
entries?: SshMuxEntryMap;
|
|
31
32
|
}): SshMuxRegistry {
|
|
32
33
|
const entries = options.entries ?? processMuxEntries();
|
|
@@ -36,12 +37,13 @@ export function createSshMuxRegistry(options: {
|
|
|
36
37
|
...resolved,
|
|
37
38
|
runner: options.runner,
|
|
38
39
|
sessionScope,
|
|
40
|
+
...(options.platform !== undefined ? { platform: options.platform } : {}),
|
|
39
41
|
...(options.controlPathRoot ? { controlPathRoot: options.controlPathRoot } : {}),
|
|
40
42
|
});
|
|
41
43
|
|
|
42
44
|
const controllerFor = (resolved: ResolvedSshCommand, sessionScope: string): SshMuxController => {
|
|
43
45
|
const controller = makeController(resolved, sessionScope);
|
|
44
|
-
entries.set(controller.controlPath, { sessionScope, resolved });
|
|
46
|
+
if (controller.controlPath) entries.set(controller.controlPath, { sessionScope, resolved });
|
|
45
47
|
return controller;
|
|
46
48
|
};
|
|
47
49
|
|
|
@@ -35,6 +35,7 @@ export interface RemoteBashDependencies {
|
|
|
35
35
|
runner: RemoteRunner;
|
|
36
36
|
sessionScope: string;
|
|
37
37
|
controlPathRoot?: string;
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
38
39
|
muxRegistry?: Pick<SshMuxRegistry, "controllerFor">;
|
|
39
40
|
activeProfile?: {
|
|
40
41
|
host: string;
|
|
@@ -52,7 +53,7 @@ export interface RemoteBashResult {
|
|
|
52
53
|
target: string;
|
|
53
54
|
workdir?: string;
|
|
54
55
|
mux: {
|
|
55
|
-
state: "up";
|
|
56
|
+
state: "up" | "down";
|
|
56
57
|
reused: boolean;
|
|
57
58
|
};
|
|
58
59
|
truncation?: TruncationResult;
|
|
@@ -79,6 +80,7 @@ export async function executeRemoteBash(
|
|
|
79
80
|
...resolved,
|
|
80
81
|
runner: dependencies.runner,
|
|
81
82
|
sessionScope: dependencies.sessionScope,
|
|
83
|
+
...(dependencies.platform !== undefined ? { platform: dependencies.platform } : {}),
|
|
82
84
|
...(dependencies.controlPathRoot ? { controlPathRoot: dependencies.controlPathRoot } : {}),
|
|
83
85
|
});
|
|
84
86
|
const muxState = await mux.ensure();
|
|
@@ -113,7 +115,7 @@ export async function executeRemoteBash(
|
|
|
113
115
|
truncated: truncation.truncated,
|
|
114
116
|
target: resolved.identity.target,
|
|
115
117
|
...(effectiveParams.workdir !== undefined ? { workdir: effectiveParams.workdir } : {}),
|
|
116
|
-
mux: { state:
|
|
118
|
+
mux: { state: muxState.state, reused: muxState.reused },
|
|
117
119
|
...(truncation.truncated ? { truncation } : {}),
|
|
118
120
|
...(fullOutputPath ? { fullOutputPath } : {}),
|
|
119
121
|
};
|
|
@@ -90,6 +90,7 @@ export interface SshMuxControllerOptions extends ResolvedSshCommand {
|
|
|
90
90
|
runner: RemoteRunner;
|
|
91
91
|
sessionScope: string;
|
|
92
92
|
controlPathRoot?: string;
|
|
93
|
+
platform?: NodeJS.Platform;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
export interface SshMuxStatus {
|
|
@@ -101,7 +102,6 @@ export interface SshMuxStatus {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
export interface SshMuxEnsureResult extends SshMuxStatus {
|
|
104
|
-
state: "up";
|
|
105
105
|
reused: boolean;
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -340,6 +340,35 @@ export function defaultSshControlPathRoot(): string {
|
|
|
340
340
|
*/
|
|
341
341
|
export function createSshMuxController(options: SshMuxControllerOptions): SshMuxController {
|
|
342
342
|
const sessionScope = requireValue(options.sessionScope, "ssh mux sessionScope is required").trim();
|
|
343
|
+
// Native Windows OpenSSH cannot provide a working ControlMaster socket.
|
|
344
|
+
if ((options.platform ?? process.platform) === "win32") {
|
|
345
|
+
const commandResult: CommandResult = {
|
|
346
|
+
exitCode: null, signal: null, stdout: "", stderr: "", startedAt: 0, endedAt: 0,
|
|
347
|
+
};
|
|
348
|
+
const disabled: SshMuxStatus = {
|
|
349
|
+
state: "down",
|
|
350
|
+
target: options.identity.target,
|
|
351
|
+
controlPath: "",
|
|
352
|
+
detail: "SSH multiplexing is disabled on Windows; commands use independent connections.",
|
|
353
|
+
commandResult,
|
|
354
|
+
};
|
|
355
|
+
const unsupportedForward = async (): Promise<SshMuxForwardResult> => {
|
|
356
|
+
throw new SshMuxError("SSH Unix socket forwarding is not supported on Windows without ControlMaster.", commandResult);
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
controlPath: "",
|
|
360
|
+
forwardUnixSocket: unsupportedForward,
|
|
361
|
+
cancelUnixSocketForward: unsupportedForward,
|
|
362
|
+
status: async () => disabled,
|
|
363
|
+
ensure: async () => ({ ...disabled, reused: false }),
|
|
364
|
+
cleanup: async () => ({ ...disabled, state: "not_running" }),
|
|
365
|
+
withMux: (commandSpec) => muxCommandSpec(commandSpec, options.identity.target, [
|
|
366
|
+
"-o", "ControlMaster=no",
|
|
367
|
+
"-o", "ControlPersist=no",
|
|
368
|
+
"-o", "ControlPath=none",
|
|
369
|
+
], true),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
343
372
|
const controlPathRoot = resolve(options.controlPathRoot ?? defaultSshControlPathRoot());
|
|
344
373
|
const controlPath = join(controlPathRoot, `cm-${muxIdentityHash(options.identity, sessionScope)}`);
|
|
345
374
|
if (Buffer.byteLength(controlPath) > MAX_SSH_CONTROL_PATH_BYTES) {
|
|
@@ -1625,10 +1625,10 @@ function returnWidgetNavToInput(): void {
|
|
|
1625
1625
|
exitWidgetNav();
|
|
1626
1626
|
}
|
|
1627
1627
|
|
|
1628
|
-
function viewWidgetNavSelection(ctx: ExtensionContext): void {
|
|
1628
|
+
function viewWidgetNavSelection(ctx: ExtensionContext, owner?: symbol): void {
|
|
1629
1629
|
const selected = selectedWidgetNavRun();
|
|
1630
1630
|
if (!selected) return;
|
|
1631
|
-
openNavigator(ctx, selected.id);
|
|
1631
|
+
openNavigator(ctx, selected.id, owner);
|
|
1632
1632
|
}
|
|
1633
1633
|
|
|
1634
1634
|
function stopWidgetNavSelection(ctx: ExtensionContext): void {
|
|
@@ -1640,9 +1640,9 @@ function stopWidgetNavSelection(ctx: ExtensionContext): void {
|
|
|
1640
1640
|
}
|
|
1641
1641
|
|
|
1642
1642
|
/** Open the focused navigator overlay. No-op without a UI or visible runs. */
|
|
1643
|
-
function openNavigator(ctx: ExtensionContext, initialDetailId?: string): void {
|
|
1643
|
+
function openNavigator(ctx: ExtensionContext, initialDetailId?: string, owner?: symbol): void {
|
|
1644
1644
|
void initialDetailId;
|
|
1645
|
-
ensureNavigator(ctx);
|
|
1645
|
+
ensureNavigator(ctx, owner);
|
|
1646
1646
|
}
|
|
1647
1647
|
|
|
1648
1648
|
/** Refresh the shared navigator after run changes. */
|
|
@@ -1651,7 +1651,7 @@ function updateNavigatorFooter(ctx: ExtensionContext | undefined): void {
|
|
|
1651
1651
|
}
|
|
1652
1652
|
|
|
1653
1653
|
/** Install the empty-editor ← wrapper once per UI (reload-safe, composable). */
|
|
1654
|
-
function ensureNavigator(ctx: ExtensionContext): void {
|
|
1654
|
+
function ensureNavigator(ctx: ExtensionContext, owner?: symbol): void {
|
|
1655
1655
|
if (!isNavigatorUiAvailable(ctx)) return;
|
|
1656
1656
|
try {
|
|
1657
1657
|
ensureSubagentProvider();
|
|
@@ -1662,7 +1662,7 @@ function ensureNavigator(ctx: ExtensionContext): void {
|
|
|
1662
1662
|
matchKey: (data: string, keyId: string) => matchesKey(data, keyId),
|
|
1663
1663
|
truncate: truncateToWidth,
|
|
1664
1664
|
createTranscriptComponent: createSubagentTranscriptComponent,
|
|
1665
|
-
});
|
|
1665
|
+
}, owner);
|
|
1666
1666
|
} catch { /* ignore */ }
|
|
1667
1667
|
}
|
|
1668
1668
|
|
|
@@ -2012,6 +2012,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2012
2012
|
restrictContextFilesToWorktree(event.systemPromptOptions, PiCodingAgent.getAgentDir());
|
|
2013
2013
|
});
|
|
2014
2014
|
initializeNavigatorPanels(pi);
|
|
2015
|
+
// Each cached factory instance owns the navigator it installs (#460).
|
|
2016
|
+
const navigatorOwner = Symbol("subagents navigator factory");
|
|
2015
2017
|
const agentSessionSettings = createAgentSessionSettings(pi);
|
|
2016
2018
|
let catalogModelRegistry: ExtensionContext["modelRegistry"] | undefined;
|
|
2017
2019
|
let delegationOverride: DelegationMode | undefined;
|
|
@@ -3932,10 +3934,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3932
3934
|
// Adopt records left non-terminal by a pi that died without a clean
|
|
3933
3935
|
// shutdown, so the age sweep can eventually retire them.
|
|
3934
3936
|
reconcileAbandonedRuns();
|
|
3935
|
-
disposeBackgroundWorkNavigator(ctx);
|
|
3937
|
+
disposeBackgroundWorkNavigator(ctx, navigatorOwner);
|
|
3936
3938
|
widgetNavActive = false;
|
|
3937
3939
|
widgetNavSelectedId = undefined;
|
|
3938
|
-
ensureNavigator(ctx);
|
|
3940
|
+
ensureNavigator(ctx, navigatorOwner);
|
|
3939
3941
|
publishAcceptanceHooks();
|
|
3940
3942
|
updateNavigatorFooter(ctx);
|
|
3941
3943
|
// Clear the retired legacy widget so the shared navigator is the only
|
|
@@ -3956,7 +3958,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3956
3958
|
cancelCallbackBatch(pi);
|
|
3957
3959
|
mainAgentStartedAt = undefined;
|
|
3958
3960
|
mainAgentTools.clear();
|
|
3959
|
-
|
|
3961
|
+
// Owned disposal only: a headless child or a stale factory must not clear
|
|
3962
|
+
// the parent's navigator by omitting the context and owner.
|
|
3963
|
+
disposeBackgroundWorkNavigator(undefined, navigatorOwner);
|
|
3960
3964
|
});
|
|
3961
3965
|
|
|
3962
3966
|
// Tear down the timer and clear the widget when the session ends.
|
|
@@ -4008,7 +4012,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4008
4012
|
resetParseRunCursor();
|
|
4009
4013
|
// Always dispose navigator detail timers (no UI call — just clearInterval).
|
|
4010
4014
|
// Safe in every mode; the dispose hook is only set when a TUI overlay opened.
|
|
4011
|
-
disposeBackgroundWorkNavigator(ctx);
|
|
4015
|
+
disposeBackgroundWorkNavigator(ctx, navigatorOwner);
|
|
4012
4016
|
// Widget clear is intentional in every mode that exposes ui, including RPC.
|
|
4013
4017
|
try { ctx.ui.setWidget("subagents", WIDGET_CLEAR); } catch { /* ignore */ }
|
|
4014
4018
|
});
|
|
@@ -82,8 +82,10 @@ type HostDeps = {
|
|
|
82
82
|
|
|
83
83
|
type NavigatorState = {
|
|
84
84
|
providers: Map<string, BackgroundWorkProvider>;
|
|
85
|
+
registrationTokens?: Map<string, symbol>;
|
|
85
86
|
unsubscribers: Map<string, () => void>;
|
|
86
87
|
uiCtx?: ExtensionContext;
|
|
88
|
+
uiOwner?: symbol;
|
|
87
89
|
deps?: HostDeps;
|
|
88
90
|
lastHint?: string | null;
|
|
89
91
|
lastMainListLines?: string[];
|
|
@@ -137,6 +139,8 @@ function state(): NavigatorState {
|
|
|
137
139
|
|
|
138
140
|
export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
|
|
139
141
|
const s = state();
|
|
142
|
+
const token = Symbol(provider.id);
|
|
143
|
+
(s.registrationTokens ??= new Map()).set(provider.id, token);
|
|
140
144
|
const previousUnsub = s.unsubscribers.get(provider.id);
|
|
141
145
|
if (previousUnsub) {
|
|
142
146
|
try { previousUnsub(); } catch { /* ignore */ }
|
|
@@ -152,7 +156,10 @@ export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider)
|
|
|
152
156
|
s.unsubscribers.delete(provider.id);
|
|
153
157
|
}
|
|
154
158
|
refreshBackgroundWorkNavigator();
|
|
155
|
-
|
|
159
|
+
// A stale factory must not unregister its replacement from another cached module.
|
|
160
|
+
return () => {
|
|
161
|
+
if (state().registrationTokens?.get(provider.id) === token) unregisterBackgroundWorkProvider(provider.id);
|
|
162
|
+
};
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
export function unregisterBackgroundWorkProvider(id: string): void {
|
|
@@ -163,6 +170,7 @@ export function unregisterBackgroundWorkProvider(id: string): void {
|
|
|
163
170
|
}
|
|
164
171
|
s.unsubscribers.delete(id);
|
|
165
172
|
s.providers.delete(id);
|
|
173
|
+
s.registrationTokens?.delete(id);
|
|
166
174
|
refreshBackgroundWorkNavigator();
|
|
167
175
|
}
|
|
168
176
|
|
|
@@ -188,10 +196,11 @@ export function applyCloseConfirmFooter(ui: { setStatus(key: string, value: stri
|
|
|
188
196
|
return hint ?? null;
|
|
189
197
|
}
|
|
190
198
|
|
|
191
|
-
export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostDeps): void {
|
|
199
|
+
export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostDeps, owner?: symbol): void {
|
|
192
200
|
if (!isNavigatorUiAvailable(ctx)) return;
|
|
193
201
|
const s = state();
|
|
194
202
|
s.uiCtx = ctx;
|
|
203
|
+
s.uiOwner = owner;
|
|
195
204
|
s.deps = {
|
|
196
205
|
...deps,
|
|
197
206
|
createTranscriptComponent: deps.createTranscriptComponent ?? s.deps?.createTranscriptComponent,
|
|
@@ -207,14 +216,26 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
|
|
|
207
216
|
refreshMainListWidget();
|
|
208
217
|
}
|
|
209
218
|
|
|
210
|
-
export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
219
|
+
export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext, owner?: symbol): void {
|
|
211
220
|
const s = state();
|
|
221
|
+
if (owner !== undefined) {
|
|
222
|
+
// Only the factory that installed the current navigator may dispose it.
|
|
223
|
+
if (s.uiOwner !== owner) return;
|
|
224
|
+
} else if (s.uiOwner !== undefined) {
|
|
225
|
+
// Ownership is never inferred: an omitted owner must not clear a navigator
|
|
226
|
+
// another cached factory (or a headless child) owns.
|
|
227
|
+
return;
|
|
228
|
+
} else if (ctx !== undefined) {
|
|
229
|
+
// Unowned navigators stay releasable by a live, matching UI context.
|
|
230
|
+
if (!isNavigatorUiAvailable(ctx) || (s.uiCtx && ctx.ui !== s.uiCtx.ui)) return;
|
|
231
|
+
}
|
|
212
232
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
213
233
|
s.dispose = undefined;
|
|
214
234
|
s.overlay = undefined;
|
|
215
235
|
stopMainListWidget();
|
|
216
236
|
const activeCtx = ctx ?? s.uiCtx;
|
|
217
237
|
s.uiCtx = undefined;
|
|
238
|
+
s.uiOwner = undefined;
|
|
218
239
|
if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
|
|
219
240
|
try { setNavigatorPanel(activeCtx, MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
220
241
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* reference channels (`continuationNote`, `reason`, `summary`, `authorityRef`)
|
|
12
12
|
* that a caller could otherwise use to persist a secret.
|
|
13
13
|
*/
|
|
14
|
+
import { isAbsolute } from "node:path";
|
|
14
15
|
import { StoreError } from "./errors.ts";
|
|
15
16
|
import type { EvidenceKind, OwnerIdentity, OwnerKind } from "./types.ts";
|
|
16
17
|
|
|
@@ -143,7 +144,8 @@ export function requirePath(field: string, value: unknown): string {
|
|
|
143
144
|
/** Workspace binding: an absolute path that may contain spaces. */
|
|
144
145
|
export function requireWorkspace(value: unknown): string {
|
|
145
146
|
const workspace = assertPlainString("owner.workspace", value, TEXT_LIMITS.workspace).trim();
|
|
146
|
-
|
|
147
|
+
// Platform-aware: a Windows workspace is "C:\...", which never starts with "/".
|
|
148
|
+
if (!isAbsolute(workspace)) fail("owner.workspace", "must be an absolute path");
|
|
147
149
|
return workspace;
|
|
148
150
|
}
|
|
149
151
|
|