pi-better-harness 0.19.0 → 0.20.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/README.md +5 -4
- package/extensions/minimal-output/hook.ts +20 -1
- package/extensions/minimal-output/index.ts +3 -3
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-failure-observations.ts +51 -6
- package/node_modules/pi-better-background-tasks/src/shared-permission-blocker.ts +48 -0
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/continuation.ts +5 -1
- package/node_modules/pi-better-goal/src/goal-state.ts +1 -1
- package/node_modules/pi-better-goal/src/index.ts +107 -12
- package/node_modules/pi-better-goal/src/permission-hold.ts +150 -0
- package/node_modules/pi-better-goal/src/shared-permission-blocker.ts +48 -0
- package/node_modules/pi-better-goal/src/types.ts +4 -2
- package/node_modules/pi-better-sandbox/README.md +12 -5
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +8 -7
- package/node_modules/pi-better-sandbox/permissions.ts +1 -1
- package/node_modules/pi-better-sandbox/shared-permission-blocker.ts +48 -0
- package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +2 -1
- package/node_modules/pi-better-sandbox/shared-task-tools.ts +7 -3
- package/node_modules/pi-better-subagents/child-incidents.ts +6 -2
- package/node_modules/pi-better-subagents/docs/failure-observations.md +34 -0
- package/node_modules/pi-better-subagents/docs/usage.md +8 -0
- package/node_modules/pi-better-subagents/failures.ts +1 -1
- package/node_modules/pi-better-subagents/incident-model.ts +24 -4
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-failure-observations.ts +51 -6
- package/node_modules/pi-better-subagents/shared-permission-blocker.ts +48 -0
- package/node_modules/pi-better-subagents/shared-task-sandbox.ts +2 -1
- package/node_modules/pi-better-subagents/shared-task-tools.ts +7 -3
- package/node_modules/pi-better-subagents/tools.ts +32 -6
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -65,12 +65,13 @@ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.c
|
|
|
65
65
|
## Minimal Tool Output
|
|
66
66
|
|
|
67
67
|
The bundled harness also provides `/tool-output minimal`, `/tool-output normal`,
|
|
68
|
-
and `/tool-output` (toggle). Minimal mode
|
|
69
|
-
running calls
|
|
68
|
+
and `/tool-output` (toggle). Minimal mode folds each tool call into a single-line
|
|
69
|
+
header, including running calls. Long headers are truncated to the terminal
|
|
70
|
+
width; result bodies, images, boxes, and tool spacers are hidden. Built-in,
|
|
70
71
|
extension, and MCP calls are included. Execution, sandboxing, and agent-facing
|
|
71
72
|
payloads are unchanged. Ctrl+O reveals the original tool blocks; Ctrl+O again
|
|
72
|
-
|
|
73
|
-
are also hidden in minimal mode and remain available when
|
|
73
|
+
folds them. `/tool-output normal` restores ordinary rendering. Error result
|
|
74
|
+
bodies are also hidden in minimal mode and remain available when expanded.
|
|
74
75
|
|
|
75
76
|
Normal mode is the default. The preference is saved in the current session,
|
|
76
77
|
including resume/reload. This is a version-sensitive internal TUI adapter,
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
1
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
7
|
|
|
6
8
|
interface ToolComponent {
|
|
9
|
+
toolName: string;
|
|
10
|
+
args: unknown;
|
|
11
|
+
callRendererComponent?: Component;
|
|
7
12
|
expanded: boolean;
|
|
8
13
|
showImages: boolean;
|
|
9
14
|
resultRendererComponent?: unknown;
|
|
@@ -37,6 +42,20 @@ export interface MinimalOutputHook {
|
|
|
37
42
|
const HOOK = Symbol.for("pi-better-harness.minimal-output-hook");
|
|
38
43
|
const emptyResult = () => ({ render: () => [] as string[], invalidate() {} });
|
|
39
44
|
|
|
45
|
+
function compactCall(component: ToolComponent, width: number): string[] {
|
|
46
|
+
if (width <= 0) return [];
|
|
47
|
+
// Render the call alone at a wider width so terminal wrapping becomes truncation.
|
|
48
|
+
const header = component.callRendererComponent?.render(Math.max(4096, width + 1))
|
|
49
|
+
.map((line) => stripVTControlCharacters(line).trim())
|
|
50
|
+
.find((line) => line.length > 0);
|
|
51
|
+
let text = header;
|
|
52
|
+
if (!text) {
|
|
53
|
+
const args = JSON.stringify(component.args);
|
|
54
|
+
text = `${component.toolName}${args && args !== "{}" ? ` ${args}` : ""}`;
|
|
55
|
+
}
|
|
56
|
+
return [truncateToWidth(text, width)];
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
function hostRequire(): NodeRequire {
|
|
41
60
|
let require = createRequire(import.meta.url);
|
|
42
61
|
if (process.argv[1]) {
|
|
@@ -145,7 +164,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
|
|
|
145
164
|
return current.enabled && !this.expanded ? "" : originals.getTextOutput.apply(this, args);
|
|
146
165
|
},
|
|
147
166
|
render(this: ToolComponent, width: number): string[] {
|
|
148
|
-
return current.enabled && !this.expanded ?
|
|
167
|
+
return current.enabled && !this.expanded ? compactCall(this, width) : originals.render.call(this, width);
|
|
149
168
|
},
|
|
150
169
|
};
|
|
151
170
|
Object.assign(prototype, wrappers);
|
|
@@ -56,11 +56,11 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
pi.registerCommand("tool-output", {
|
|
59
|
-
description: "[minimal|normal] —
|
|
59
|
+
description: "[minimal|normal] — Fold tool output into single-line call headers, or restore normal output. No argument toggles. Display only.",
|
|
60
60
|
getArgumentCompletions: (argumentPrefix) => {
|
|
61
61
|
const prefix = argumentPrefix.trimStart().toLowerCase();
|
|
62
62
|
const matches = [
|
|
63
|
-
{ value: "minimal", label: "minimal", description: "
|
|
63
|
+
{ value: "minimal", label: "minimal", description: "Show compact call headers without result bodies" },
|
|
64
64
|
{ value: "normal", label: "normal", description: "Restore ordinary tool output" },
|
|
65
65
|
].filter((option) => option.value.startsWith(prefix));
|
|
66
66
|
return matches.length > 0 ? matches : null;
|
|
@@ -78,7 +78,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
78
78
|
pi.appendEntry(ENTRY, { version: 1, enabled });
|
|
79
79
|
status(ctx);
|
|
80
80
|
ctx.ui.notify(enabled
|
|
81
|
-
? "Minimal tool output on.
|
|
81
|
+
? "Minimal tool output on. Compact call headers remain visible. Ctrl+O reveals results."
|
|
82
82
|
: "Normal tool output restored.", "info");
|
|
83
83
|
},
|
|
84
84
|
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, mkdirSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
import { isPermissionBlocker, permissionBlockerKey, type PermissionBlocker } from "./shared-permission-blocker.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Explicit, append-only incident dispositions (#315).
|
|
@@ -30,6 +31,7 @@ export interface FailureEvent {
|
|
|
30
31
|
disposition?: IncidentDisposition;
|
|
31
32
|
/** Disposition events only: why the incidents are classified this way. */
|
|
32
33
|
reason?: string;
|
|
34
|
+
permissionBlockers?: PermissionBlocker[];
|
|
33
35
|
}
|
|
34
36
|
export interface DispositionRecord {
|
|
35
37
|
eventId: string;
|
|
@@ -38,6 +40,7 @@ export interface DispositionRecord {
|
|
|
38
40
|
reason: string;
|
|
39
41
|
evidence?: string;
|
|
40
42
|
at: number;
|
|
43
|
+
permissionBlockers?: PermissionBlocker[];
|
|
41
44
|
}
|
|
42
45
|
export interface FailureObservation {
|
|
43
46
|
id: string;
|
|
@@ -56,6 +59,8 @@ export interface FailureObservation {
|
|
|
56
59
|
resolvedAt?: number;
|
|
57
60
|
/** Latest explicit disposition applied to this incident. */
|
|
58
61
|
disposition?: DispositionRecord;
|
|
62
|
+
/** Retained even after recovery; active surfaces filter by current actionability. */
|
|
63
|
+
permissionBlockers?: PermissionBlocker[];
|
|
59
64
|
}
|
|
60
65
|
export interface FailureState {
|
|
61
66
|
version: 1;
|
|
@@ -247,12 +252,26 @@ export function validateDisposition(state: FailureState, event: FailureEvent): s
|
|
|
247
252
|
if (current.status !== "unresolved") return `Incident ${id} is already disposed (${failureLabel(current).toLowerCase()})`;
|
|
248
253
|
if (event.disposition === "open" && current.disposition?.disposition === "open") return `Incident ${id} is already open`;
|
|
249
254
|
}
|
|
255
|
+
if (event.permissionBlockers !== undefined) {
|
|
256
|
+
if (event.disposition !== "open" || !Array.isArray(event.permissionBlockers) || !event.permissionBlockers.length ||
|
|
257
|
+
event.permissionBlockers.length > 20 || !event.permissionBlockers.every(isPermissionBlocker)) return "Invalid permission blocker metadata";
|
|
258
|
+
const bound = new Set<string>();
|
|
259
|
+
for (const blocker of event.permissionBlockers) {
|
|
260
|
+
const current = blocker.incidentId ? findIncident(state, blocker.incidentId) : undefined;
|
|
261
|
+
if (!current || !ids.includes(current.id) || current.category !== "tool" || current.operation !== blocker.operation ||
|
|
262
|
+
blocker.context !== "worker" || blocker.basis !== "agent-reported" || blocker.remoteOutcome !== "unknown" ||
|
|
263
|
+
blocker.policySnapshotId !== undefined || bound.has(current.id)) return "Permission blocker must name an unresolved failed worker attempt";
|
|
264
|
+
bound.add(current.id);
|
|
265
|
+
}
|
|
266
|
+
if (bound.size !== ids.length) return "Permission blocker metadata must cover every target";
|
|
267
|
+
}
|
|
250
268
|
return undefined;
|
|
251
269
|
}
|
|
252
270
|
|
|
253
271
|
/** Pure transition. Lifecycle is deliberately not an input or an output. */
|
|
254
272
|
export function reduceFailure(state: FailureState, event: FailureEvent, observedAt: number): FailureState {
|
|
255
273
|
if (state.seen.includes(event.id)) return state;
|
|
274
|
+
if (event.permissionBlockers !== undefined && event.kind !== "disposition") return state;
|
|
256
275
|
// Invalid dispositions fail closed: nothing changes and the id is not consumed.
|
|
257
276
|
if (event.kind === "disposition" && validateDisposition(state, event)) return state;
|
|
258
277
|
const next: FailureState = { ...state, seen: [...state.seen, event.id],
|
|
@@ -265,11 +284,13 @@ export function reduceFailure(state: FailureState, event: FailureEvent, observed
|
|
|
265
284
|
const at = event.at ?? observedAt;
|
|
266
285
|
const record: DispositionRecord = { eventId: event.id, disposition: event.disposition!, incidents: [...event.incidents!],
|
|
267
286
|
reason: text(event.reason, ""), ...(event.evidence ? { evidence: text(event.evidence, "") } : {}), at };
|
|
287
|
+
if (event.permissionBlockers) record.permissionBlockers = event.permissionBlockers.map(x => ({ ...x }));
|
|
268
288
|
for (const id of record.incidents) {
|
|
269
289
|
const [key, current] = Object.entries(next.observations).find(([, item]) => item.id === id)!;
|
|
270
290
|
const status = record.disposition === "recovered" ? "resolved" : record.disposition === "superseded" ? "superseded"
|
|
271
291
|
: record.disposition === "expected" ? "expected" : current.status;
|
|
272
292
|
next.observations[key] = { ...current, status, disposition: record,
|
|
293
|
+
...(record.permissionBlockers ? { permissionBlockers: record.permissionBlockers.filter(x => x.incidentId === id) } : {}),
|
|
273
294
|
...(status === "resolved" || status === "superseded" ? { resolvedAt: at } : {}) };
|
|
274
295
|
if (status === "resolved" || status === "superseded") next.resolved = { ...next.resolved, [id]: at };
|
|
275
296
|
}
|
|
@@ -298,6 +319,7 @@ export function reduceFailure(state: FailureState, event: FailureEvent, observed
|
|
|
298
319
|
lastObservedAt: observedAt, lastSequence: next.seen.length, at: event.at,
|
|
299
320
|
count: active ? previous.count + 1 : 1,
|
|
300
321
|
...(active && previous.disposition ? { disposition: previous.disposition } : {}),
|
|
322
|
+
...(active && previous.permissionBlockers ? { permissionBlockers: previous.permissionBlockers } : {}),
|
|
301
323
|
};
|
|
302
324
|
return next;
|
|
303
325
|
}
|
|
@@ -315,6 +337,16 @@ export function activeFailures(state: FailureState): FailureObservation[] {
|
|
|
315
337
|
export function actionableFailures(state: FailureState): FailureObservation[] {
|
|
316
338
|
return activeFailures(state).filter(needsAction);
|
|
317
339
|
}
|
|
340
|
+
/** Only currently actionable reports, not closed history or arbitrary tool-result claims. */
|
|
341
|
+
export function actionablePermissionBlockers(state: FailureState): PermissionBlocker[] {
|
|
342
|
+
const unique = new Map<string, PermissionBlocker>();
|
|
343
|
+
for (const incident of actionableFailures(state)) {
|
|
344
|
+
for (const blocker of incident.permissionBlockers ?? []) {
|
|
345
|
+
if (isPermissionBlocker(blocker)) unique.set(permissionBlockerKey(blocker), { ...blocker });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return [...unique.values()];
|
|
349
|
+
}
|
|
318
350
|
function closedOrder(a: FailureObservation, b: FailureObservation): number {
|
|
319
351
|
return (b.lastSequence ?? 0) - (a.lastSequence ?? 0) || b.lastObservedAt - a.lastObservedAt;
|
|
320
352
|
}
|
|
@@ -449,12 +481,20 @@ export function shortEvidence(evidence: string): string {
|
|
|
449
481
|
function failureRow(x: FailureObservation, detail: IncidentDetail = "compact"): string {
|
|
450
482
|
const time = x.at === undefined ? `observed ${new Date(x.firstObservedAt).toISOString()}` : new Date(x.at).toISOString();
|
|
451
483
|
const compact = detail !== "full";
|
|
484
|
+
const permission = Boolean(x.permissionBlockers?.length);
|
|
485
|
+
let annotation = "";
|
|
486
|
+
if (x.permissionBlockers?.length) {
|
|
487
|
+
const resources = x.permissionBlockers.map(b => b.resource).join(", ");
|
|
488
|
+
annotation = ` · Worker permission blocker: ${resources} (agent-reported; remote outcome unknown; not a foreground denial). No remote authorization or successful validation inferred.`;
|
|
489
|
+
if (needsAction(x)) annotation += " Changed worker settings require a fresh launch.";
|
|
490
|
+
}
|
|
452
491
|
// Full rows (raw evidence) show the journal summary verbatim; compact rows unwrap and cap it.
|
|
453
|
-
const summary = compact ? capUtf8(dropLoneSurrogates(unwrapToolResultText(x.summary)).replace(/\s+/g, " ").trim(), COMPACT_EXCERPT_BYTES) : x.summary;
|
|
492
|
+
const summary = compact && permission ? "Permission access failure" : compact ? capUtf8(dropLoneSurrogates(unwrapToolResultText(x.summary)).replace(/\s+/g, " ").trim(), COMPACT_EXCERPT_BYTES) : x.summary;
|
|
454
493
|
const reason = x.disposition ? text(x.disposition.reason, "") : "";
|
|
455
|
-
const disposition = x.disposition ? ` · ${x.disposition.disposition}: ${compact ? capUtf8(reason, COMPACT_EXCERPT_BYTES) : reason}` : "";
|
|
456
|
-
const evidence = x.evidence ? text(x.evidence, "") : "";
|
|
457
|
-
|
|
494
|
+
const disposition = x.disposition ? ` · ${x.disposition.disposition}${compact && permission ? "" : `: ${compact ? capUtf8(reason, COMPACT_EXCERPT_BYTES) : reason}`}` : "";
|
|
495
|
+
const evidence = x.evidence && !(compact && permission) ? text(x.evidence, "") : "";
|
|
496
|
+
const proof = !compact && permission && x.disposition?.evidence ? ` · disposition evidence: ${text(x.disposition.evidence, "")}` : "";
|
|
497
|
+
return `${failureLabel(x)} · ${time} · ${summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${disposition}${evidence ? ` · evidence: ${compact ? shortEvidence(evidence) : evidence}` : ""}${proof}${annotation}`;
|
|
458
498
|
}
|
|
459
499
|
|
|
460
500
|
/**
|
|
@@ -573,13 +613,15 @@ export interface FailureIncidentPage {
|
|
|
573
613
|
*/
|
|
574
614
|
export function failureRevision(state: FailureState): string {
|
|
575
615
|
return failureIdentity(Object.values(state.observations)
|
|
576
|
-
.map((item) => [item.id, item.status, item.category, item.count, item.lastSequence ?? 0, item.summary, item.evidence ?? ""
|
|
616
|
+
.map((item) => [item.id, item.status, item.category, item.count, item.lastSequence ?? 0, item.summary, item.evidence ?? "",
|
|
617
|
+
...(item.permissionBlockers ? [item.permissionBlockers.map(permissionBlockerKey).sort()] : [])])
|
|
577
618
|
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
|
|
578
619
|
}
|
|
579
620
|
|
|
580
621
|
/** Digest of the rows a cursor pages. Scope and detail are part of it: the same incidents render different bytes. */
|
|
581
622
|
function incidentRevision(state: FailureState, scope: IncidentScope = "actionable", detail: IncidentDetail = "compact"): string {
|
|
582
|
-
return failureIdentity(scope, detail, scopedFailures(state, scope).map((item) => [item.id, item.status, item.count, item.summary, item.evidence ?? ""
|
|
623
|
+
return failureIdentity(scope, detail, scopedFailures(state, scope).map((item) => [item.id, item.status, item.count, item.summary, item.evidence ?? "",
|
|
624
|
+
...(item.permissionBlockers ? [item.permissionBlockers.map(permissionBlockerKey).sort()] : [])])).slice(0, 16);
|
|
583
625
|
}
|
|
584
626
|
|
|
585
627
|
/** Cursors carry a digest of their resource/scope, not the scope text. */
|
|
@@ -999,6 +1041,8 @@ function readStoredState(path: string): FailureState {
|
|
|
999
1041
|
!Number.isFinite(row.observedAt) || Math.abs(row.observedAt) > 8.64e15 ||
|
|
1000
1042
|
(e.at !== undefined && (!Number.isFinite(e.at) || Math.abs(e.at) > 8.64e15)) ||
|
|
1001
1043
|
(e.incidents !== undefined && (!Array.isArray(e.incidents) || !e.incidents.every((id: unknown) => typeof id === "string"))) ||
|
|
1044
|
+
(e.permissionBlockers !== undefined && (e.kind !== "disposition" ||
|
|
1045
|
+
(!state.seen.includes(e.id) && validateDisposition(state, e) !== undefined))) ||
|
|
1002
1046
|
[e.summary, e.category, e.evidence, e.reason].some((v) => v !== undefined && typeof v !== "string")) throw new Error("invalid record");
|
|
1003
1047
|
state = reduceFailure(state, e, row.observedAt);
|
|
1004
1048
|
} catch { state = storageProblem(state, "Failure journal contains unreadable records; observations may be incomplete"); }
|
|
@@ -1021,6 +1065,7 @@ export function observeFailures(path: string, events: readonly FailureEvent[], n
|
|
|
1021
1065
|
}
|
|
1022
1066
|
// Rejected dispositions are never journaled or marked seen.
|
|
1023
1067
|
if (raw.kind === "disposition" && validateDisposition(state, raw)) continue;
|
|
1068
|
+
if (raw.permissionBlockers !== undefined && raw.kind !== "disposition") continue;
|
|
1024
1069
|
const event = { ...raw, ...(raw.summary ? { summary: text(raw.summary, "") } : {}),
|
|
1025
1070
|
...(raw.evidence ? { evidence: text(raw.evidence, "") } : {}), ...(raw.reason ? { reason: text(raw.reason, "") } : {}) };
|
|
1026
1071
|
try {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated from packages/failure-observations/permission-blocker.ts. Do not edit directly.
|
|
2
|
+
/** Privacy-safe wire contract. Context and identity must be assigned by a trusted adapter. */
|
|
3
|
+
export const PERMISSION_RESOURCES = Object.freeze([
|
|
4
|
+
"credential-files", "process-inspection", "project-files", "outside-project-files",
|
|
5
|
+
"runtime-control-files", "command-execution", "network-access", "unknown",
|
|
6
|
+
] as const);
|
|
7
|
+
export type PermissionResource = typeof PERMISSION_RESOURCES[number];
|
|
8
|
+
export interface PermissionBlocker {
|
|
9
|
+
version: 1;
|
|
10
|
+
kind: "permission-blocker";
|
|
11
|
+
context: "worker" | "foreground";
|
|
12
|
+
resource: PermissionResource;
|
|
13
|
+
basis: "policy-refusal" | "os-permission-error" | "agent-reported";
|
|
14
|
+
/** A stable hash or bounded logical identifier, never a command or path. */
|
|
15
|
+
operation: string;
|
|
16
|
+
remoteOutcome: "unknown" | "not-started";
|
|
17
|
+
incidentId?: string;
|
|
18
|
+
runId?: string;
|
|
19
|
+
policySnapshotId?: string;
|
|
20
|
+
}
|
|
21
|
+
const FIELDS = new Set(["version", "kind", "context", "resource", "basis", "operation", "remoteOutcome", "incidentId", "runId", "policySnapshotId"]);
|
|
22
|
+
const LOGICAL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
23
|
+
// Responses SDK references join call_id and item_id with "|"; these are opaque evidence ids,
|
|
24
|
+
// not caller-chosen logical operations. Keep the same bound and exclude paths and prose.
|
|
25
|
+
const INCIDENT_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:|\-]{0,199}$/;
|
|
26
|
+
export function isPermissionResource(value: unknown): value is PermissionResource {
|
|
27
|
+
return typeof value === "string" && (PERMISSION_RESOURCES as readonly string[]).includes(value);
|
|
28
|
+
}
|
|
29
|
+
/** Pure and strict: no coercion, unknown fields, paths, argv, or free-form prose. */
|
|
30
|
+
export function isPermissionBlocker(value: unknown): value is PermissionBlocker {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
32
|
+
const prototype = Object.getPrototypeOf(value);
|
|
33
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
34
|
+
if (Reflect.ownKeys(value).some(key => typeof key !== "string" || !FIELDS.has(key))) return false;
|
|
35
|
+
const v = value as Record<string, unknown>;
|
|
36
|
+
if (v.version !== 1 || v.kind !== "permission-blocker" || !["worker", "foreground"].includes(v.context as string) ||
|
|
37
|
+
!isPermissionResource(v.resource) || !["policy-refusal", "os-permission-error", "agent-reported"].includes(v.basis as string) ||
|
|
38
|
+
!["unknown", "not-started"].includes(v.remoteOutcome as string) ||
|
|
39
|
+
typeof v.operation !== "string" || v.operation.length > 64 || !LOGICAL_ID.test(v.operation)) return false;
|
|
40
|
+
if (Object.hasOwn(v, "incidentId") && (typeof v.incidentId !== "string" || !INCIDENT_REFERENCE.test(v.incidentId))) return false;
|
|
41
|
+
return ["runId", "policySnapshotId"].every(key => !Object.hasOwn(v, key) ||
|
|
42
|
+
(typeof v[key] === "string" && LOGICAL_ID.test(v[key] as string)));
|
|
43
|
+
}
|
|
44
|
+
/** Order-independent identity without I/O or a platform-specific crypto dependency. */
|
|
45
|
+
export function permissionBlockerKey(blocker: PermissionBlocker): string {
|
|
46
|
+
return JSON.stringify([blocker.version, blocker.kind, blocker.context, blocker.resource, blocker.basis,
|
|
47
|
+
blocker.operation, blocker.remoteOutcome, blocker.incidentId ?? null, blocker.runId ?? null, blocker.policySnapshotId ?? null]);
|
|
48
|
+
}
|
|
@@ -53,6 +53,10 @@ function resultContent(content: unknown): unknown[] {
|
|
|
53
53
|
export function continuationEvidence(messages: readonly unknown[]): ContinuationEvidence {
|
|
54
54
|
const records: unknown[] = [];
|
|
55
55
|
const toolNames: string[] = [];
|
|
56
|
+
const hasActions = messages.some((candidate) => isRecord(candidate) && (
|
|
57
|
+
candidate.role === "toolResult" || (candidate.role === "assistant" && Array.isArray(candidate.content) &&
|
|
58
|
+
candidate.content.some((block) => isRecord(block) && block.type === "toolCall"))
|
|
59
|
+
));
|
|
56
60
|
|
|
57
61
|
for (const candidate of messages) {
|
|
58
62
|
if (!isRecord(candidate)) {
|
|
@@ -67,7 +71,7 @@ export function continuationEvidence(messages: readonly unknown[]): Continuation
|
|
|
67
71
|
toolNames.push(block.name);
|
|
68
72
|
return [{ name: block.name, arguments: block.arguments }];
|
|
69
73
|
});
|
|
70
|
-
const text = message.content.flatMap((block) =>
|
|
74
|
+
const text = hasActions ? [] : message.content.flatMap((block) =>
|
|
71
75
|
isRecord(block) && block.type === "text" ? [{ text: block.text }] : [],
|
|
72
76
|
);
|
|
73
77
|
if (actions.length > 0 || text.length > 0) {
|
|
@@ -66,7 +66,7 @@ function isGoalStatus(value: unknown): value is GoalStatus {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function isPauseReason(value: unknown): value is GoalPauseReason {
|
|
69
|
-
return value === "interrupt";
|
|
69
|
+
return value === "interrupt" || value === "permission-blocker";
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function isGoalLike(value: unknown): value is GoalLike {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type GoalEntrySource,
|
|
29
29
|
} from "./goal-state.js";
|
|
30
30
|
import { continuationEvidence, type ContinuationEvidence } from "./continuation.js";
|
|
31
|
+
import { currentPermissionHold, logicalPermissionBlockerKey, MAX_PERMISSION_BLOCKERS, MAX_PERMISSION_RECORDS, permissionHoldRecord, permissionInstruction, permissionRecord, reportedPermissionBlockers, reportedPermissionGap, type PermissionHold } from "./permission-hold.js";
|
|
31
32
|
import { observeGoalStall } from "./stall.js";
|
|
32
33
|
import {
|
|
33
34
|
flattenObjectiveForRail,
|
|
@@ -91,6 +92,7 @@ export function agentResumable(goal: GoalSnapshot | null, conversationalResume =
|
|
|
91
92
|
|
|
92
93
|
/** Footer status for a paused goal, telling the user how to resume it. */
|
|
93
94
|
export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true): string {
|
|
95
|
+
if (goal.pauseReason === "permission-blocker") return "goal held: permission blocker · /goal resume (one retry)";
|
|
94
96
|
return agentResumable(goal, conversationalResume) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -229,6 +231,7 @@ function formatGoal(
|
|
|
229
231
|
continuation: ReturnType<typeof currentContinuationState> = null,
|
|
230
232
|
stall = observeGoalStall(goal, continuation),
|
|
231
233
|
preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true },
|
|
234
|
+
permissions?: PermissionHold | null,
|
|
232
235
|
): string {
|
|
233
236
|
const settings = formatPreferences(preferences);
|
|
234
237
|
if (!goal) {
|
|
@@ -242,6 +245,11 @@ function formatGoal(
|
|
|
242
245
|
return [
|
|
243
246
|
`Goal: ${goal.objective}`,
|
|
244
247
|
`Status: ${goal.status}`,
|
|
248
|
+
...(goal.pauseReason ? [`Pause reason: ${goal.pauseReason}`] : []),
|
|
249
|
+
...(permissions?.blockers.length ? [
|
|
250
|
+
`Permission hold: ${permissions.blockers.length} scope(s); ${permissions.recordCount} history records; retry ${permissions.retryPending ? "released once" : "held"}`,
|
|
251
|
+
...(permissions.saturated ? ["Permission observation incomplete: history/scope limit reached; release refused."] : []),
|
|
252
|
+
] : []),
|
|
245
253
|
`Token budget: ${budget}`,
|
|
246
254
|
`Tokens used: ${goal.usage.tokensUsed}`,
|
|
247
255
|
`Active time: ${timing.activeSeconds}s`,
|
|
@@ -375,6 +383,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
375
383
|
};
|
|
376
384
|
|
|
377
385
|
const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: GoalEntrySource): void => {
|
|
386
|
+
const permissionHold = currentPermissionHold(ctx, goal.goalId);
|
|
387
|
+
if (goal.status === "paused" && permissionHold.blockers.length > 0) {
|
|
388
|
+
if (permissionHold.retryPending && permissionHold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
389
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
|
|
390
|
+
}
|
|
391
|
+
goal = { ...goal, pauseReason: "permission-blocker" };
|
|
392
|
+
}
|
|
378
393
|
executionGeneration += 1;
|
|
379
394
|
lastAgentEvidence = null;
|
|
380
395
|
const previous = getGoal(ctx);
|
|
@@ -418,7 +433,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
418
433
|
return;
|
|
419
434
|
}
|
|
420
435
|
setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
|
|
421
|
-
notifyGoal(ctx,
|
|
436
|
+
notifyGoal(ctx, currentPermissionHold(ctx, goal.goalId).blockers.length > 0
|
|
437
|
+
? "Goal remains permission-held. Use /goal resume or alt+g for one same-scope retry."
|
|
438
|
+
: preferences.conversationalResume
|
|
422
439
|
? 'Goal paused. Say "go" to resume it, or use /goal resume.'
|
|
423
440
|
: "Goal paused. Use /goal resume or alt+g to resume it.");
|
|
424
441
|
};
|
|
@@ -457,6 +474,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
457
474
|
return { ok: false, message: blocker };
|
|
458
475
|
}
|
|
459
476
|
const goal = held ? current : goalWithStatus(current, "active");
|
|
477
|
+
const permissions = currentPermissionHold(ctx, current.goalId);
|
|
478
|
+
if (current.pauseReason === "permission-blocker" && permissions.blockers.length === 0) {
|
|
479
|
+
return { ok: false, message: "Permission hold evidence is unavailable. The goal stays held; inspect the report before replacing this goal explicitly." };
|
|
480
|
+
}
|
|
481
|
+
if (permissions.blockers.length > 0) {
|
|
482
|
+
if (source === "tool" || permissions.saturated) {
|
|
483
|
+
return { ok: false, message: permissions.saturated
|
|
484
|
+
? "Permission hold history is full. The goal stays held; inspect it and create a new goal explicitly if needed."
|
|
485
|
+
: "Only /goal resume or alt+g can release one permission retry." };
|
|
486
|
+
}
|
|
487
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(current.goalId, "permission-release"));
|
|
488
|
+
}
|
|
460
489
|
setGoal(goal, ctx, source);
|
|
461
490
|
queueGoalContinuation(goal, ctx);
|
|
462
491
|
return { ok: true, goal };
|
|
@@ -489,8 +518,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
489
518
|
if (!boundCommandReady(goal, ctx)) return;
|
|
490
519
|
clearIdleContinuation();
|
|
491
520
|
continuationQueuedFor = goal.goalId;
|
|
492
|
-
|
|
493
|
-
|
|
521
|
+
const permissions = currentPermissionHold(ctx, goal.goalId);
|
|
522
|
+
if (permissions.blockers.length === 0) resetContinuationState(goal);
|
|
523
|
+
sendGoalContinuation(goal, permissions.blockers.length > 0
|
|
524
|
+
? `${continuationPrompt(goal, getWorkflow(ctx))}\n\n${permissionInstruction(permissions)}`
|
|
525
|
+
: continuationPrompt(goal, getWorkflow(ctx)), "continuation");
|
|
494
526
|
};
|
|
495
527
|
|
|
496
528
|
function clearIdleContinuation(): void {
|
|
@@ -511,6 +543,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
511
543
|
if (WAKE_DISABLED || !preferences.autoContinue || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
|
|
512
544
|
return;
|
|
513
545
|
}
|
|
546
|
+
if (currentPermissionHold(ctx, goal.goalId).blockers.length > 0) return;
|
|
514
547
|
if (kind === "continuation" && currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
515
548
|
return;
|
|
516
549
|
}
|
|
@@ -541,6 +574,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
541
574
|
if (WAKE_DISABLED || !preferences.autoContinue || execution !== executionGeneration || wake !== wakeGeneration) return false;
|
|
542
575
|
const current = currentGoalSnapshot(ctx);
|
|
543
576
|
return isPokeable(current) && current.goalId === goalId &&
|
|
577
|
+
currentPermissionHold(ctx, goalId).blockers.length === 0 &&
|
|
544
578
|
continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
|
|
545
579
|
};
|
|
546
580
|
const goal = currentGoalSnapshot(ctx);
|
|
@@ -603,7 +637,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
603
637
|
const goal = currentGoalSnapshot(ctx);
|
|
604
638
|
const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
|
|
605
639
|
backgroundDrainTracker = wakePlan.nextTracker;
|
|
606
|
-
if (isPokeable(goal) && wakePlan.wakeSignature) {
|
|
640
|
+
if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0) {
|
|
607
641
|
// Persist external progress now: a foreground/callback turn may cancel its delayed wake.
|
|
608
642
|
clearIdleContinuation();
|
|
609
643
|
resetContinuationState(goal);
|
|
@@ -861,7 +895,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
861
895
|
if (!trimmed) {
|
|
862
896
|
const continuation = current ? currentContinuationState(ctx, current.goalId) : null;
|
|
863
897
|
// Inspection is always explicit user intent; show the full summary even while busy.
|
|
864
|
-
ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning }), preferences
|
|
898
|
+
ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning }), preferences,
|
|
899
|
+
current ? currentPermissionHold(ctx, current.goalId) : null), "info");
|
|
865
900
|
return;
|
|
866
901
|
}
|
|
867
902
|
|
|
@@ -938,8 +973,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
938
973
|
backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
|
|
939
974
|
});
|
|
940
975
|
return {
|
|
941
|
-
content: [{ type: "text", text: formatGoal(goal, continuation, stall, preferences) }],
|
|
942
|
-
details: { goal, continuation, stall, preferences: { ...preferences }, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
|
|
976
|
+
content: [{ type: "text", text: formatGoal(goal, continuation, stall, preferences, goal ? currentPermissionHold(ctx, goal.goalId) : null) }],
|
|
977
|
+
details: { goal, continuation, permissionHold: goal ? currentPermissionHold(ctx, goal.goalId) : null, stall, preferences: { ...preferences }, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
|
|
943
978
|
};
|
|
944
979
|
},
|
|
945
980
|
});
|
|
@@ -1104,6 +1139,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1104
1139
|
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1105
1140
|
}
|
|
1106
1141
|
const restoredGoal = getGoal(ctx);
|
|
1142
|
+
if (restoredGoal?.status === "active" && currentPermissionHold(ctx, restoredGoal.goalId).blockers.length > 0) {
|
|
1143
|
+
// A persisted release is not a replayable dispatch ticket after reload.
|
|
1144
|
+
setGoal(goalWithStatus(restoredGoal, "paused", undefined, "permission-blocker"), ctx, "runtime");
|
|
1145
|
+
}
|
|
1107
1146
|
if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
|
|
1108
1147
|
setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
|
|
1109
1148
|
notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
|
|
@@ -1159,7 +1198,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1159
1198
|
// A paused goal stays paused while the user talks: the message is ordinary
|
|
1160
1199
|
// conversation. Only /goal resume, the hotkey, or goal_resume resume it.
|
|
1161
1200
|
const goal = getGoal(ctx);
|
|
1162
|
-
if (goal?.status === "active") {
|
|
1201
|
+
if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0) {
|
|
1163
1202
|
executionGeneration += 1;
|
|
1164
1203
|
clearIdleContinuation();
|
|
1165
1204
|
lastAgentEvidence = null;
|
|
@@ -1173,8 +1212,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1173
1212
|
const owner = getWorkflow(ctx);
|
|
1174
1213
|
const snapshot = await publishSnapshot(ctx);
|
|
1175
1214
|
if (!snapshot) return;
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1215
|
+
const permissions = goal ? currentPermissionHold(ctx, goal.goalId) : null;
|
|
1216
|
+
const permissionPrompt = permissions && goal?.status !== "complete" &&
|
|
1217
|
+
(permissions.blockers.length > 0 || goal?.pauseReason === "permission-blocker")
|
|
1218
|
+
? permissionInstruction({ ...permissions, retryPending: permissions.retryPending && goal?.status === "active" }) +
|
|
1219
|
+
(permissions.blockers.length === 0 ? "\nBlocker evidence is unavailable. Stay held; do not invent a retry scope." : "") : "";
|
|
1220
|
+
// A held goal overrides workflow ownership without invoking or invalidating that workflow.
|
|
1221
|
+
if (permissionPrompt && goal?.status === "paused") {
|
|
1222
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${permissionPrompt}` };
|
|
1223
|
+
}
|
|
1224
|
+
const pausedInstruction = permissionPrompt || (goal?.status === "paused" && goal.pauseReason === "interrupt"
|
|
1225
|
+
? pausedGoalPrompt(goal, preferences.conversationalResume) : "");
|
|
1178
1226
|
if (!isPokeable(goal) && !owner) {
|
|
1179
1227
|
return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
|
|
1180
1228
|
}
|
|
@@ -1199,7 +1247,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1199
1247
|
return {
|
|
1200
1248
|
systemPrompt: `${event.systemPrompt}\n\n` +
|
|
1201
1249
|
(pausedInstruction
|
|
1202
|
-
? `${pausedInstruction}\
|
|
1250
|
+
? `${pausedInstruction}\n` + (permissionPrompt
|
|
1251
|
+
? "These permission constraints override the workflow instructions below.\n\n"
|
|
1252
|
+
: "While the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n")
|
|
1203
1253
|
: "") +
|
|
1204
1254
|
`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}` +
|
|
1205
1255
|
(isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
|
|
@@ -1214,7 +1264,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1214
1264
|
return {
|
|
1215
1265
|
systemPrompt:
|
|
1216
1266
|
`${event.systemPrompt}\n\n` +
|
|
1217
|
-
`Pi Better Goal active objective: ${goal.objective}. Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit.${backgroundInstruction}${questionInstruction}
|
|
1267
|
+
`Pi Better Goal active objective: ${goal.objective}. Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit.${backgroundInstruction}${questionInstruction}` +
|
|
1268
|
+
(permissionPrompt ? `\n\n${permissionPrompt}` : ""),
|
|
1218
1269
|
};
|
|
1219
1270
|
});
|
|
1220
1271
|
|
|
@@ -1255,6 +1306,40 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1255
1306
|
escapeAbortSuppression = undefined;
|
|
1256
1307
|
});
|
|
1257
1308
|
|
|
1309
|
+
pi.on("tool_result", (event, ctx) => {
|
|
1310
|
+
const goal = getGoal(ctx);
|
|
1311
|
+
if (!goal || (goal.status !== "active" && goal.status !== "paused")) return;
|
|
1312
|
+
const reports = reportedPermissionBlockers(pi, event.toolName, event.details);
|
|
1313
|
+
const incomplete = reportedPermissionGap(pi, event.toolName, event.details);
|
|
1314
|
+
if (reports.length === 0 && !incomplete) return;
|
|
1315
|
+
let hold = currentPermissionHold(ctx, goal.goalId);
|
|
1316
|
+
let changed = false;
|
|
1317
|
+
for (const blocker of reports) {
|
|
1318
|
+
const previous = hold.blockers.find((item) => logicalPermissionBlockerKey(item) === logicalPermissionBlockerKey(blocker));
|
|
1319
|
+
if (!hold.retryPending && previous && JSON.stringify(previous) === JSON.stringify(blocker)) continue;
|
|
1320
|
+
if (hold.saturated || (!previous && hold.blockers.length >= MAX_PERMISSION_BLOCKERS)) {
|
|
1321
|
+
if (!hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1322
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-gap"));
|
|
1323
|
+
}
|
|
1324
|
+
break;
|
|
1325
|
+
}
|
|
1326
|
+
pi.appendEntry(EXTENSION_NAME, permissionHoldRecord(goal.goalId, blocker));
|
|
1327
|
+
changed = true;
|
|
1328
|
+
hold = currentPermissionHold(ctx, goal.goalId);
|
|
1329
|
+
}
|
|
1330
|
+
if (incomplete && !hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1331
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-gap"));
|
|
1332
|
+
changed = true;
|
|
1333
|
+
hold = currentPermissionHold(ctx, goal.goalId);
|
|
1334
|
+
}
|
|
1335
|
+
if (hold.retryPending && hold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1336
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
|
|
1337
|
+
}
|
|
1338
|
+
if (!changed && goal.status === "paused" && goal.pauseReason === "permission-blocker") return;
|
|
1339
|
+
setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
|
|
1340
|
+
notifyGoal(ctx, "Goal held for a permission blocker. /goal resume or alt+g releases one same-scope retry.", "warning");
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1258
1343
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
1259
1344
|
if (!BLOCKING_QUESTION_TOOLS.has(event.toolName)) return;
|
|
1260
1345
|
currentCtx = ctx;
|
|
@@ -1291,6 +1376,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1291
1376
|
const snapshot = await publishSnapshot(ctx);
|
|
1292
1377
|
if (!snapshot) return;
|
|
1293
1378
|
const goal = getGoal(ctx);
|
|
1379
|
+
if (isPokeable(goal)) {
|
|
1380
|
+
const permissions = currentPermissionHold(ctx, goal.goalId);
|
|
1381
|
+
if (permissions.blockers.length > 0) {
|
|
1382
|
+
if (permissions.retryPending && permissions.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1383
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
|
|
1384
|
+
}
|
|
1385
|
+
setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1294
1389
|
if (!isPokeable(goal) || snapshot.backgroundRunning) {
|
|
1295
1390
|
return;
|
|
1296
1391
|
}
|