pi-better-harness 0.30.0 → 0.31.0
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/extensions/minimal-output/index.ts +0 -6
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/process.ts +5 -1
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +3 -2
- package/node_modules/pi-better-background-tasks/src/shared-ssh-core/index.ts +50 -2
- package/node_modules/pi-better-background-tasks/src/types.ts +2 -0
- package/node_modules/pi-better-goal/README.md +9 -0
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +79 -14
- package/node_modules/pi-better-goal/src/workflow-gates.ts +247 -0
- package/node_modules/pi-better-sandbox/README.md +5 -6
- package/node_modules/pi-better-sandbox/index.ts +2 -15
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-ssh/package.json +1 -1
- package/node_modules/pi-better-ssh/src/shared-ssh-core/index.ts +50 -2
- package/node_modules/pi-better-subagents/index.ts +15 -2
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +3 -2
- package/package.json +6 -6
|
@@ -21,10 +21,6 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
21
21
|
return initialDefault = value?.enabled === true;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function status(ctx: ExtensionContext): void {
|
|
25
|
-
ctx.ui.setStatus(ENTRY, enabled ? "tools: minimal" : undefined);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
24
|
function collapseTools(ctx: ExtensionContext): void {
|
|
29
25
|
// setToolsExpanded is a no-op when already false; force a redraw of restored transcript rows.
|
|
30
26
|
if (!ctx.ui.getToolsExpanded()) ctx.ui.setToolsExpanded(true);
|
|
@@ -77,7 +73,6 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
77
73
|
if (enabled) collapseTools(ctx);
|
|
78
74
|
hook!.setEnabled(enabled);
|
|
79
75
|
} else enabled = false;
|
|
80
|
-
status(ctx);
|
|
81
76
|
}
|
|
82
77
|
|
|
83
78
|
async function change(value: string, ctx: ExtensionContext): Promise<void> {
|
|
@@ -98,7 +93,6 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
98
93
|
if (enabled) collapseTools(ctx);
|
|
99
94
|
hook!.setEnabled(enabled);
|
|
100
95
|
if (enabled && !running) hook!.completeRun();
|
|
101
|
-
status(ctx);
|
|
102
96
|
}
|
|
103
97
|
|
|
104
98
|
const setting: SettingsControl = {
|
|
@@ -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). */
|
|
@@ -174,8 +174,9 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
export function navigatorFooterHint(
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
}
|
|
@@ -57,6 +57,15 @@ Invoking the alias (`/skill:resolve-issues`, or `/goal /skill:resolve-issues tas
|
|
|
57
57
|
|
|
58
58
|
After every successful context compaction (Pi's `session_compact` event) while a coordinator owns the session, the goal extension re-injects the skill so the model sees it before its next step; the compaction summary keeps run state but not the skill's rules, and the summary itself is left unchanged. If the skill directory ships `scripts/rehydrate.mjs` (checked first) or `rehydrate.mjs`, it runs as `node <script> --cwd <session cwd>` (no shell, 10-second timeout, stderr ignored) and its trimmed stdout becomes the message; empty output injects nothing, so the script decides whether a run is in progress. Without a script, or when it fails or times out, the message is: "Your context was just compacted while you coordinate the `<name>` workflow. Before your next step, read `<SKILL.md path>` in full, with no line limit." The message is a `workflow-rehydrate` custom message: inside a running agent loop (or before an overflow retry) it steers the next model call; otherwise it is appended without starting a turn.
|
|
59
59
|
|
|
60
|
+
### Scheduler-backed workflow gates
|
|
61
|
+
|
|
62
|
+
A coordinator that ships the resolve-issues scheduler (`scripts/next-actions.mjs` in its skill directory, or in a sibling `resolve-issues` skill) keeps its Goal from ending while the run still has work. The extension runs `node next-actions.mjs --run <run dir> --slots 1 --peek --json` (read-only, 20-second timeout), where the run directory is the folder of the task plan bound through `update_plan`. Every gate fails open: if the scheduler is missing, errors, times out, prints bad output, or no plan is bound yet, the call proceeds and its result carries a warning, so nothing deadlocks.
|
|
63
|
+
|
|
64
|
+
- **Completion.** `update_goal` complete is refused, with each due action (action, unit, first 200 characters of detail), unless the scheduler reports `finish` and nothing else is due (an empty due list means workers are still running, so it is refused too). The Goal stays active.
|
|
65
|
+
- **Pause.** `release_workflow` is refused unless every unfinished unit is quiet (nothing due for it) or blocked `human-decision` with `humanItem` 2-5. A unit blocked `retry-ci` or any other reason the run retries itself, or a `human-decision` item 1, is named in the refusal and the Goal stays active.
|
|
66
|
+
- **Auto-bind.** Invoking `/skill:rush-issues` or `/skill:resolve-issues` with no active goal (none, or a completed one) creates and activates a Goal whose objective is the invocation text, exactly as `/goal <invocation>` would, without sending the command a second time. A paused or active Goal is left alone.
|
|
67
|
+
- **Wait timer.** When the scheduler's only due action is `wait` with a "next automatic attempt is at <ISO time>" detail, the next automatic continuation is scheduled for that time (at most the maximum timer delay, at least 5 seconds) and the wake-up is not counted as a no-progress retry. Other due work, or an unparseable time, uses the ordinary retry schedule. `autoContinue` off disables this timer.
|
|
68
|
+
|
|
60
69
|
When `pi-better-plan` is installed, it defers its prompt, checklist, and `update_plan` tool to a skill-owned task plan. Skills without this metadata retain normal goal and plan behavior. The skill itself owns its planning format and worker policy; the harness does not enumerate skills or impose a shared workflow schema. The former `pi-better-plan-workflow: coordinator` metadata remains supported for installed skills.
|
|
61
70
|
|
|
62
71
|
## Install
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { createRenderScheduler } from "./shared-render-scheduler.js";
|
|
41
41
|
import { collectSubagentActivity } from "./subagents.js";
|
|
42
42
|
import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
|
|
43
|
+
import { completionGate, pauseGate, schedulerWaitUntil } from "./workflow-gates.js";
|
|
43
44
|
import { workflowRehydrateMessage, WORKFLOW_REHYDRATE_MESSAGE_TYPE } from "./rehydrate.js";
|
|
44
45
|
import {
|
|
45
46
|
EVENT_ACTIVITY,
|
|
@@ -58,6 +59,10 @@ const POLL_INTERVAL_MS = 2_000;
|
|
|
58
59
|
const DEFAULT_IDLE_CONTINUATION_DELAY_MS = 60_000;
|
|
59
60
|
const DEFAULT_MAX_NO_PROGRESS_RETRIES = 10;
|
|
60
61
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
62
|
+
/** Never re-poll a workflow's scheduler sooner than this, even when its stated attempt time has passed. */
|
|
63
|
+
const MIN_WAIT_DELAY_MS = 5_000;
|
|
64
|
+
/** Workflow skills whose invocation binds a goal when none is active, so the run cannot stop silently. */
|
|
65
|
+
const AUTO_BIND_SKILLS = new Set(["rush-issues", "resolve-issues"]);
|
|
61
66
|
const WAKE_DISABLED =
|
|
62
67
|
process.env.PI_BETTER_GOAL_DISABLE_WAKE === "1" ||
|
|
63
68
|
process.env.PI_BETTER_EXTENSION_DISABLE_WAKE === "1";
|
|
@@ -477,6 +482,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
477
482
|
? workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath)
|
|
478
483
|
: null;
|
|
479
484
|
|
|
485
|
+
/**
|
|
486
|
+
* The session's workflow owner when an active goal is bound to it through its skill command, else
|
|
487
|
+
* null. Only these goals are gated by the workflow's scheduler.
|
|
488
|
+
*/
|
|
489
|
+
const boundWorkflow = (goal: GoalSnapshot, ctx: ExtensionContext): NonNullable<ReturnType<typeof currentWorkflowOwner>> | null => {
|
|
490
|
+
const owner = getWorkflow(ctx);
|
|
491
|
+
if (!owner || goal.status !== "active" || goal.command?.source !== "skill") return null;
|
|
492
|
+
try {
|
|
493
|
+
const bound = goalWorkflowOwner(goal);
|
|
494
|
+
return bound?.name === owner.name && bound.path === owner.path ? owner : null;
|
|
495
|
+
} catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
480
500
|
/**
|
|
481
501
|
* The one resume path shared by `/goal resume`, the hotkey, and `goal_resume`:
|
|
482
502
|
* reactivate a paused goal or reopen an active no-progress hold.
|
|
@@ -568,6 +588,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
568
588
|
ctx: ExtensionContext,
|
|
569
589
|
kind: "continuation" | "background-drained",
|
|
570
590
|
snapshot?: ActivitySnapshot,
|
|
591
|
+
/** Fixed delay for a workflow scheduler's wait; replaces any pending timer and ignores no-progress backoff. */
|
|
592
|
+
waitDelayMs?: number,
|
|
571
593
|
): void => {
|
|
572
594
|
if (WAKE_DISABLED || !preferences.autoContinue || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
|
|
573
595
|
return;
|
|
@@ -576,8 +598,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
576
598
|
if (currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
577
599
|
return;
|
|
578
600
|
}
|
|
579
|
-
const signature = `${goal.goalId}:${kind}`;
|
|
580
|
-
if (idleContinuationTimer && (idleContinuationSignature === signature ||
|
|
601
|
+
const signature = `${goal.goalId}:${waitDelayMs === undefined ? kind : "wait"}`;
|
|
602
|
+
if (waitDelayMs === undefined && idleContinuationTimer && (idleContinuationSignature === signature ||
|
|
581
603
|
(kind === "continuation" && idleContinuationSignature === `${goal.goalId}:background-drained`))) {
|
|
582
604
|
return;
|
|
583
605
|
}
|
|
@@ -587,7 +609,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
587
609
|
idleContinuationTimer = undefined;
|
|
588
610
|
idleContinuationSignature = "";
|
|
589
611
|
void sendIdleContinuationAfterAudit(goal.goalId, ctx, kind, snapshot);
|
|
590
|
-
}, idleContinuationDelay(kind === "continuation" ? currentContinuationState(ctx, goal.goalId)?.noProgressRetries ?? 0 : 0));
|
|
612
|
+
}, waitDelayMs ?? idleContinuationDelay(kind === "continuation" ? currentContinuationState(ctx, goal.goalId)?.noProgressRetries ?? 0 : 0));
|
|
591
613
|
idleContinuationTimer.unref?.();
|
|
592
614
|
};
|
|
593
615
|
|
|
@@ -626,6 +648,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
626
648
|
tokenBudget: number | null,
|
|
627
649
|
ctx: ExtensionContext,
|
|
628
650
|
source: "command",
|
|
651
|
+
/** False when the user's own message already invoked the command, so the goal must not send it again. */
|
|
652
|
+
dispatch = true,
|
|
629
653
|
): GoalSnapshot => {
|
|
630
654
|
const objectiveError = validateObjective(objective);
|
|
631
655
|
if (objectiveError) {
|
|
@@ -647,8 +671,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
647
671
|
setGoal(goal, ctx, source);
|
|
648
672
|
if (command) {
|
|
649
673
|
if (owner) recordWorkflow(owner);
|
|
650
|
-
pi.sendUserMessage(commandInvocation(command), { deliverAs: "followUp", expandPromptTemplates: true });
|
|
651
|
-
if (command.source === "extension") scheduleIdleContinuation(goal, ctx, "continuation");
|
|
674
|
+
if (dispatch) pi.sendUserMessage(commandInvocation(command), { deliverAs: "followUp", expandPromptTemplates: true });
|
|
675
|
+
if (dispatch && command.source === "extension") scheduleIdleContinuation(goal, ctx, "continuation");
|
|
652
676
|
} else {
|
|
653
677
|
queueGoalContinuation(goal, ctx);
|
|
654
678
|
}
|
|
@@ -703,11 +727,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
703
727
|
}
|
|
704
728
|
}
|
|
705
729
|
if (waitingOnAnswer > 0) return `${waitingOnAnswer} background done; waiting on your answer`;
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
: undefined;
|
|
709
|
-
if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal, preferences.conversationalResume)} · ${background}` : pausedGoalStatus(goal, preferences.conversationalResume);
|
|
710
|
-
if (background) return background;
|
|
730
|
+
if (goal?.status === "paused") return pausedGoalStatus(goal, preferences.conversationalResume);
|
|
731
|
+
if (snapshot?.backgroundRunning) return undefined;
|
|
711
732
|
const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
|
|
712
733
|
if (continuation?.blocked) return "waiting: no progress";
|
|
713
734
|
const goalStall = observeGoalStall(goal, continuation, {
|
|
@@ -1052,12 +1073,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1052
1073
|
details: { ok: true, goal: current },
|
|
1053
1074
|
};
|
|
1054
1075
|
}
|
|
1076
|
+
const gated = boundWorkflow(current, ctx);
|
|
1077
|
+
const verdict = gated ? await completionGate(ctx.sessionManager.getBranch(), gated) : null;
|
|
1078
|
+
if (verdict && !verdict.allow) {
|
|
1079
|
+
return {
|
|
1080
|
+
content: [{ type: "text", text: verdict.message }],
|
|
1081
|
+
details: { ok: false, goal: current, due: verdict.due },
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1055
1084
|
const goal = goalWithStatus(current, "complete");
|
|
1056
1085
|
setGoal(goal, ctx, "tool");
|
|
1057
1086
|
if (getWorkflow(ctx)) recordWorkflow(null);
|
|
1087
|
+
const warning = verdict?.allow ? verdict.warning : undefined;
|
|
1058
1088
|
return {
|
|
1059
|
-
content: [{ type: "text", text: "Goal marked complete." }],
|
|
1060
|
-
details: { ok: true, goal },
|
|
1089
|
+
content: [{ type: "text", text: warning ? `Goal marked complete. ${warning}` : "Goal marked complete." }],
|
|
1090
|
+
details: { ok: true, goal, ...(warning ? { warning } : {}) },
|
|
1061
1091
|
};
|
|
1062
1092
|
},
|
|
1063
1093
|
});
|
|
@@ -1138,6 +1168,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1138
1168
|
const owner = getWorkflow(ctx);
|
|
1139
1169
|
if (!owner) return { content: [{ type: "text", text: "No workflow owns this session." }], details: { released: false } };
|
|
1140
1170
|
const goal = getGoal(ctx);
|
|
1171
|
+
const gated = goal ? boundWorkflow(goal, ctx) : null;
|
|
1172
|
+
const verdict = gated ? await pauseGate(ctx.sessionManager.getBranch(), gated) : null;
|
|
1173
|
+
if (verdict && !verdict.allow) {
|
|
1174
|
+
return { content: [{ type: "text", text: verdict.message }], details: { released: false, goalPaused: false } };
|
|
1175
|
+
}
|
|
1176
|
+
const warning = verdict?.allow ? verdict.warning : undefined;
|
|
1141
1177
|
let resumeCommand: string | undefined;
|
|
1142
1178
|
if (goal?.status === "active" && goal.command?.source === "skill") {
|
|
1143
1179
|
const boundOwner = workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath);
|
|
@@ -1148,7 +1184,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1148
1184
|
}
|
|
1149
1185
|
recordWorkflow(null);
|
|
1150
1186
|
return {
|
|
1151
|
-
content: [{ type: "text", text: `Released ${owner.name} workflow ownership.${resumeCommand ? ` Its Goal is paused, not complete; the user's go-ahead or /goal resume rebinds /${resumeCommand}.` : ""}` }],
|
|
1187
|
+
content: [{ type: "text", text: `Released ${owner.name} workflow ownership.${resumeCommand ? ` Its Goal is paused, not complete; the user's go-ahead or /goal resume rebinds /${resumeCommand}.` : ""}${warning ? ` ${warning}` : ""}` }],
|
|
1152
1188
|
details: { released: true, owner: owner.name, goalPaused: resumeCommand !== undefined },
|
|
1153
1189
|
};
|
|
1154
1190
|
},
|
|
@@ -1245,7 +1281,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1245
1281
|
if (command) {
|
|
1246
1282
|
try {
|
|
1247
1283
|
const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path, registeredSkillPath);
|
|
1248
|
-
if (owner)
|
|
1284
|
+
if (owner) {
|
|
1285
|
+
// Without a goal nothing re-prompts the coordinator when it stops early, so bind one now
|
|
1286
|
+
// exactly as `/goal <this invocation>` would. The user's message already runs the skill.
|
|
1287
|
+
const existing = getGoal(ctx);
|
|
1288
|
+
if (AUTO_BIND_SKILLS.has(skillName) && (!existing || existing.status === "complete")) {
|
|
1289
|
+
try {
|
|
1290
|
+
startOrReplaceGoal(event.text.trim(), null, ctx, "command", false);
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
recordWorkflow(owner);
|
|
1293
|
+
notifyGoal(ctx, `Could not bind a goal to /skill:${skillName}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
1294
|
+
}
|
|
1295
|
+
} else {
|
|
1296
|
+
recordWorkflow(owner);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1249
1299
|
} catch (error) {
|
|
1250
1300
|
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1251
1301
|
return { action: "handled" as const };
|
|
@@ -1453,6 +1503,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1453
1503
|
if (!isPokeable(goal) || snapshot.backgroundRunning) {
|
|
1454
1504
|
return;
|
|
1455
1505
|
}
|
|
1506
|
+
// A run the scheduler says is only waiting (a retry is due at a known time) is not stuck: wake at
|
|
1507
|
+
// that time and leave the no-progress count alone, since nothing the model could do changes it.
|
|
1508
|
+
const waitOwner = preferences.autoContinue && !WAKE_DISABLED ? boundWorkflow(goal, ctx) : null;
|
|
1509
|
+
if (waitOwner) {
|
|
1510
|
+
const execution = executionGeneration;
|
|
1511
|
+
const waitUntil = await schedulerWaitUntil(ctx.sessionManager.getBranch(), waitOwner);
|
|
1512
|
+
if (execution !== executionGeneration) return;
|
|
1513
|
+
if (waitUntil !== undefined) {
|
|
1514
|
+
resetContinuationState(goal);
|
|
1515
|
+
lastAgentEvidence = null;
|
|
1516
|
+
scheduleIdleContinuation(goal, ctx, "continuation", snapshot,
|
|
1517
|
+
Math.min(MAX_TIMER_DELAY_MS, Math.max(MIN_WAIT_DELAY_MS, waitUntil - Date.now())));
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1456
1521
|
const evidence = lastAgentEvidence;
|
|
1457
1522
|
if (!evidence) {
|
|
1458
1523
|
scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { WorkflowOwner } from "./workflow.js";
|
|
6
|
+
|
|
7
|
+
/** Branch entry the plan extension writes when it binds a workflow-owned task plan. */
|
|
8
|
+
const WORKFLOW_PLAN_ENTRY = "pi-better-workflow-plan";
|
|
9
|
+
const WORKFLOW_ENTRY = "pi-better-workflow";
|
|
10
|
+
export const SCHEDULER_TIMEOUT_MS = 20_000;
|
|
11
|
+
const DETAIL_LIMIT = 200;
|
|
12
|
+
|
|
13
|
+
/** Scheduler locations relative to the owner's skill directory, in precedence order. */
|
|
14
|
+
const SCHEDULER_SCRIPTS = [
|
|
15
|
+
join("scripts", "next-actions.mjs"),
|
|
16
|
+
join("..", "resolve-issues", "scripts", "next-actions.mjs"),
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Units in these states are done; every other unit is unfinished work. */
|
|
20
|
+
const FINISHED_STATUSES = new Set(["succeeded", "cancelled", "canceled"]);
|
|
21
|
+
/** `humanItem` values 2-5 are genuine holds only a human can lift; item 1 is a decision the run takes itself. */
|
|
22
|
+
const HUMAN_HOLD_ITEMS = new Set([2, 3, 4, 5]);
|
|
23
|
+
|
|
24
|
+
export interface SchedulerAction {
|
|
25
|
+
action: string;
|
|
26
|
+
unit: string | null;
|
|
27
|
+
detail: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SchedulerRun =
|
|
31
|
+
| { ok: true; due: SchedulerAction[] }
|
|
32
|
+
| { ok: false; reason: string };
|
|
33
|
+
|
|
34
|
+
/** A gate either lets the call through (optionally with a warning when it could not check) or refuses it. */
|
|
35
|
+
export type GateVerdict =
|
|
36
|
+
| { allow: true; warning?: string }
|
|
37
|
+
| { allow: false; message: string; due?: SchedulerAction[] };
|
|
38
|
+
|
|
39
|
+
interface EntryLike {
|
|
40
|
+
type: string;
|
|
41
|
+
customType?: string;
|
|
42
|
+
data?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The first next-actions scheduler the owner's skill ships, or undefined when it ships none. */
|
|
50
|
+
export function findScheduler(owner: WorkflowOwner): string | undefined {
|
|
51
|
+
const skillDir = dirname(owner.path);
|
|
52
|
+
for (const relative of SCHEDULER_SCRIPTS) {
|
|
53
|
+
const candidate = join(skillDir, relative);
|
|
54
|
+
try {
|
|
55
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
56
|
+
} catch {
|
|
57
|
+
// Missing or unreadable: try the next location.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The task-plan.json the session bound for `owner`, or undefined before a plan is bound.
|
|
65
|
+
* Invoking a workflow skill again starts a new run, so a later owner entry drops the old binding.
|
|
66
|
+
*/
|
|
67
|
+
export function boundPlanPath(entries: Iterable<EntryLike>, owner: WorkflowOwner): string | undefined {
|
|
68
|
+
let path: string | undefined;
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.type !== "custom") continue;
|
|
71
|
+
const data = entry.data;
|
|
72
|
+
if (entry.customType === WORKFLOW_ENTRY && isRecord(data) && data.version === 1 && data.kind === "set") path = undefined;
|
|
73
|
+
if (entry.customType !== WORKFLOW_PLAN_ENTRY || !isRecord(data) || data.version !== 1) continue;
|
|
74
|
+
if (data.kind === "clear") path = undefined;
|
|
75
|
+
else if (data.kind === "set" && data.owner === owner.name && typeof data.path === "string") path = data.path;
|
|
76
|
+
}
|
|
77
|
+
return path;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseSchedulerOutput(stdout: string): SchedulerRun {
|
|
81
|
+
let parsed: unknown;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(stdout);
|
|
84
|
+
} catch {
|
|
85
|
+
return { ok: false, reason: "the scheduler printed output that is not JSON" };
|
|
86
|
+
}
|
|
87
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.due)) return { ok: false, reason: "the scheduler output has no due list" };
|
|
88
|
+
const due = parsed.due.flatMap((item): SchedulerAction[] => isRecord(item) && typeof item.action === "string"
|
|
89
|
+
? [{
|
|
90
|
+
action: item.action,
|
|
91
|
+
unit: item.unit === null || item.unit === undefined ? null : String(item.unit),
|
|
92
|
+
detail: typeof item.detail === "string" ? item.detail : "",
|
|
93
|
+
}]
|
|
94
|
+
: []);
|
|
95
|
+
return { ok: true, due };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Runs the scheduler read-only (`--peek`) for a run directory. The scheduler exits 1 when actions
|
|
100
|
+
* are due and 2 on a usage or plan error, so only the latter, a timeout, or bad output is a failure.
|
|
101
|
+
*/
|
|
102
|
+
export function runScheduler(script: string, runDir: string, timeoutMs = SCHEDULER_TIMEOUT_MS): Promise<SchedulerRun> {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
execFile("node", [script, "--run", runDir, "--slots", "1", "--peek", "--json"],
|
|
105
|
+
{ cwd: runDir, timeout: timeoutMs, encoding: "utf8", windowsHide: true, maxBuffer: 8 * 1024 * 1024 },
|
|
106
|
+
(error, stdout, stderr) => {
|
|
107
|
+
if (error) {
|
|
108
|
+
// An exit status of 1 means "actions due"; any other failure (exit 2, timeout, spawn error) is real.
|
|
109
|
+
if ((error as { code?: unknown }).code !== 1) {
|
|
110
|
+
const detail = String(stderr).trim().split("\n")[0] || error.message;
|
|
111
|
+
resolve({ ok: false, reason: `the scheduler failed (${detail.slice(0, DETAIL_LIMIT)})` });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
resolve(parseSchedulerOutput(stdout));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface PlannedRun {
|
|
121
|
+
runDir: string;
|
|
122
|
+
script: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function locateRun(entries: Iterable<EntryLike>, owner: WorkflowOwner): PlannedRun | { warning: string } {
|
|
126
|
+
const script = findScheduler(owner);
|
|
127
|
+
if (!script) return { warning: `${owner.name} ships no scheduler, so the run was not checked` };
|
|
128
|
+
const plan = boundPlanPath(entries, owner);
|
|
129
|
+
if (!plan) return { warning: `no task plan is bound for ${owner.name} yet, so the run was not checked` };
|
|
130
|
+
return { runDir: dirname(plan), script };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function unchecked(warning: string): GateVerdict {
|
|
134
|
+
return { allow: true, warning: `Warning: ${warning}. The gate is open so nothing deadlocks.` };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function describe(action: SchedulerAction): string {
|
|
138
|
+
const detail = action.detail.length > DETAIL_LIMIT ? `${action.detail.slice(0, DETAIL_LIMIT)}...` : action.detail;
|
|
139
|
+
return `- ${action.action}${action.unit ? ` (unit ${action.unit})` : ""}: ${detail}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Completion is allowed only when the scheduler reports `finish` and nothing else is due. */
|
|
143
|
+
export async function completionGate(
|
|
144
|
+
entries: Iterable<EntryLike>,
|
|
145
|
+
owner: WorkflowOwner,
|
|
146
|
+
options: { timeoutMs?: number } = {},
|
|
147
|
+
): Promise<GateVerdict> {
|
|
148
|
+
const located = locateRun(entries, owner);
|
|
149
|
+
if ("warning" in located) return unchecked(located.warning);
|
|
150
|
+
const run = await runScheduler(located.script, located.runDir, options.timeoutMs);
|
|
151
|
+
if (!run.ok) return unchecked(`could not check the ${owner.name} run (${run.reason})`);
|
|
152
|
+
const open = run.due.filter((action) => action.action !== "finish");
|
|
153
|
+
if (open.length === 0 && run.due.length > 0) return { allow: true };
|
|
154
|
+
if (open.length === 0) {
|
|
155
|
+
// Nothing due means workers are still running, not that the run is done.
|
|
156
|
+
return {
|
|
157
|
+
allow: false,
|
|
158
|
+
due: [],
|
|
159
|
+
message: `Goal not marked complete: the ${owner.name} run's workers are still running; the scheduler has not reported finish. ` +
|
|
160
|
+
"Wait for them, then call update_goal again once the scheduler reports finish.",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
allow: false,
|
|
165
|
+
due: open,
|
|
166
|
+
message: `Goal not marked complete: the ${owner.name} scheduler still has work due, so the run is not finished. ` +
|
|
167
|
+
`Do the due actions, then call update_goal again once the scheduler reports finish.\n${open.map(describe).join("\n")}`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function readUnits(runDir: string): Array<Record<string, unknown>> | null {
|
|
172
|
+
try {
|
|
173
|
+
const plan: unknown = JSON.parse(readFileSync(join(runDir, "task-plan.json"), "utf8"));
|
|
174
|
+
return isRecord(plan) && Array.isArray(plan.units) ? plan.units.filter(isRecord) : null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Releasing (pausing the goal) is allowed only for genuine human holds. Every unfinished unit must be
|
|
182
|
+
* either quiet (the scheduler has nothing due for it) or blocked on human-decision item 2-5.
|
|
183
|
+
*/
|
|
184
|
+
export async function pauseGate(
|
|
185
|
+
entries: Iterable<EntryLike>,
|
|
186
|
+
owner: WorkflowOwner,
|
|
187
|
+
options: { timeoutMs?: number } = {},
|
|
188
|
+
): Promise<GateVerdict> {
|
|
189
|
+
const located = locateRun(entries, owner);
|
|
190
|
+
if ("warning" in located) return unchecked(located.warning);
|
|
191
|
+
const units = readUnits(located.runDir);
|
|
192
|
+
if (!units) return unchecked(`could not read the ${owner.name} task plan`);
|
|
193
|
+
const run = await runScheduler(located.script, located.runDir, options.timeoutMs);
|
|
194
|
+
if (!run.ok) return unchecked(`could not check the ${owner.name} run (${run.reason})`);
|
|
195
|
+
|
|
196
|
+
const refusals: string[] = [];
|
|
197
|
+
const unfinished = new Set<string>();
|
|
198
|
+
for (const unit of units) {
|
|
199
|
+
const id = String(unit.id);
|
|
200
|
+
const status = typeof unit.status === "string" ? unit.status : "";
|
|
201
|
+
if (FINISHED_STATUSES.has(status)) continue;
|
|
202
|
+
unfinished.add(id);
|
|
203
|
+
const due = run.due.filter((action) => action.unit === id && action.action !== "finish");
|
|
204
|
+
if (due.length === 0) continue;
|
|
205
|
+
const humanHold = status === "blocked" && unit.blockedReason === "human-decision" &&
|
|
206
|
+
typeof unit.humanItem === "number" && HUMAN_HOLD_ITEMS.has(unit.humanItem);
|
|
207
|
+
if (humanHold) continue;
|
|
208
|
+
refusals.push(status === "blocked" && typeof unit.blockedReason === "string"
|
|
209
|
+
? unit.blockedReason === "human-decision"
|
|
210
|
+
? `unit ${id} is blocked human-decision${unit.humanItem === undefined ? "" : ` item ${String(unit.humanItem)}`}; only items 2-5 are genuine holds, so decide it yourself (${due[0]!.action})`
|
|
211
|
+
: `unit ${id} is blocked ${unit.blockedReason}; the run retries it itself (${due[0]!.action})`
|
|
212
|
+
: `unit ${id} is ${status || "unfinished"} with ${due[0]!.action} due`);
|
|
213
|
+
}
|
|
214
|
+
for (const action of run.due) {
|
|
215
|
+
if (action.action !== "finish" && (action.unit === null || !unfinished.has(action.unit))) {
|
|
216
|
+
refusals.push(`${action.unit ? `unit ${action.unit}` : "the run"} has ${action.action} due: ${action.detail.slice(0, DETAIL_LIMIT)}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (refusals.length === 0) return { allow: true };
|
|
220
|
+
return {
|
|
221
|
+
allow: false,
|
|
222
|
+
message: `Workflow not released: the ${owner.name} run still has work it can do itself, so the goal stays active.\n` +
|
|
223
|
+
refusals.map((line) => `- ${line}`).join("\n"),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The instant of the scheduler's `wait` heartbeat, when it is the only thing due. */
|
|
228
|
+
export function waitUntil(due: readonly SchedulerAction[]): number | undefined {
|
|
229
|
+
if (due.length === 0 || due.some((action) => action.action !== "wait")) return undefined;
|
|
230
|
+
const times = due.map((action) => {
|
|
231
|
+
const match = /next automatic attempt is at (\d{4}-\d{2}-\d{2}T[0-9:.]+Z?)/.exec(action.detail);
|
|
232
|
+
return match ? Date.parse(match[1]!) : Number.NaN;
|
|
233
|
+
}).filter(Number.isFinite);
|
|
234
|
+
return times.length > 0 ? Math.min(...times) : undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** When the bound run next wants a continuation, or undefined when it is not only waiting. */
|
|
238
|
+
export async function schedulerWaitUntil(
|
|
239
|
+
entries: Iterable<EntryLike>,
|
|
240
|
+
owner: WorkflowOwner,
|
|
241
|
+
options: { timeoutMs?: number } = {},
|
|
242
|
+
): Promise<number | undefined> {
|
|
243
|
+
const located = locateRun(entries, owner);
|
|
244
|
+
if ("warning" in located) return undefined;
|
|
245
|
+
const run = await runScheduler(located.script, located.runDir, options.timeoutMs);
|
|
246
|
+
return run.ok ? waitUntil(run.due) : undefined;
|
|
247
|
+
}
|
|
@@ -322,12 +322,11 @@ directory. Existing evidence is retained; enforcement is unaffected.
|
|
|
322
322
|
/sandbox rules open the write-denied paths editor
|
|
323
323
|
```
|
|
324
324
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
executable — never what was merely configured.
|
|
325
|
+
Sandbox status is not displayed in the footer. Use `/sandbox` to inspect the
|
|
326
|
+
permission table, or the text status report outside the TUI. Explicitly enabled
|
|
327
|
+
sessions still warn when protection cannot be applied. Status reports describe
|
|
328
|
+
what the runtime actually resolved, including the backend and executable, never
|
|
329
|
+
what was merely configured.
|
|
331
330
|
|
|
332
331
|
`/sandbox off` and `/sandbox default off` need interactive confirmation and are
|
|
333
332
|
refused outright when there is no interactive UI. There is no tool for changing
|
|
@@ -42,12 +42,9 @@ import { defaultSandboxPermissions, describeLoosening, type SandboxPermissionSet
|
|
|
42
42
|
import { openPermissionsPage } from "./permissions-page.ts";
|
|
43
43
|
import { appendSessionPermissions, readSessionPermissions } from "./session-permissions.ts";
|
|
44
44
|
|
|
45
|
-
import { footerTone, formatFooterStatus } from "./status.ts";
|
|
46
45
|
import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
|
|
47
46
|
import { SandboxDiagnostics, diagnosticPackageVersion } from "./shared-sandbox-diagnostics.ts";
|
|
48
47
|
|
|
49
|
-
const FOOTER_KEY = "sandbox";
|
|
50
|
-
|
|
51
48
|
export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
52
49
|
const controller = new ForegroundSandboxController();
|
|
53
50
|
let diagnosticsWarning: ((error: unknown) => void) | undefined;
|
|
@@ -97,11 +94,8 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
97
94
|
}
|
|
98
95
|
});
|
|
99
96
|
|
|
100
|
-
let paintFooter: ((status: ForegroundSandboxStatus) => void) | undefined;
|
|
101
|
-
|
|
102
97
|
const announce = (status: ForegroundSandboxStatus): void => {
|
|
103
98
|
publishForegroundSandboxPolicy(pi.events, status);
|
|
104
|
-
paintFooter?.(status);
|
|
105
99
|
};
|
|
106
100
|
|
|
107
101
|
// A consumer that loaded after the last publication can ask for the current
|
|
@@ -115,13 +109,6 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
115
109
|
const denyRules = new DenyRuleManager({ controller, onStateChange: announce });
|
|
116
110
|
|
|
117
111
|
const restorePermissions = (ctx: ExtensionContext): void => {
|
|
118
|
-
paintFooter = (status) => {
|
|
119
|
-
ctx.ui.setStatus(
|
|
120
|
-
FOOTER_KEY,
|
|
121
|
-
formatFooterStatus(status, (tone, text) => ctx.ui.theme.fg(tone, text)),
|
|
122
|
-
);
|
|
123
|
-
};
|
|
124
|
-
|
|
125
112
|
// Session policy takes precedence over global defaults. Never skip an
|
|
126
113
|
// invalid latest entry to recover an older, potentially broader policy.
|
|
127
114
|
let settings = defaultSandboxPermissions();
|
|
@@ -151,8 +138,8 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
151
138
|
|
|
152
139
|
// Then the rules are re-read and re-resolved, because the same global
|
|
153
140
|
// template set means different absolute paths in a different project.
|
|
154
|
-
// Loading
|
|
155
|
-
// the
|
|
141
|
+
// Loading announces the session's first policy, so consumers never see
|
|
142
|
+
// the pre-rule state.
|
|
156
143
|
const report = denyRules.load();
|
|
157
144
|
const status = report.status;
|
|
158
145
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -572,7 +572,15 @@ function syncWidgetNavSelection(running: RunMeta[]): void {
|
|
|
572
572
|
*/
|
|
573
573
|
function renderWidget(): void {
|
|
574
574
|
const ctx = uiCtx;
|
|
575
|
-
if (!ctx
|
|
575
|
+
if (!ctx) return;
|
|
576
|
+
// A run can exit after Pi replaced the session that captured this ctx; Pi then throws on any
|
|
577
|
+
// property read. That session's UI is gone, so drop the ctx instead of failing the finalizer.
|
|
578
|
+
try {
|
|
579
|
+
if (!ctx.hasUI) return;
|
|
580
|
+
} catch {
|
|
581
|
+
if (uiCtx === ctx) uiCtx = undefined;
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
576
584
|
updateNavigatorFooter(ctx);
|
|
577
585
|
try { ctx.ui.setWidget("subagents", WIDGET_CLEAR); } catch { /* ignore */ }
|
|
578
586
|
spendCache.clear();
|
|
@@ -1847,7 +1855,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1847
1855
|
const snapshot = taskRuntime ? takeRecoverySnapshot(taskRuntime.policy.permissions) : undefined;
|
|
1848
1856
|
if (snapshot?.started) {
|
|
1849
1857
|
void snapshot.done.then((outcome) => {
|
|
1850
|
-
if (
|
|
1858
|
+
if (outcome.ok) return;
|
|
1859
|
+
// The snapshot can finish after the session was replaced and ctx went stale.
|
|
1860
|
+
try { ctx.ui?.notify?.(`Subagent ${id}: recovery snapshot failed (run continues): ${outcome.detail}`, "warning"); } catch { /* ignore */ }
|
|
1851
1861
|
});
|
|
1852
1862
|
}
|
|
1853
1863
|
const cmd = taskRuntime ? { file: taskRuntime.file, fileArgs: [...taskRuntime.fileArgs, ...args] } : { file: piBin, fileArgs: args };
|
|
@@ -2497,6 +2507,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
2497
2507
|
// running/orphaned children before dropping the session origin, so they
|
|
2498
2508
|
// cannot become live process groups with no coordinator.
|
|
2499
2509
|
stopCurrentSessionSubagents(ctx);
|
|
2510
|
+
// The stopped children exit after this handler returns; their finalizers
|
|
2511
|
+
// must not reach for a ctx that Pi invalidates once the session is replaced.
|
|
2512
|
+
uiCtx = undefined;
|
|
2500
2513
|
agentSessionSettings.clear();
|
|
2501
2514
|
catalogModelRegistry = undefined;
|
|
2502
2515
|
activeCallbackOrigin = undefined;
|
|
@@ -174,8 +174,9 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
export function navigatorFooterHint(
|
|
178
|
-
|
|
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 {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -53,12 +53,12 @@
|
|
|
53
53
|
"test": "node --test test/*.test.mjs"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"pi-better-background-tasks": "0.9.
|
|
57
|
-
"pi-better-goal": "0.
|
|
56
|
+
"pi-better-background-tasks": "0.9.1",
|
|
57
|
+
"pi-better-goal": "0.10.0",
|
|
58
58
|
"pi-better-plan": "0.9.1",
|
|
59
|
-
"pi-better-sandbox": "0.12.
|
|
60
|
-
"pi-better-ssh": "0.1.
|
|
61
|
-
"pi-better-subagents": "0.19.
|
|
59
|
+
"pi-better-sandbox": "0.12.1",
|
|
60
|
+
"pi-better-ssh": "0.1.3",
|
|
61
|
+
"pi-better-subagents": "0.19.1",
|
|
62
62
|
"proper-lockfile": "^4.1.2",
|
|
63
63
|
"smol-toml": "1.9.0",
|
|
64
64
|
"yaml": "^2.9.1"
|