pi-better-harness 0.7.0 → 0.8.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 (45) hide show
  1. package/node_modules/pi-better-background-tasks/README.md +6 -4
  2. package/node_modules/pi-better-background-tasks/package.json +1 -1
  3. package/node_modules/pi-better-background-tasks/src/failures.ts +9 -1
  4. package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +9 -6
  5. package/node_modules/pi-better-background-tasks/src/output.ts +5 -1
  6. package/node_modules/pi-better-background-tasks/src/runtime.ts +25 -9
  7. package/node_modules/pi-better-background-tasks/src/shared-failure-observations.ts +48 -12
  8. package/node_modules/pi-better-background-tasks/src/shared-log-utils.ts +6 -12
  9. package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +169 -125
  10. package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +78 -14
  11. package/node_modules/pi-better-background-tasks/src/tools.ts +1 -1
  12. package/node_modules/pi-better-goal/package.json +1 -1
  13. package/node_modules/pi-better-sandbox/README.md +32 -6
  14. package/node_modules/pi-better-sandbox/index.ts +4 -0
  15. package/node_modules/pi-better-sandbox/package.json +1 -1
  16. package/node_modules/pi-better-sandbox/permissions-page.ts +74 -7
  17. package/node_modules/pi-better-sandbox/permissions.ts +13 -2
  18. package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +78 -14
  19. package/node_modules/pi-better-sandbox/shared-task-apply-patch.ts +409 -0
  20. package/node_modules/pi-better-sandbox/shared-task-files.ts +69 -7
  21. package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +11 -0
  22. package/node_modules/pi-better-sandbox/shared-task-tools.ts +111 -0
  23. package/node_modules/pi-better-sandbox/state.ts +5 -1
  24. package/node_modules/pi-better-subagents/README.md +1 -1
  25. package/node_modules/pi-better-subagents/child-incidents.ts +2 -2
  26. package/node_modules/pi-better-subagents/docs/failure-observations.md +1 -1
  27. package/node_modules/pi-better-subagents/failures.ts +48 -6
  28. package/node_modules/pi-better-subagents/incident-model.ts +11 -4
  29. package/node_modules/pi-better-subagents/index.ts +47 -18
  30. package/node_modules/pi-better-subagents/output-payload.ts +2 -3
  31. package/node_modules/pi-better-subagents/package.json +1 -1
  32. package/node_modules/pi-better-subagents/permission-policy.ts +9 -3
  33. package/node_modules/pi-better-subagents/registry.ts +19 -2
  34. package/node_modules/pi-better-subagents/shared-failure-observations.ts +48 -12
  35. package/node_modules/pi-better-subagents/shared-log-utils.ts +6 -12
  36. package/node_modules/pi-better-subagents/shared-navigator.ts +169 -125
  37. package/node_modules/pi-better-subagents/shared-sandbox-core.ts +78 -14
  38. package/node_modules/pi-better-subagents/shared-task-apply-patch.ts +409 -0
  39. package/node_modules/pi-better-subagents/shared-task-files.ts +69 -7
  40. package/node_modules/pi-better-subagents/shared-task-sandbox.ts +11 -0
  41. package/node_modules/pi-better-subagents/shared-task-tools.ts +111 -0
  42. package/node_modules/pi-better-subagents/subagent-tools.ts +95 -0
  43. package/node_modules/pi-better-subagents/task-guard.ts +42 -7
  44. package/node_modules/pi-better-subagents/task-policy.ts +28 -0
  45. package/package.json +4 -4
@@ -99,9 +99,11 @@ After `/new`, `/resume`, fork, or switching to another session, the previous
99
99
  session's tasks keep running but are paused from Pi's side: watches do not poll,
100
100
  remote tmux output is not collected, and `timeout_seconds` deadlines are not
101
101
  enforced until that session is active again. An overdue deadline is enforced as
102
- soon as the session resumes, so a timeout can land late but is never skipped. A
103
- local process that exits in the meantime is recorded as finished, and its
104
- callback is delivered when its session resumes.
102
+ soon as the session resumes, so a timeout can land late but is never skipped.
103
+ While the same Pi process stays open, a local process that exits in the meantime
104
+ is recorded as finished, and its callback is delivered when its session resumes.
105
+ If you quit Pi first, nothing records that exit: when the session is resumed in a
106
+ new Pi process, a task whose process is gone is marked lost.
105
107
 
106
108
  ## Watch conditions
107
109
 
@@ -151,7 +153,7 @@ evidence is reported as **observation incomplete**.
151
153
  Pass `operation_id` and `expected_exit_codes` on `bg_task_spawn`, `bg_task_watch`,
152
154
  or `bg_task` to declare intent before launch; a malformed declaration starts
153
155
  nothing. An exit code in `expected_exit_codes` (distinct integers 1-255, such as
154
- `[1]` for a no-match probe) is recorded as an **Expected failure** that needs no
156
+ `[1]` for a no-match probe; a `0` is ignored) is recorded as an **Expected failure** that needs no
155
157
  action; signals and timeouts never are. When a task with an `operation_id`
156
158
  succeeds, earlier failed tasks with the same `operation_id`, kind, cwd, SSH
157
159
  target, and owner (the same session id, or for sessionless tasks the same Pi
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
- activeFailures, failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
4
+ actionableFailures, activeFailures, failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
5
5
  observeFailures, pendingAttentionNote, pendingAttentionRows, pendingFailureAttention, readCommandIntent, readFailureState,
6
6
  type FailureEvent, type FailureState,
7
7
  } from "./shared-failure-observations.js";
@@ -12,6 +12,14 @@ import type { BackgroundTaskMeta } from "./types.js";
12
12
 
13
13
  export const failurePath = (id: string): string => join(taskDir(id), "failures.jsonl");
14
14
  export const failureSummary = (id: string): string => formatFailureSummary(readFailureState(failurePath(id)));
15
+ /**
16
+ * The failure summary plus whether anything needs action. A navigator row leads with failure text
17
+ * only when something does; the quiet history line never displaces the command (#332).
18
+ */
19
+ export function failureView(id: string): { text: string; actionable: boolean } {
20
+ const state = readFailureState(failurePath(id));
21
+ return { text: formatFailureSummary(state), actionable: actionableFailures(state).length > 0 };
22
+ }
15
23
 
16
24
  export function recordFailure(meta: BackgroundTaskMeta, operation: string, summary: string, eventKey: unknown,
17
25
  options: { category?: string; expected?: boolean; incomplete?: boolean; evidence?: string; at?: number } = {}): void {
@@ -10,8 +10,8 @@ import {
10
10
  } from "./shared-navigator.ts";
11
11
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
12
12
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
13
- import { activeFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
14
- import { failurePath, failureSummary } from "./failures.js";
13
+ import { actionableFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
14
+ import { failurePath, failureView } from "./failures.js";
15
15
  import { readLog } from "./logs.js";
16
16
  import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
17
17
  import { stopTask } from "./runtime.js";
@@ -105,7 +105,9 @@ function isExpiredTerminalNavigatorRow(meta: BackgroundTaskMeta, now: number): b
105
105
  }
106
106
 
107
107
  function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
108
- const failure = failureSummary(meta.id);
108
+ // Only a failure that needs action replaces the row's command; history stays in the detail view (#332).
109
+ const view = failureView(meta.id);
110
+ const failure = view.actionable ? view.text : "";
109
111
  const elapsed = formatDuration((meta.endedAt ?? now) - meta.startedAt);
110
112
  return {
111
113
  providerId: "background-tasks",
@@ -129,7 +131,8 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
129
131
 
130
132
  function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
131
133
  if (!meta) return null;
132
- const failure = failureSummary(meta.id);
134
+ const view = failureView(meta.id);
135
+ const failure = view.text;
133
136
  const log = readLog(meta.logPath, options?.logTailLines ?? NAVIGATOR_DETAIL_ROWS);
134
137
  const command = commandLabel(meta);
135
138
  const metadata = [
@@ -164,7 +167,7 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
164
167
  title: meta.name || meta.id,
165
168
  status: meta.status,
166
169
  statusTone: toneForStatus(meta.status),
167
- subtitle: failure ? failure.split("\n")[0]! : compactCommandLabel(meta),
170
+ subtitle: view.actionable ? failure.split("\n")[0]! : compactCommandLabel(meta),
168
171
  metadata,
169
172
  foldedSections: [{
170
173
  id: "command",
@@ -217,7 +220,7 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
217
220
 
218
221
  function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
219
222
  const facts: string[] = [];
220
- const incident = activeFailures(readFailureState(failurePath(meta.id)))[0];
223
+ const incident = actionableFailures(readFailureState(failurePath(meta.id)))[0];
221
224
  if (incident) facts.push(`${failureLabel(incident)}: ${incident.summary}`);
222
225
  if (meta.status === "running") {
223
226
  const stall = observeBackgroundTaskStall(meta, now);
@@ -548,13 +548,16 @@ export function formatStatus(
548
548
  if (pageKind === "f") return formatLog(meta.id, { ...options, raw: true });
549
549
  if (pageKind === "t") return formatVerbose(meta, options);
550
550
  if (options.verbose) return formatVerbose(meta, options);
551
+ // A bg_task_list page cursor pages the list, not this task: say so rather than report it as a
552
+ // stale status cursor (#332).
553
+ const listCursor = pageKind === "l";
551
554
  const state = failureStateFor(meta.id);
552
555
  const resource = `status:${scopeKey(options)}:${meta.id}`;
553
556
  const revision = inspectStatusRevision({
554
557
  resource,
555
558
  contentRevision: contentRevision(meta),
556
559
  failureRevision: failureRevision(state),
557
- cursor: options.cursor,
560
+ cursor: listCursor ? undefined : options.cursor,
558
561
  });
559
562
  if (options.cursor && revision.change === "none") {
560
563
  return assembleBackgroundContent({
@@ -574,6 +577,7 @@ export function formatStatus(
574
577
  // Change/reset and read-gap facts are short and decision-relevant: they are
575
578
  // budgeted with the decision section, ahead of long incident rows.
576
579
  const changeFacts = [
580
+ ...(listCursor ? ["cursor ignored: it is a bg_task_list page cursor; pass it to bg_task_list, or pass this status's statusCursor here."] : []),
577
581
  ...(revision.change === "failure" ? ["change=failure"] : []),
578
582
  ...(revision.reset ? [`reset=${revision.reset}`] : []),
579
583
  ...(log.error ? [`log unreadable: ${oneLine(log.error, 200)}; cannot treat this as an empty healthy log.`] : []),
@@ -34,8 +34,11 @@ const remoteSessionStarts = new Map<string, Promise<CommandResult>>();
34
34
  const activePolls = new Set<string>();
35
35
  const logRetentionTimers = new Map<string, ReturnType<typeof setInterval>>();
36
36
  const LOG_RETENTION_CHECK_MS = 1000;
37
- const handoffTimers = new Map<string, ReturnType<typeof setInterval>>();
37
+ const handoffTimers = new Map<string, ReturnType<typeof setTimeout>>();
38
+ // The handoff check backs off from 250 ms to 4 s while the task keeps running,
39
+ // and returns to 250 ms once its process is gone so the grace period is timed closely.
38
40
  const HANDOFF_CHECK_MS = 250;
41
+ const HANDOFF_MAX_CHECK_MS = 4_000;
39
42
  const HANDOFF_LOST_GRACE_MS = 5_000;
40
43
  let scheduledWorkSuspended = false;
41
44
  const REMOTE_SESSION_POLL_MS = 100;
@@ -62,8 +65,9 @@ export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
62
65
  * tasks keep running, but their watches do not poll, remote tmux output is not
63
66
  * collected, and their `timeout_seconds` deadlines are not enforced until that
64
67
  * session is active again; an overdue deadline is enforced immediately on resume.
65
- * A local process that exits meanwhile is recorded as terminal and its callback
66
- * is delivered when its session resumes.
68
+ * A local process that exits meanwhile, while this Pi process is still running,
69
+ * is recorded as terminal and its callback is delivered when its session resumes;
70
+ * after Pi quits, a resumed task whose process is gone is marked lost instead.
67
71
  */
68
72
  export function suspendScheduledWork(): void {
69
73
  scheduledWorkSuspended = true;
@@ -71,7 +75,7 @@ export function suspendScheduledWork(): void {
71
75
  for (const timer of remoteSessionTimers.values()) clearTimeout(timer);
72
76
  for (const timer of processTimeoutTimers.values()) clearTimeout(timer);
73
77
  for (const timer of logRetentionTimers.values()) clearInterval(timer);
74
- for (const timer of handoffTimers.values()) clearInterval(timer);
78
+ for (const timer of handoffTimers.values()) clearTimeout(timer);
75
79
  handoffTimers.clear();
76
80
  watcherTimers.clear();
77
81
  remoteSessionTimers.clear();
@@ -528,7 +532,9 @@ function scheduleHandoff(
528
532
  stopHandoff(id);
529
533
  if (scheduledWorkSuspended) return;
530
534
  let deadSince: number | undefined;
531
- const timer = setInterval(() => {
535
+ let delayMs = HANDOFF_CHECK_MS;
536
+ const check = () => {
537
+ handoffTimers.delete(id);
532
538
  const meta = readMeta(id);
533
539
  if (!meta) {
534
540
  stopHandoff(id);
@@ -554,16 +560,26 @@ function scheduleHandoff(
554
560
  clearProcessTimeout(id);
555
561
  stopLogRetention(id);
556
562
  markProcessLost(pi, meta, getActiveSession);
563
+ return;
557
564
  }
565
+ delayMs = HANDOFF_CHECK_MS;
566
+ } else {
567
+ delayMs = Math.min(HANDOFF_MAX_CHECK_MS, delayMs * 2);
558
568
  }
559
- }, HANDOFF_CHECK_MS);
560
- timer.unref();
561
- handoffTimers.set(id, timer);
569
+ arm();
570
+ };
571
+ const arm = () => {
572
+ if (scheduledWorkSuspended) return;
573
+ const timer = setTimeout(check, delayMs);
574
+ timer.unref();
575
+ handoffTimers.set(id, timer);
576
+ };
577
+ arm();
562
578
  }
563
579
 
564
580
  function stopHandoff(id: string): void {
565
581
  const timer = handoffTimers.get(id);
566
- if (timer) clearInterval(timer);
582
+ if (timer) clearTimeout(timer);
567
583
  handoffTimers.delete(id);
568
584
  }
569
585
 
@@ -104,24 +104,60 @@ export interface CommandIntent {
104
104
  export type CommandIntentField = keyof CommandIntent;
105
105
  const INTENT_FIELDS: readonly CommandIntentField[] = ["operationId", "attemptId", "expectedExitCodes"];
106
106
  /**
107
- * The one normalization of intent-bearing arguments: an explicit `null` intent field means
108
- * "not declared", exactly like an omitted one. Models routinely send optional fields as null, and
109
- * Pi's argument validation (0.87+) drops optional nulls before a tool runs, while the process log
110
- * and session record keep the raw arguments. Every reader of intent, the executing tool and the
111
- * replay alike, goes through this so both sides see the same declaration from the same input.
107
+ * Pi's own argument coercion for the intent fields (pi-ai `validateToolArguments` against the confined
108
+ * bash schema, where each field is `anyOf: [field schema, null]`). Pi applies it before execute, but the
109
+ * process log and session record keep the raw arguments; mirroring it lets the parent's replay read
110
+ * the declaration the child executed with (#332).
111
+ * - ids: a number or boolean becomes its string; `""` fails the pattern and falls through to null.
112
+ * - exit codes: array items that read as integers become integers (null -> 0, true -> 1);
113
+ * a scalar `0`, `""`, or `false` falls through to null.
114
+ */
115
+ function coerceInteger(value: unknown): unknown {
116
+ if (value === null) return 0;
117
+ if (typeof value === "string" && value.trim() !== "" && Number.isInteger(Number(value))) return Number(value);
118
+ if (typeof value === "boolean") return value ? 1 : 0;
119
+ return value;
120
+ }
121
+ function normalizeIntentField(key: CommandIntentField, value: unknown): unknown {
122
+ if (value == null) return undefined;
123
+ if (key === "expectedExitCodes") {
124
+ if (value === 0 || value === "" || value === false) return undefined;
125
+ if (!Array.isArray(value)) return value;
126
+ // 0 is a no-op declaration: exit 0 is already success. A list of only zeros declares nothing.
127
+ const codes = value.map(coerceInteger).filter((code) => code !== 0);
128
+ if (codes.length === 0 && value.length > 0) return undefined;
129
+ return codes.length === value.length && codes.every((code, index) => code === value[index]) ? value : codes;
130
+ }
131
+ if (value === "") return undefined;
132
+ return typeof value === "number" || typeof value === "boolean" ? String(value) : value;
133
+ }
134
+ /**
135
+ * The one normalization of intent-bearing arguments. An explicit `null` intent field means "not
136
+ * declared", exactly like an omitted one: models routinely send optional fields as null, and Pi's
137
+ * argument validation drops or nulls them before a tool runs, while the process log and session
138
+ * record keep the raw arguments. Values Pi would coerce are coerced the same way, and `0` is dropped
139
+ * from `expectedExitCodes`. Every reader of intent, the executing tool and the replay alike, and the
140
+ * exact operation identity go through this, so all see the same declaration from the same input.
141
+ * Malformed values are kept as they are for the validator to refuse.
112
142
  */
113
143
  export function withoutAbsentIntent<T>(args: T): T {
114
144
  if (!args || typeof args !== "object" || Array.isArray(args)) return args;
115
145
  const input = args as Record<string, unknown>;
116
- if (!INTENT_FIELDS.some((key) => key in input && input[key] == null)) return args;
117
- const out = { ...input };
118
- for (const key of INTENT_FIELDS) if (key in out && out[key] == null) delete out[key];
119
- return out as T;
146
+ let out: Record<string, unknown> | undefined;
147
+ for (const key of INTENT_FIELDS) {
148
+ if (!(key in input)) continue;
149
+ const value = normalizeIntentField(key, input[key]);
150
+ if (value === input[key]) continue;
151
+ out ??= { ...input };
152
+ if (value === undefined) delete out[key];
153
+ else out[key] = value;
154
+ }
155
+ return (out ?? args) as T;
120
156
  }
121
157
  /**
122
- * Validate structured intent fields. Absent fields (omitted, undefined, or null) are fine; malformed
158
+ * Validate structured intent fields after `withoutAbsentIntent`. Absent fields are fine; malformed
123
159
  * ones are an error, and the caller must not run the command. `names` renames fields in the error
124
- * (e.g. snake_case parameters).
160
+ * (e.g. snake_case parameters). `0` in `expectedExitCodes` is accepted and dropped (#332).
125
161
  */
126
162
  export function readCommandIntent(args: unknown, names: Partial<Record<CommandIntentField, string>> = {}): { intent: CommandIntent; error?: string } {
127
163
  const input = withoutAbsentIntent((args && typeof args === "object" ? args : {}) as Record<string, unknown>);
@@ -138,7 +174,7 @@ export function readCommandIntent(args: unknown, names: Partial<Record<CommandIn
138
174
  if (codes !== undefined) {
139
175
  if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
140
176
  !codes.every((code) => Number.isInteger(code) && code >= 1 && code <= 255) || new Set(codes).size !== codes.length) {
141
- return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255` };
177
+ return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255 (0 is allowed and ignored)` };
142
178
  }
143
179
  intent.expectedExitCodes = [...codes] as number[];
144
180
  }
@@ -819,29 +819,22 @@ export interface OutputControls {
819
819
  maxBytes?: unknown;
820
820
  /** Raw requested line count (canonical `lines`, else deprecated `tail_lines`). */
821
821
  lines?: unknown;
822
- /** Deprecated alias names the caller used (whether or not the canonical name won). */
823
- deprecated: string[];
824
822
  }
825
823
 
826
824
  /**
827
825
  * Resolve output-control parameters. Both spellings work; when both are given
828
- * the canonical name wins. Values are returned unvalidated so each surface
829
- * keeps its own defaults and hard caps.
826
+ * the canonical name wins. An explicit `null` is "not given", so a null
827
+ * canonical value never hides an alias value (#332). Values are returned
828
+ * unvalidated so each surface keeps its own defaults and hard caps.
830
829
  */
831
830
  export function readOutputControls(params: unknown): OutputControls {
832
831
  const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
833
- const deprecated: string[] = [];
834
- const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => {
835
- const alias = OUTPUT_CONTROL_ALIASES[canonical];
836
- if (p[alias] !== undefined) deprecated.push(alias);
837
- return p[canonical] !== undefined ? p[canonical] : p[alias];
838
- };
832
+ const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => p[canonical] ?? p[OUTPUT_CONTROL_ALIASES[canonical]] ?? undefined;
839
833
  const maxBytes = pick("max_bytes");
840
834
  const lines = pick("lines");
841
835
  return {
842
836
  ...(maxBytes !== undefined ? { maxBytes } : {}),
843
837
  ...(lines !== undefined ? { lines } : {}),
844
- deprecated,
845
838
  };
846
839
  }
847
840
 
@@ -851,7 +844,8 @@ export type OutputInclude = (typeof OUTPUT_INCLUDE_VALUES)[number];
851
844
 
852
845
  /** Parse an `include` request into known values and unknown ones (both deduplicated). */
853
846
  export function readOutputInclude(value: unknown): { include: Set<OutputInclude>; unknown: string[] } {
854
- const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
847
+ // Any other non-null value (a number, boolean, or object) is one unknown entry, never silently nothing (#332).
848
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : value == null ? [] : [value];
855
849
  const include = new Set<OutputInclude>();
856
850
  const unknown: string[] = [];
857
851
  for (const entry of raw) {