pi-better-harness 0.6.1 → 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 (78) hide show
  1. package/node_modules/pi-better-background-tasks/README.md +43 -0
  2. package/node_modules/pi-better-background-tasks/package.json +1 -1
  3. package/node_modules/pi-better-background-tasks/src/failures.ts +85 -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 +11 -7
  6. package/node_modules/pi-better-background-tasks/src/output.ts +83 -49
  7. package/node_modules/pi-better-background-tasks/src/runtime.ts +189 -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 +414 -47
  11. package/node_modules/pi-better-background-tasks/src/shared-log-utils.ts +108 -0
  12. package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +180 -134
  13. package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +724 -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-goal/package.json +1 -1
  17. package/node_modules/pi-better-plan/README.md +30 -5
  18. package/node_modules/pi-better-plan/package.json +1 -1
  19. package/node_modules/pi-better-plan/src/index.ts +88 -16
  20. package/node_modules/pi-better-plan/src/workflow-plan-update.ts +503 -0
  21. package/node_modules/pi-better-plan/src/workflow-plan.ts +24 -6
  22. package/node_modules/pi-better-sandbox/README.md +143 -15
  23. package/node_modules/pi-better-sandbox/index.ts +11 -1
  24. package/node_modules/pi-better-sandbox/package.json +1 -1
  25. package/node_modules/pi-better-sandbox/permissions-page.ts +141 -10
  26. package/node_modules/pi-better-sandbox/permissions.ts +54 -7
  27. package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +724 -17
  28. package/node_modules/pi-better-sandbox/shared-task-apply-patch.ts +409 -0
  29. package/node_modules/pi-better-sandbox/shared-task-files.ts +69 -7
  30. package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +21 -5
  31. package/node_modules/pi-better-sandbox/shared-task-tools.ts +111 -0
  32. package/node_modules/pi-better-sandbox/state.ts +5 -1
  33. package/node_modules/pi-better-subagents/README.md +13 -1
  34. package/node_modules/pi-better-subagents/agent-commands.ts +26 -7
  35. package/node_modules/pi-better-subagents/agent-inspection.ts +4 -1
  36. package/node_modules/pi-better-subagents/batch.mjs +7 -0
  37. package/node_modules/pi-better-subagents/callback-fields.ts +4 -1
  38. package/node_modules/pi-better-subagents/catalog-ui.ts +167 -0
  39. package/node_modules/pi-better-subagents/child-incidents.ts +20 -6
  40. package/node_modules/pi-better-subagents/child-steer.ts +102 -0
  41. package/node_modules/pi-better-subagents/cleanup.ts +18 -2
  42. package/node_modules/pi-better-subagents/config.json +1 -0
  43. package/node_modules/pi-better-subagents/config.ts +5 -1
  44. package/node_modules/pi-better-subagents/delegation.ts +22 -0
  45. package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +2 -0
  46. package/node_modules/pi-better-subagents/docs/agent-catalog.md +9 -0
  47. package/node_modules/pi-better-subagents/docs/failure-observations.md +18 -5
  48. package/node_modules/pi-better-subagents/failures.ts +83 -8
  49. package/node_modules/pi-better-subagents/incident-model.ts +73 -55
  50. package/node_modules/pi-better-subagents/index.ts +352 -25
  51. package/node_modules/pi-better-subagents/list.mjs +4 -1
  52. package/node_modules/pi-better-subagents/output-payload.ts +152 -58
  53. package/node_modules/pi-better-subagents/package.json +4 -4
  54. package/node_modules/pi-better-subagents/parse.ts +6 -0
  55. package/node_modules/pi-better-subagents/permission-policy.ts +15 -7
  56. package/node_modules/pi-better-subagents/registry.ts +79 -1
  57. package/node_modules/pi-better-subagents/roles/role.architect.md +3 -3
  58. package/node_modules/pi-better-subagents/roles/role.developer.md +3 -3
  59. package/node_modules/pi-better-subagents/roles/role.explorer.md +3 -4
  60. package/node_modules/pi-better-subagents/roles/role.product-manager.md +3 -4
  61. package/node_modules/pi-better-subagents/roles/role.researcher.md +3 -4
  62. package/node_modules/pi-better-subagents/roles/role.reviewer.md +3 -3
  63. package/node_modules/pi-better-subagents/shared-callback-batcher.ts +34 -3
  64. package/node_modules/pi-better-subagents/shared-failure-observations.ts +414 -47
  65. package/node_modules/pi-better-subagents/shared-log-utils.ts +108 -0
  66. package/node_modules/pi-better-subagents/shared-navigator.ts +180 -134
  67. package/node_modules/pi-better-subagents/shared-sandbox-core.ts +724 -17
  68. package/node_modules/pi-better-subagents/shared-task-apply-patch.ts +409 -0
  69. package/node_modules/pi-better-subagents/shared-task-files.ts +69 -7
  70. package/node_modules/pi-better-subagents/shared-task-sandbox.ts +21 -5
  71. package/node_modules/pi-better-subagents/shared-task-tools.ts +111 -0
  72. package/node_modules/pi-better-subagents/spawn.ts +3 -1
  73. package/node_modules/pi-better-subagents/subagent-tools.ts +95 -0
  74. package/node_modules/pi-better-subagents/task-guard.ts +42 -7
  75. package/node_modules/pi-better-subagents/task-policy.ts +45 -5
  76. package/node_modules/pi-better-subagents/timing.ts +425 -0
  77. package/node_modules/pi-better-subagents/tools.ts +52 -14
  78. 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,23 @@ 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.
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.
107
+
83
108
  ## Watch conditions
84
109
 
85
110
  JSON conditions require a root-prefixed path, such as `$.status` or
@@ -125,6 +150,17 @@ an explicitly configured nonzero success exit is treated as expected. Verbose
125
150
  status includes observation details and the journal path. Corrupt or unreadable
126
151
  evidence is reported as **observation incomplete**.
127
152
 
153
+ Pass `operation_id` and `expected_exit_codes` on `bg_task_spawn`, `bg_task_watch`,
154
+ or `bg_task` to declare intent before launch; a malformed declaration starts
155
+ nothing. An exit code in `expected_exit_codes` (distinct integers 1-255, such as
156
+ `[1]` for a no-match probe; a `0` is ignored) is recorded as an **Expected failure** that needs no
157
+ action; signals and timeouts never are. When a task with an `operation_id`
158
+ succeeds, earlier failed tasks with the same `operation_id`, kind, cwd, SSH
159
+ target, and owner (the same session id, or for sessionless tasks the same Pi
160
+ process) recover, so a retry with a changed command or timeout
161
+ closes the original incident. Observation gaps are never recovered this way, and
162
+ no command text is compared.
163
+
128
164
  Task failures are labeled **Action required**; the shared labels also include
129
165
  **Expected failure** and **Observation incomplete**. Unresolved running failures
130
166
  become eligible for attention after 60 seconds; observation gaps are eligible
@@ -135,6 +171,13 @@ notification delivery does not clear the failure. `callback:false` stays quiet
135
171
  while all inspection surfaces retain the evidence. Journals follow the task's
136
172
  existing retention and explicit-clear behavior.
137
173
 
174
+ Status, log, and list count and list only failures that need action (**Action
175
+ required** and **Observation incomplete**). Expected and closed failures are one
176
+ history count line with no incident cursor; pass `history: true` to
177
+ `bg_task_status` (or `action:status`) to page them. Incident rows are compact
178
+ (120-byte excerpt, evidence such as `output.log#poll=3`); the raw log keeps the
179
+ full evidence path.
180
+
138
181
  ## When To Use
139
182
 
140
183
  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.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,16 +1,25 @@
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
+ actionableFailures, 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
 
12
13
  export const failurePath = (id: string): string => join(taskDir(id), "failures.jsonl");
13
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
+ }
14
23
 
15
24
  export function recordFailure(meta: BackgroundTaskMeta, operation: string, summary: string, eventKey: unknown,
16
25
  options: { category?: string; expected?: boolean; incomplete?: boolean; evidence?: string; at?: number } = {}): void {
@@ -32,7 +41,79 @@ export function recoverFailure(meta: BackgroundTaskMeta, operation: string, even
32
41
  }]);
33
42
  }
34
43
 
44
+ /**
45
+ * Validate `operation_id` / `expected_exit_codes` before anything is launched (#325), with the same
46
+ * validator the subagent task runtime's bash uses. Background commands are launched by the parent
47
+ * agent itself, so the declaration is trusted; a malformed one refuses the launch.
48
+ */
49
+ export function readTaskIntent(params: { operation_id?: unknown; expected_exit_codes?: unknown }): Pick<BackgroundTaskMeta, "operationId" | "expectedExitCodes"> {
50
+ const { intent, error } = readCommandIntent({ operationId: params.operation_id, expectedExitCodes: params.expected_exit_codes },
51
+ { operationId: "operation_id", expectedExitCodes: "expected_exit_codes" });
52
+ if (error) throw new Error(`Invalid command intent: ${error}. The task was not started.`);
53
+ return { ...(intent.operationId ? { operationId: intent.operationId } : {}), ...(intent.expectedExitCodes ? { expectedExitCodes: intent.expectedExitCodes } : {}) };
54
+ }
55
+
56
+ /** A structured exit code the caller declared intentional before launch. Signals and timeouts never are. */
57
+ export function isDeclaredExpectedExit(meta: BackgroundTaskMeta, exitCode: number | null | undefined): exitCode is number {
58
+ return typeof exitCode === "number" && exitCode !== 0 && (meta.expectedExitCodes ?? []).includes(exitCode);
59
+ }
60
+
61
+ /** Record a non-zero command exit, classified as expected only when its code was declared before launch. */
62
+ export function recordExitFailure(meta: BackgroundTaskMeta, operation: string, summary: string, exitCode: number | null | undefined, eventKey: unknown,
63
+ options: { at?: number; expected?: boolean } = {}): void {
64
+ const declared = isDeclaredExpectedExit(meta, exitCode);
65
+ recordFailure(meta, operation, declared ? `${summary} (declared expected)` : summary, eventKey,
66
+ { category: "exit", expected: declared || options.expected === true, at: options.at });
67
+ }
68
+
69
+ function declaredOperation(meta: BackgroundTaskMeta): string | undefined {
70
+ return meta.operationId ? failureIdentity("bg-operation", meta.kind, meta.operationId, meta.cwd, meta.ssh?.target ?? null) : undefined;
71
+ }
72
+
73
+ /**
74
+ * Recovery crosses tasks only within one owner (#325): the same non-empty session id, or, for
75
+ * sessionless tasks, the same spawning process (#312's sessionless ownership rule). The origin
76
+ * index alone treats two sessionless sessions in one cwd as one origin.
77
+ */
78
+ function sameRecoveryOwner(a: BackgroundTaskMeta, b: BackgroundTaskMeta): boolean {
79
+ const sa = a.callbackOrigin?.sessionId, sb = b.callbackOrigin?.sessionId;
80
+ if (sa || sb) return Boolean(sa) && sa === sb;
81
+ return a.spawnPid === b.spawnPid && a.spawnPidStartTime === b.spawnPidStartTime;
82
+ }
83
+
84
+ /**
85
+ * A task that succeeded with a declared `operationId` recovers the unresolved failures of earlier
86
+ * tasks of the same owner (session, or spawning process when sessionless) that declared the same operation (same kind, cwd, and SSH target) and
87
+ * failed before this task started: a modified retry as a new task. Observation gaps and expected
88
+ * failures are left alone; nothing is matched on command text.
89
+ */
90
+ export function recoverDeclaredOperation(meta: BackgroundTaskMeta, at = Date.now()): void {
91
+ const operation = declaredOperation(meta);
92
+ if (!operation) return;
93
+ let earlier: BackgroundTaskMeta[];
94
+ try { earlier = listMetasForOrigin(originOf(meta)); } catch { return; }
95
+ for (const task of earlier) {
96
+ if (task.id === meta.id || task.startedAt >= meta.startedAt || declaredOperation(task) !== operation || !sameRecoveryOwner(task, meta)) continue;
97
+ const path = failurePath(task.id);
98
+ const events: FailureEvent[] = activeFailures(readFailureState(path))
99
+ .filter((x) => x.status === "unresolved" && x.category !== "observation-incomplete" && x.lastObservedAt <= meta.startedAt)
100
+ .map((x) => ({ id: failureIdentity(task.id, x.id, "recovered-by", meta.id), operation: x.operation, kind: "recovered", incidents: [x.id], at,
101
+ evidence: `task ${meta.id} (operation ${meta.operationId}) succeeded` }));
102
+ if (events.length) observeFailures(path, events);
103
+ }
104
+ }
105
+
35
106
  const attentionTimers = new Map<string, ReturnType<typeof setTimeout>>();
107
+ let attentionSuspended = false;
108
+ /** Session shutdown: stop this instance's attention timers; the next session_start reschedules (#324). */
109
+ export function suspendFailureAttention(): void {
110
+ attentionSuspended = true;
111
+ for (const timer of attentionTimers.values()) clearTimeout(timer);
112
+ attentionTimers.clear();
113
+ }
114
+ export function resumeFailureAttention(): void {
115
+ attentionSuspended = false;
116
+ }
36
117
  export function stopFailureAttention(id: string): void {
37
118
  const timer = attentionTimers.get(id);
38
119
  if (timer) clearTimeout(timer);
@@ -65,6 +146,7 @@ export function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureS
65
146
  /** Running incidents get one grace wake. Terminal incidents ride the completion callback. */
66
147
  export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
67
148
  stopFailureAttention(id);
149
+ if (attentionSuspended) return;
68
150
  const meta = readMeta(id);
69
151
  if (!meta) {
70
152
  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,
@@ -9,8 +10,8 @@ import {
9
10
  } from "./shared-navigator.ts";
10
11
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
12
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
- import { activeFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
13
- import { failurePath, failureSummary } from "./failures.js";
13
+ import { actionableFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
14
+ import { failurePath, failureView } from "./failures.js";
14
15
  import { readLog } from "./logs.js";
15
16
  import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
16
17
  import { stopTask } from "./runtime.js";
@@ -104,7 +105,9 @@ function isExpiredTerminalNavigatorRow(meta: BackgroundTaskMeta, now: number): b
104
105
  }
105
106
 
106
107
  function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
107
- 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 : "";
108
111
  const elapsed = formatDuration((meta.endedAt ?? now) - meta.startedAt);
109
112
  return {
110
113
  providerId: "background-tasks",
@@ -128,8 +131,9 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
128
131
 
129
132
  function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
130
133
  if (!meta) return null;
131
- const failure = failureSummary(meta.id);
132
- const log = readLog(meta.logPath, options?.logTailLines ?? 10);
134
+ const view = failureView(meta.id);
135
+ const failure = view.text;
136
+ const log = readLog(meta.logPath, options?.logTailLines ?? NAVIGATOR_DETAIL_ROWS);
133
137
  const command = commandLabel(meta);
134
138
  const metadata = [
135
139
  { label: "provider", value: "Background Tasks" },
@@ -163,7 +167,7 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
163
167
  title: meta.name || meta.id,
164
168
  status: meta.status,
165
169
  statusTone: toneForStatus(meta.status),
166
- subtitle: failure ? failure.split("\n")[0]! : compactCommandLabel(meta),
170
+ subtitle: view.actionable ? failure.split("\n")[0]! : compactCommandLabel(meta),
167
171
  metadata,
168
172
  foldedSections: [{
169
173
  id: "command",
@@ -216,7 +220,7 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
216
220
 
217
221
  function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
218
222
  const facts: string[] = [];
219
- const incident = activeFailures(readFailureState(failurePath(meta.id)))[0];
223
+ const incident = actionableFailures(readFailureState(failurePath(meta.id)))[0];
220
224
  if (incident) facts.push(`${failureLabel(incident)}: ${incident.summary}`);
221
225
  if (meta.status === "running") {
222
226
  const stall = observeBackgroundTaskStall(meta, now);
@@ -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,15 +540,24 @@ 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);
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";
522
554
  const state = failureStateFor(meta.id);
523
555
  const resource = `status:${scopeKey(options)}:${meta.id}`;
524
556
  const revision = inspectStatusRevision({
525
557
  resource,
526
558
  contentRevision: contentRevision(meta),
527
559
  failureRevision: failureRevision(state),
528
- cursor: options.cursor,
560
+ cursor: listCursor ? undefined : options.cursor,
529
561
  });
530
562
  if (options.cursor && revision.change === "none") {
531
563
  return assembleBackgroundContent({
@@ -533,8 +565,8 @@ export function formatStatus(
533
565
  maxBytes: options.maxBytes,
534
566
  sections: {
535
567
  identity: `${identityLine(meta)} · unchanged`,
536
- failure: activeFailures(state).length
537
- ? `${activeFailures(state).length} active failure observation(s), unchanged.`
568
+ failure: actionableFailures(state).length
569
+ ? `${actionableFailures(state).length} active failure observation(s), unchanged.`
538
570
  : undefined,
539
571
  decision: formatUnchangedEvidence(options.cursor),
540
572
  },
@@ -545,6 +577,7 @@ export function formatStatus(
545
577
  // Change/reset and read-gap facts are short and decision-relevant: they are
546
578
  // budgeted with the decision section, ahead of long incident rows.
547
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."] : []),
548
581
  ...(revision.change === "failure" ? ["change=failure"] : []),
549
582
  ...(revision.reset ? [`reset=${revision.reset}`] : []),
550
583
  ...(log.error ? [`log unreadable: ${oneLine(log.error, 200)}; cannot treat this as an empty healthy log.`] : []),
@@ -576,7 +609,8 @@ export function formatLog(id: string, options: OutputOptions = {}): string {
576
609
  if (ownership !== "allow") return formatOwnershipGap(meta.id, ownership, options);
577
610
  const fileCursor = cursorKind(options.cursor) === "f";
578
611
  const raw = options.raw === true || options.tailLines === 0 || fileCursor;
579
- const failure = incidentSection(id, options);
612
+ // Raw evidence keeps whole excerpts and evidence paths.
613
+ const failure = incidentSection(id, options, undefined, raw ? "full" : "compact");
580
614
  if (raw) {
581
615
  return assembleBackgroundContent({
582
616
  surface: "rawPage",
@@ -594,7 +628,7 @@ export function formatLog(id: string, options: OutputOptions = {}): string {
594
628
  }
595
629
  const tailLines = options.tailLines && options.tailLines > 0 ? Math.floor(options.tailLines) : DEFAULT_LOG_TAIL_ROWS;
596
630
  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)"] : [];
631
+ const staleCursor = options.cursor ? ["reset=stale-cursor (compact tails have no cursor; pass a raw nextCursor or lines:0)"] : [];
598
632
  if (log.error) {
599
633
  return assembleBackgroundContent({
600
634
  surface: "log",
@@ -674,7 +708,7 @@ export function formatList(options: OutputOptions = {}): string {
674
708
  const resource = `list:${scope}:${statusesKey}`;
675
709
  const limit = Math.max(1, Math.min(Math.floor(options.limit ?? DEFAULT_LIST_ENTRIES), MAX_LIST_ENTRIES));
676
710
  const states = new Map(allowed.map((meta) => [meta.id, failureStateFor(meta.id)] as const));
677
- const incidentsOf = (id: string) => activeFailures(states.get(id)!).length;
711
+ const incidentsOf = (id: string) => actionableFailures(states.get(id)!).length;
678
712
  const revision = inspectStatusRevision({
679
713
  resource,
680
714
  contentRevision: revisionOf(allowed.map((meta) => [meta.id, meta.status, meta.endedAt ?? null])),