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.
Files changed (25) hide show
  1. package/README.md +3 -2
  2. package/extensions/task-context/index.ts +8 -7
  3. package/node_modules/pi-better-background-tasks/package.json +1 -1
  4. package/node_modules/pi-better-background-tasks/src/batch.ts +5 -2
  5. package/node_modules/pi-better-background-tasks/src/failures.ts +14 -6
  6. package/node_modules/pi-better-background-tasks/src/index.ts +14 -13
  7. package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +37 -10
  8. package/node_modules/pi-better-background-tasks/src/runtime.ts +99 -89
  9. package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +24 -3
  10. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/validate.ts +3 -1
  11. package/node_modules/pi-better-background-tasks/src/shared-ssh-core/index.ts +30 -1
  12. package/node_modules/pi-better-background-tasks/src/supervisor/generated/store.mjs +4 -2
  13. package/node_modules/pi-better-background-tasks/src/supervisor/store.ts +4 -2
  14. package/node_modules/pi-better-background-tasks/src/tools.ts +32 -24
  15. package/node_modules/pi-better-ssh/README.md +2 -0
  16. package/node_modules/pi-better-ssh/package.json +1 -1
  17. package/node_modules/pi-better-ssh/src/index.ts +4 -1
  18. package/node_modules/pi-better-ssh/src/mux-registry.ts +3 -1
  19. package/node_modules/pi-better-ssh/src/remote-bash.ts +4 -2
  20. package/node_modules/pi-better-ssh/src/shared-ssh-core/index.ts +30 -1
  21. package/node_modules/pi-better-subagents/index.ts +14 -10
  22. package/node_modules/pi-better-subagents/package.json +1 -1
  23. package/node_modules/pi-better-subagents/shared-navigator.ts +24 -3
  24. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/validate.ts +3 -1
  25. package/package.json +4 -4
@@ -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
- if (!workspace.startsWith("/")) fail("owner.workspace", "must be an absolute path");
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
- return join(dirname(path), `.${path.split("/").at(-1)}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`);
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
- return join(dirname(path), `.${path.split("/").at(-1)}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`);
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
- refreshBackgroundTasksNavigator(ctx);
351
+ refreshNavigator(ctx);
349
352
  return text(durable);
350
353
  }
351
354
  const launched = reportLaunch(() => spawnTask(pi, params, ctx.cwd, activeSession, getActiveSession));
352
- refreshBackgroundTasksNavigator(ctx);
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
- refreshBackgroundTasksNavigator(ctx);
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
- refreshBackgroundTasksNavigator(ctx);
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
- refreshBackgroundTasksNavigator(ctx);
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
- refreshBackgroundTasksNavigator(ctx);
442
+ refreshNavigator(ctx);
440
443
  return text(durable);
441
444
  }
442
- const result = await formatStop(pi, params.id, ctx, getActiveSession, statusOptions(params, activeSession));
443
- refreshBackgroundTasksNavigator(ctx);
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?: AbortSignal,
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?: AbortSignal,
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:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-ssh",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Pi extension for safe synchronous remote bash commands over reusable SSH connections.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 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.`,
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: "up", reused: muxState.reused },
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
- disposeBackgroundWorkNavigator();
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
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.28.1",
3
+ "version": "0.28.2",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- return () => unregisterBackgroundWorkProvider(provider.id);
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
- if (!workspace.startsWith("/")) fail("owner.workspace", "must be an absolute path");
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