pi-better-harness 0.41.0 → 0.41.2
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/node_modules/pi-better-goal/README.md +2 -1
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/goal-state.ts +12 -2
- package/node_modules/pi-better-goal/src/index.ts +20 -85
- package/node_modules/pi-better-goal/src/permission-hold.ts +13 -26
- package/node_modules/pi-better-goal/src/types.ts +4 -5
- package/node_modules/pi-better-subagents/docs/usage.md +13 -6
- package/node_modules/pi-better-subagents/index.ts +44 -11
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/subagent-tools.ts +43 -0
- package/node_modules/pi-better-subagents/task-policy.ts +4 -2
- package/package.json +3 -3
|
@@ -19,11 +19,12 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
19
19
|
- A compact goal widget that does not replace Pi's footer.
|
|
20
20
|
- Background activity tracking for subagents and other registered providers.
|
|
21
21
|
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a sticky hold recoverable with `/goal resume` or `alt+g`. Conversation, changed callback results, and background drains do not reopen an exhausted hold. Delays saturate at Node's timer limit, and observed background drains reset non-held progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
22
|
+
- Permission failure reports remain available to the agent as diagnostic evidence. They do not pause Goal or require a one-turn `/goal resume` release. The agent can retry, wait, or continue other authorized work; tool and sandbox permissions remain enforced independently. Repeated unchanged work still reaches the ordinary no-progress hold. Legacy permission-paused goals resume normally with `/goal resume` or `alt+g`.
|
|
22
23
|
- An observable-progress stall state for active goals.
|
|
23
24
|
|
|
24
25
|
## Optional Harness-Owned Outcome & Todo
|
|
25
26
|
|
|
26
|
-
When `pi-better-harness` is installed, its optional **Outcome & Todo** runtime owns the session's structured outcome and checklist. Goal keeps its own objective, clocks,
|
|
27
|
+
When `pi-better-harness` is installed, its optional **Outcome & Todo** runtime owns the session's structured outcome and checklist. Goal keeps its own objective, clocks, pauses, workflow gates, background wake behavior, and continuation policy; it also sends lifecycle requests to the Harness runtime so a bound outcome starts, pauses, resumes, and completes with the Goal. When the runtime is present, Goal stops adding its own active-objective text to avoid duplicating the Harness-owned outcome, whether Outcome & Todo is enabled or off.
|
|
27
28
|
|
|
28
29
|
`/goal` does not install, enable, or configure Outcome & Todo. Install Harness and enable **Outcome & Todo** in `/harness-settings` if you want that shared outcome and checklist. Without Harness, this package keeps its standalone prompts and behavior unchanged. The stored Goal objective remains the raw original trigger (for example, a bound command invocation) so `/goal resume` can re-enter the work; it is not the authoritative business outcome when Harness owns one.
|
|
29
30
|
|
|
@@ -66,7 +66,16 @@ function isGoalStatus(value: unknown): value is GoalStatus {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function isPauseReason(value: unknown): value is GoalPauseReason {
|
|
69
|
-
return value === "interrupt" || value === "
|
|
69
|
+
return value === "interrupt" || value === "handback";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Goals persisted by older releases can carry `permission-blocker`. Reports no
|
|
74
|
+
* longer pause a goal, so such a goal resumes like `/goal pause` (human-only
|
|
75
|
+
* `/goal resume` or alt+g); loading grants no permission and rewrites nothing.
|
|
76
|
+
*/
|
|
77
|
+
function normalizedPauseReason(value: unknown): GoalPauseReason | undefined {
|
|
78
|
+
return isPauseReason(value) ? value : undefined;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
function isGoalLike(value: unknown): value is GoalLike {
|
|
@@ -119,12 +128,13 @@ function normalizeGoal(goal: GoalLike): GoalSnapshot {
|
|
|
119
128
|
const now = unixSeconds();
|
|
120
129
|
const createdAt = typeof goal.createdAt === "number" ? goal.createdAt : now;
|
|
121
130
|
const updatedAt = typeof goal.updatedAt === "number" ? goal.updatedAt : createdAt;
|
|
131
|
+
const pauseReason = goal.status === "paused" ? normalizedPauseReason(goal.pauseReason) : undefined;
|
|
122
132
|
return {
|
|
123
133
|
goalId: goal.goalId,
|
|
124
134
|
objective: goal.objective,
|
|
125
135
|
...(isCommandBinding(goal.command) ? { command: goal.command } : {}),
|
|
126
136
|
status: goal.status,
|
|
127
|
-
...(
|
|
137
|
+
...(pauseReason ? { pauseReason } : {}),
|
|
128
138
|
tokenBudget: typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
|
|
129
139
|
usage: {
|
|
130
140
|
tokensUsed: typeof goal.usage?.tokensUsed === "number" ? goal.usage.tokensUsed : 0,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
type GoalEntrySource,
|
|
30
30
|
} from "./goal-state.js";
|
|
31
31
|
import { continuationEvidence, type ContinuationEvidence } from "./continuation.js";
|
|
32
|
-
import { currentPermissionHold, logicalPermissionBlockerKey, MAX_PERMISSION_BLOCKERS, MAX_PERMISSION_RECORDS,
|
|
32
|
+
import { currentPermissionHold, logicalPermissionBlockerKey, MAX_PERMISSION_BLOCKERS, MAX_PERMISSION_RECORDS, permissionGapRecord, permissionHoldRecord, reportedPermissionBlockers, reportedPermissionGap, type PermissionHold } from "./permission-hold.js";
|
|
33
33
|
import { publishGoalGuidance } from "./prompt.js";
|
|
34
34
|
import { observeGoalStall } from "./stall.js";
|
|
35
35
|
import {
|
|
@@ -103,7 +103,6 @@ export function agentResumable(goal: GoalSnapshot | null, conversationalResume =
|
|
|
103
103
|
|
|
104
104
|
/** Footer status for a paused goal, telling the user how to resume it. */
|
|
105
105
|
export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true): string {
|
|
106
|
-
if (goal.pauseReason === "permission-blocker") return "goal held: permission blocker · /goal resume (one retry)";
|
|
107
106
|
return agentResumable(goal, conversationalResume) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -111,9 +110,11 @@ function pausedGoalPrompt(goal: GoalSnapshot, conversationalResume: boolean, inc
|
|
|
111
110
|
return [
|
|
112
111
|
goal.pauseReason === "handback"
|
|
113
112
|
? `Pi Better Goal is paused because its workflow handed back unfinished work for the user's answer.${includeObjective ? ` Goal: ${goal.objective}` : ""}`
|
|
114
|
-
:
|
|
113
|
+
: goal.pauseReason === "interrupt"
|
|
114
|
+
? `Pi Better Goal is paused because the user pressed escape.${includeObjective ? ` Goal: ${goal.objective}` : ""}`
|
|
115
|
+
: `Pi Better Goal is paused.${includeObjective ? ` Goal: ${goal.objective}` : ""}`,
|
|
115
116
|
"Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
|
|
116
|
-
conversationalResume ? GOAL_RESUME_RULE : "Only the user can resume this goal, with /goal resume or alt+g. A conversational go-ahead does not resume it.",
|
|
117
|
+
agentResumable(goal, conversationalResume) ? GOAL_RESUME_RULE : "Only the user can resume this goal, with /goal resume or alt+g. A conversational go-ahead does not resume it.",
|
|
117
118
|
].join("\n");
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -260,8 +261,8 @@ function formatGoal(
|
|
|
260
261
|
`Status: ${goal.status}`,
|
|
261
262
|
...(goal.pauseReason ? [`Pause reason: ${goal.pauseReason}`] : []),
|
|
262
263
|
...(permissions?.blockers.length ? [
|
|
263
|
-
`Permission
|
|
264
|
-
...(permissions.saturated ? ["Permission observation incomplete: history/scope limit reached;
|
|
264
|
+
`Permission diagnostics: ${permissions.blockers.length} scope(s); ${permissions.recordCount} history records`,
|
|
265
|
+
...(permissions.saturated ? ["Permission observation incomplete: history/scope limit reached. Diagnostics only; continuation is unaffected."] : []),
|
|
265
266
|
] : []),
|
|
266
267
|
`Token budget: ${budget}`,
|
|
267
268
|
`Tokens used: ${goal.usage.tokensUsed}`,
|
|
@@ -396,13 +397,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
396
397
|
};
|
|
397
398
|
|
|
398
399
|
const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: GoalEntrySource): void => {
|
|
399
|
-
const permissionHold = currentPermissionHold(ctx, goal.goalId);
|
|
400
|
-
if (goal.status === "paused" && permissionHold.blockers.length > 0) {
|
|
401
|
-
if (permissionHold.retryPending && permissionHold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
402
|
-
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
|
|
403
|
-
}
|
|
404
|
-
goal = { ...goal, pauseReason: "permission-blocker" };
|
|
405
|
-
}
|
|
406
400
|
executionGeneration += 1;
|
|
407
401
|
lastAgentEvidence = null;
|
|
408
402
|
const previous = getGoal(ctx);
|
|
@@ -458,9 +452,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
458
452
|
}
|
|
459
453
|
const held = currentContinuationState(ctx, goal.goalId)?.blocked === true;
|
|
460
454
|
setGoal(goalWithStatus(goal, "paused", undefined, held ? undefined : "interrupt"), ctx, "runtime");
|
|
461
|
-
notifyGoal(ctx,
|
|
462
|
-
? "Goal remains permission-held. Use /goal resume or alt+g for one same-scope retry."
|
|
463
|
-
: preferences.conversationalResume && !held
|
|
455
|
+
notifyGoal(ctx, preferences.conversationalResume && !held
|
|
464
456
|
? 'Goal paused. Say "go" to resume it, or use /goal resume.'
|
|
465
457
|
: "Goal paused. Use /goal resume or alt+g to resume it.");
|
|
466
458
|
};
|
|
@@ -530,18 +522,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
530
522
|
return { ok: false, message: blocker };
|
|
531
523
|
}
|
|
532
524
|
const goal = held ? current : goalWithStatus(current, "active");
|
|
533
|
-
const permissions = currentPermissionHold(ctx, current.goalId);
|
|
534
|
-
if (current.pauseReason === "permission-blocker" && permissions.blockers.length === 0) {
|
|
535
|
-
return { ok: false, message: "Permission hold evidence is unavailable. The goal stays held; inspect the report before replacing this goal explicitly." };
|
|
536
|
-
}
|
|
537
|
-
if (permissions.blockers.length > 0) {
|
|
538
|
-
if (source === "tool" || permissions.saturated) {
|
|
539
|
-
return { ok: false, message: permissions.saturated
|
|
540
|
-
? "Permission hold history is full. The goal stays held; inspect it and create a new goal explicitly if needed."
|
|
541
|
-
: "Only /goal resume or alt+g can release one permission retry." };
|
|
542
|
-
}
|
|
543
|
-
pi.appendEntry(EXTENSION_NAME, permissionRecord(current.goalId, "permission-release"));
|
|
544
|
-
}
|
|
545
525
|
// Resuming a skill-bound goal is explicit re-entry into its workflow: rebind the owner the
|
|
546
526
|
// goal was created with (released by a handback) before the continuation runs.
|
|
547
527
|
const owner = goalWorkflowOwner(current);
|
|
@@ -579,11 +559,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
579
559
|
if (!boundCommandReady(goal, ctx)) return;
|
|
580
560
|
clearIdleContinuation();
|
|
581
561
|
continuationQueuedFor = goal.goalId;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
sendGoalContinuation(goal, permissions.blockers.length > 0
|
|
585
|
-
? `${continuationPrompt(goal, getWorkflow(ctx))}\n\n${permissionInstruction(permissions)}`
|
|
586
|
-
: continuationPrompt(goal, getWorkflow(ctx)), "continuation");
|
|
562
|
+
resetContinuationState(goal);
|
|
563
|
+
sendGoalContinuation(goal, continuationPrompt(goal, getWorkflow(ctx)), "continuation");
|
|
587
564
|
};
|
|
588
565
|
|
|
589
566
|
function clearIdleContinuation(): void {
|
|
@@ -606,7 +583,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
606
583
|
if (WAKE_DISABLED || !preferences.autoContinue || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
|
|
607
584
|
return;
|
|
608
585
|
}
|
|
609
|
-
if (currentPermissionHold(ctx, goal.goalId).blockers.length > 0) return;
|
|
610
586
|
if (currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
611
587
|
return;
|
|
612
588
|
}
|
|
@@ -637,7 +613,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
637
613
|
if (WAKE_DISABLED || !preferences.autoContinue || execution !== executionGeneration || wake !== wakeGeneration) return false;
|
|
638
614
|
const current = currentGoalSnapshot(ctx);
|
|
639
615
|
return isPokeable(current) && current.goalId === goalId &&
|
|
640
|
-
currentPermissionHold(ctx, goalId).blockers.length === 0 &&
|
|
641
616
|
!currentContinuationState(ctx, goalId)?.blocked &&
|
|
642
617
|
continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
|
|
643
618
|
};
|
|
@@ -703,8 +678,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
703
678
|
const goal = currentGoalSnapshot(ctx);
|
|
704
679
|
const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
|
|
705
680
|
backgroundDrainTracker = wakePlan.nextTracker;
|
|
706
|
-
if (isPokeable(goal) && wakePlan.wakeSignature &&
|
|
707
|
-
!currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
681
|
+
if (isPokeable(goal) && wakePlan.wakeSignature && !currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
708
682
|
// Persist external progress now: a foreground/callback turn may cancel its delayed wake.
|
|
709
683
|
clearIdleContinuation();
|
|
710
684
|
resetContinuationState(goal);
|
|
@@ -878,7 +852,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
878
852
|
if ((request.owner.goalId ?? null) !== (goal?.goalId ?? null)) { held("Owning Goal is no longer current"); return; }
|
|
879
853
|
if (goal && !preferences.autoContinue) { held("Goal automatic continuation is disabled"); return; }
|
|
880
854
|
if (goal && !isPokeable(goal)) { held("Goal is not active"); return; }
|
|
881
|
-
if (goal && currentPermissionHold(ctx, goal.goalId).blockers.length) { held("Goal has an active permission hold"); return; }
|
|
882
855
|
if (goal && currentContinuationState(ctx, goal.goalId)?.blocked) { held("Goal continuation requires human resume"); return; }
|
|
883
856
|
if (goal && !boundCommandReady(goal, ctx)) { held("Bound workflow is not ready"); return; }
|
|
884
857
|
const workflow = getWorkflow(ctx);
|
|
@@ -889,7 +862,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
889
862
|
const latest = getGoal(ctx);
|
|
890
863
|
if ((request.owner!.goalId ?? null) !== (latest?.goalId ?? null)) { held("Owning Goal changed during resume admission"); return; }
|
|
891
864
|
if (latest && !preferences.autoContinue) { held("Goal automatic continuation is disabled"); return; }
|
|
892
|
-
if (latest && (!isPokeable(latest) ||
|
|
865
|
+
if (latest && (!isPokeable(latest) || currentContinuationState(ctx, latest.goalId)?.blocked)) {
|
|
893
866
|
held("Goal became held during resume admission"); return;
|
|
894
867
|
}
|
|
895
868
|
if (!gate.ok) { held(gate.reason); return; }
|
|
@@ -903,7 +876,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
903
876
|
if ((request.owner!.goalId ?? null) !== (active?.goalId ?? null)) return false;
|
|
904
877
|
if (JSON.stringify(getWorkflow(ctx)) !== JSON.stringify(workflow)) return false;
|
|
905
878
|
return !active || (preferences.autoContinue && isPokeable(active)
|
|
906
|
-
&& !currentPermissionHold(ctx, active.goalId).blockers.length
|
|
907
879
|
&& !currentContinuationState(ctx, active.goalId)?.blocked && boundCommandReady(active, ctx));
|
|
908
880
|
};
|
|
909
881
|
reply({ allowed: true, revision, isCurrent });
|
|
@@ -1263,10 +1235,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1263
1235
|
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1264
1236
|
}
|
|
1265
1237
|
const restoredGoal = getGoal(ctx);
|
|
1266
|
-
if (restoredGoal?.status === "active" && currentPermissionHold(ctx, restoredGoal.goalId).blockers.length > 0) {
|
|
1267
|
-
// A persisted release is not a replayable dispatch ticket after reload.
|
|
1268
|
-
setGoal(goalWithStatus(restoredGoal, "paused", undefined, "permission-blocker"), ctx, "runtime");
|
|
1269
|
-
}
|
|
1270
1238
|
if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
|
|
1271
1239
|
setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
|
|
1272
1240
|
notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
|
|
@@ -1336,8 +1304,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1336
1304
|
// A paused goal stays paused while the user talks: the message is ordinary
|
|
1337
1305
|
// conversation. Only /goal resume, the hotkey, or goal_resume resume it.
|
|
1338
1306
|
const goal = getGoal(ctx);
|
|
1339
|
-
if (goal?.status === "active" &&
|
|
1340
|
-
!currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
1307
|
+
if (goal?.status === "active" && !currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
1341
1308
|
executionGeneration += 1;
|
|
1342
1309
|
clearIdleContinuation();
|
|
1343
1310
|
lastAgentEvidence = null;
|
|
@@ -1354,17 +1321,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1354
1321
|
const harnessPresent = requestTaskContext(pi, "inspect", goal).enabled !== undefined;
|
|
1355
1322
|
const snapshot = await publishSnapshot(ctx);
|
|
1356
1323
|
if (!snapshot) return;
|
|
1357
|
-
const
|
|
1358
|
-
|
|
1359
|
-
(permissions.blockers.length > 0 || goal?.pauseReason === "permission-blocker")
|
|
1360
|
-
? permissionInstruction({ ...permissions, retryPending: permissions.retryPending && goal?.status === "active" }) +
|
|
1361
|
-
(permissions.blockers.length === 0 ? "\nBlocker evidence is unavailable. Stay held; do not invent a retry scope." : "") : "";
|
|
1362
|
-
// A held goal overrides workflow ownership without invoking or invalidating that workflow.
|
|
1363
|
-
if (permissionPrompt && goal?.status === "paused") {
|
|
1364
|
-
return publishGoalGuidance(event, permissionPrompt);
|
|
1365
|
-
}
|
|
1366
|
-
const pausedInstruction = permissionPrompt || (goal?.status === "paused" && (goal.pauseReason === "interrupt" || goal.pauseReason === "handback")
|
|
1367
|
-
? pausedGoalPrompt(goal, preferences.conversationalResume, !harnessPresent) : "");
|
|
1324
|
+
const pausedInstruction = goal?.status === "paused"
|
|
1325
|
+
? pausedGoalPrompt(goal, preferences.conversationalResume, !harnessPresent) : "";
|
|
1368
1326
|
if (!isPokeable(goal) && !owner) {
|
|
1369
1327
|
return publishGoalGuidance(event, pausedInstruction);
|
|
1370
1328
|
}
|
|
@@ -1392,9 +1350,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1392
1350
|
return publishGoalGuidance(
|
|
1393
1351
|
event,
|
|
1394
1352
|
pausedInstruction &&
|
|
1395
|
-
`${pausedInstruction}\n` +
|
|
1396
|
-
? "These permission constraints override the workflow instructions below.\n\n"
|
|
1397
|
-
: "While the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n"),
|
|
1353
|
+
`${pausedInstruction}\n` + "While the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n",
|
|
1398
1354
|
`Active workflow: ${owner.name} (${owner.path}). Its task plan owns planning and the parent is a coordinator, not a product-code implementer. Follow the workflow instructions below, including on resumed turns:\n\n${instructions}`,
|
|
1399
1355
|
isPokeable(goal) && (harnessPresent
|
|
1400
1356
|
? "Complete the goal only after the workflow completion audit."
|
|
@@ -1416,13 +1372,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1416
1372
|
heldInstruction,
|
|
1417
1373
|
backgroundInstruction.trim(),
|
|
1418
1374
|
questionInstruction.trim(),
|
|
1419
|
-
permissionPrompt,
|
|
1420
1375
|
);
|
|
1421
1376
|
}
|
|
1422
1377
|
return publishGoalGuidance(
|
|
1423
1378
|
event,
|
|
1424
1379
|
`Pi Better Goal active objective: ${goal.objective}. ${heldInstruction || "Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit."}${backgroundInstruction}${questionInstruction}`,
|
|
1425
|
-
permissionPrompt,
|
|
1426
1380
|
);
|
|
1427
1381
|
});
|
|
1428
1382
|
|
|
@@ -1472,31 +1426,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1472
1426
|
const incomplete = reportedPermissionGap(pi, event.toolName, event.details);
|
|
1473
1427
|
if (reports.length === 0 && !incomplete) return;
|
|
1474
1428
|
let hold = currentPermissionHold(ctx, goal.goalId);
|
|
1475
|
-
let changed = false;
|
|
1476
1429
|
for (const blocker of reports) {
|
|
1477
1430
|
const previous = hold.blockers.find((item) => logicalPermissionBlockerKey(item) === logicalPermissionBlockerKey(blocker));
|
|
1478
|
-
if (
|
|
1431
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(blocker)) continue;
|
|
1479
1432
|
if (hold.saturated || (!previous && hold.blockers.length >= MAX_PERMISSION_BLOCKERS)) {
|
|
1480
1433
|
if (!hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1481
|
-
pi.appendEntry(EXTENSION_NAME,
|
|
1434
|
+
pi.appendEntry(EXTENSION_NAME, permissionGapRecord(goal.goalId));
|
|
1482
1435
|
}
|
|
1483
1436
|
break;
|
|
1484
1437
|
}
|
|
1485
1438
|
pi.appendEntry(EXTENSION_NAME, permissionHoldRecord(goal.goalId, blocker));
|
|
1486
|
-
changed = true;
|
|
1487
1439
|
hold = currentPermissionHold(ctx, goal.goalId);
|
|
1488
1440
|
}
|
|
1489
1441
|
if (incomplete && !hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1490
|
-
pi.appendEntry(EXTENSION_NAME,
|
|
1491
|
-
changed = true;
|
|
1492
|
-
hold = currentPermissionHold(ctx, goal.goalId);
|
|
1493
|
-
}
|
|
1494
|
-
if (hold.retryPending && hold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1495
|
-
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
|
|
1442
|
+
pi.appendEntry(EXTENSION_NAME, permissionGapRecord(goal.goalId));
|
|
1496
1443
|
}
|
|
1497
|
-
|
|
1498
|
-
setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
|
|
1499
|
-
notifyGoal(ctx, "Goal held for a permission blocker. /goal resume or alt+g releases one same-scope retry.", "warning");
|
|
1444
|
+
// Diagnostics only: the goal keeps running and continuation is unchanged.
|
|
1500
1445
|
});
|
|
1501
1446
|
|
|
1502
1447
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
@@ -1535,16 +1480,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1535
1480
|
const snapshot = await publishSnapshot(ctx);
|
|
1536
1481
|
if (!snapshot) return;
|
|
1537
1482
|
const goal = getGoal(ctx);
|
|
1538
|
-
if (isPokeable(goal)) {
|
|
1539
|
-
const permissions = currentPermissionHold(ctx, goal.goalId);
|
|
1540
|
-
if (permissions.blockers.length > 0) {
|
|
1541
|
-
if (permissions.retryPending && permissions.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1542
|
-
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
|
|
1543
|
-
}
|
|
1544
|
-
setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
|
|
1545
|
-
return;
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
1483
|
if (!isPokeable(goal) || snapshot.backgroundRunning) {
|
|
1549
1484
|
return;
|
|
1550
1485
|
}
|
|
@@ -4,6 +4,12 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { isPermissionBlocker, permissionBlockerKey, type PermissionBlocker } from "./shared-permission-blocker.js";
|
|
5
5
|
import { EXTENSION_NAME } from "./types.js";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Permission failure reports are diagnostics. This ledger records observed
|
|
9
|
+
* blockers so `/goal` and `get_goal` can show them; it never pauses a goal,
|
|
10
|
+
* limits resume, suppresses continuation/wakes/audits, or grants permission.
|
|
11
|
+
* Tool and sandbox enforcement remain the only authority.
|
|
12
|
+
*/
|
|
7
13
|
export const MAX_PERMISSION_BLOCKERS = 32;
|
|
8
14
|
export const MAX_PERMISSION_RECORDS = 128;
|
|
9
15
|
|
|
@@ -13,7 +19,6 @@ export type PermissionRecord =
|
|
|
13
19
|
|
|
14
20
|
export interface PermissionHold {
|
|
15
21
|
blockers: PermissionBlocker[];
|
|
16
|
-
retryPending: boolean;
|
|
17
22
|
recordCount: number;
|
|
18
23
|
saturated: boolean;
|
|
19
24
|
}
|
|
@@ -26,10 +31,9 @@ export function logicalPermissionBlockerKey(blocker: PermissionBlocker): string
|
|
|
26
31
|
});
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
/** Replay
|
|
34
|
+
/** Replay the goal's compact diagnostic records, independently of progress resets. */
|
|
30
35
|
export function currentPermissionHold(ctx: ExtensionContext, goalId: string): PermissionHold {
|
|
31
36
|
const blockers = new Map<string, PermissionBlocker>();
|
|
32
|
-
let retryPending = false;
|
|
33
37
|
let recordCount = 0;
|
|
34
38
|
let saturated = false;
|
|
35
39
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
@@ -41,7 +45,6 @@ export function currentPermissionHold(ctx: ExtensionContext, goalId: string): Pe
|
|
|
41
45
|
if (data.version !== 1 || typeof data.at !== "number" || !Number.isFinite(data.at) ||
|
|
42
46
|
(data.kind === "permission-hold" && !isPermissionBlocker(data.blocker))) {
|
|
43
47
|
saturated = true;
|
|
44
|
-
retryPending = false;
|
|
45
48
|
recordCount += 1;
|
|
46
49
|
continue;
|
|
47
50
|
}
|
|
@@ -49,20 +52,15 @@ export function currentPermissionHold(ctx: ExtensionContext, goalId: string): Pe
|
|
|
49
52
|
const key = logicalPermissionBlockerKey(data.blocker);
|
|
50
53
|
if (blockers.has(key) || blockers.size < MAX_PERMISSION_BLOCKERS) blockers.set(key, compactBlocker(data.blocker));
|
|
51
54
|
else saturated = true;
|
|
52
|
-
retryPending = false;
|
|
53
|
-
} else if (data.kind === "permission-release" && blockers.size > 0) {
|
|
54
|
-
retryPending = !saturated;
|
|
55
|
-
} else if (data.kind === "permission-retry-finished") {
|
|
56
|
-
retryPending = false;
|
|
57
55
|
} else if (data.kind === "permission-gap") {
|
|
58
56
|
saturated = true;
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
}
|
|
58
|
+
// Legacy release records remain countable history but carry no authority.
|
|
61
59
|
recordCount += 1;
|
|
62
|
-
// Reserve room to record an incomplete scope
|
|
60
|
+
// Reserve room to record an incomplete scope.
|
|
63
61
|
if (recordCount >= MAX_PERMISSION_RECORDS - 2) saturated = true;
|
|
64
62
|
}
|
|
65
|
-
return { blockers: [...blockers.values()],
|
|
63
|
+
return { blockers: [...blockers.values()], recordCount, saturated };
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
function compactBlocker(blocker: PermissionBlocker): PermissionBlocker {
|
|
@@ -130,21 +128,10 @@ export function reportedPermissionGap(pi: ExtensionAPI, toolName: string, detail
|
|
|
130
128
|
return typeof omitted === "number" && Number.isSafeInteger(omitted) && omitted > 0;
|
|
131
129
|
}
|
|
132
130
|
|
|
133
|
-
export function
|
|
134
|
-
return { version: 1, kind, goalId, at: Math.floor(Date.now() / 1000) };
|
|
131
|
+
export function permissionGapRecord(goalId: string): PermissionRecord {
|
|
132
|
+
return { version: 1, kind: "permission-gap", goalId, at: Math.floor(Date.now() / 1000) };
|
|
135
133
|
}
|
|
136
134
|
|
|
137
135
|
export function permissionHoldRecord(goalId: string, blocker: PermissionBlocker): PermissionRecord {
|
|
138
136
|
return { version: 1, kind: "permission-hold", goalId, blocker: compactBlocker(blocker), at: Math.floor(Date.now() / 1000) };
|
|
139
137
|
}
|
|
140
|
-
|
|
141
|
-
export function permissionInstruction(hold: PermissionHold): string {
|
|
142
|
-
return [
|
|
143
|
-
hold.retryPending
|
|
144
|
-
? "The human explicitly released ONE bounded retry of the held operation and scope below. This is not a permission grant or evidence of success."
|
|
145
|
-
: "The goal is paused for a permission blocker. Answer ordinary questions, but do not advance goal or workflow work. Only /goal resume or alt+g releases one bounded retry; goal_resume cannot release it.",
|
|
146
|
-
...hold.blockers.map((blocker) => `- ${blocker.context}: ${blocker.resource}; operation=${blocker.operation}; basis=${blocker.basis}; remoteOutcome=${blocker.remoteOutcome}` +
|
|
147
|
-
(blocker.runId ? `; runId=${blocker.runId}` : "") + (blocker.policySnapshotId ? `; policySnapshotId=${blocker.policySnapshotId}` : "")),
|
|
148
|
-
"Keep the same operation and scope. Do not switch to foreground execution, copy credentials, change authentication, or broaden permissions. Changed worker settings require a fresh worker; retain the logical operation identity. Runtime-observed refusals and agent-reported blockers are distinct. Unknown remote outcome is never evidence of success.",
|
|
149
|
-
].join("\n");
|
|
150
|
-
}
|
|
@@ -71,12 +71,11 @@ export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
|
|
|
71
71
|
* resume a goal on their own. An `interrupt` pause may be resumed by the agent
|
|
72
72
|
* with `goal_resume` when the user clearly says to proceed. A paused goal
|
|
73
73
|
* without a reason (`/goal pause`, an unavailable command or workflow) resumes
|
|
74
|
-
* only through `/goal resume` or the resume hotkey.
|
|
75
|
-
*
|
|
76
|
-
* keeping its separate blocker/release history.
|
|
74
|
+
* only through `/goal resume` or the resume hotkey. Permission failure reports
|
|
75
|
+
* are diagnostics and never create a pause reason.
|
|
77
76
|
*/
|
|
78
|
-
/** Why a goal paused: escape,
|
|
79
|
-
export type GoalPauseReason = "interrupt" | "
|
|
77
|
+
/** Why a goal paused: escape, or a workflow handing back unfinished work for the user's answer. */
|
|
78
|
+
export type GoalPauseReason = "interrupt" | "handback";
|
|
80
79
|
|
|
81
80
|
export interface GoalUsage {
|
|
82
81
|
tokensUsed: number;
|
|
@@ -146,7 +146,10 @@ Each attempt gets a new run id, log, timing record, and capacity slot while the
|
|
|
146
146
|
native session id and history stay the same. The launch recipe preserves the
|
|
147
147
|
worker's role, tools, skill exposure, model, and workspace, including uncommitted
|
|
148
148
|
changes. Repeating a launch-only option is accepted only with the saved value;
|
|
149
|
-
a different `skills` list is refused rather than silently dropped.
|
|
149
|
+
a different `skills` list is refused rather than silently dropped. A continuation
|
|
150
|
+
whose frozen tool loadout can no longer be admitted (for example its trusted
|
|
151
|
+
package is gone) is refused before anything is spawned, instead of reopening the
|
|
152
|
+
saved conversation with different tools.
|
|
150
153
|
|
|
151
154
|
A run stopped with `subagent_stop` (status `killed`) can be continued the same
|
|
152
155
|
way. Stopping ends that execution, not the saved conversation or its
|
|
@@ -522,11 +525,15 @@ chosen in `/sandbox` → Subagents · Tools
|
|
|
522
525
|
name is refused. A single extension file with no package manifest is loaded
|
|
523
526
|
and admitted by itself, never its directory. Known network tool names
|
|
524
527
|
(`web_fetch`, `web_search`, `firecrawl_scrape`, `firecrawl_extract`, `mcp`, `mcpScript`, `remote_bash`, and any `mcp__*` name) are refused while Network access is Off; this is a name list, not a
|
|
525
|
-
network sandbox. A ticked tool whose package can't be found is
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
528
|
+
network sandbox. A ticked tool whose package can't be found is left out of a
|
|
529
|
+
default loadout and reported with its reason in the launch output; an explicit
|
|
530
|
+
`tools` request for a tool the task boundary cannot provide refuses the launch.
|
|
531
|
+
Ticked tools join the default tool list; an explicit `tools` list still decides.
|
|
532
|
+
|
|
533
|
+
Other tools are disabled in confined children. One that only joined the default
|
|
534
|
+
loadout is omitted from the child and listed in launch output with the reason;
|
|
535
|
+
one the caller named in `tools` refuses the launch with the reason. The launch
|
|
536
|
+
line shows what was admitted, for example
|
|
530
537
|
`Runtime: isolated · guarded apply_patch · trusted web_fetch (@juicesharp/rpiv-web-tools)`.
|
|
531
538
|
Provider extensions are trusted runtime code; arbitrary extension tools are not
|
|
532
539
|
admitted merely because their names were requested. Confined children disable project
|
|
@@ -62,7 +62,7 @@ import { resolveExtensions, extensionArgs } from "./extensions.ts";
|
|
|
62
62
|
import { prepareTaskRuntime, applyDirectApplicationLoadout, RUN_APPLICATION, SDK_BUILTIN_TOOLS } from "./task-policy.ts";
|
|
63
63
|
import { canonicalizePath, takeRecoverySnapshot } from "./shared-sandbox-core.ts";
|
|
64
64
|
import { TASK_BUILTINS } from "./shared-task-sandbox.ts";
|
|
65
|
-
import { APPLY_PATCH, describeTaskTools, planTaskTools } from "./subagent-tools.ts";
|
|
65
|
+
import { APPLY_PATCH, describeTaskTools, planTaskTools, selectEffectiveTaskTools } from "./subagent-tools.ts";
|
|
66
66
|
import {
|
|
67
67
|
announceExecutionAdapter, observeSandboxPermissions, resolveCommandSettings, resolveSubagentPermissions,
|
|
68
68
|
} from "./permission-policy.ts";
|
|
@@ -2574,13 +2574,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
2574
2574
|
}).tools.join(",");
|
|
2575
2575
|
}
|
|
2576
2576
|
const bridgeToolNames = new Set<string>(bridgeEnabled ? bridgePlan.tools : []);
|
|
2577
|
+
// A bridge tool the supervisor withholds (no production schema, clean,
|
|
2578
|
+
// excluded) explains itself better than extension admission would.
|
|
2579
|
+
const withheldBridgeReasons = new Map<string, string>(bridgePlan.withheld.map((tool) => [tool.name, tool.reason]));
|
|
2580
|
+
// Candidates for the task-tool plan: exclusions and bridge-supplied
|
|
2581
|
+
// names never reach extension admission.
|
|
2582
|
+
const plannedTools = allow.split(",").filter((name) => name && !requestedExcludes.has(name) && !bridgeToolNames.has(name));
|
|
2577
2583
|
const taskTools = sandboxEnabled ? planTaskTools({
|
|
2578
|
-
requested:
|
|
2584
|
+
requested: plannedTools,
|
|
2579
2585
|
settings: permissionPlan.tools, network: permissionPlan.permissions?.network ?? true,
|
|
2580
2586
|
processAccess: permissionPlan.permissions?.processAccess ?? "off",
|
|
2581
2587
|
builtins: [...TASK_BUILTINS, "codemode"], registered: pi.getAllTools?.() ?? [],
|
|
2582
2588
|
toolExtensions: cfg.toolExtensions, resolvePath: resolveExtensionPath,
|
|
2583
2589
|
}) : undefined;
|
|
2590
|
+
// One effective selection feeds the task policy, child preflight, worker
|
|
2591
|
+
// bridge and frozen recipe, so a refused tool cannot leak into any of them
|
|
2592
|
+
// (#669). A name this attempt requires - the caller's explicit `tools`, or a
|
|
2593
|
+
// continuation's frozen loadout - refuses the launch instead of narrowing it;
|
|
2594
|
+
// an inherited default is dropped from the child with the reason.
|
|
2595
|
+
const requiredToolNames = new Set(requestedTools === undefined
|
|
2596
|
+
? []
|
|
2597
|
+
: requestedTools.split(",").map((name) => name.trim()).filter(Boolean));
|
|
2598
|
+
const toolSelection = taskTools
|
|
2599
|
+
? selectEffectiveTaskTools({
|
|
2600
|
+
tools: allow.split(",").filter(Boolean), plan: taskTools, required: requiredToolNames,
|
|
2601
|
+
})
|
|
2602
|
+
: undefined;
|
|
2603
|
+
if (toolSelection?.required.length) {
|
|
2604
|
+
const detail = toolSelection.required
|
|
2605
|
+
.map((tool) => `${tool.name} (${withheldBridgeReasons.get(tool.name) ?? tool.reason})`).join(", ");
|
|
2606
|
+
throw resume
|
|
2607
|
+
? new ContinuationError(
|
|
2608
|
+
`Cannot continue ${resume.prior.id}: its frozen launch requires tool(s) this parent can no longer provide: ${detail}. ` +
|
|
2609
|
+
`Launch a new run instead of continuing this conversation.`,
|
|
2610
|
+
)
|
|
2611
|
+
: new Error(
|
|
2612
|
+
`Requested tool(s) are not available in the task sandbox: ${detail}. ` +
|
|
2613
|
+
`Remove them from the tools list, or make them available in /sandbox (Subagents · Tools); nothing was launched.`,
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
if (toolSelection) {
|
|
2617
|
+
if (plannedTools.length > 0 && toolSelection.tools.length === 0) {
|
|
2618
|
+
throw new Error(`No requested tool has a verified task sandbox adapter: ${toolSelection.refused.map((tool) => `${tool.name} (${withheldBridgeReasons.get(tool.name) ?? tool.reason})`).join(", ")}.`);
|
|
2619
|
+
}
|
|
2620
|
+
allow = toolSelection.tools.join(",");
|
|
2621
|
+
}
|
|
2584
2622
|
const dependencyOptions = {
|
|
2585
2623
|
model, virtual: (() => {
|
|
2586
2624
|
const slash = model?.indexOf("/") ?? -1;
|
|
@@ -2671,15 +2709,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2671
2709
|
p.prompt,
|
|
2672
2710
|
];
|
|
2673
2711
|
|
|
2674
|
-
const piBin = resolvePiBinary();
|
|
2675
2712
|
const selectedTools = allow.split(",").filter((name) => name && !excludes.has(name));
|
|
2676
|
-
const
|
|
2677
|
-
const unavailableTools = unavailable.map((tool) => tool.name);
|
|
2678
|
-
if (sandboxEnabled && selectedTools.length && unavailableTools.length === selectedTools.length) {
|
|
2679
|
-
throw new Error(`No requested tool has a verified task sandbox adapter: ${unavailable.map((tool) => `${tool.name} (${tool.reason})`).join(", ")}.`);
|
|
2680
|
-
}
|
|
2713
|
+
const dropped = toolSelection?.dropped ?? [];
|
|
2681
2714
|
const taskRuntime = sandboxEnabled && requestedSandboxDir ? prepareTaskRuntime({
|
|
2682
|
-
root: requestedSandboxDir, controlDir: join(runDir(id), "control"),
|
|
2715
|
+
root: requestedSandboxDir, controlDir: join(runDir(id), "control"),
|
|
2683
2716
|
tools: selectedTools,
|
|
2684
2717
|
permissions: permissionPlan.permissions && { ...permissionPlan.permissions, ...commandSettings },
|
|
2685
2718
|
applyPatch: taskTools?.applyPatch === true,
|
|
@@ -2708,7 +2741,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2708
2741
|
try { ctx.ui?.notify?.(`Subagent ${id}: recovery snapshot failed (run continues): ${outcome.detail}`, "warning"); } catch { /* ignore */ }
|
|
2709
2742
|
});
|
|
2710
2743
|
}
|
|
2711
|
-
const cmd = taskRuntime ? { file: taskRuntime.file, fileArgs: [...taskRuntime.fileArgs, ...args] } : { file: process.execPath, fileArgs: [join(selfDir(), "child-runtime.mjs"),
|
|
2744
|
+
const cmd = taskRuntime ? { file: taskRuntime.file, fileArgs: [...taskRuntime.fileArgs, ...args] } : { file: process.execPath, fileArgs: [join(selfDir(), "child-runtime.mjs"), resolvePiBinary(), ...args] };
|
|
2712
2745
|
const sandboxDir = taskRuntime ? requestedSandboxDir : undefined;
|
|
2713
2746
|
|
|
2714
2747
|
// Reservations prevent competing admissions, but do not grandfather
|
|
@@ -2945,7 +2978,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2945
2978
|
: resolution.specs.length
|
|
2946
2979
|
? `Runtime: isolated · extensions ${resolution.specs.join(", ")}\n`
|
|
2947
2980
|
: `Runtime: isolated · built-in tools only\n`;
|
|
2948
|
-
const warn = (
|
|
2981
|
+
const warn = (dropped.length ? `Task sandbox: ${dropped.map((tool) => `${tool.name} disabled (${withheldBridgeReasons.get(tool.name) ?? tool.reason})`).join("; ")}.\n` : "") +
|
|
2949
2982
|
(!sandboxEnabled && resolution.unmapped.length
|
|
2950
2983
|
? `NOTE: no extension mapped for ${resolution.unmapped.join(", ")} — ` +
|
|
2951
2984
|
`${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
|
|
@@ -90,6 +90,49 @@ export function planTaskTools(options: {
|
|
|
90
90
|
return plan;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** One effective child tool list and what the plan refused, split by who asked for it. */
|
|
94
|
+
export interface EffectiveTaskToolSelection {
|
|
95
|
+
/** The child tool list: refused names are absent, so policy, preflight and recipe agree. */
|
|
96
|
+
tools: string[];
|
|
97
|
+
/** Requested names the plan refuses, each with an actionable reason. */
|
|
98
|
+
refused: { name: string; reason: string }[];
|
|
99
|
+
/** Refused names this attempt requires (explicit request or frozen recipe): the launch must fail. */
|
|
100
|
+
required: { name: string; reason: string }[];
|
|
101
|
+
/** Refused names that only came from the default/inherited loadout: dropped with a warning. */
|
|
102
|
+
dropped: { name: string; reason: string }[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The single effective tool selection every downstream authority reads (ADR 0009).
|
|
107
|
+
*
|
|
108
|
+
* A refused tool never reaches the task policy, the child preflight, the worker
|
|
109
|
+
* bridge or the frozen launch recipe: the child cannot register it, so listing it
|
|
110
|
+
* only makes the parent announce a tool the child then dies without (#669).
|
|
111
|
+
*
|
|
112
|
+
* Who asked matters. A name this attempt requires - an explicit `tools` request,
|
|
113
|
+
* or a continuation's frozen loadout - stops the launch, because silently
|
|
114
|
+
* narrowing it would change what the caller (or the saved conversation) asked
|
|
115
|
+
* for. An inherited default is dropped from the child instead, with the reason.
|
|
116
|
+
*/
|
|
117
|
+
export function selectEffectiveTaskTools(options: {
|
|
118
|
+
/** Candidate loadout after the confinement adjustments. */
|
|
119
|
+
tools: readonly string[];
|
|
120
|
+
plan: TaskToolPlan;
|
|
121
|
+
/** Names this attempt requires; absent means the whole loadout is implicit. */
|
|
122
|
+
required?: ReadonlySet<string>;
|
|
123
|
+
}): EffectiveTaskToolSelection {
|
|
124
|
+
const refused = options.plan.refused.filter((tool) => options.tools.includes(tool.name));
|
|
125
|
+
const refusedNames = new Set(refused.map((tool) => tool.name));
|
|
126
|
+
const required = refused.filter((tool) => options.required?.has(tool.name) === true);
|
|
127
|
+
const requiredNames = new Set(required.map((tool) => tool.name));
|
|
128
|
+
return {
|
|
129
|
+
tools: options.tools.filter((name) => !refusedNames.has(name)),
|
|
130
|
+
refused,
|
|
131
|
+
required,
|
|
132
|
+
dropped: refused.filter((tool) => !requiredNames.has(tool.name)),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
/** "apply_patch (guarded) · web_fetch, web_search (trusted)" for the launch Runtime line. */
|
|
94
137
|
export function describeTaskTools(plan: TaskToolPlan): string | undefined {
|
|
95
138
|
const parts = [
|
|
@@ -172,7 +172,7 @@ export function parseTaskPolicy(value: unknown): TaskPolicy { if (!value || t
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
export function prepareTaskRuntime(options: {
|
|
175
|
-
root: string; controlDir: string; tools: readonly string[]; piBin
|
|
175
|
+
root: string; controlDir: string; tools: readonly string[]; piBin?: string;
|
|
176
176
|
permissions?: SandboxPermissions & Partial<CommandSettings>; extensionPaths?: readonly string[]; runtimeRoots?: readonly string[];
|
|
177
177
|
applyPatch?: boolean; extensionTools?: readonly TaskExtensionTool[];
|
|
178
178
|
}): { file: string; fileArgs: string[]; policy: TaskPolicy; policyPath: string } {
|
|
@@ -194,7 +194,9 @@ export function prepareTaskRuntime(options: {
|
|
|
194
194
|
mkdirSync(controlDir, { recursive: true });
|
|
195
195
|
const sdkEntry = canonicalizePath(join(PiCodingAgent.getPackageDir(), "dist", "index.js"));
|
|
196
196
|
const cliPaths = ["cli.js", join("bundle", "cli.js")].map((entry) => canonicalizePath(join(PiCodingAgent.getPackageDir(), "dist", entry)));
|
|
197
|
-
|
|
197
|
+
// The guarded runtime invokes this SDK directly. PATH may name a managed
|
|
198
|
+
// shell launcher, so only validate a CLI when the caller explicitly pins one.
|
|
199
|
+
if (options.piBin !== undefined && !cliPaths.includes(canonicalizePath(options.piBin))) {
|
|
198
200
|
throw new Error("Task sandbox requires the active Pi SDK CLI; a different pi executable is on PATH.");
|
|
199
201
|
}
|
|
200
202
|
const ownEntry = fileURLToPath(import.meta.url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.2",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, model routing, and Outcome & Todo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -58,12 +58,12 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"pi-better-background-tasks": "0.14.2",
|
|
61
|
-
"pi-better-goal": "0.12.
|
|
61
|
+
"pi-better-goal": "0.12.3",
|
|
62
62
|
"pi-better-model-router": "0.1.4",
|
|
63
63
|
"pi-ollama-cloud": "0.13.1",
|
|
64
64
|
"pi-better-sandbox": "0.14.1",
|
|
65
65
|
"pi-better-ssh": "0.1.4",
|
|
66
|
-
"pi-better-subagents": "0.28.
|
|
66
|
+
"pi-better-subagents": "0.28.1",
|
|
67
67
|
"proper-lockfile": "^4.1.2",
|
|
68
68
|
"shell-quote": "1.12.0",
|
|
69
69
|
"smol-toml": "1.9.0",
|