pi-better-background-tasks 0.3.0 → 0.5.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,72 @@ 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 setTimeout>>();
38
+ // The handoff check backs off from 250 ms to 4 s while the task keeps running,
39
+ // and returns to 250 ms once its process is gone so the grace period is timed closely.
40
+ const HANDOFF_CHECK_MS = 250;
41
+ const HANDOFF_MAX_CHECK_MS = 4_000;
42
+ const HANDOFF_LOST_GRACE_MS = 5_000;
43
+ let scheduledWorkSuspended = false;
36
44
  const REMOTE_SESSION_POLL_MS = 100;
37
45
 
38
46
  export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
39
47
 
48
+ /**
49
+ * Stop this extension instance's scheduled work when its session shuts down (#324).
50
+ *
51
+ * Pi emits session_shutdown for /reload, /new, /resume, fork, session switch,
52
+ * and quit, and the next session loads a fresh extension instance. Work left in
53
+ * the old instance runs with no active session, so a task finishing there used to
54
+ * have its completion callback durably suppressed ("active session identity is
55
+ * unavailable") and the next session never heard about it.
56
+ *
57
+ * After suspension this instance schedules nothing and notifies nothing. Work
58
+ * already in flight (a child's close listener, a remote tmux bootstrap/start, a
59
+ * timeout kill) still records its result in metadata, but leaves the callback to
60
+ * the instance that resumes the task. Processes are not stopped.
61
+ *
62
+ * Resume is per callback origin: session_start resumes only tasks whose origin
63
+ * (cwd and session id) matches the new session. After /reload that is every task
64
+ * of the session. After switching to another session, the previous session's
65
+ * tasks keep running, but their watches do not poll, remote tmux output is not
66
+ * collected, and their `timeout_seconds` deadlines are not enforced until that
67
+ * session is active again; an overdue deadline is enforced immediately on resume.
68
+ * A local process that exits meanwhile, while this Pi process is still running,
69
+ * is recorded as terminal and its callback is delivered when its session resumes;
70
+ * after Pi quits, a resumed task whose process is gone is marked lost instead.
71
+ */
72
+ export function suspendScheduledWork(): void {
73
+ scheduledWorkSuspended = true;
74
+ for (const timer of watcherTimers.values()) clearTimeout(timer);
75
+ for (const timer of remoteSessionTimers.values()) clearTimeout(timer);
76
+ for (const timer of processTimeoutTimers.values()) clearTimeout(timer);
77
+ for (const timer of logRetentionTimers.values()) clearInterval(timer);
78
+ for (const timer of handoffTimers.values()) clearTimeout(timer);
79
+ handoffTimers.clear();
80
+ watcherTimers.clear();
81
+ remoteSessionTimers.clear();
82
+ processTimeoutTimers.clear();
83
+ logRetentionTimers.clear();
84
+ suspendFailureAttention();
85
+ }
86
+
87
+ /** Allow scheduling again; called at session_start before running tasks are resumed. */
88
+ export function resumeScheduledWork(): void {
89
+ scheduledWorkSuspended = false;
90
+ resumeFailureAttention();
91
+ }
92
+
40
93
 
41
94
 
42
95
  export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
43
96
 
44
97
  export interface SpawnTaskParams extends CommandSpec {
45
98
  name?: string;
99
+ /** Structured intent (#325): stable id shared by modified retries of one operation. */
100
+ operation_id?: string | null;
101
+ /** Structured intent (#325): non-zero exit codes declared intentional before launch. */
102
+ expected_exit_codes?: number[] | null;
46
103
  callback?: boolean;
47
104
  timeout_seconds?: number;
48
105
  max_log_bytes?: number;
@@ -52,6 +109,10 @@ export interface SpawnTaskParams extends CommandSpec {
52
109
 
53
110
  export interface WatchTaskParams extends CommandSpec {
54
111
  name?: string;
112
+ /** Structured intent (#325): stable id shared by modified retries of one operation. */
113
+ operation_id?: string | null;
114
+ /** Structured intent (#325): non-zero exit codes declared intentional before launch. */
115
+ expected_exit_codes?: number[] | null;
55
116
  callback?: boolean;
56
117
  interval_seconds?: number;
57
118
  timeout_seconds?: number;
@@ -78,6 +139,7 @@ export function spawnTask(
78
139
  ): BackgroundTaskMeta {
79
140
  // Resolved before any task directory, log, or metadata exists so a blocked
80
141
  // launch leaves nothing behind. Structured SSH must also honor launch restrictions.
142
+ const intent = readTaskIntent(params);
81
143
  const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
82
144
  const id = nextTaskId();
83
145
  const cwd = params.cwd ?? defaultCwd;
@@ -99,11 +161,21 @@ export function spawnTask(
99
161
  ? commandSpec
100
162
  : confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id));
101
163
  const tmuxBacked = remoteTask?.metadata.remote.session === "tmux";
164
+ // Outside project = Write or Write & delete can change files outside the
165
+ // project: start an APFS local snapshot (macOS, background, never blocks).
166
+ const snapshot = !remoteTask && sandboxPlan.confined ? takeRecoverySnapshot(sandboxPlan.permissions) : undefined;
102
167
  const spawned = tmuxBacked
103
168
  ? undefined
104
169
  : remoteTask
105
170
  ? remoteTask.spawn(logPath, true)
106
171
  : spawnCommand(launchSpec, logPath, true);
172
+ if (snapshot?.started) {
173
+ void snapshot.done.then((outcome) => {
174
+ if (!outcome.ok) {
175
+ try { appendLine(logPath, `--- recovery snapshot failed (task continues): ${outcome.detail} ---`); } catch { /* log gone */ }
176
+ }
177
+ });
178
+ }
107
179
  const now = Date.now();
108
180
  const meta: BackgroundTaskMeta = {
109
181
  id,
@@ -130,6 +202,7 @@ export function spawnTask(
130
202
  spawnPidStartTime: currentProcessStartToken(),
131
203
  ssh: remoteTask?.metadata.ssh,
132
204
  remote: remoteTask?.metadata.remote,
205
+ ...intent,
133
206
  };
134
207
  writeMeta(meta);
135
208
  scheduleLogRetention(id);
@@ -146,8 +219,11 @@ export function spawnTask(
146
219
  if (!latest) return;
147
220
  enforceLogRetention(latest);
148
221
  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");
222
+ if (exitCode !== 0) recordExitFailure(latest, "execution", `Process exited with code ${exitCode ?? "unknown"}${signal ? ` (${signal})` : ""}`, exitCode, "close", { at: Date.now() });
223
+ else {
224
+ recoverFailure(latest, "execution", "close");
225
+ recoverDeclaredOperation(latest);
226
+ }
151
227
  latest.status = exitCode === 0 ? "succeeded" : "failed";
152
228
  latest.endedAt = Date.now();
153
229
  latest.lastExitCode = exitCode;
@@ -227,6 +303,7 @@ function scheduleRemoteSessionPoll(
227
303
  getActiveSession?: ActiveSessionProvider,
228
304
  ): void {
229
305
  clearRemoteSessionTimer(id);
306
+ if (scheduledWorkSuspended) return;
230
307
  const timer = setTimeout(() => void pollRemoteSession(pi, id, getActiveSession), delayMs);
231
308
  timer.unref();
232
309
  remoteSessionTimers.set(id, timer);
@@ -250,6 +327,7 @@ async function pollRemoteSession(
250
327
  const remoteTask = activeRemoteTasks.get(id);
251
328
  if (!meta || meta.status !== "running" || meta.remote?.session !== "tmux" || !remoteTask) return;
252
329
  const poll = await remoteTask.pollTmuxSession(meta.remote.logOffset ?? 0, remainingDeadlineMs(meta.deadlineAt));
330
+ if (scheduledWorkSuspended) return;
253
331
  appendTaskOutput(meta.logPath, poll.output);
254
332
  if (poll.status === "timed_out") {
255
333
  clearProcessTimeout(id);
@@ -280,7 +358,7 @@ async function pollRemoteSession(
280
358
  }
281
359
  const commandResult = { ...poll.commandResult, exitCode: poll.status, stdout: poll.output };
282
360
  latest.lastExitCode = poll.status;
283
- if (poll.status !== 0) recordFailure(latest, "execution", `Remote command exited with code ${poll.status}`, "exit", { category: "exit" });
361
+ if (poll.status !== 0) recordExitFailure(latest, "execution", `Remote command exited with code ${poll.status}`, poll.status, "exit");
284
362
  else recoverFailure(latest, "execution", "exit");
285
363
  finalize(latest, {
286
364
  status: poll.status === 0 ? "succeeded" : "failed",
@@ -326,6 +404,7 @@ export function startWatchTask(
326
404
  const error = condition && validateCondition(condition);
327
405
  if (error) throw new Error(`${name}: ${error}`);
328
406
  }
407
+ const intent = readTaskIntent(params);
329
408
  const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
330
409
  const id = nextTaskId();
331
410
  const cwd = params.cwd ?? defaultCwd;
@@ -371,6 +450,7 @@ export function startWatchTask(
371
450
  notifyOn: "terminal",
372
451
  ssh: remoteTask?.metadata.ssh,
373
452
  remote: remoteTask?.metadata.remote,
453
+ ...intent,
374
454
  };
375
455
  ensureTaskDir(id);
376
456
  appendLine(meta.logPath, `--- watch ${new Date(now).toISOString()} interval_ms=${meta.intervalMs} ---`);
@@ -399,6 +479,9 @@ export function resumeRunningTask(
399
479
  }
400
480
  scheduleFailureAttention(pi, meta.id, getActiveSession);
401
481
 
482
+ // Same Pi process: an earlier (reloaded or switched-away) extension instance may
483
+ // still hold this task's child or in-flight remote work and record its result.
484
+ const ownedByThisProcess = meta.spawnPid === process.pid && meta.spawnPidStartTime === currentProcessStartToken();
402
485
  if (meta.spawnPid !== process.pid || meta.spawnPidStartTime !== currentProcessStartToken()) {
403
486
  meta.spawnPid = process.pid;
404
487
  meta.spawnPidStartTime = currentProcessStartToken();
@@ -418,22 +501,98 @@ export function resumeRunningTask(
418
501
  activeRemoteTasks.set(meta.id, remoteTask);
419
502
  scheduleRemoteSessionPoll(pi, meta.id, 0, getActiveSession);
420
503
  if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
504
+ // An earlier instance's in-flight timeout kill may still finalize it.
505
+ if (ownedByThisProcess) scheduleHandoff(pi, meta.id, getActiveSession);
421
506
  return meta;
422
507
  }
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);
508
+ if (ownedByThisProcess) {
509
+ // A dead pid is not yet "lost" here: the earlier instance's close listener may
510
+ // still record the real exit. The handoff marks it lost after a grace period.
511
+ scheduleHandoff(pi, meta.id, getActiveSession, remoteTask);
512
+ } else if (meta.pid && !processExists(meta.pid)) {
513
+ markProcessLost(pi, meta, getActiveSession);
431
514
  return meta;
432
515
  }
433
516
  if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
434
517
  return meta;
435
518
  }
436
519
 
520
+ /**
521
+ * Watch a task whose driver may still be an earlier instance in this process: a
522
+ * local child's close listener, or a remote tmux launch that was mid-bootstrap.
523
+ * Deliver the completion once it is recorded terminal, and take over remote
524
+ * polling once the tmux session is recorded as started (#324).
525
+ */
526
+ function scheduleHandoff(
527
+ pi: ExtensionAPI,
528
+ id: string,
529
+ getActiveSession?: ActiveSessionProvider,
530
+ remoteTask?: ResolvedSshRemoteTask,
531
+ ): void {
532
+ stopHandoff(id);
533
+ if (scheduledWorkSuspended) return;
534
+ let deadSince: number | undefined;
535
+ let delayMs = HANDOFF_CHECK_MS;
536
+ const check = () => {
537
+ handoffTimers.delete(id);
538
+ const meta = readMeta(id);
539
+ if (!meta) {
540
+ stopHandoff(id);
541
+ return;
542
+ }
543
+ if (meta.status !== "running") {
544
+ stopHandoff(id);
545
+ clearProcessTimeout(id);
546
+ stopLogRetention(id);
547
+ void notifyTerminal(pi, meta, getActiveSession);
548
+ return;
549
+ }
550
+ if (meta.remote?.session === "tmux" && meta.remote.sessionStarted === true && remoteTask && !activeRemoteTasks.has(id)) {
551
+ stopHandoff(id);
552
+ activeRemoteTasks.set(id, remoteTask);
553
+ scheduleRemoteSessionPoll(pi, id, 0, getActiveSession);
554
+ return;
555
+ }
556
+ if (meta.remote?.session !== "tmux" && meta.pid && !processExists(meta.pid)) {
557
+ deadSince ??= Date.now();
558
+ if (Date.now() - deadSince >= HANDOFF_LOST_GRACE_MS) {
559
+ stopHandoff(id);
560
+ clearProcessTimeout(id);
561
+ stopLogRetention(id);
562
+ markProcessLost(pi, meta, getActiveSession);
563
+ return;
564
+ }
565
+ delayMs = HANDOFF_CHECK_MS;
566
+ } else {
567
+ delayMs = Math.min(HANDOFF_MAX_CHECK_MS, delayMs * 2);
568
+ }
569
+ arm();
570
+ };
571
+ const arm = () => {
572
+ if (scheduledWorkSuspended) return;
573
+ const timer = setTimeout(check, delayMs);
574
+ timer.unref();
575
+ handoffTimers.set(id, timer);
576
+ };
577
+ arm();
578
+ }
579
+
580
+ function stopHandoff(id: string): void {
581
+ const timer = handoffTimers.get(id);
582
+ if (timer) clearTimeout(timer);
583
+ handoffTimers.delete(id);
584
+ }
585
+
586
+ function markProcessLost(pi: ExtensionAPI, meta: BackgroundTaskMeta, getActiveSession?: ActiveSessionProvider): void {
587
+ meta.status = "failed";
588
+ meta.endedAt = Date.now();
589
+ meta.error = "process is no longer alive; exit result was not captured by this pi session";
590
+ meta.result = { reason: meta.error };
591
+ recordFailure(meta, "execution", meta.error, "lost", { incomplete: true });
592
+ writeMeta(meta);
593
+ void notifyTerminal(pi, meta, getActiveSession);
594
+ }
595
+
437
596
  function resolvePersistedRemoteTask(
438
597
  meta: BackgroundTaskMeta,
439
598
  remoteRunner?: RemoteRunner,
@@ -550,6 +709,7 @@ function scheduleWatch(
550
709
  runOnce?: WatchPollRunner,
551
710
  ): void {
552
711
  clearWatchTimer(id);
712
+ if (scheduledWorkSuspended) return;
553
713
  const timer = setTimeout(() => void pollWatch(pi, id, getActiveSession, runOnce), delayMs);
554
714
  timer.unref();
555
715
  watcherTimers.set(id, timer);
@@ -581,6 +741,8 @@ async function pollWatch(
581
741
  const result = runOnce
582
742
  ? await runOnce(timeoutMs)
583
743
  : await runCommandOnce(commandSpecFromMeta(meta), undefined, timeoutMs);
744
+ // A poll that was in flight when the session shut down belongs to a stale instance.
745
+ if (scheduledWorkSuspended) return;
584
746
  appendWatchResult(meta.logPath, result);
585
747
  const latest = readMeta(id);
586
748
  if (!latest || latest.status !== "running") return;
@@ -604,8 +766,8 @@ async function pollWatch(
604
766
  const error = condition && validateCondition(condition);
605
767
  if (error) {
606
768
  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 });
769
+ if (result.exitCode !== 0) recordExitFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, result.exitCode, pollKey,
770
+ { at: result.endedAt });
609
771
  recordFailure(latest, name, latest.error, pollKey, { incomplete: true, at: result.endedAt });
610
772
  finalize(latest, { status: "failed", reason: latest.error, commandResult: result }, pi, getActiveSession);
611
773
  return;
@@ -628,8 +790,8 @@ async function pollWatch(
628
790
  success?.matched === true && latest.successWhen?.type === "exit_code";
629
791
  if (expectedPollExit) recoverFailure(latest, "watch-poll", pollKey, result.endedAt);
630
792
  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 });
793
+ else if (result.exitCode !== 0) recordExitFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, result.exitCode, pollKey,
794
+ { expected: expectedPollExit, at: result.endedAt });
633
795
  else recoverFailure(latest, "watch-poll", pollKey, result.endedAt);
634
796
  if (conditionErrors.length) latest.error = conditionErrors.join("; ");
635
797
  if (failure?.matched) {
@@ -692,6 +854,7 @@ function finalize(
692
854
  ): void {
693
855
  stopFailureAttention(meta.id);
694
856
  if (terminal.status === "timed_out") recordFailure(meta, "timeout", terminal.reason, "deadline", { category: "timeout" });
857
+ if (terminal.status === "succeeded") recoverDeclaredOperation(meta);
695
858
  meta.status = terminal.status;
696
859
  meta.endedAt = Date.now();
697
860
  meta.result = {
@@ -750,6 +913,7 @@ function scheduleProcessTimeout(
750
913
  getActiveSession?: ActiveSessionProvider,
751
914
  ): void {
752
915
  clearProcessTimeout(id);
916
+ if (scheduledWorkSuspended) return;
753
917
  const delay = Math.max(0, deadlineAt - Date.now());
754
918
  const timer = setTimeout(() => void timeoutProcess(pi, id, getActiveSession), delay);
755
919
  timer.unref();
@@ -839,6 +1003,9 @@ async function notifyTerminal(
839
1003
  meta: BackgroundTaskMeta,
840
1004
  getActiveSession?: ActiveSessionProvider,
841
1005
  ): Promise<void> {
1006
+ // A suspended instance has no active session: delivering or suppressing here would
1007
+ // be wrong either way. The instance that resumes the task delivers it (#324).
1008
+ if (scheduledWorkSuspended) return;
842
1009
  if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
843
1010
  const latest = readMeta(meta.id) ?? meta;
844
1011
  if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
@@ -858,7 +1025,11 @@ async function notifyTerminal(
858
1025
  source: "background-task",
859
1026
  id: latest.id,
860
1027
  label,
861
- status: pending ? `${latest.status}: ${pending.summary}` : latest.status,
1028
+ // Lifecycle plus an attention count; the incident text rides failureRows
1029
+ // with exact counts, so the status field is never a clipped summary (#323).
1030
+ status: pending
1031
+ ? `${latest.status}; ${pending.incidents.length} incident${pending.incidents.length === 1 ? "" : "s"} need${pending.incidents.length === 1 ? "s" : ""} attention`
1032
+ : latest.status,
862
1033
  detailTool: "bg_task_status",
863
1034
  outcome: facts.outcome,
864
1035
  failureRows: facts.failureRows,
@@ -939,6 +1110,7 @@ function launchArgvOf(commandSpec: CommandSpec, launchSpec: CommandSpec): string
939
1110
 
940
1111
  function scheduleLogRetention(id: string): void {
941
1112
  stopLogRetention(id);
1113
+ if (scheduledWorkSuspended) return;
942
1114
  const timer = setInterval(() => {
943
1115
  const meta = readMeta(id);
944
1116
  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)}`;