pi-better-harness 0.31.1 → 0.32.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 (32) hide show
  1. package/node_modules/pi-better-background-tasks/README.md +88 -35
  2. package/node_modules/pi-better-background-tasks/package.json +1 -1
  3. package/node_modules/pi-better-background-tasks/src/adapter-contract.ts +55 -0
  4. package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +5 -6
  5. package/node_modules/pi-better-background-tasks/src/output.ts +61 -20
  6. package/node_modules/pi-better-background-tasks/src/runtime.ts +20 -8
  7. package/node_modules/pi-better-background-tasks/src/sandbox.ts +60 -15
  8. package/node_modules/pi-better-background-tasks/src/stall-attention.ts +31 -0
  9. package/node_modules/pi-better-background-tasks/src/stall.ts +14 -2
  10. package/node_modules/pi-better-background-tasks/src/task-diagnostics.ts +71 -0
  11. package/node_modules/pi-better-background-tasks/src/tools.ts +36 -12
  12. package/node_modules/pi-better-background-tasks/src/types.ts +12 -0
  13. package/node_modules/pi-better-sandbox/README.md +81 -21
  14. package/node_modules/pi-better-sandbox/background-admission.ts +74 -0
  15. package/node_modules/pi-better-sandbox/commands.ts +5 -5
  16. package/node_modules/pi-better-sandbox/events.ts +21 -3
  17. package/node_modules/pi-better-sandbox/index.ts +17 -7
  18. package/node_modules/pi-better-sandbox/package.json +1 -1
  19. package/node_modules/pi-better-sandbox/permission-settings.ts +1 -0
  20. package/node_modules/pi-better-sandbox/permissions-page.ts +23 -20
  21. package/node_modules/pi-better-sandbox/permissions.ts +14 -7
  22. package/node_modules/pi-better-sandbox/state.ts +21 -8
  23. package/node_modules/pi-better-subagents/README.md +6 -4
  24. package/node_modules/pi-better-subagents/agent-inspection.ts +18 -14
  25. package/node_modules/pi-better-subagents/catalog-runtime.ts +3 -1
  26. package/node_modules/pi-better-subagents/docs/usage.md +15 -11
  27. package/node_modules/pi-better-subagents/goal-provider.ts +52 -0
  28. package/node_modules/pi-better-subagents/index.ts +6 -43
  29. package/node_modules/pi-better-subagents/package.json +1 -1
  30. package/node_modules/pi-better-subagents/permission-policy.ts +2 -2
  31. package/node_modules/pi-better-subagents/sandbox.ts +2 -2
  32. package/package.json +4 -4
@@ -17,47 +17,45 @@ Use `pi-better-background-tasks` when a command should keep running while the fo
17
17
  - Keep task metadata and logs available across reloads.
18
18
  - Retain completed task artifacts for seven days, then remove them during rate-limited maintenance.
19
19
  - Show active work in Pi's background-work navigator.
20
- - Flag running tasks with no observable output or completed poll as stalled.
20
+ - Report quiet work and suspected stalls separately from task lifecycle.
21
21
  - Confine local task writes to the project directory when `pi-better-sandbox` is enabled.
22
22
 
23
23
  ## Write Sandbox
24
24
 
25
25
  When [`pi-better-sandbox`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme)
26
- is installed and enabled, every **local** task captures
27
- the effective foreground policy at launch and runs under the platform's write
28
- sandbox: reads and network stay unrestricted, writes are confined to the
29
- canonical project directory, and denied paths stay denied.
30
-
31
- The policy is captured once, when the task starts. The foreground sandbox is
32
- inactive by default, so local tasks ordinarily launch unconfined. A later
33
- `/sandbox on`, `/sandbox off`, `/sandbox default on|off`, or a deny-rule change
34
- reaches tasks launched after it; a task
35
- already running — including a watcher resumed in a later Pi session — keeps the
36
- policy it started with.
37
-
38
- If the foreground sandbox reports `unavailable` or `failed`, a local launch is
39
- refused with an explanation instead of running unconfined. `/sandbox off` is the
40
- deliberate way to run local tasks unsandboxed.
41
-
42
- A task follows Main's permission profile. With Main's Outside project = Write, the
43
- task writes across home and temp but can remove files outside the project only in
44
- temp, hidden home directories, and worktree folders (see
26
+ is installed, local tasks follow the independent **Background tasks** column in
27
+ `/sandbox`. Its Sandbox switch starts Off for fresh settings. When enabled,
28
+ commands run under macOS Seatbelt or Linux Bubblewrap with that column's project,
29
+ outside-project, credential-file, command and network permissions. Protected
30
+ paths remain denied.
31
+
32
+ The policy is captured once at launch. Later settings changes affect only new
33
+ tasks. An existing process, including a watcher resumed in a later Pi session,
34
+ keeps its launch policy. `/sandbox on|off` and `/sandbox default on|off` now
35
+ change Main only; use the Background tasks column to change new task launches.
36
+ Older settings migrate by copying Main's values into Background tasks once.
37
+
38
+ If the Background policy is `unavailable` or `failed`, launch is refused rather
39
+ than retried unconfined. Turning its Sandbox switch Off deliberately permits
40
+ unconfined local launches.
41
+
42
+ With Background tasks' Outside project = Write, the task can write across home
43
+ and temp but can remove files outside the project only in temp, hidden home
44
+ directories, and worktree folders (see
45
45
  [pi-better-sandbox](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme)).
46
- On macOS, a task launched with Outside project = Write or Write & delete starts an
47
- APFS local snapshot (`tmutil localsnapshot`) in the background as a recovery aid.
48
- A failed snapshot is noted in the task log and never blocks the task.
49
-
50
- Structured remote SSH tasks are unaffected: the foreground sandbox describes this
51
- machine, and remote work keeps its existing remote semantics. Without
52
- `pi-better-sandbox` installed, local tasks behave exactly as they always have.
53
-
54
- Reads and network access are never restricted; only writes are. Pi's own
55
- process, `pi.exec` calls, and unrelated third-party extension code stay outside
56
- the guarantee, and confinement is per surface: a confined process on another
57
- first-party surface can still write this one's task registry. Installing
58
- [`pi-better-harness`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-harness#readme)
59
- installs the sandbox extension, but leaves foreground tools and local background
60
- tasks inactive until a human opts in.
46
+ On macOS, Outside project = Write or Write & delete starts an APFS local snapshot
47
+ (`tmutil localsnapshot`) as a recovery aid. A failed snapshot is noted in the log
48
+ and never blocks execution.
49
+
50
+ Structured SSH cannot apply local file and credential permissions remotely, so
51
+ an enabled permission-aware Background profile refuses those launches. When
52
+ unconfined, SSH retains its documented remote behavior; local policy never
53
+ establishes remote confinement. Without the sandbox extension, local tasks
54
+ retain their historical unconfined behavior.
55
+
56
+ Pi's own process, provider transport, `pi.exec` calls, and unrelated runtime
57
+ extensions remain outside the task boundary. Installing the harness does not
58
+ activate Main or Background confinement by default.
61
59
 
62
60
  ## Remote SSH
63
61
 
@@ -105,6 +103,61 @@ is recorded as finished, and its callback is delivered when its session resumes.
105
103
  If you quit Pi first, nothing records that exit: when the session is resumed in a
106
104
  new Pi process, a task whose process is gone is marked lost.
107
105
 
106
+ ## Progress And Suspected Stalls
107
+
108
+ A task can remain `running` without recent progress evidence. Compact status and
109
+ navigator use the same observation. Process tasks use recorded progress and the
110
+ log's modification time. Watches use recorded progress and completed polls. Before
111
+ any progress, the task's launch time anchors the silence duration. This does not
112
+ measure CPU activity, network waits, or useful application work.
113
+
114
+ At 60 seconds without observed progress, work is `quiet`. At 5 minutes, it has a
115
+ `suspected stall`. Both thresholds are inclusive. Set `PI_BETTER_STALL_QUIET_MS`
116
+ and `PI_BETTER_STALL_MS` to positive millisecond durations to override them.
117
+ Compact status shows the last observed time, silence duration, and a warning:
118
+ silence alone does not prove the task is hung. Terminal tasks are not reported as
119
+ suspected stalls. New output or a completed watch poll refreshes the observation.
120
+
121
+ A status cursor changes when the observation crosses either threshold, even if
122
+ metadata and log bytes have not changed. Within the same observation state,
123
+ unchanged status still shows the current silence duration. A suspected stall
124
+ records an observation, not a process failure. After the existing 60-second
125
+ attention grace, callback-enabled tasks wake the parent once per silent spell.
126
+ New progress closes the observation and a later spell can notify again. Quiet
127
+ work does not alert, `callback:false` suppresses notification, and no silence
128
+ threshold stops a task or changes its lifecycle. The active session's runtime
129
+ checks observations once per second, including an in-flight watch poll. Reload
130
+ restores monitoring and the persisted attention receipts. Log-retention writes
131
+ are excluded from task progress.
132
+
133
+ ## Launch Policy And Diagnostics
134
+
135
+ `/sandbox` has an independent **Background tasks** column. New tasks capture
136
+ that column at launch; subsequent changes do not alter an existing process or
137
+ its future watch polls. Older saved settings copy Main into Background tasks
138
+ once so the migration preserves behavior. Older sandbox publishers without the
139
+ Background field retain their Main-policy behavior.
140
+
141
+ Launch and status show `confined`, `unconfined`, or `unknown`, plus the backend
142
+ when known and the policy source. `verbose:true` includes the persisted policy
143
+ summary. Restored tasks without recorded launch evidence report `unknown`;
144
+ missing profile files or current process-tool denials are not confinement proof.
145
+ Remote execution also reports unknown remote confinement.
146
+
147
+ Use `bg_task_status` with `diagnostics:true` (or either wrapper's `status`
148
+ action) for a bounded, current process snapshot. Both Main and Background tasks
149
+ must currently permit **Process access: Read**, even when sandbox switches are
150
+ Off. The result checks the recorded PID and start identity, then reports process
151
+ state, accumulated CPU time, and available wait evidence. It never reads command
152
+ arguments or environment values and never probes a remote host or descendants.
153
+ A snapshot cannot establish useful work or a hang. Watch poll PIDs are not
154
+ retained, so their activity evidence is explicitly unavailable.
155
+
156
+ The process-activity adapter is unavailable under Main confinement; it never
157
+ retries `ps` unconfined. Unsupported systems, absent launch identities, reused
158
+ PIDs, and blocked diagnostics each receive a separate explanation. Diagnostics
159
+ permissions do not change or describe the task's launch-time confinement.
160
+
108
161
  ## Watch conditions
109
162
 
110
163
  JSON conditions require a root-prefixed path, such as `$.status` or
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,55 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { inspectMeta } from "./registry.js";
3
+
4
+ /** Optional-peer contract, mirrored by pi-better-sandbox/background-admission.ts. */
5
+ export const BACKGROUND_ADAPTER_REQUEST = "pi-better-background-tasks:adapter-request";
6
+ export const BACKGROUND_ADAPTER_VERSION = 1;
7
+
8
+ type Admission = "local-launch" | "registry";
9
+ type Request = {
10
+ version: number;
11
+ name: string;
12
+ input: unknown;
13
+ reply(value: { version: number; parameters: object; admission: Admission }): void;
14
+ };
15
+
16
+ /** The schemas belong to one registerTools invocation, never to a module singleton. */
17
+ export function advertiseBackgroundAdapter(pi: ExtensionAPI, schemas: ReadonlyMap<string, object>): void {
18
+ const unsubscribe = pi.events?.on(BACKGROUND_ADAPTER_REQUEST, (data: unknown) => {
19
+ const request = data as Partial<Request> | undefined;
20
+ if (request?.version !== BACKGROUND_ADAPTER_VERSION || typeof request.name !== "string" ||
21
+ typeof request.reply !== "function") return;
22
+ const parameters = schemas.get(request.name);
23
+ if (!parameters) return;
24
+ const admission = classify(request.name, request.input);
25
+ if (admission) request.reply({ version: BACKGROUND_ADAPTER_VERSION, parameters, admission });
26
+ });
27
+ pi.on("session_shutdown", () => { if (typeof unsubscribe === "function") unsubscribe(); });
28
+ }
29
+
30
+ function classify(name: string, input: unknown): Admission | undefined {
31
+ if (!input || typeof input !== "object" || Array.isArray(input)) return;
32
+ const params = input as Record<string, unknown>;
33
+ let action: unknown;
34
+ switch (name) {
35
+ case "bg_task_spawn": action = "spawn"; break;
36
+ case "bg_task_watch": action = "watch"; break;
37
+ case "bg_task_list": action = "list"; break;
38
+ case "bg_task_status": action = "status"; break;
39
+ case "bg_task_log": action = "log"; break;
40
+ case "bg_task_stop": action = "stop"; break;
41
+ case "bg_task": action = params.action; break;
42
+ case "bg_status":
43
+ if (params.action === "spawn" || params.action === "watch") return;
44
+ action = params.action;
45
+ break;
46
+ default: return;
47
+ }
48
+ if (action === "spawn" || action === "watch") return params.ssh ? undefined : "local-launch";
49
+ if (action === "list" || action === "status" || action === "log" || action === "clear") return "registry";
50
+ if (action !== "stop" || typeof params.id !== "string" || !params.id) return;
51
+ // Never trust caller flags about locality. Missing/corrupt metadata is not proof
52
+ // of a local stop; a remote stop can execute SSH in the trusted Pi process.
53
+ const meta = inspectMeta(params.id).meta;
54
+ if (meta && !meta.ssh && !meta.remote && (meta.kind === "process" || meta.kind === "command_watch")) return "registry";
55
+ }
@@ -15,7 +15,7 @@ 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";
18
- import { observeBackgroundTaskStall } from "./stall.js";
18
+ import { formatBackgroundTaskStall, observeBackgroundTaskStall } from "./stall.js";
19
19
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
20
20
 
21
21
  let unregister: (() => void) | undefined;
@@ -152,8 +152,8 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
152
152
  if (meta.deadlineAt) metadata.push({ label: "deadline", value: formatDuration(meta.deadlineAt - now) });
153
153
  if (meta.lastCheckedAt) metadata.push({ label: "checked", value: `${formatDuration(now - meta.lastCheckedAt)} ago` });
154
154
  if (meta.status === "running") {
155
- const stall = observeBackgroundTaskStall(meta, now);
156
- if (stall.state !== "healthy") metadata.push({ label: "activity", value: stall.state });
155
+ const activity = formatBackgroundTaskStall(observeBackgroundTaskStall(meta, now));
156
+ if (activity) metadata.push({ label: "activity", value: activity });
157
157
  }
158
158
  if (meta.lastExitCode !== undefined) metadata.push({ label: "exit", value: String(meta.lastExitCode) });
159
159
  if (meta.error) metadata.push({ label: "error", value: meta.error });
@@ -223,9 +223,8 @@ function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
223
223
  const incident = actionableFailures(readFailureState(failurePath(meta.id)))[0];
224
224
  if (incident) facts.push(`${failureLabel(incident)}: ${incident.summary}`);
225
225
  if (meta.status === "running") {
226
- const stall = observeBackgroundTaskStall(meta, now);
227
- if (stall.state === "stalled") facts.push("stalled");
228
- else if (stall.state === "quiet") facts.push("quiet");
226
+ const activity = formatBackgroundTaskStall(observeBackgroundTaskStall(meta, now));
227
+ if (activity) facts.push(activity);
229
228
  }
230
229
  if (meta.kind === "command_watch" && meta.intervalMs) facts.push(`every ${formatDuration(meta.intervalMs)}`);
231
230
  if (meta.deadlineAt && meta.status === "running") facts.push(`${formatDuration(meta.deadlineAt - now)} left`);
@@ -10,8 +10,11 @@ import {
10
10
  incidentResource,
11
11
  incidentVerbatimPage,
12
12
  isIncidentCursor,
13
+ pendingAttentionNote,
14
+ pendingFailureAttention,
13
15
  quietFailureLine,
14
16
  readFailureState,
17
+ terminalFailureParts,
15
18
  scopedFailures,
16
19
  type FailureState,
17
20
  type IncidentDetail,
@@ -40,6 +43,9 @@ import {
40
43
  import { failurePath } from "./failures.js";
41
44
  import { captureGapsFor, pageTaskLog, readLog, type LogRead } from "./logs.js";
42
45
  import { belongsToOrigin, inspectMeta, listTaskRecords, originOf, type MetaInspection } from "./registry.js";
46
+ import { formatBackgroundTaskStall, observeBackgroundTaskStall } from "./stall.js";
47
+ import type { TaskProcessDiagnostics } from "./task-diagnostics.js";
48
+ import type { StallObservation } from "./shared-stall-detector.js";
43
49
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, Condition, FirstWatchCheck } from "./types.js";
44
50
 
45
51
  /** The launch tool stopped waiting while a watch's first check was still running (#359). */
@@ -75,6 +81,7 @@ const LIST_LEAD_PREVIEW_BYTES = 200;
75
81
  export type BackgroundOutputSurface = keyof typeof BACKGROUND_OUTPUT_BUDGET_BYTES;
76
82
 
77
83
  export interface OutputOptions {
84
+ processDiagnostics?: TaskProcessDiagnostics;
78
85
  cursor?: string;
79
86
  maxBytes?: number;
80
87
  verbose?: boolean;
@@ -237,7 +244,9 @@ export function formatCallbackFacts(meta: BackgroundTaskMeta): {
237
244
  incidentCount?: number;
238
245
  } {
239
246
  const state = failureStateFor(meta.id);
240
- const rows = formatFailureLines(state);
247
+ const pending = pendingFailureAttention(state, Date.now(), { terminal: true });
248
+ const incidents = pending?.incidents ?? [];
249
+ const rows = terminalFailureParts(state, incidents).rows;
241
250
  const gapLines = [
242
251
  meta.logDiscardedBytes ? `retention discarded ${meta.logDiscardedBytes} bytes; not recoverable` : undefined,
243
252
  meta.captureDiscardedBytes ? `capture overflow discarded ${meta.captureDiscardedBytes} bytes; not full history` : undefined,
@@ -245,8 +254,8 @@ export function formatCallbackFacts(meta: BackgroundTaskMeta): {
245
254
  // History (expected and closed incidents) is not a row, but the callback still says it exists, so a
246
255
  // declared expected exit is not read as a plain failure. It leads the decision: a tight callback
247
256
  // budget keeps a prefix of the decision, and this short line must survive it (#332).
248
- const history = rows.length ? historyTailLine(state) : quietFailureLine(state);
249
- const decision = [history, formatDecision(meta), ...gapLines].filter(Boolean).join("\n") || undefined;
257
+ const history = actionableFailures(state).length ? historyTailLine(state) : quietFailureLine(state);
258
+ const decision = [history, pendingAttentionNote(state, incidents), formatDecision(meta), ...gapLines].filter(Boolean).join("\n") || undefined;
250
259
  return {
251
260
  outcome: meta.status,
252
261
  ...(rows.length ? { failureRows: rows, incidentCount: rows.length } : {}),
@@ -271,6 +280,28 @@ function formatDiagnostics(meta: BackgroundTaskMeta, extra: string[] = []): stri
271
280
  return lines.length ? lines.join("\n") : undefined;
272
281
  }
273
282
 
283
+ function formatProcessDiagnostics(diagnostics: TaskProcessDiagnostics | undefined): string | undefined {
284
+ if (!diagnostics) return undefined;
285
+ const facts = diagnostics.availability === "available" && diagnostics.identity === "matches"
286
+ ? `identity matches; state=${diagnostics.state}; CPU=${diagnostics.cpuTime}; wait=${diagnostics.waitChannel}`
287
+ : diagnostics.reason ?? diagnostics.availability;
288
+ return `process diagnostics: ${facts}\nA snapshot cannot prove useful work or a hang.`;
289
+ }
290
+
291
+ function formatLaunchSandbox(meta: BackgroundTaskMeta): string | undefined {
292
+ const sandbox = meta.sandbox;
293
+ if (!sandbox) return "sandbox: unknown; launch-time policy was not recorded.";
294
+ const backend = sandbox.backend ? `; ${oneLine(sandbox.backend, 40)}` : "";
295
+ return `sandbox: ${sandbox.confinement}${backend}; source: ${oneLine(sandbox.policySource, 80)}; later changes do not affect this task.`;
296
+ }
297
+
298
+ function formatStaleProgress(stall: StallObservation | undefined): string | undefined {
299
+ if (!stall) return undefined;
300
+ const summary = formatBackgroundTaskStall(stall);
301
+ if (!summary) return undefined;
302
+ return `progress: ${summary}; last observed: ${new Date(stall.observedAt!).toISOString()}.\nSilence alone does not prove the task is hung.`;
303
+ }
304
+
274
305
  function formatProgress(meta: BackgroundTaskMeta): string | undefined {
275
306
  const lines = [`kind: ${meta.kind}`];
276
307
  if (meta.name) lines.push(`name: ${oneLine(meta.name, 80)}`);
@@ -289,8 +320,10 @@ function identityLine(meta: BackgroundTaskMeta): string {
289
320
  }
290
321
 
291
322
  /** Lifecycle, result, and retained-log facts. A deleted or rewritten log is a change. */
292
- function contentRevision(meta: BackgroundTaskMeta): string {
323
+ function contentRevision(meta: BackgroundTaskMeta, stall?: StallObservation): string {
293
324
  return lifecycleContentRevision([
325
+ stall ? [stall.state, stall.observedAt ?? null] : null,
326
+ meta.sandbox ?? null,
294
327
  meta.status,
295
328
  meta.endedAt ?? null,
296
329
  meta.lastCheckedAt ?? null,
@@ -460,7 +493,7 @@ export function formatLaunch(meta: BackgroundTaskMeta, firstCheck?: FirstWatchCh
460
493
  identity: `Started background ${meta.kind} ${label}. Status: ${meta.status}.`,
461
494
  failure: incidentSection(meta.id, {}),
462
495
  decision: formatDecision(meta),
463
- diagnostics: [...remoteLines, ...(checkText ? [checkText] : [])].join("\n") || undefined,
496
+ diagnostics: [formatLaunchSandbox(meta), ...remoteLines, ...(checkText ? [checkText] : [])].filter(Boolean).join("\n") || undefined,
464
497
  progress: withLog ? logLine : undefined,
465
498
  },
466
499
  gaps: taskGaps(meta),
@@ -520,7 +553,11 @@ export function formatFirstWatchCheck(meta: BackgroundTaskMeta, check: FirstWatc
520
553
  ...(keptOut.length ? [`stdout tail:\n${keptOut.map((line) => ` ${line}`).join("\n")}`] : []),
521
554
  ...(keptEmptyStdout ? ["stdout: (empty)"] : []),
522
555
  ].join("\n");
523
- const fits = () => utf8ByteLength(render()) <= maxBytes;
556
+ const omissionNote = () => {
557
+ const omitted = (stderr.length - keptErr.length) + (stdout.length - keptOut.length);
558
+ return omitted > 0 ? `(${omitted} output line${omitted === 1 ? "" : "s"} omitted; see bg_task_log)` : undefined;
559
+ };
560
+ const fits = () => utf8ByteLength([render(), omissionNote()].filter(Boolean).join("\n")) <= maxBytes;
524
561
  // Newest first within each stream; each kept line goes in front to stay in order.
525
562
  for (const line of [...stderr].reverse()) {
526
563
  keptErr.unshift(line);
@@ -534,21 +571,17 @@ export function formatFirstWatchCheck(meta: BackgroundTaskMeta, check: FirstWatc
534
571
  keptEmptyStdout = true;
535
572
  if (!fits()) keptEmptyStdout = false;
536
573
  }
537
- const omitted = (stderr.length - keptErr.length) + (stdout.length - keptOut.length);
538
- if (omitted > 0) {
539
- const note = `(${omitted} output line${omitted === 1 ? "" : "s"} omitted; see bg_task_log)`;
540
- const text = `${render()}\n${note}`;
541
- if (utf8ByteLength(text) <= maxBytes) return text;
542
- }
543
- return render();
574
+ return [render(), omissionNote()].filter(Boolean).join("\n");
544
575
  }
545
576
 
546
- function redactedVerbose(meta: BackgroundTaskMeta): unknown {
577
+ function redactedVerbose(meta: BackgroundTaskMeta, options: OutputOptions): unknown {
547
578
  const { env, ...rest } = meta;
548
579
  const state = failureStateFor(meta.id);
549
580
  const observations = Object.values(state.observations);
550
581
  const body = {
551
582
  ...rest,
583
+ sandbox: meta.sandbox ?? { confinement: "unknown", policySummary: "Launch-time policy was not recorded." },
584
+ ...(options.processDiagnostics ? { processDiagnostics: options.processDiagnostics } : {}),
552
585
  ...(env ? { env: { omitted: true, keyCount: Object.keys(env).length } } : {}),
553
586
  };
554
587
  if (!observations.length) return body;
@@ -569,16 +602,20 @@ function redactedVerbose(meta: BackgroundTaskMeta): unknown {
569
602
  * with a caller cursor like any other retained evidence.
570
603
  */
571
604
  function formatVerbose(meta: BackgroundTaskMeta, options: OutputOptions): string {
572
- const json = JSON.stringify(redactedVerbose(meta), null, 2);
605
+ const json = JSON.stringify(redactedVerbose(meta, options), null, 2);
573
606
  const resource = `verbose:${scopeKey(options)}:${meta.id}`;
574
607
  if (!options.cursor && utf8ByteLength(json) <= backgroundBudget("rawPage", options.maxBytes)) return json;
608
+ // Live process samples are not part of the paged document: CPU/state changes
609
+ // must not invalidate continuation, even when diagnostics is omitted next time.
610
+ const stableJson = JSON.stringify(redactedVerbose(meta, { ...options, processDiagnostics: undefined }), null, 2);
575
611
  return assembleBackgroundContent({
576
612
  surface: "rawPage",
577
613
  maxBytes: options.maxBytes,
578
614
  sections: {
579
615
  identity: `Background task ${meta.id} metadata (environment values omitted).`,
616
+ diagnostics: formatProcessDiagnostics(options.processDiagnostics),
580
617
  },
581
- verbatim: (budget) => pageVerbatimText(json, { cursor: options.cursor, maxBytes: budget, resource }),
618
+ verbatim: (budget) => pageVerbatimText(stableJson, { cursor: options.cursor, maxBytes: budget, resource }),
582
619
  });
583
620
  }
584
621
 
@@ -641,10 +678,13 @@ export function formatStatus(
641
678
  // stale status cursor (#332).
642
679
  const listCursor = pageKind === "l";
643
680
  const state = failureStateFor(meta.id);
681
+ const stall = meta.status === "running" ? observeBackgroundTaskStall(meta) : undefined;
682
+ const staleProgress = formatStaleProgress(stall);
683
+ const sandbox = formatLaunchSandbox(meta);
644
684
  const resource = `status:${scopeKey(options)}:${meta.id}`;
645
685
  const revision = inspectStatusRevision({
646
686
  resource,
647
- contentRevision: contentRevision(meta),
687
+ contentRevision: contentRevision(meta, stall),
648
688
  failureRevision: failureRevision(state),
649
689
  cursor: listCursor ? undefined : options.cursor,
650
690
  });
@@ -657,7 +697,8 @@ export function formatStatus(
657
697
  failure: actionableFailures(state).length
658
698
  ? `${actionableFailures(state).length} active failure observation(s), unchanged.`
659
699
  : undefined,
660
- decision: formatUnchangedEvidence(options.cursor),
700
+ decision: [staleProgress, formatUnchangedEvidence(options.cursor)].filter(Boolean).join("\n"),
701
+ diagnostics: sandbox,
661
702
  },
662
703
  statusCursor: revision.nextCursor,
663
704
  });
@@ -677,8 +718,8 @@ export function formatStatus(
677
718
  sections: {
678
719
  identity: identityLine(meta),
679
720
  failure: incidentSection(meta.id, options, state),
680
- decision: [...changeFacts, formatDecision(meta)].filter(Boolean).join("\n") || undefined,
681
- diagnostics: formatDiagnostics(meta),
721
+ decision: [...changeFacts, formatDecision(meta), staleProgress].filter(Boolean).join("\n") || undefined,
722
+ diagnostics: formatDiagnostics(meta, [formatProcessDiagnostics(options.processDiagnostics), sandbox].filter((line): line is string => !!line)),
682
723
  progress: formatProgress(meta),
683
724
  },
684
725
  verbatim: log.error ? undefined : (budget) => excerptPage(meta, options, log, budget),
@@ -7,7 +7,7 @@ import { currentProcessStartToken, readProcessStartToken } from "./process-ident
7
7
  import { DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, expandSshRemoteTaskPreset } from "./remote-task-preset.js";
8
8
  import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.js";
9
9
  import { ensureTaskDir, logPathFor, nextTaskId, readMeta, sandboxProfilePathFor, writeMeta } from "./registry.js";
10
- import { confineCommandSpec, resolveForegroundSandboxPlan } from "./sandbox.js";
10
+ import { confineCommandSpec, resolveForegroundSandboxPlan, sandboxProvenance } from "./sandbox.js";
11
11
  import { takeRecoverySnapshot } from "./shared-sandbox-core.js";
12
12
  import { failurePath, readTaskIntent, recordExitFailure, recordFailure, recoverDeclaredOperation, recoverFailure, resumeFailureAttention, scheduleFailureAttention, stopFailureAttention, suspendFailureAttention, terminalFailureAttention } from "./failures.js";
13
13
  import { markFailureAttentionDelivered } from "./shared-failure-observations.js";
@@ -24,6 +24,7 @@ import type {
24
24
  SshConnectionParams,
25
25
  TerminalResult,
26
26
  } from "./types.js";
27
+ import { observeTaskStallAttention } from "./stall-attention.js";
27
28
  import { isTerminalStatus } from "./types.js";
28
29
 
29
30
  const watcherTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -262,6 +263,7 @@ export function spawnTask(
262
263
  cwd,
263
264
  env: params.env,
264
265
  launchArgv: launchArgvOf(commandSpec, launchSpec),
266
+ sandbox: sandboxProvenance(sandboxPlan, !!remoteTask),
265
267
  maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
266
268
  pid: spawned?.child.pid,
267
269
  pidStartTime: spawned?.child.pid ? readProcessStartToken(spawned.child.pid) : undefined,
@@ -273,7 +275,7 @@ export function spawnTask(
273
275
  ...intent,
274
276
  };
275
277
  writeMeta(meta);
276
- scheduleLogRetention(id);
278
+ scheduleLogRetention(pi, id, getActiveSession);
277
279
  if (tmuxBacked && remoteTask) {
278
280
  activeRemoteTasks.set(id, remoteTask);
279
281
  appendLine(logPath, `--- remote tmux bootstrap ${new Date(now).toISOString()} target=${remoteTask.metadata.ssh.target} session=${remoteTask.metadata.remote.sessionName} ---`);
@@ -533,6 +535,7 @@ export function startWatchTask(
533
535
  cwd,
534
536
  env: params.env,
535
537
  launchArgv: launchArgvOf(commandSpec, launchSpec),
538
+ sandbox: sandboxProvenance(sandboxPlan, !!remoteTask),
536
539
  maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
537
540
  spawnPid: process.pid,
538
541
  spawnPidStartTime: currentProcessStartToken(),
@@ -548,6 +551,7 @@ export function startWatchTask(
548
551
  appendLine(meta.logPath, `--- watch ${new Date(now).toISOString()} interval_ms=${meta.intervalMs} ---`);
549
552
  for (const line of sandboxNotices) appendLine(meta.logPath, `--- ${line} ---`);
550
553
  writeMeta(meta);
554
+ scheduleLogRetention(pi, meta.id, getActiveSession);
551
555
  let resolveFirst!: (check: FirstWatchCheck | "suspended" | undefined) => void;
552
556
  const firstCheck = new Promise<FirstWatchCheck | "suspended" | undefined>((resolve) => { resolveFirst = resolve; });
553
557
  firstCheckWaiters.set(id, { promise: firstCheck, resolve: resolveFirst });
@@ -592,6 +596,7 @@ export function resumeRunningTask(
592
596
  writeMeta(meta);
593
597
  }
594
598
 
599
+ scheduleLogRetention(pi, meta.id, getActiveSession);
595
600
  const remoteTask = resolvePersistedRemoteTask(meta, dependencies.remoteRunner);
596
601
  if (meta.kind === "command_watch") {
597
602
  scheduleWatch(pi, meta.id, 0, getActiveSession, remoteTask
@@ -600,7 +605,6 @@ export function resumeRunningTask(
600
605
  return meta;
601
606
  }
602
607
 
603
- scheduleLogRetention(meta.id);
604
608
  if (meta.remote?.session === "tmux" && meta.remote.sessionStarted !== false && remoteTask) {
605
609
  activeRemoteTasks.set(meta.id, remoteTask);
606
610
  scheduleRemoteSessionPoll(pi, meta.id, 0, getActiveSession);
@@ -1155,6 +1159,7 @@ async function notifyTerminal(
1155
1159
  meta: BackgroundTaskMeta,
1156
1160
  getActiveSession?: ActiveSessionProvider,
1157
1161
  ): Promise<void> {
1162
+ observeTaskStallAttention(readMeta(meta.id) ?? meta);
1158
1163
  // A suspended instance has no active session: delivering or suppressing here would
1159
1164
  // be wrong either way. The instance that resumes the task delivers it (#324).
1160
1165
  if (scheduledWorkSuspended) return;
@@ -1260,16 +1265,17 @@ function launchArgvOf(commandSpec: CommandSpec, launchSpec: CommandSpec): string
1260
1265
  return launchSpec === commandSpec ? undefined : launchSpec.argv;
1261
1266
  }
1262
1267
 
1263
- function scheduleLogRetention(id: string): void {
1268
+ function scheduleLogRetention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
1264
1269
  stopLogRetention(id);
1265
1270
  if (scheduledWorkSuspended) return;
1266
1271
  const timer = setInterval(() => {
1267
1272
  const meta = readMeta(id);
1268
- if (!meta || meta.status !== "running" || meta.kind !== "process") {
1273
+ if (!meta || meta.status !== "running") {
1269
1274
  stopLogRetention(id);
1270
1275
  return;
1271
1276
  }
1272
- enforceLogRetention(meta);
1277
+ if (meta.kind === "process") enforceLogRetention(meta);
1278
+ if (observeTaskStallAttention(meta)) scheduleFailureAttention(pi, id, getActiveSession);
1273
1279
  }, LOG_RETENTION_CHECK_MS);
1274
1280
  timer.unref();
1275
1281
  logRetentionTimers.set(id, timer);
@@ -1283,8 +1289,10 @@ function stopLogRetention(id: string): void {
1283
1289
 
1284
1290
  function enforceLogRetention(meta: BackgroundTaskMeta): void {
1285
1291
  try {
1286
- const mtimeMs = Math.trunc(statSync(meta.logPath).mtimeMs);
1287
- if (mtimeMs > (meta.lastProgressAt ?? meta.startedAt)) {
1292
+ const log = statSync(meta.logPath);
1293
+ const mtimeMs = Math.trunc(log.mtimeMs);
1294
+ const maintenance = meta.logMaintenance?.mtimeMs === mtimeMs && meta.logMaintenance.size === log.size;
1295
+ if (!maintenance && mtimeMs > (meta.lastProgressAt ?? meta.startedAt)) {
1288
1296
  meta.lastProgressAt = mtimeMs;
1289
1297
  writeMeta(meta);
1290
1298
  }
@@ -1296,6 +1304,10 @@ function enforceLogRetention(meta: BackgroundTaskMeta): void {
1296
1304
  meta.logDiscardedBytes = (meta.logDiscardedBytes ?? 0) + compacted.discardedBytes;
1297
1305
  meta.logRetentionEvents = (meta.logRetentionEvents ?? 0) + 1;
1298
1306
  meta.logGeneration = (meta.logGeneration ?? 0) + 1;
1307
+ try {
1308
+ const log = statSync(meta.logPath);
1309
+ meta.logMaintenance = { mtimeMs: Math.trunc(log.mtimeMs), size: log.size };
1310
+ } catch { /* Missing log remains an evidence gap. */ }
1299
1311
  writeMeta(meta);
1300
1312
  }
1301
1313