pi-better-harness 0.18.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.
Files changed (46) hide show
  1. package/README.md +7 -4
  2. package/extensions/minimal-output/hook.ts +20 -1
  3. package/extensions/minimal-output/index.ts +3 -3
  4. package/node_modules/pi-better-background-tasks/package.json +1 -1
  5. package/node_modules/pi-better-background-tasks/src/shared-failure-observations.ts +51 -6
  6. package/node_modules/pi-better-background-tasks/src/shared-permission-blocker.ts +48 -0
  7. package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +2 -0
  8. package/node_modules/pi-better-goal/README.md +2 -2
  9. package/node_modules/pi-better-goal/package.json +1 -1
  10. package/node_modules/pi-better-goal/src/continuation.ts +5 -1
  11. package/node_modules/pi-better-goal/src/goal-state.ts +1 -1
  12. package/node_modules/pi-better-goal/src/index.ts +184 -26
  13. package/node_modules/pi-better-goal/src/permission-hold.ts +150 -0
  14. package/node_modules/pi-better-goal/src/preferences.ts +7 -4
  15. package/node_modules/pi-better-goal/src/settings-page.ts +1 -0
  16. package/node_modules/pi-better-goal/src/shared-permission-blocker.ts +48 -0
  17. package/node_modules/pi-better-goal/src/types.ts +4 -2
  18. package/node_modules/pi-better-sandbox/README.md +46 -9
  19. package/node_modules/pi-better-sandbox/index.ts +1 -0
  20. package/node_modules/pi-better-sandbox/package.json +1 -1
  21. package/node_modules/pi-better-sandbox/permissions-page.ts +12 -11
  22. package/node_modules/pi-better-sandbox/permissions.ts +10 -4
  23. package/node_modules/pi-better-sandbox/shared-permission-blocker.ts +48 -0
  24. package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +2 -0
  25. package/node_modules/pi-better-sandbox/shared-task-process-list.ts +203 -0
  26. package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +10 -2
  27. package/node_modules/pi-better-sandbox/shared-task-tools.ts +8 -4
  28. package/node_modules/pi-better-subagents/child-incidents.ts +6 -2
  29. package/node_modules/pi-better-subagents/docs/failure-observations.md +34 -0
  30. package/node_modules/pi-better-subagents/docs/usage.md +23 -4
  31. package/node_modules/pi-better-subagents/failures.ts +1 -1
  32. package/node_modules/pi-better-subagents/incident-model.ts +24 -4
  33. package/node_modules/pi-better-subagents/index.ts +6 -1
  34. package/node_modules/pi-better-subagents/package.json +1 -1
  35. package/node_modules/pi-better-subagents/permission-policy.ts +4 -1
  36. package/node_modules/pi-better-subagents/shared-failure-observations.ts +51 -6
  37. package/node_modules/pi-better-subagents/shared-permission-blocker.ts +48 -0
  38. package/node_modules/pi-better-subagents/shared-sandbox-core.ts +2 -0
  39. package/node_modules/pi-better-subagents/shared-task-process-list.ts +203 -0
  40. package/node_modules/pi-better-subagents/shared-task-sandbox.ts +10 -2
  41. package/node_modules/pi-better-subagents/shared-task-tools.ts +8 -4
  42. package/node_modules/pi-better-subagents/subagent-tools.ts +5 -0
  43. package/node_modules/pi-better-subagents/task-guard.ts +3 -1
  44. package/node_modules/pi-better-subagents/task-policy.ts +4 -1
  45. package/node_modules/pi-better-subagents/tools.ts +32 -6
  46. package/package.json +5 -5
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-better-harness
2
2
 
3
+ <img src="https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/brand/logo.png" alt="Pi Better Harness logo" width="128" height="128" />
4
+
3
5
  `pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: an opt-in foreground write sandbox, delegated subagents, durable background tasks, synchronous SSH commands, goal tracking, and structured plans.
4
6
 
5
7
  ## Quick Answer
@@ -63,12 +65,13 @@ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.c
63
65
  ## Minimal Tool Output
64
66
 
65
67
  The bundled harness also provides `/tool-output minimal`, `/tool-output normal`,
66
- and `/tool-output` (toggle). Minimal mode hides tool blocks entirely, including
67
- running calls, headers, results, images, summary rows, and tool spacers. Built-in,
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,
68
71
  extension, and MCP calls are included. Execution, sandboxing, and agent-facing
69
72
  payloads are unchanged. Ctrl+O reveals the original tool blocks; Ctrl+O again
70
- hides them. `/tool-output normal` restores ordinary rendering. Error results
71
- are also hidden in minimal mode and remain available when revealed.
73
+ folds them. `/tool-output normal` restores ordinary rendering. Error result
74
+ bodies are also hidden in minimal mode and remain available when expanded.
72
75
 
73
76
  Normal mode is the default. The preference is saved in the current session,
74
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 ? [] : originals.render.call(this, width);
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] — Hide tool blocks entirely, or restore normal output. No argument toggles. Display only.",
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: "Hide all tool blocks, including running tools" },
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. Tool blocks are hidden. Ctrl+O reveals them."
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
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- return `${failureLabel(x)} · ${time} · ${summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${disposition}${evidence ? ` · evidence: ${compact ? shortEvidence(evidence) : evidence}` : ""}`;
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 ?? ""])).slice(0, 16);
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
+ }
@@ -60,6 +60,8 @@ export type SandboxPermissions = {
60
60
  storedCredentials: "off" | "read" | "read-write";
61
61
  commands: boolean;
62
62
  network: boolean;
63
+ /** Fixed process_list adapter only; omitted legacy profiles keep it Off. */
64
+ processAccess?: "off" | "read";
63
65
  };
64
66
 
65
67
  /** Whether a file access level allows creating and overwriting files. */
@@ -13,8 +13,8 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
13
13
  ## Core Features
14
14
 
15
15
  - `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
16
- - `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. Both controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool. Goal kickoff, `/goal resume`, and `alt+g` remain available with either control off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off` and `/goal settings conversational-resume on|off`; bare `/goal settings` prints the saved values outside the TUI.
17
- - `escape` pauses the active goal, and it stays paused while you talk to the agent. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
16
+ - `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. All three controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool; **Pause on Esc** controls whether editor Escape pauses the active goal. Goal kickoff, `/goal resume`, and `alt+g` remain available with these controls off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off`, `/goal settings conversational-resume on|off`, and `/goal settings pause-on-escape on|off`; bare `/goal settings` prints the saved values outside the TUI.
17
+ - `escape` pauses the active goal by default, and it stays paused while you talk to the agent. With **Pause on Esc** off, observed editor Escape leaves the goal active while preserving native streaming interruption and menu/dialog cancellation. Automatic continuation remains independent. Hosts without terminal/focus inspection and unknown custom editors retain generic abort-to-pause behavior because they cannot identify Escape reliably. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
18
18
  - Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
19
19
  - A compact goal widget that does not replace Pi's footer.
20
20
  - Background activity tracking for subagents and other registered providers.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 {