pi-better-background-tasks 0.8.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -5,8 +5,10 @@ import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, e
5
5
  import { resumeScheduledWork, suspendScheduledWork } from "./runtime.js";
6
6
  import { observeForegroundSandboxPolicy } from "./sandbox.js";
7
7
  import { registerTools } from "./tools.js";
8
+ import { appendExecutionPolicy } from "./shared-execution-policy.js";
8
9
 
9
10
  export default function backgroundTasksExtension(pi: ExtensionAPI): void {
11
+ pi.on("before_agent_start", (event) => ({ systemPrompt: appendExecutionPolicy(event.systemPrompt) }));
10
12
  registerBackgroundTasksGoalProvider(pi);
11
13
  ensureBackgroundTasksNavigatorProvider(pi);
12
14
  // Subscribed at load so a sandbox extension that publishes later is heard, and
package/src/process.ts CHANGED
@@ -95,6 +95,10 @@ export function withWindowsLogRedirect(spec: CommandSpec, logPath: string): Comm
95
95
  return {
96
96
  ...spec,
97
97
  shell: true,
98
+ // The trampoline only redirects and execs an absolute program; sourcing
99
+ // the login profile (/etc/profile and friends) adds seconds of cold-start
100
+ // cost on a Windows runner and nothing the exec needs.
101
+ loginShell: false,
98
102
  // The MSYS2 runtime rewrites POSIX-looking argv (e.g. `/c`, `/opt/x.sh`)
99
103
  // when exec'ing native Windows binaries. Node spawn passed argv verbatim,
100
104
  // so conversion is disabled to keep raw-argv semantics unchanged. The
@@ -404,7 +408,7 @@ export function commandExecution(spec: CommandSpec): { execPath: string; execArg
404
408
  const [command, ...args] = spec.argv!;
405
409
  return { execPath: command!, execArgs: args };
406
410
  }
407
- return { execPath: resolveDefaultShell(), execArgs: ["-lc", spec.command!] };
411
+ return { execPath: resolveDefaultShell(), execArgs: [spec.loginShell === false ? "-c" : "-lc", spec.command!] };
408
412
  }
409
413
 
410
414
  /** Stdio for a spawned task: stdin is always ignored; stdout/stderr are piped (collected) or ignored (redirected into the log by the child itself). */
@@ -0,0 +1,19 @@
1
+ // Generated from packages/pi-better-harness/extensions/execution-policy.ts. Do not edit directly.
2
+ /** Harness-wide scheduling guidance, vendored so standalone extensions share it. */
3
+ const EXECUTION_POLICY = [
4
+ "Harness execution policy: concurrency is separate from subagent delegation.",
5
+ "Before multi-unit work, identify ready, authorized, independent units, actual prerequisites, shared mutable resources, and capacity or availability constraints. Represent independently verifiable deliverables (such as each host migration) separately in the plan; do not hide them in one serial rollout step or create dependencies merely from list order.",
6
+ "Start all ready, authorized, non-conflicting units concurrently within known resource limits before waiting for any one to finish. Reassess ready work after launches and results; continue unblocked foreground work instead of polling running tasks. Do not equate one background launch with scheduling all ready work.",
7
+ "Serialize only for a concrete dependency, conflicting writes or ownership, resource or concurrency limit, documented rollout gate, or explicit availability requirement. Record the reason in the plan or explain it to the user. Do not invent a canary or host-by-host availability requirement.",
8
+ "If concurrency raises an unresolved material operational trade-off (for example, draining all hosts could leave no available runners), ask the user once before the affected operations. Continue independent preparation meanwhile. Existing authorization, safety checks, workflow prerequisites, and human-approved rollout constraints remain binding; urgency does not waive them.",
9
+ "Choose the execution mechanism after deciding what can run together: foreground tools for small, tightly coupled, or interactive work; subagents for bounded independent reasoning or implementation when the active delegation mode permits; background tasks for independent long-running commands or waits. Manual delegation forbids proactive subagents, not concurrent foreground tools or background commands. Use available batch or parallel tool interfaces where appropriate and inspect every launch receipt for failures or skipped work.",
10
+ "A unit waiting or failing does not block other ready independent units unless the evidence shows a shared blocker. Preserve each unit's guards and rollback boundary. Before completing verification, the plan, or the goal, inspect and integrate every relevant terminal result or failure; launch is not completion.",
11
+ ].join("\n");
12
+
13
+ export function appendExecutionPolicy(systemPrompt: string): string {
14
+ // Inspect the prompt, not module-global state: vendored copies have separate
15
+ // module identities, and every fresh turn or reload needs the policy again.
16
+ return systemPrompt.includes(EXECUTION_POLICY)
17
+ ? systemPrompt
18
+ : `${systemPrompt}\n\n${EXECUTION_POLICY}`;
19
+ }
@@ -174,8 +174,9 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
174
174
  }
175
175
  }
176
176
 
177
- export function navigatorFooterHint(count: number): string | null {
178
- return count > 0 ? `← work · ${count}` : null;
177
+ export function navigatorFooterHint(_count: number): string | null {
178
+ // Retain the compatibility helper so refresh/reload clears any old badge.
179
+ return null;
179
180
  }
180
181
 
181
182
  export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
@@ -58,6 +58,8 @@ export interface ResolvedSshIdentity {
58
58
  export const DEFAULT_SSH_CONNECT_TIMEOUT_SECONDS = 10;
59
59
  export const DEFAULT_SSH_CONTROL_PERSIST_SECONDS = 600;
60
60
  export const MAX_SSH_CONTROL_PATH_BYTES = 100;
61
+ /** The longest socket name under a ControlPath root: `cm-` plus a 32-char hash. */
62
+ const MUX_CONTROL_PATH_NAME_TEMPLATE = `cm-${"0".repeat(32)}`;
61
63
  export const DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS = 120_000;
62
64
 
63
65
  export interface RemoteRunner {
@@ -111,18 +113,33 @@ export interface SshMuxCleanupResult {
111
113
  commandResult: CommandResult;
112
114
  }
113
115
 
116
+ export interface SshMuxForwardResult {
117
+ target: string;
118
+ localPath: string;
119
+ remotePath: string;
120
+ detail: string;
121
+ commandResult: CommandResult;
122
+ }
123
+
114
124
  export interface SshMuxController {
115
125
  readonly controlPath: string;
116
126
  ensure(): Promise<SshMuxEnsureResult>;
127
+ /** Forward a local Unix socket to a remote Unix socket through the running ControlMaster (`ssh -O forward -L local:remote`). */
128
+ forwardUnixSocket(localPath: string, remotePath: string): Promise<SshMuxForwardResult>;
129
+ /** Cancel a forward made by `forwardUnixSocket` (`ssh -O cancel -L local:remote`) and remove the local socket file. */
130
+ cancelUnixSocketForward(localPath: string, remotePath: string): Promise<SshMuxForwardResult>;
117
131
  status(): Promise<SshMuxStatus>;
118
132
  cleanup(): Promise<SshMuxCleanupResult>;
119
133
  withMux(commandSpec: CommandSpec): CommandSpec;
120
134
  }
121
135
 
122
136
  export class SshMuxError extends Error {
123
- constructor(message: string, readonly commandResult: CommandResult) {
137
+ // Declared, not a parameter property: Node's native type stripping (pi-better-server) cannot run those.
138
+ readonly commandResult: CommandResult;
139
+ constructor(message: string, commandResult: CommandResult) {
124
140
  super(message);
125
141
  this.name = "SshMuxError";
142
+ this.commandResult = commandResult;
126
143
  }
127
144
  }
128
145
 
@@ -311,7 +328,10 @@ export function wrapRemoteBashCommand(input: RemoteBashCommandInput): string {
311
328
 
312
329
  export function defaultSshControlPathRoot(): string {
313
330
  const agentDirectory = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
314
- return join(agentDirectory, "ssh-control");
331
+ const root = join(agentDirectory, "ssh-control");
332
+ // A long agent directory would push the socket past the unix path limit; fall back to a short per-user root.
333
+ if (Buffer.byteLength(join(root, MUX_CONTROL_PATH_NAME_TEMPLATE)) <= MAX_SSH_CONTROL_PATH_BYTES) return root;
334
+ return join("/tmp", `pi-ssh-${process.getuid?.() ?? "user"}`);
315
335
  }
316
336
 
317
337
  /**
@@ -328,6 +348,26 @@ export function createSshMuxController(options: SshMuxControllerOptions): SshMux
328
348
  const target = options.identity.target;
329
349
 
330
350
  const prepareRoot = (): void => ensureControlPathRoot(controlPathRoot);
351
+ const forwardOperation = async (operation: "forward" | "cancel", localPath: string, remotePath: string): Promise<SshMuxForwardResult> => {
352
+ for (const [name, value] of [["localPath", localPath], ["remotePath", remotePath]] as const) {
353
+ if (!value || /[\s:]/.test(value)) throw new Error(`ssh forward ${name} must be non-empty and contain no whitespace or colon`);
354
+ }
355
+ prepareRoot();
356
+ const commandResult = await options.runner.runOnce(muxCommandSpec(options.commandSpec, target, [
357
+ "-o", "ControlMaster=no",
358
+ "-o", `ControlPath=${controlPath}`,
359
+ "-O", operation,
360
+ "-L", `${localPath}:${remotePath}`,
361
+ ], false));
362
+ if (operation === "cancel") rmSync(localPath, { force: true });
363
+ if (!commandSucceeded(commandResult)) {
364
+ throw new SshMuxError(
365
+ `SSH ${operation} of ${localPath} -> ${target}:${remotePath} failed${resultDetail(commandResult) ? `: ${resultDetail(commandResult)}` : "."}`,
366
+ commandResult,
367
+ );
368
+ }
369
+ return { target, localPath, remotePath, detail: resultDetail(commandResult), commandResult };
370
+ };
331
371
  const buildControlCommand = (operation: "check" | "exit"): CommandSpec => {
332
372
  prepareRoot();
333
373
  return muxCommandSpec(options.commandSpec, target, [
@@ -350,6 +390,11 @@ export function createSshMuxController(options: SshMuxControllerOptions): SshMux
350
390
  return {
351
391
  controlPath,
352
392
  status,
393
+ forwardUnixSocket: async (localPath, remotePath) => {
394
+ rmSync(localPath, { force: true }); // ssh will not bind over a stale socket file
395
+ return await forwardOperation("forward", localPath, remotePath);
396
+ },
397
+ cancelUnixSocketForward: (localPath, remotePath) => forwardOperation("cancel", localPath, remotePath),
353
398
  withMux: (commandSpec) => {
354
399
  prepareRoot();
355
400
  return muxCommandSpec(commandSpec, target, [
@@ -452,6 +497,9 @@ function ensureControlPathRoot(controlPathRoot: string): void {
452
497
  if (!stats.isDirectory() || stats.isSymbolicLink()) {
453
498
  throw new Error(`ssh mux ControlPath root must be a real directory: ${controlPathRoot}`);
454
499
  }
500
+ if (process.getuid && stats.uid !== process.getuid()) {
501
+ throw new Error(`ssh mux ControlPath root must be owned by the current user: ${controlPathRoot}`);
502
+ }
455
503
  chmodSync(controlPathRoot, 0o700);
456
504
  }
457
505
 
package/src/tools.ts CHANGED
@@ -184,6 +184,7 @@ const StatusActionParams = Type.Object({
184
184
  });
185
185
 
186
186
  const BACKGROUND_ORCHESTRATION_GUIDELINES = [
187
+ "Follow the Harness execution policy when scheduling independent background operations; subagent delegation mode does not require background commands to run serially.",
187
188
  "Use background tasks for genuinely long-running processes or repeated checks. Run short commands in the foreground.",
188
189
  "When a structured plan is active, keep it as the coordinator ledger: launch relevant background work early, continue unblocked foreground work without polling, and update the plan after inspecting each terminal result or failure.",
189
190
  "Do not treat launch as completion of the parent milestone; relevant background work must be terminal, inspected, and integrated before verification or completion.",
package/src/types.ts CHANGED
@@ -22,6 +22,8 @@ export interface CommandSpec {
22
22
  command?: string;
23
23
  argv?: string[];
24
24
  shell?: boolean;
25
+ /** Run the shell as a login shell (`bash -lc`, the default). False runs `bash -c`, skipping profile sourcing. */
26
+ loginShell?: boolean;
25
27
  cwd?: string;
26
28
  env?: Record<string, string>;
27
29
  }