pi-better-harness 0.6.1 → 0.7.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 (68) hide show
  1. package/node_modules/pi-better-background-tasks/README.md +41 -0
  2. package/node_modules/pi-better-background-tasks/package.json +1 -1
  3. package/node_modules/pi-better-background-tasks/src/failures.ts +77 -3
  4. package/node_modules/pi-better-background-tasks/src/index.ts +5 -0
  5. package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +2 -1
  6. package/node_modules/pi-better-background-tasks/src/output.ts +78 -48
  7. package/node_modules/pi-better-background-tasks/src/runtime.ts +173 -17
  8. package/node_modules/pi-better-background-tasks/src/sandbox.ts +6 -4
  9. package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +34 -3
  10. package/node_modules/pi-better-background-tasks/src/shared-failure-observations.ts +378 -47
  11. package/node_modules/pi-better-background-tasks/src/shared-log-utils.ts +114 -0
  12. package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +12 -10
  13. package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +660 -17
  14. package/node_modules/pi-better-background-tasks/src/tools.ts +107 -32
  15. package/node_modules/pi-better-background-tasks/src/types.ts +7 -0
  16. package/node_modules/pi-better-plan/README.md +30 -5
  17. package/node_modules/pi-better-plan/package.json +1 -1
  18. package/node_modules/pi-better-plan/src/index.ts +88 -16
  19. package/node_modules/pi-better-plan/src/workflow-plan-update.ts +503 -0
  20. package/node_modules/pi-better-plan/src/workflow-plan.ts +24 -6
  21. package/node_modules/pi-better-sandbox/README.md +113 -11
  22. package/node_modules/pi-better-sandbox/index.ts +7 -1
  23. package/node_modules/pi-better-sandbox/package.json +1 -1
  24. package/node_modules/pi-better-sandbox/permissions-page.ts +68 -4
  25. package/node_modules/pi-better-sandbox/permissions.ts +42 -6
  26. package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +660 -17
  27. package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +10 -5
  28. package/node_modules/pi-better-subagents/README.md +13 -1
  29. package/node_modules/pi-better-subagents/agent-commands.ts +26 -7
  30. package/node_modules/pi-better-subagents/agent-inspection.ts +4 -1
  31. package/node_modules/pi-better-subagents/batch.mjs +7 -0
  32. package/node_modules/pi-better-subagents/callback-fields.ts +4 -1
  33. package/node_modules/pi-better-subagents/catalog-ui.ts +167 -0
  34. package/node_modules/pi-better-subagents/child-incidents.ts +19 -5
  35. package/node_modules/pi-better-subagents/child-steer.ts +102 -0
  36. package/node_modules/pi-better-subagents/cleanup.ts +18 -2
  37. package/node_modules/pi-better-subagents/config.json +1 -0
  38. package/node_modules/pi-better-subagents/config.ts +5 -1
  39. package/node_modules/pi-better-subagents/delegation.ts +22 -0
  40. package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +2 -0
  41. package/node_modules/pi-better-subagents/docs/agent-catalog.md +9 -0
  42. package/node_modules/pi-better-subagents/docs/failure-observations.md +17 -4
  43. package/node_modules/pi-better-subagents/failures.ts +38 -5
  44. package/node_modules/pi-better-subagents/incident-model.ts +64 -53
  45. package/node_modules/pi-better-subagents/index.ts +314 -16
  46. package/node_modules/pi-better-subagents/list.mjs +4 -1
  47. package/node_modules/pi-better-subagents/output-payload.ts +151 -56
  48. package/node_modules/pi-better-subagents/package.json +4 -4
  49. package/node_modules/pi-better-subagents/parse.ts +6 -0
  50. package/node_modules/pi-better-subagents/permission-policy.ts +6 -4
  51. package/node_modules/pi-better-subagents/registry.ts +62 -1
  52. package/node_modules/pi-better-subagents/roles/role.architect.md +3 -3
  53. package/node_modules/pi-better-subagents/roles/role.developer.md +3 -3
  54. package/node_modules/pi-better-subagents/roles/role.explorer.md +3 -4
  55. package/node_modules/pi-better-subagents/roles/role.product-manager.md +3 -4
  56. package/node_modules/pi-better-subagents/roles/role.researcher.md +3 -4
  57. package/node_modules/pi-better-subagents/roles/role.reviewer.md +3 -3
  58. package/node_modules/pi-better-subagents/shared-callback-batcher.ts +34 -3
  59. package/node_modules/pi-better-subagents/shared-failure-observations.ts +378 -47
  60. package/node_modules/pi-better-subagents/shared-log-utils.ts +114 -0
  61. package/node_modules/pi-better-subagents/shared-navigator.ts +12 -10
  62. package/node_modules/pi-better-subagents/shared-sandbox-core.ts +660 -17
  63. package/node_modules/pi-better-subagents/shared-task-sandbox.ts +10 -5
  64. package/node_modules/pi-better-subagents/spawn.ts +3 -1
  65. package/node_modules/pi-better-subagents/task-policy.ts +17 -5
  66. package/node_modules/pi-better-subagents/timing.ts +425 -0
  67. package/node_modules/pi-better-subagents/tools.ts +52 -14
  68. package/package.json +5 -5
@@ -39,6 +39,14 @@ If the foreground sandbox reports `unavailable` or `failed`, a local launch is
39
39
  refused with an explanation instead of running unconfined. `/sandbox off` is the
40
40
  deliberate way to run local tasks unsandboxed.
41
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
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
+
42
50
  Structured remote SSH tasks are unaffected: the foreground sandbox describes this
43
51
  machine, and remote work keeps its existing remote semantics. Without
44
52
  `pi-better-sandbox` installed, local tasks behave exactly as they always have.
@@ -80,6 +88,21 @@ timeout can terminate the local SSH client but the remote process may still be
80
88
  running. See the detailed usage notes for bootstrap policy, watch conditions,
81
89
  timeouts, and v1 non-goals.
82
90
 
91
+ ## Reloads and session switches
92
+
93
+ Tasks belong to the Pi session that started them. After `/reload`, the same
94
+ session picks every task back up: watches keep polling, remote tmux output keeps
95
+ being collected, and a task that finished during the reload (or exits later) gets
96
+ its completion callback exactly once.
97
+
98
+ After `/new`, `/resume`, fork, or switching to another session, the previous
99
+ session's tasks keep running but are paused from Pi's side: watches do not poll,
100
+ remote tmux output is not collected, and `timeout_seconds` deadlines are not
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.
105
+
83
106
  ## Watch conditions
84
107
 
85
108
  JSON conditions require a root-prefixed path, such as `$.status` or
@@ -125,6 +148,17 @@ an explicitly configured nonzero success exit is treated as expected. Verbose
125
148
  status includes observation details and the journal path. Corrupt or unreadable
126
149
  evidence is reported as **observation incomplete**.
127
150
 
151
+ Pass `operation_id` and `expected_exit_codes` on `bg_task_spawn`, `bg_task_watch`,
152
+ or `bg_task` to declare intent before launch; a malformed declaration starts
153
+ 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
155
+ action; signals and timeouts never are. When a task with an `operation_id`
156
+ succeeds, earlier failed tasks with the same `operation_id`, kind, cwd, SSH
157
+ target, and owner (the same session id, or for sessionless tasks the same Pi
158
+ process) recover, so a retry with a changed command or timeout
159
+ closes the original incident. Observation gaps are never recovered this way, and
160
+ no command text is compared.
161
+
128
162
  Task failures are labeled **Action required**; the shared labels also include
129
163
  **Expected failure** and **Observation incomplete**. Unresolved running failures
130
164
  become eligible for attention after 60 seconds; observation gaps are eligible
@@ -135,6 +169,13 @@ notification delivery does not clear the failure. `callback:false` stays quiet
135
169
  while all inspection surfaces retain the evidence. Journals follow the task's
136
170
  existing retention and explicit-clear behavior.
137
171
 
172
+ Status, log, and list count and list only failures that need action (**Action
173
+ required** and **Observation incomplete**). Expected and closed failures are one
174
+ history count line with no incident cursor; pass `history: true` to
175
+ `bg_task_status` (or `action:status`) to page them. Incident rows are compact
176
+ (120-byte excerpt, evidence such as `output.log#poll=3`); the raw log keeps the
177
+ full evidence path.
178
+
138
179
  ## When To Use
139
180
 
140
181
  Use this package for shell commands that need logs, status, cancellation, or completion notifications across a Pi turn.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.3.0",
3
+ "version": "0.4.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,11 +1,12 @@
1
1
  import { join } from "node:path";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
- failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
5
- observeFailures, pendingAttentionNote, pendingAttentionRows, pendingFailureAttention, readFailureState, type FailureState,
4
+ activeFailures, failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
5
+ observeFailures, pendingAttentionNote, pendingAttentionRows, pendingFailureAttention, readCommandIntent, readFailureState,
6
+ type FailureEvent, type FailureState,
6
7
  } from "./shared-failure-observations.js";
7
8
  import { getCallbackBatcher } from "./shared-callback-batcher.js";
8
- import { readMeta, taskDir } from "./registry.js";
9
+ import { listMetasForOrigin, originOf, readMeta, taskDir } from "./registry.js";
9
10
  import type { ActiveSessionProvider } from "./runtime.js";
10
11
  import type { BackgroundTaskMeta } from "./types.js";
11
12
 
@@ -32,7 +33,79 @@ export function recoverFailure(meta: BackgroundTaskMeta, operation: string, even
32
33
  }]);
33
34
  }
34
35
 
36
+ /**
37
+ * Validate `operation_id` / `expected_exit_codes` before anything is launched (#325), with the same
38
+ * validator the subagent task runtime's bash uses. Background commands are launched by the parent
39
+ * agent itself, so the declaration is trusted; a malformed one refuses the launch.
40
+ */
41
+ export function readTaskIntent(params: { operation_id?: unknown; expected_exit_codes?: unknown }): Pick<BackgroundTaskMeta, "operationId" | "expectedExitCodes"> {
42
+ const { intent, error } = readCommandIntent({ operationId: params.operation_id, expectedExitCodes: params.expected_exit_codes },
43
+ { operationId: "operation_id", expectedExitCodes: "expected_exit_codes" });
44
+ if (error) throw new Error(`Invalid command intent: ${error}. The task was not started.`);
45
+ return { ...(intent.operationId ? { operationId: intent.operationId } : {}), ...(intent.expectedExitCodes ? { expectedExitCodes: intent.expectedExitCodes } : {}) };
46
+ }
47
+
48
+ /** A structured exit code the caller declared intentional before launch. Signals and timeouts never are. */
49
+ export function isDeclaredExpectedExit(meta: BackgroundTaskMeta, exitCode: number | null | undefined): exitCode is number {
50
+ return typeof exitCode === "number" && exitCode !== 0 && (meta.expectedExitCodes ?? []).includes(exitCode);
51
+ }
52
+
53
+ /** Record a non-zero command exit, classified as expected only when its code was declared before launch. */
54
+ export function recordExitFailure(meta: BackgroundTaskMeta, operation: string, summary: string, exitCode: number | null | undefined, eventKey: unknown,
55
+ options: { at?: number; expected?: boolean } = {}): void {
56
+ const declared = isDeclaredExpectedExit(meta, exitCode);
57
+ recordFailure(meta, operation, declared ? `${summary} (declared expected)` : summary, eventKey,
58
+ { category: "exit", expected: declared || options.expected === true, at: options.at });
59
+ }
60
+
61
+ function declaredOperation(meta: BackgroundTaskMeta): string | undefined {
62
+ return meta.operationId ? failureIdentity("bg-operation", meta.kind, meta.operationId, meta.cwd, meta.ssh?.target ?? null) : undefined;
63
+ }
64
+
65
+ /**
66
+ * Recovery crosses tasks only within one owner (#325): the same non-empty session id, or, for
67
+ * sessionless tasks, the same spawning process (#312's sessionless ownership rule). The origin
68
+ * index alone treats two sessionless sessions in one cwd as one origin.
69
+ */
70
+ function sameRecoveryOwner(a: BackgroundTaskMeta, b: BackgroundTaskMeta): boolean {
71
+ const sa = a.callbackOrigin?.sessionId, sb = b.callbackOrigin?.sessionId;
72
+ if (sa || sb) return Boolean(sa) && sa === sb;
73
+ return a.spawnPid === b.spawnPid && a.spawnPidStartTime === b.spawnPidStartTime;
74
+ }
75
+
76
+ /**
77
+ * A task that succeeded with a declared `operationId` recovers the unresolved failures of earlier
78
+ * tasks of the same owner (session, or spawning process when sessionless) that declared the same operation (same kind, cwd, and SSH target) and
79
+ * failed before this task started: a modified retry as a new task. Observation gaps and expected
80
+ * failures are left alone; nothing is matched on command text.
81
+ */
82
+ export function recoverDeclaredOperation(meta: BackgroundTaskMeta, at = Date.now()): void {
83
+ const operation = declaredOperation(meta);
84
+ if (!operation) return;
85
+ let earlier: BackgroundTaskMeta[];
86
+ try { earlier = listMetasForOrigin(originOf(meta)); } catch { return; }
87
+ for (const task of earlier) {
88
+ if (task.id === meta.id || task.startedAt >= meta.startedAt || declaredOperation(task) !== operation || !sameRecoveryOwner(task, meta)) continue;
89
+ const path = failurePath(task.id);
90
+ const events: FailureEvent[] = activeFailures(readFailureState(path))
91
+ .filter((x) => x.status === "unresolved" && x.category !== "observation-incomplete" && x.lastObservedAt <= meta.startedAt)
92
+ .map((x) => ({ id: failureIdentity(task.id, x.id, "recovered-by", meta.id), operation: x.operation, kind: "recovered", incidents: [x.id], at,
93
+ evidence: `task ${meta.id} (operation ${meta.operationId}) succeeded` }));
94
+ if (events.length) observeFailures(path, events);
95
+ }
96
+ }
97
+
35
98
  const attentionTimers = new Map<string, ReturnType<typeof setTimeout>>();
99
+ let attentionSuspended = false;
100
+ /** Session shutdown: stop this instance's attention timers; the next session_start reschedules (#324). */
101
+ export function suspendFailureAttention(): void {
102
+ attentionSuspended = true;
103
+ for (const timer of attentionTimers.values()) clearTimeout(timer);
104
+ attentionTimers.clear();
105
+ }
106
+ export function resumeFailureAttention(): void {
107
+ attentionSuspended = false;
108
+ }
36
109
  export function stopFailureAttention(id: string): void {
37
110
  const timer = attentionTimers.get(id);
38
111
  if (timer) clearTimeout(timer);
@@ -65,6 +138,7 @@ export function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureS
65
138
  /** Running incidents get one grace wake. Terminal incidents ride the completion callback. */
66
139
  export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
67
140
  stopFailureAttention(id);
141
+ if (attentionSuspended) return;
68
142
  const meta = readMeta(id);
69
143
  if (!meta) {
70
144
  const timer = setTimeout(() => scheduleFailureAttention(pi, id, getActiveSession), 1_000);
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { disposeBackgroundWorkNavigator } from "./shared-navigator.ts";
3
3
  import { registerBackgroundTasksGoalProvider } from "./goal-provider.js";
4
4
  import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, ensureBackgroundTasksNavigatorProvider } from "./navigator-provider.js";
5
+ import { resumeScheduledWork, suspendScheduledWork } from "./runtime.js";
5
6
  import { observeForegroundSandboxPolicy } from "./sandbox.js";
6
7
  import { registerTools } from "./tools.js";
7
8
 
@@ -11,7 +12,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
11
12
  // Subscribed at load so a sandbox extension that publishes later is heard, and
12
13
  // asked for a snapshot in case one published before this extension existed.
13
14
  observeForegroundSandboxPolicy(pi);
15
+ // Registered before registerTools(pi), so this runs before running tasks are resumed.
14
16
  pi.on("session_start", async (_event, ctx) => {
17
+ resumeScheduledWork();
15
18
  registerBackgroundTasksGoalProvider(pi);
16
19
  ensureBackgroundTasksNavigator(ctx);
17
20
  });
@@ -20,6 +23,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
20
23
  disposeBackgroundWorkNavigator();
21
24
  });
22
25
  pi.on("session_shutdown", async (_event, ctx) => {
26
+ // /reload, session replacement, and quit load a fresh instance; stop this one's timers (#324).
27
+ suspendScheduledWork();
23
28
  clearBackgroundTasksNavigatorSession();
24
29
  disposeBackgroundWorkNavigator(ctx);
25
30
  });
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
+ DEFAULT_LOG_TAIL_ROWS as NAVIGATOR_DETAIL_ROWS,
3
4
  ensureBackgroundWorkNavigator,
4
5
  refreshBackgroundWorkNavigator,
5
6
  registerBackgroundWorkProvider,
@@ -129,7 +130,7 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
129
130
  function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
130
131
  if (!meta) return null;
131
132
  const failure = failureSummary(meta.id);
132
- const log = readLog(meta.logPath, options?.logTailLines ?? 10);
133
+ const log = readLog(meta.logPath, options?.logTailLines ?? NAVIGATOR_DETAIL_ROWS);
133
134
  const command = commandLabel(meta);
134
135
  const metadata = [
135
136
  { label: "provider", value: "Background Tasks" },
@@ -1,14 +1,21 @@
1
- import { statSync } from "node:fs";
2
1
  import {
3
- activeFailures,
2
+ actionableFailures,
4
3
  failureRevision,
5
4
  formatFailureLines,
6
5
  formatFailureSummary,
7
6
  formatIncidentSummary,
7
+ historyTailLine,
8
+ incidentCursorScope,
9
+ incidentPageHeading,
10
+ incidentResource,
11
+ incidentVerbatimPage,
8
12
  isIncidentCursor,
9
- pageFailureIncidents,
13
+ quietFailureLine,
10
14
  readFailureState,
15
+ scopedFailures,
11
16
  type FailureState,
17
+ type IncidentDetail,
18
+ type IncidentScope,
12
19
  } from "./shared-failure-observations.js";
13
20
  import {
14
21
  assemblePriorityEnvelope,
@@ -16,9 +23,11 @@ import {
16
23
  cursorKind,
17
24
  formatUnchangedEvidence,
18
25
  inspectStatusRevision,
26
+ lifecycleContentRevision,
19
27
  pageRows,
20
28
  pageVerbatimText,
21
29
  revisionOf,
30
+ sessionScopeKey,
22
31
  sliceUtf8Bytes,
23
32
  utf8ByteLength,
24
33
  OUTPUT_BUDGET_BYTES,
@@ -66,6 +75,8 @@ export interface OutputOptions {
66
75
  cursor?: string;
67
76
  maxBytes?: number;
68
77
  verbose?: boolean;
78
+ /** Status: return an incident page that also lists failure history (expected and closed incidents). */
79
+ history?: boolean;
69
80
  tailLines?: number;
70
81
  raw?: boolean;
71
82
  statuses?: string[];
@@ -112,11 +123,7 @@ function stringifyObserved(value: unknown): string {
112
123
 
113
124
  /** Cursor scope: pagination and revision cursors never cross session scopes. */
114
125
  function scopeKey(options: OutputOptions): string {
115
- if (options.all) return "all";
116
- if (options.sessionUnavailable) return "unavailable";
117
- const origin = options.origin;
118
- if (!origin) return "none";
119
- return revisionOf([origin.cwd, origin.sessionId ?? ""]);
126
+ return sessionScopeKey({ all: options.all, unavailable: options.sessionUnavailable, origin: options.origin });
120
127
  }
121
128
 
122
129
  function taskGaps(meta: BackgroundTaskMeta): EvidenceGap[] {
@@ -143,39 +150,31 @@ function failureStateFor(id: string): FailureState {
143
150
  * incident rows when they fit, otherwise a count line (total / shown /
144
151
  * omitted) with an incident cursor that resumes at the first byte not shown.
145
152
  */
146
- function incidentSection(id: string, options: OutputOptions, state = failureStateFor(id)): ((budget: number) => string | undefined) | undefined {
147
- if (activeFailures(state).length === 0) return undefined;
148
- const resource = `incidents:${scopeKey(options)}:${id}`;
153
+ function incidentSection(id: string, options: OutputOptions, state = failureStateFor(id), detail: IncidentDetail = "compact"): ((budget: number) => string | undefined) | undefined {
154
+ // Only what needs action is listed; history (expected, closed) is one count line without a cursor.
155
+ if (!formatIncidentSummary(state, { maxBytes: Number.MAX_SAFE_INTEGER }).text) return undefined;
156
+ const resource = incidentResource(scopeKey(options), id);
149
157
  return (budget) => formatIncidentSummary(state, {
150
158
  maxBytes: budget,
151
159
  resource,
152
160
  retrieval: `pass as cursor to bg_task_status id=${id}`,
161
+ detail,
153
162
  }).text || undefined;
154
163
  }
155
164
 
156
165
  function assembleIncidentPage(meta: BackgroundTaskMeta, options: OutputOptions): string {
157
166
  const state = failureStateFor(meta.id);
158
- const resource = `incidents:${scopeKey(options)}:${meta.id}`;
159
- const total = activeFailures(state).length;
167
+ const resource = incidentResource(scopeKey(options), meta.id);
168
+ const cursor = isIncidentCursor(options.cursor) ? options.cursor : undefined;
169
+ const scope: IncidentScope = options.history ? "all" : incidentCursorScope(cursor) ?? "actionable";
160
170
  return assembleBackgroundContent({
161
171
  surface: "status",
162
172
  maxBytes: options.maxBytes,
163
173
  sections: {
164
- identity: `${identityLine(meta)} Incident page of ${total} active failure observation${total === 1 ? "" : "s"}.`,
174
+ identity: `${identityLine(meta)} ${incidentPageHeading(scopedFailures(state, scope).length, scope)}`,
165
175
  decision: formatDecision(meta),
166
176
  },
167
- verbatim: (budget) => {
168
- const page = pageFailureIncidents(state, { cursor: options.cursor, maxBytes: budget, resource });
169
- return {
170
- text: page.text || (page.total === 0 ? "No active failure observations." : ""),
171
- hasMore: page.hasMore,
172
- cursor: page.cursor,
173
- nextCursor: page.nextCursor,
174
- omittedBytes: 0,
175
- omittedRows: page.omitted,
176
- reset: page.reset,
177
- };
178
- },
177
+ verbatim: (budget) => incidentVerbatimPage(state, { cursor, scope, maxBytes: budget, resource }),
179
178
  });
180
179
  }
181
180
 
@@ -240,7 +239,10 @@ export function formatCallbackFacts(meta: BackgroundTaskMeta): {
240
239
  meta.logDiscardedBytes ? `retention discarded ${meta.logDiscardedBytes} bytes; not recoverable` : undefined,
241
240
  meta.captureDiscardedBytes ? `capture overflow discarded ${meta.captureDiscardedBytes} bytes; not full history` : undefined,
242
241
  ].filter((line): line is string => Boolean(line));
243
- const decision = [formatDecision(meta), ...gapLines].filter(Boolean).join("\n") || undefined;
242
+ // History (expected and closed incidents) is not a row, but the callback still says it exists, so a
243
+ // declared expected exit is not read as a plain failure.
244
+ const history = rows.length ? historyTailLine(state) : quietFailureLine(state);
245
+ const decision = [formatDecision(meta), ...gapLines, history].filter(Boolean).join("\n") || undefined;
244
246
  return {
245
247
  outcome: meta.status,
246
248
  ...(rows.length ? { failureRows: rows, incidentCount: rows.length } : {}),
@@ -284,14 +286,7 @@ function identityLine(meta: BackgroundTaskMeta): string {
284
286
 
285
287
  /** Lifecycle, result, and retained-log facts. A deleted or rewritten log is a change. */
286
288
  function contentRevision(meta: BackgroundTaskMeta): string {
287
- let log: unknown;
288
- try {
289
- const stats = statSync(meta.logPath);
290
- log = [stats.dev, stats.ino, stats.size, Math.trunc(stats.mtimeMs)];
291
- } catch (error) {
292
- log = ["unreadable", (error as NodeJS.ErrnoException).code ?? String(error)];
293
- }
294
- return revisionOf([
289
+ return lifecycleContentRevision([
295
290
  meta.status,
296
291
  meta.endedAt ?? null,
297
292
  meta.lastCheckedAt ?? null,
@@ -307,8 +302,7 @@ function contentRevision(meta: BackgroundTaskMeta): string {
307
302
  meta.lastState ?? null,
308
303
  meta.remote?.bootstrapStatus ?? null,
309
304
  meta.remote?.stopMessage ?? null,
310
- log,
311
- ]);
305
+ ], meta.logPath);
312
306
  }
313
307
 
314
308
  export function assembleBackgroundContent(input: {
@@ -376,14 +370,43 @@ function formatOwnershipGap(id: string, kind: "foreign" | "unknown", options: Ou
376
370
  });
377
371
  }
378
372
 
379
- type Ownership = "allow" | "foreign" | "unknown";
373
+ export type Ownership = "allow" | "foreign" | "unknown";
374
+
375
+ /**
376
+ * Refusal for a mutation (stop, clear) of a task outside the current session
377
+ * scope. Mutations use the same ownership rule as reads (#322): only an owned
378
+ * task changes; nothing about a foreign or unverifiable task is disclosed.
379
+ */
380
+ export function formatMutationRefusal(
381
+ id: string,
382
+ kind: Exclude<Ownership, "allow">,
383
+ action: "stop" | "clear",
384
+ options: OutputOptions,
385
+ ): string {
386
+ const verb = action === "stop" ? "stopped" : "dismissed";
387
+ const detail = kind === "foreign"
388
+ ? `This task belongs to another session, so it was not ${verb}. Pass all:true to ${action} it explicitly.`
389
+ : options.sessionUnavailable
390
+ ? `The current session identity is unavailable, so ownership cannot be verified and the task was not ${verb}. Pass all:true to ${action} it explicitly.`
391
+ : `Task ownership is unavailable or unreadable, so the task was not ${verb}. Pass all:true to ${action} it explicitly.`;
392
+ return assembleBackgroundContent({
393
+ surface: "status",
394
+ maxBytes: options.maxBytes,
395
+ sections: {
396
+ identity: `Background task ${id} is outside the current session scope; not ${verb}.`,
397
+ diagnostics: detail,
398
+ },
399
+ gaps: [{ kind: "read", detail: kind === "foreign" ? "foreign-session" : "ownership-unavailable" }],
400
+ });
401
+ }
380
402
 
381
403
  /**
382
- * Current-session ownership. Without a current session id, ownership is only
383
- * verified for a task this process launched with the same sessionless origin;
384
- * a legacy task with no recorded origin is never assumed to be ours.
404
+ * Current-session ownership, shared by reads and mutations. Without a current
405
+ * session id, ownership is only verified for a task this process launched with
406
+ * the same sessionless origin; a legacy task with no recorded origin is never
407
+ * assumed to be ours.
385
408
  */
386
- function classifyOwnership(meta: BackgroundTaskMeta, options: OutputOptions): Ownership {
409
+ export function classifyOwnership(meta: BackgroundTaskMeta, options: OutputOptions): Ownership {
387
410
  if (options.all === true) return "allow";
388
411
  if (options.sessionUnavailable) return "unknown";
389
412
  const origin = options.origin;
@@ -517,7 +540,13 @@ export function formatStatus(
517
540
  const meta = inspectionValue.meta;
518
541
  const ownership = classifyOwnership(meta, options);
519
542
  if (ownership !== "allow") return formatOwnershipGap(meta.id, ownership, options);
520
- if (isIncidentCursor(options.cursor)) return assembleIncidentPage(meta, options);
543
+ if (isIncidentCursor(options.cursor) || options.history) return assembleIncidentPage(meta, options);
544
+ // A page cursor from another view of this task continues that view instead
545
+ // of resetting a status revision (#323): a raw/file cursor pages the log,
546
+ // a text cursor pages verbose metadata.
547
+ const pageKind = cursorKind(options.cursor);
548
+ if (pageKind === "f") return formatLog(meta.id, { ...options, raw: true });
549
+ if (pageKind === "t") return formatVerbose(meta, options);
521
550
  if (options.verbose) return formatVerbose(meta, options);
522
551
  const state = failureStateFor(meta.id);
523
552
  const resource = `status:${scopeKey(options)}:${meta.id}`;
@@ -533,8 +562,8 @@ export function formatStatus(
533
562
  maxBytes: options.maxBytes,
534
563
  sections: {
535
564
  identity: `${identityLine(meta)} · unchanged`,
536
- failure: activeFailures(state).length
537
- ? `${activeFailures(state).length} active failure observation(s), unchanged.`
565
+ failure: actionableFailures(state).length
566
+ ? `${actionableFailures(state).length} active failure observation(s), unchanged.`
538
567
  : undefined,
539
568
  decision: formatUnchangedEvidence(options.cursor),
540
569
  },
@@ -576,7 +605,8 @@ export function formatLog(id: string, options: OutputOptions = {}): string {
576
605
  if (ownership !== "allow") return formatOwnershipGap(meta.id, ownership, options);
577
606
  const fileCursor = cursorKind(options.cursor) === "f";
578
607
  const raw = options.raw === true || options.tailLines === 0 || fileCursor;
579
- const failure = incidentSection(id, options);
608
+ // Raw evidence keeps whole excerpts and evidence paths.
609
+ const failure = incidentSection(id, options, undefined, raw ? "full" : "compact");
580
610
  if (raw) {
581
611
  return assembleBackgroundContent({
582
612
  surface: "rawPage",
@@ -594,7 +624,7 @@ export function formatLog(id: string, options: OutputOptions = {}): string {
594
624
  }
595
625
  const tailLines = options.tailLines && options.tailLines > 0 ? Math.floor(options.tailLines) : DEFAULT_LOG_TAIL_ROWS;
596
626
  const log = readLog(meta.logPath, tailLines);
597
- const staleCursor = options.cursor ? ["reset=stale-cursor (compact tails have no cursor; pass a raw nextCursor or tail_lines:0)"] : [];
627
+ const staleCursor = options.cursor ? ["reset=stale-cursor (compact tails have no cursor; pass a raw nextCursor or lines:0)"] : [];
598
628
  if (log.error) {
599
629
  return assembleBackgroundContent({
600
630
  surface: "log",
@@ -674,7 +704,7 @@ export function formatList(options: OutputOptions = {}): string {
674
704
  const resource = `list:${scope}:${statusesKey}`;
675
705
  const limit = Math.max(1, Math.min(Math.floor(options.limit ?? DEFAULT_LIST_ENTRIES), MAX_LIST_ENTRIES));
676
706
  const states = new Map(allowed.map((meta) => [meta.id, failureStateFor(meta.id)] as const));
677
- const incidentsOf = (id: string) => activeFailures(states.get(id)!).length;
707
+ const incidentsOf = (id: string) => actionableFailures(states.get(id)!).length;
678
708
  const revision = inspectStatusRevision({
679
709
  resource,
680
710
  contentRevision: revisionOf(allowed.map((meta) => [meta.id, meta.status, meta.endedAt ?? null])),