pi-better-background-tasks 0.3.0 → 0.4.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.
package/src/runtime.ts CHANGED
@@ -8,7 +8,8 @@ import { DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, expandSshRemoteTaskPreset } from "./
8
8
  import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.js";
9
9
  import { ensureTaskDir, logPathFor, nextTaskId, readMeta, sandboxProfilePathFor, writeMeta } from "./registry.js";
10
10
  import { confineCommandSpec, resolveForegroundSandboxPlan } from "./sandbox.js";
11
- import { failurePath, recordFailure, recoverFailure, scheduleFailureAttention, stopFailureAttention, terminalFailureAttention } from "./failures.js";
11
+ import { takeRecoverySnapshot } from "./shared-sandbox-core.js";
12
+ import { failurePath, readTaskIntent, recordExitFailure, recordFailure, recoverDeclaredOperation, recoverFailure, resumeFailureAttention, scheduleFailureAttention, stopFailureAttention, suspendFailureAttention, terminalFailureAttention } from "./failures.js";
12
13
  import { markFailureAttentionDelivered } from "./shared-failure-observations.js";
13
14
  import { getCallbackBatcher } from "./shared-callback-batcher.js";
14
15
  import { formatCallbackFacts } from "./output.js";
@@ -33,16 +34,68 @@ const remoteSessionStarts = new Map<string, Promise<CommandResult>>();
33
34
  const activePolls = new Set<string>();
34
35
  const logRetentionTimers = new Map<string, ReturnType<typeof setInterval>>();
35
36
  const LOG_RETENTION_CHECK_MS = 1000;
37
+ const handoffTimers = new Map<string, ReturnType<typeof setInterval>>();
38
+ const HANDOFF_CHECK_MS = 250;
39
+ const HANDOFF_LOST_GRACE_MS = 5_000;
40
+ let scheduledWorkSuspended = false;
36
41
  const REMOTE_SESSION_POLL_MS = 100;
37
42
 
38
43
  export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
39
44
 
45
+ /**
46
+ * Stop this extension instance's scheduled work when its session shuts down (#324).
47
+ *
48
+ * Pi emits session_shutdown for /reload, /new, /resume, fork, session switch,
49
+ * and quit, and the next session loads a fresh extension instance. Work left in
50
+ * the old instance runs with no active session, so a task finishing there used to
51
+ * have its completion callback durably suppressed ("active session identity is
52
+ * unavailable") and the next session never heard about it.
53
+ *
54
+ * After suspension this instance schedules nothing and notifies nothing. Work
55
+ * already in flight (a child's close listener, a remote tmux bootstrap/start, a
56
+ * timeout kill) still records its result in metadata, but leaves the callback to
57
+ * the instance that resumes the task. Processes are not stopped.
58
+ *
59
+ * Resume is per callback origin: session_start resumes only tasks whose origin
60
+ * (cwd and session id) matches the new session. After /reload that is every task
61
+ * of the session. After switching to another session, the previous session's
62
+ * tasks keep running, but their watches do not poll, remote tmux output is not
63
+ * collected, and their `timeout_seconds` deadlines are not enforced until that
64
+ * session is active again; an overdue deadline is enforced immediately on resume.
65
+ * A local process that exits meanwhile is recorded as terminal and its callback
66
+ * is delivered when its session resumes.
67
+ */
68
+ export function suspendScheduledWork(): void {
69
+ scheduledWorkSuspended = true;
70
+ for (const timer of watcherTimers.values()) clearTimeout(timer);
71
+ for (const timer of remoteSessionTimers.values()) clearTimeout(timer);
72
+ for (const timer of processTimeoutTimers.values()) clearTimeout(timer);
73
+ for (const timer of logRetentionTimers.values()) clearInterval(timer);
74
+ for (const timer of handoffTimers.values()) clearInterval(timer);
75
+ handoffTimers.clear();
76
+ watcherTimers.clear();
77
+ remoteSessionTimers.clear();
78
+ processTimeoutTimers.clear();
79
+ logRetentionTimers.clear();
80
+ suspendFailureAttention();
81
+ }
82
+
83
+ /** Allow scheduling again; called at session_start before running tasks are resumed. */
84
+ export function resumeScheduledWork(): void {
85
+ scheduledWorkSuspended = false;
86
+ resumeFailureAttention();
87
+ }
88
+
40
89
 
41
90
 
42
91
  export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
43
92
 
44
93
  export interface SpawnTaskParams extends CommandSpec {
45
94
  name?: string;
95
+ /** Structured intent (#325): stable id shared by modified retries of one operation. */
96
+ operation_id?: string | null;
97
+ /** Structured intent (#325): non-zero exit codes declared intentional before launch. */
98
+ expected_exit_codes?: number[] | null;
46
99
  callback?: boolean;
47
100
  timeout_seconds?: number;
48
101
  max_log_bytes?: number;
@@ -52,6 +105,10 @@ export interface SpawnTaskParams extends CommandSpec {
52
105
 
53
106
  export interface WatchTaskParams extends CommandSpec {
54
107
  name?: string;
108
+ /** Structured intent (#325): stable id shared by modified retries of one operation. */
109
+ operation_id?: string | null;
110
+ /** Structured intent (#325): non-zero exit codes declared intentional before launch. */
111
+ expected_exit_codes?: number[] | null;
55
112
  callback?: boolean;
56
113
  interval_seconds?: number;
57
114
  timeout_seconds?: number;
@@ -78,6 +135,7 @@ export function spawnTask(
78
135
  ): BackgroundTaskMeta {
79
136
  // Resolved before any task directory, log, or metadata exists so a blocked
80
137
  // launch leaves nothing behind. Structured SSH must also honor launch restrictions.
138
+ const intent = readTaskIntent(params);
81
139
  const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
82
140
  const id = nextTaskId();
83
141
  const cwd = params.cwd ?? defaultCwd;
@@ -99,11 +157,21 @@ export function spawnTask(
99
157
  ? commandSpec
100
158
  : confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id));
101
159
  const tmuxBacked = remoteTask?.metadata.remote.session === "tmux";
160
+ // Outside project = Write or Write & delete can change files outside the
161
+ // project: start an APFS local snapshot (macOS, background, never blocks).
162
+ const snapshot = !remoteTask && sandboxPlan.confined ? takeRecoverySnapshot(sandboxPlan.permissions) : undefined;
102
163
  const spawned = tmuxBacked
103
164
  ? undefined
104
165
  : remoteTask
105
166
  ? remoteTask.spawn(logPath, true)
106
167
  : spawnCommand(launchSpec, logPath, true);
168
+ if (snapshot?.started) {
169
+ void snapshot.done.then((outcome) => {
170
+ if (!outcome.ok) {
171
+ try { appendLine(logPath, `--- recovery snapshot failed (task continues): ${outcome.detail} ---`); } catch { /* log gone */ }
172
+ }
173
+ });
174
+ }
107
175
  const now = Date.now();
108
176
  const meta: BackgroundTaskMeta = {
109
177
  id,
@@ -130,6 +198,7 @@ export function spawnTask(
130
198
  spawnPidStartTime: currentProcessStartToken(),
131
199
  ssh: remoteTask?.metadata.ssh,
132
200
  remote: remoteTask?.metadata.remote,
201
+ ...intent,
133
202
  };
134
203
  writeMeta(meta);
135
204
  scheduleLogRetention(id);
@@ -146,8 +215,11 @@ export function spawnTask(
146
215
  if (!latest) return;
147
216
  enforceLogRetention(latest);
148
217
  if (isTerminalStatus(latest.status)) return;
149
- if (exitCode !== 0) recordFailure(latest, "execution", `Process exited with code ${exitCode ?? "unknown"}${signal ? ` (${signal})` : ""}`, "close", { category: "exit", at: Date.now() });
150
- else recoverFailure(latest, "execution", "close");
218
+ if (exitCode !== 0) recordExitFailure(latest, "execution", `Process exited with code ${exitCode ?? "unknown"}${signal ? ` (${signal})` : ""}`, exitCode, "close", { at: Date.now() });
219
+ else {
220
+ recoverFailure(latest, "execution", "close");
221
+ recoverDeclaredOperation(latest);
222
+ }
151
223
  latest.status = exitCode === 0 ? "succeeded" : "failed";
152
224
  latest.endedAt = Date.now();
153
225
  latest.lastExitCode = exitCode;
@@ -227,6 +299,7 @@ function scheduleRemoteSessionPoll(
227
299
  getActiveSession?: ActiveSessionProvider,
228
300
  ): void {
229
301
  clearRemoteSessionTimer(id);
302
+ if (scheduledWorkSuspended) return;
230
303
  const timer = setTimeout(() => void pollRemoteSession(pi, id, getActiveSession), delayMs);
231
304
  timer.unref();
232
305
  remoteSessionTimers.set(id, timer);
@@ -250,6 +323,7 @@ async function pollRemoteSession(
250
323
  const remoteTask = activeRemoteTasks.get(id);
251
324
  if (!meta || meta.status !== "running" || meta.remote?.session !== "tmux" || !remoteTask) return;
252
325
  const poll = await remoteTask.pollTmuxSession(meta.remote.logOffset ?? 0, remainingDeadlineMs(meta.deadlineAt));
326
+ if (scheduledWorkSuspended) return;
253
327
  appendTaskOutput(meta.logPath, poll.output);
254
328
  if (poll.status === "timed_out") {
255
329
  clearProcessTimeout(id);
@@ -280,7 +354,7 @@ async function pollRemoteSession(
280
354
  }
281
355
  const commandResult = { ...poll.commandResult, exitCode: poll.status, stdout: poll.output };
282
356
  latest.lastExitCode = poll.status;
283
- if (poll.status !== 0) recordFailure(latest, "execution", `Remote command exited with code ${poll.status}`, "exit", { category: "exit" });
357
+ if (poll.status !== 0) recordExitFailure(latest, "execution", `Remote command exited with code ${poll.status}`, poll.status, "exit");
284
358
  else recoverFailure(latest, "execution", "exit");
285
359
  finalize(latest, {
286
360
  status: poll.status === 0 ? "succeeded" : "failed",
@@ -326,6 +400,7 @@ export function startWatchTask(
326
400
  const error = condition && validateCondition(condition);
327
401
  if (error) throw new Error(`${name}: ${error}`);
328
402
  }
403
+ const intent = readTaskIntent(params);
329
404
  const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
330
405
  const id = nextTaskId();
331
406
  const cwd = params.cwd ?? defaultCwd;
@@ -371,6 +446,7 @@ export function startWatchTask(
371
446
  notifyOn: "terminal",
372
447
  ssh: remoteTask?.metadata.ssh,
373
448
  remote: remoteTask?.metadata.remote,
449
+ ...intent,
374
450
  };
375
451
  ensureTaskDir(id);
376
452
  appendLine(meta.logPath, `--- watch ${new Date(now).toISOString()} interval_ms=${meta.intervalMs} ---`);
@@ -399,6 +475,9 @@ export function resumeRunningTask(
399
475
  }
400
476
  scheduleFailureAttention(pi, meta.id, getActiveSession);
401
477
 
478
+ // Same Pi process: an earlier (reloaded or switched-away) extension instance may
479
+ // still hold this task's child or in-flight remote work and record its result.
480
+ const ownedByThisProcess = meta.spawnPid === process.pid && meta.spawnPidStartTime === currentProcessStartToken();
402
481
  if (meta.spawnPid !== process.pid || meta.spawnPidStartTime !== currentProcessStartToken()) {
403
482
  meta.spawnPid = process.pid;
404
483
  meta.spawnPidStartTime = currentProcessStartToken();
@@ -418,22 +497,86 @@ export function resumeRunningTask(
418
497
  activeRemoteTasks.set(meta.id, remoteTask);
419
498
  scheduleRemoteSessionPoll(pi, meta.id, 0, getActiveSession);
420
499
  if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
500
+ // An earlier instance's in-flight timeout kill may still finalize it.
501
+ if (ownedByThisProcess) scheduleHandoff(pi, meta.id, getActiveSession);
421
502
  return meta;
422
503
  }
423
- if (meta.pid && !processExists(meta.pid)) {
424
- meta.status = "failed";
425
- meta.endedAt = Date.now();
426
- meta.error = "process is no longer alive; exit result was not captured by this pi session";
427
- meta.result = { reason: meta.error };
428
- recordFailure(meta, "execution", meta.error, "lost", { incomplete: true });
429
- writeMeta(meta);
430
- void notifyTerminal(pi, meta, getActiveSession);
504
+ if (ownedByThisProcess) {
505
+ // A dead pid is not yet "lost" here: the earlier instance's close listener may
506
+ // still record the real exit. The handoff marks it lost after a grace period.
507
+ scheduleHandoff(pi, meta.id, getActiveSession, remoteTask);
508
+ } else if (meta.pid && !processExists(meta.pid)) {
509
+ markProcessLost(pi, meta, getActiveSession);
431
510
  return meta;
432
511
  }
433
512
  if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
434
513
  return meta;
435
514
  }
436
515
 
516
+ /**
517
+ * Watch a task whose driver may still be an earlier instance in this process: a
518
+ * local child's close listener, or a remote tmux launch that was mid-bootstrap.
519
+ * Deliver the completion once it is recorded terminal, and take over remote
520
+ * polling once the tmux session is recorded as started (#324).
521
+ */
522
+ function scheduleHandoff(
523
+ pi: ExtensionAPI,
524
+ id: string,
525
+ getActiveSession?: ActiveSessionProvider,
526
+ remoteTask?: ResolvedSshRemoteTask,
527
+ ): void {
528
+ stopHandoff(id);
529
+ if (scheduledWorkSuspended) return;
530
+ let deadSince: number | undefined;
531
+ const timer = setInterval(() => {
532
+ const meta = readMeta(id);
533
+ if (!meta) {
534
+ stopHandoff(id);
535
+ return;
536
+ }
537
+ if (meta.status !== "running") {
538
+ stopHandoff(id);
539
+ clearProcessTimeout(id);
540
+ stopLogRetention(id);
541
+ void notifyTerminal(pi, meta, getActiveSession);
542
+ return;
543
+ }
544
+ if (meta.remote?.session === "tmux" && meta.remote.sessionStarted === true && remoteTask && !activeRemoteTasks.has(id)) {
545
+ stopHandoff(id);
546
+ activeRemoteTasks.set(id, remoteTask);
547
+ scheduleRemoteSessionPoll(pi, id, 0, getActiveSession);
548
+ return;
549
+ }
550
+ if (meta.remote?.session !== "tmux" && meta.pid && !processExists(meta.pid)) {
551
+ deadSince ??= Date.now();
552
+ if (Date.now() - deadSince >= HANDOFF_LOST_GRACE_MS) {
553
+ stopHandoff(id);
554
+ clearProcessTimeout(id);
555
+ stopLogRetention(id);
556
+ markProcessLost(pi, meta, getActiveSession);
557
+ }
558
+ }
559
+ }, HANDOFF_CHECK_MS);
560
+ timer.unref();
561
+ handoffTimers.set(id, timer);
562
+ }
563
+
564
+ function stopHandoff(id: string): void {
565
+ const timer = handoffTimers.get(id);
566
+ if (timer) clearInterval(timer);
567
+ handoffTimers.delete(id);
568
+ }
569
+
570
+ function markProcessLost(pi: ExtensionAPI, meta: BackgroundTaskMeta, getActiveSession?: ActiveSessionProvider): void {
571
+ meta.status = "failed";
572
+ meta.endedAt = Date.now();
573
+ meta.error = "process is no longer alive; exit result was not captured by this pi session";
574
+ meta.result = { reason: meta.error };
575
+ recordFailure(meta, "execution", meta.error, "lost", { incomplete: true });
576
+ writeMeta(meta);
577
+ void notifyTerminal(pi, meta, getActiveSession);
578
+ }
579
+
437
580
  function resolvePersistedRemoteTask(
438
581
  meta: BackgroundTaskMeta,
439
582
  remoteRunner?: RemoteRunner,
@@ -550,6 +693,7 @@ function scheduleWatch(
550
693
  runOnce?: WatchPollRunner,
551
694
  ): void {
552
695
  clearWatchTimer(id);
696
+ if (scheduledWorkSuspended) return;
553
697
  const timer = setTimeout(() => void pollWatch(pi, id, getActiveSession, runOnce), delayMs);
554
698
  timer.unref();
555
699
  watcherTimers.set(id, timer);
@@ -581,6 +725,8 @@ async function pollWatch(
581
725
  const result = runOnce
582
726
  ? await runOnce(timeoutMs)
583
727
  : await runCommandOnce(commandSpecFromMeta(meta), undefined, timeoutMs);
728
+ // A poll that was in flight when the session shut down belongs to a stale instance.
729
+ if (scheduledWorkSuspended) return;
584
730
  appendWatchResult(meta.logPath, result);
585
731
  const latest = readMeta(id);
586
732
  if (!latest || latest.status !== "running") return;
@@ -604,8 +750,8 @@ async function pollWatch(
604
750
  const error = condition && validateCondition(condition);
605
751
  if (error) {
606
752
  latest.error = `${name}: ${error}`;
607
- if (result.exitCode !== 0) recordFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, pollKey,
608
- { category: "exit", at: result.endedAt });
753
+ if (result.exitCode !== 0) recordExitFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, result.exitCode, pollKey,
754
+ { at: result.endedAt });
609
755
  recordFailure(latest, name, latest.error, pollKey, { incomplete: true, at: result.endedAt });
610
756
  finalize(latest, { status: "failed", reason: latest.error, commandResult: result }, pi, getActiveSession);
611
757
  return;
@@ -628,8 +774,8 @@ async function pollWatch(
628
774
  success?.matched === true && latest.successWhen?.type === "exit_code";
629
775
  if (expectedPollExit) recoverFailure(latest, "watch-poll", pollKey, result.endedAt);
630
776
  if (transportFailure) recordFailure(latest, "watch-poll", transportFailure, pollKey, { category: "ssh", at: result.endedAt });
631
- else if (result.exitCode !== 0) recordFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, pollKey,
632
- { category: "exit", expected: expectedPollExit, at: result.endedAt });
777
+ else if (result.exitCode !== 0) recordExitFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, result.exitCode, pollKey,
778
+ { expected: expectedPollExit, at: result.endedAt });
633
779
  else recoverFailure(latest, "watch-poll", pollKey, result.endedAt);
634
780
  if (conditionErrors.length) latest.error = conditionErrors.join("; ");
635
781
  if (failure?.matched) {
@@ -692,6 +838,7 @@ function finalize(
692
838
  ): void {
693
839
  stopFailureAttention(meta.id);
694
840
  if (terminal.status === "timed_out") recordFailure(meta, "timeout", terminal.reason, "deadline", { category: "timeout" });
841
+ if (terminal.status === "succeeded") recoverDeclaredOperation(meta);
695
842
  meta.status = terminal.status;
696
843
  meta.endedAt = Date.now();
697
844
  meta.result = {
@@ -750,6 +897,7 @@ function scheduleProcessTimeout(
750
897
  getActiveSession?: ActiveSessionProvider,
751
898
  ): void {
752
899
  clearProcessTimeout(id);
900
+ if (scheduledWorkSuspended) return;
753
901
  const delay = Math.max(0, deadlineAt - Date.now());
754
902
  const timer = setTimeout(() => void timeoutProcess(pi, id, getActiveSession), delay);
755
903
  timer.unref();
@@ -839,6 +987,9 @@ async function notifyTerminal(
839
987
  meta: BackgroundTaskMeta,
840
988
  getActiveSession?: ActiveSessionProvider,
841
989
  ): Promise<void> {
990
+ // A suspended instance has no active session: delivering or suppressing here would
991
+ // be wrong either way. The instance that resumes the task delivers it (#324).
992
+ if (scheduledWorkSuspended) return;
842
993
  if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
843
994
  const latest = readMeta(meta.id) ?? meta;
844
995
  if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
@@ -858,7 +1009,11 @@ async function notifyTerminal(
858
1009
  source: "background-task",
859
1010
  id: latest.id,
860
1011
  label,
861
- status: pending ? `${latest.status}: ${pending.summary}` : latest.status,
1012
+ // Lifecycle plus an attention count; the incident text rides failureRows
1013
+ // with exact counts, so the status field is never a clipped summary (#323).
1014
+ status: pending
1015
+ ? `${latest.status}; ${pending.incidents.length} incident${pending.incidents.length === 1 ? "" : "s"} need${pending.incidents.length === 1 ? "s" : ""} attention`
1016
+ : latest.status,
862
1017
  detailTool: "bg_task_status",
863
1018
  outcome: facts.outcome,
864
1019
  failureRows: facts.failureRows,
@@ -939,6 +1094,7 @@ function launchArgvOf(commandSpec: CommandSpec, launchSpec: CommandSpec): string
939
1094
 
940
1095
  function scheduleLogRetention(id: string): void {
941
1096
  stopLogRetention(id);
1097
+ if (scheduledWorkSuspended) return;
942
1098
  const timer = setInterval(() => {
943
1099
  const meta = readMeta(id);
944
1100
  if (!meta || meta.status !== "running" || meta.kind !== "process") {
package/src/sandbox.ts CHANGED
@@ -32,8 +32,9 @@ import { baseDir } from "./registry.js";
32
32
  import { compileWritePolicy, maybeBuildSandboxCommand, type SandboxSeams } from "./shared-sandbox-core.js";
33
33
 
34
34
  type SandboxPermissions = {
35
- projectFiles: "off" | "read" | "read-write";
36
- outsideProject: "off" | "read" | "read-write";
35
+ /** `write` = read, create and overwrite in place; `read-write` = Write & delete. */
36
+ projectFiles: "off" | "read" | "write" | "read-write";
37
+ outsideProject: "off" | "read" | "write" | "read-write";
37
38
  storedCredentials: "off" | "read" | "read-write";
38
39
  commands: boolean;
39
40
  network: boolean;
@@ -160,10 +161,11 @@ function readPolicy(data: unknown): ForegroundSandboxPolicy | undefined {
160
161
  function readPermissions(value: unknown): ForegroundSandboxPolicy["permissions"] | undefined {
161
162
  if (!value || typeof value !== "object") return undefined;
162
163
  const p = value as Record<string, unknown>;
163
- const access = (v: unknown) => v === "off" || v === "read" || v === "read-write";
164
+ const credential = (v: unknown) => v === "off" || v === "read" || v === "read-write";
165
+ const access = (v: unknown) => credential(v) || v === "write";
164
166
  if (typeof p.enabled !== "boolean" || typeof p.commands !== "boolean" ||
165
167
  typeof p.network !== "boolean" || !access(p.projectFiles) ||
166
- !access(p.outsideProject) || !access(p.storedCredentials)) return undefined;
168
+ !access(p.outsideProject) || !credential(p.storedCredentials)) return undefined;
167
169
  return Object.freeze({
168
170
  enabled: p.enabled, commands: p.commands, network: p.network,
169
171
  projectFiles: p.projectFiles as SandboxPermissions["projectFiles"],
@@ -102,7 +102,11 @@ const DEFAULT_WINDOW_MS = 100;
102
102
  const DEFAULT_RETRY_MS = 1_000;
103
103
  const MAX_LABEL_BYTES = 160;
104
104
  const MAX_ID_BYTES = 200;
105
- const MAX_STATUS_BYTES = 80;
105
+ /**
106
+ * Status field bound. Long statuses keep whole `; `-separated notes and say
107
+ * how many were left out, instead of cutting mid-word (#323).
108
+ */
109
+ const MAX_STATUS_BYTES = 160;
106
110
  const MAX_FAILURE_BYTES = 400;
107
111
  const MAX_DECISION_BYTES = 400;
108
112
  const encoder = new TextEncoder();
@@ -169,6 +173,33 @@ function boundedField(value: unknown, maxBytes: number): string {
169
173
  return `${clipUtf8Prefix(oneLine, Math.max(0, maxBytes - utf8ByteLength(ellipsis)))}${ellipsis}`;
170
174
  }
171
175
 
176
+ /**
177
+ * The status field under MAX_STATUS_BYTES without losing meaning: the leading
178
+ * lifecycle note is always kept, later `; `-separated notes are kept whole
179
+ * while they fit, and anything left out is named with a count and the inspect
180
+ * tool rather than an ellipsis.
181
+ */
182
+ function boundedStatus(value: unknown): string {
183
+ const status = String(value ?? "").replace(/\s+/g, " ").trim();
184
+ if (utf8ByteLength(status) <= MAX_STATUS_BYTES) return status;
185
+ const notes = status.split(/;\s+/).filter(Boolean);
186
+ const omission = (count: number) => ` (+${count} more status note${count === 1 ? "" : "s"}; see inspect)`;
187
+ const reserve = utf8ByteLength(omission(notes.length));
188
+ let kept = notes[0] ?? "";
189
+ if (utf8ByteLength(kept) + reserve > MAX_STATUS_BYTES) {
190
+ const suffix = " (clipped; see inspect)";
191
+ return `${clipUtf8Prefix(kept, MAX_STATUS_BYTES - utf8ByteLength(suffix))}${suffix}`;
192
+ }
193
+ let count = 1;
194
+ for (const note of notes.slice(1)) {
195
+ const next = `${kept}; ${note}`;
196
+ if (utf8ByteLength(next) + reserve > MAX_STATUS_BYTES) break;
197
+ kept = next;
198
+ count += 1;
199
+ }
200
+ return count < notes.length ? `${kept}${omission(notes.length - count)}` : kept;
201
+ }
202
+
172
203
  function inspectFor(event: Pick<CallbackBatchEvent, "id" | "detailTool">): string {
173
204
  const id = boundedField(event.id, MAX_ID_BYTES);
174
205
  return event.detailTool === "bg_task_status"
@@ -232,7 +263,7 @@ function formatRow(event: CallbackBatchEvent, detailBytes = MAX_FAILURE_BYTES +
232
263
  const source = boundedField(event.source, 40);
233
264
  const id = boundedField(event.id, MAX_ID_BYTES);
234
265
  const label = boundedField(event.label, MAX_LABEL_BYTES);
235
- const status = boundedField(event.status, MAX_STATUS_BYTES);
266
+ const status = boundedStatus(event.status);
236
267
  const inspect = inspectFor(event);
237
268
  const lines = [
238
269
  `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${inspect}`,
@@ -291,7 +322,7 @@ export function formatUrgentCallback(
291
322
  const target = event.inspectId ?? event.id;
292
323
  const id = boundedField(target, MAX_ID_BYTES);
293
324
  const label = boundedField(event.label, MAX_LABEL_BYTES);
294
- const status = boundedField(event.status, MAX_STATUS_BYTES);
325
+ const status = boundedStatus(event.status);
295
326
  const tool = event.detailTool
296
327
  ?? (event.source === "background-task" ? "bg_task_status" : "subagent_result");
297
328
  const inspectTarget = tool === "bg_task_status" ? `bg_task_status id=${id}` : `subagent_result id=${JSON.stringify(id)}`;