pi-better-background-tasks 0.1.15 → 0.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/runtime.ts CHANGED
@@ -4,6 +4,7 @@ import { appendLine, appendWatchResult, retainLogTail, resolveMaxLogBytes } from
4
4
  import { evaluateCondition } from "./conditions.js";
5
5
  import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
6
6
  import { ensureTaskDir, logPathFor, nextTaskId, readMeta, writeMeta } from "./registry.js";
7
+ import { getCallbackBatcher } from "./shared-callback-batcher.js";
7
8
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, CommandSpec, Condition, TerminalResult } from "./types.js";
8
9
  import { isTerminalStatus } from "./types.js";
9
10
 
@@ -304,24 +305,37 @@ async function notifyTerminal(
304
305
  if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
305
306
  const latest = readMeta(meta.id) ?? meta;
306
307
  if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
307
- const suppressionReason = getCallbackSuppressionReason(latest, getActiveSession?.());
308
- if (suppressionReason) {
309
- latest.callbackSuppressedAt = Date.now();
310
- latest.callbackSuppressedReason = suppressionReason;
311
- writeMeta(latest);
312
- return;
313
- }
314
308
  const label = latest.name ? `${latest.name} (${latest.id})` : latest.id;
315
- try {
316
- await pi.sendUserMessage(
317
- `Background task ${label} reached terminal status ${latest.status}. Inspect the compact result with bg_task_status id=${latest.id}; call bg_task_log only if the status summary is insufficient.`,
318
- { deliverAs: "followUp" },
319
- );
320
- latest.callbackSentAt = Date.now();
321
- writeMeta(latest);
322
- } catch {
323
- // Leave callbackSentAt unset so the originating session can attempt delivery once.
324
- }
309
+ getCallbackBatcher(pi).enqueue({
310
+ source: "background-task",
311
+ id: latest.id,
312
+ label,
313
+ status: latest.status,
314
+ detailTool: "bg_task_status",
315
+ callback: true,
316
+ isDelivered: () => {
317
+ const current = readMeta(latest.id);
318
+ return current?.callbackSentAt !== undefined || current?.callbackSuppressedAt !== undefined;
319
+ },
320
+ getSuppressionReason: () => {
321
+ const current = readMeta(latest.id);
322
+ if (!current) return "background task metadata is unavailable";
323
+ return getCallbackSuppressionReason(current, getActiveSession?.());
324
+ },
325
+ onDelivered: (at) => {
326
+ const current = readMeta(latest.id);
327
+ if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
328
+ current.callbackSentAt = at;
329
+ writeMeta(current);
330
+ },
331
+ onSuppressed: (reason, at) => {
332
+ const current = readMeta(latest.id);
333
+ if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
334
+ current.callbackSuppressedAt = at;
335
+ current.callbackSuppressedReason = reason;
336
+ writeMeta(current);
337
+ },
338
+ });
325
339
  }
326
340
 
327
341
  function getCallbackSuppressionReason(
@@ -0,0 +1,320 @@
1
+ // Generated from packages/callback-batcher/index.ts. Do not edit directly.
2
+ export type CallbackSource = "subagent" | "background-task";
3
+ export type CallbackDetailTool = "subagent_result" | "bg_task_status";
4
+
5
+ export interface CallbackBatchHost {
6
+ sendMessage(
7
+ message: { customType: string; content: string; display: boolean },
8
+ options: Record<string, unknown>,
9
+ ): unknown;
10
+ }
11
+
12
+ export interface CallbackBatchEvent {
13
+ source: CallbackSource;
14
+ id: string;
15
+ label: string;
16
+ status: string;
17
+ detailTool: CallbackDetailTool;
18
+ callback?: boolean;
19
+ isDelivered?: () => boolean;
20
+ getSuppressionReason?: () => string | undefined;
21
+ onDelivered?: (at: number) => void;
22
+ onSuppressed?: (reason: string, at: number) => void;
23
+ }
24
+
25
+ export interface UrgentCallbackEvent {
26
+ source: CallbackSource;
27
+ id: string;
28
+ label: string;
29
+ status: "orphaned" | "lost" | string;
30
+ customType: string;
31
+ content: string;
32
+ isDelivered?: () => boolean;
33
+ getSuppressionReason?: () => string | undefined;
34
+ onDelivered?: (at: number) => void;
35
+ onSuppressed?: (reason: string, at: number) => void;
36
+ }
37
+
38
+ export interface CallbackBatcherOptions {
39
+ windowMs?: number;
40
+ retryMs?: number;
41
+ }
42
+
43
+ export interface CallbackBatcher {
44
+ enqueue(event: CallbackBatchEvent): boolean;
45
+ flush(): Promise<boolean>;
46
+ deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
47
+ cancel(): void;
48
+ pendingCount(): number;
49
+ }
50
+
51
+ interface PendingEvent {
52
+ event: CallbackBatchEvent;
53
+ sequence: number;
54
+ }
55
+
56
+ interface SharedCallbackBatcherState {
57
+ byHost: WeakMap<object, CallbackBatcher>;
58
+ }
59
+
60
+ const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
61
+ const DEFAULT_WINDOW_MS = 100;
62
+ const DEFAULT_RETRY_MS = 1_000;
63
+ const MAX_LABEL_CHARS = 160;
64
+ const MAX_ID_CHARS = 200;
65
+ const MAX_STATUS_CHARS = 80;
66
+
67
+ export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
68
+ export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
69
+
70
+ export function resolveCallbackBatchWindowMs(
71
+ value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
72
+ ): number {
73
+ if (value === undefined || value === null || value === "") return DEFAULT_WINDOW_MS;
74
+ const parsed = Number(value);
75
+ if (!Number.isFinite(parsed)) return DEFAULT_WINDOW_MS;
76
+ return Math.max(0, Math.min(5_000, Math.floor(parsed)));
77
+ }
78
+
79
+ export function formatCallbackBatch(events: readonly CallbackBatchEvent[]): string {
80
+ const count = events.length;
81
+ const heading = `${count} background completion${count === 1 ? " is" : "s are"} ready:`;
82
+ const rows = events.map((event) => {
83
+ const source = boundedField(event.source, 40);
84
+ const id = boundedField(event.id, MAX_ID_CHARS);
85
+ const label = boundedField(event.label, MAX_LABEL_CHARS);
86
+ const status = boundedField(event.status, MAX_STATUS_CHARS);
87
+ const detail = event.detailTool === "bg_task_status"
88
+ ? `bg_task_status id=${id}`
89
+ : `subagent_result id=${JSON.stringify(id)}`;
90
+ return `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${detail}`;
91
+ });
92
+ return [
93
+ heading,
94
+ ...rows,
95
+ "Retrieve durable results/status with the listed tools. Full results and logs are intentionally omitted.",
96
+ ].join("\n");
97
+ }
98
+
99
+ export function createCallbackBatcher(
100
+ host: CallbackBatchHost,
101
+ options: CallbackBatcherOptions = {},
102
+ ): CallbackBatcher {
103
+ const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
104
+ const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
105
+ const pending = new Map<string, PendingEvent>();
106
+ const inFlight = new Set<string>();
107
+ const urgentInFlight = new Set<string>();
108
+ let sequence = 0;
109
+ let timer: ReturnType<typeof setTimeout> | undefined;
110
+ let flushPromise: Promise<boolean> | undefined;
111
+
112
+ const cancelTimer = (): void => {
113
+ if (timer) clearTimeout(timer);
114
+ timer = undefined;
115
+ };
116
+
117
+ const schedule = (delayMs: number): void => {
118
+ if (timer || pending.size === 0) return;
119
+ timer = setTimeout(() => {
120
+ timer = undefined;
121
+ void api.flush();
122
+ }, Math.max(0, delayMs));
123
+ timer.unref?.();
124
+ };
125
+
126
+ const enqueue = (event: CallbackBatchEvent): boolean => {
127
+ if (event.callback === false) return false;
128
+ const key = eventKey(event);
129
+ if (pending.has(key) || inFlight.has(key)) return false;
130
+ pending.set(key, { event, sequence: sequence++ });
131
+ schedule(windowMs);
132
+ return true;
133
+ };
134
+
135
+ const performFlush = async (): Promise<boolean> => {
136
+ cancelTimer();
137
+ const snapshot = [...pending.entries()]
138
+ .sort((a, b) => a[1].sequence - b[1].sequence);
139
+ pending.clear();
140
+ for (const [key] of snapshot) inFlight.add(key);
141
+
142
+ const deliverable: Array<[string, PendingEvent]> = [];
143
+ for (const item of snapshot) {
144
+ const [key, pendingEvent] = item;
145
+ const disposition = eventDisposition(pendingEvent.event);
146
+ if (disposition.kind === "delivered") {
147
+ inFlight.delete(key);
148
+ continue;
149
+ }
150
+ if (disposition.kind === "suppressed") {
151
+ invokeSuppressed(pendingEvent.event, disposition.reason, Date.now());
152
+ inFlight.delete(key);
153
+ continue;
154
+ }
155
+ deliverable.push(item);
156
+ }
157
+
158
+ if (deliverable.length === 0) {
159
+ if (pending.size > 0) schedule(windowMs);
160
+ return true;
161
+ }
162
+
163
+ try {
164
+ await host.sendMessage(
165
+ {
166
+ customType: "background-completion-batch",
167
+ content: formatCallbackBatch(deliverable.map(([, item]) => item.event)),
168
+ display: true,
169
+ },
170
+ { deliverAs: "followUp", triggerTurn: true },
171
+ );
172
+ } catch {
173
+ for (const [key] of deliverable) inFlight.delete(key);
174
+ const retryItems = [...deliverable, ...pending.entries()]
175
+ .sort((a, b) => a[1].sequence - b[1].sequence);
176
+ pending.clear();
177
+ for (const [key, item] of retryItems) {
178
+ if (!pending.has(key)) pending.set(key, item);
179
+ }
180
+ schedule(retryMs);
181
+ return false;
182
+ }
183
+
184
+ const deliveredAt = Date.now();
185
+ for (const [key, item] of deliverable) {
186
+ invokeDelivered(item.event, deliveredAt);
187
+ inFlight.delete(key);
188
+ }
189
+ if (pending.size > 0) schedule(windowMs);
190
+ return true;
191
+ };
192
+
193
+ const flush = (): Promise<boolean> => {
194
+ if (flushPromise) return flushPromise;
195
+ flushPromise = performFlush().finally(() => {
196
+ flushPromise = undefined;
197
+ });
198
+ return flushPromise;
199
+ };
200
+
201
+ const deliverUrgent = (event: UrgentCallbackEvent): boolean | Promise<boolean> => {
202
+ const key = eventKey(event);
203
+ if (urgentInFlight.has(key)) return false;
204
+ const disposition = eventDisposition(event);
205
+ if (disposition.kind === "delivered") return true;
206
+ if (disposition.kind === "suppressed") {
207
+ invokeSuppressed(event, disposition.reason, Date.now());
208
+ return true;
209
+ }
210
+
211
+ urgentInFlight.add(key);
212
+ try {
213
+ const handoff = host.sendMessage(
214
+ { customType: event.customType, content: event.content, display: true },
215
+ { deliverAs: "followUp", triggerTurn: true },
216
+ );
217
+ if (isPromiseLike(handoff)) {
218
+ return Promise.resolve(handoff).then(
219
+ () => {
220
+ invokeDelivered(event, Date.now());
221
+ return true;
222
+ },
223
+ () => false,
224
+ ).finally(() => urgentInFlight.delete(key));
225
+ }
226
+ invokeDelivered(event, Date.now());
227
+ urgentInFlight.delete(key);
228
+ return true;
229
+ } catch {
230
+ urgentInFlight.delete(key);
231
+ return false;
232
+ }
233
+ };
234
+
235
+ const api: CallbackBatcher = {
236
+ enqueue,
237
+ flush,
238
+ deliverUrgent,
239
+ cancel() {
240
+ cancelTimer();
241
+ pending.clear();
242
+ },
243
+ pendingCount() {
244
+ return pending.size;
245
+ },
246
+ };
247
+ return api;
248
+ }
249
+
250
+ export function getCallbackBatcher(
251
+ host: CallbackBatchHost,
252
+ options: CallbackBatcherOptions = {},
253
+ ): CallbackBatcher {
254
+ const state = globalState();
255
+ const key = host as object;
256
+ const existing = state.byHost.get(key);
257
+ if (existing) return existing;
258
+ const created = createCallbackBatcher(host, options);
259
+ state.byHost.set(key, created);
260
+ return created;
261
+ }
262
+
263
+ export function cancelCallbackBatch(host: CallbackBatchHost): void {
264
+ globalState().byHost.get(host as object)?.cancel();
265
+ }
266
+
267
+ function globalState(): SharedCallbackBatcherState {
268
+ const root = globalThis as typeof globalThis & {
269
+ [GLOBAL_STATE_KEY]?: SharedCallbackBatcherState;
270
+ };
271
+ root[GLOBAL_STATE_KEY] ??= { byHost: new WeakMap<object, CallbackBatcher>() };
272
+ return root[GLOBAL_STATE_KEY];
273
+ }
274
+
275
+ function eventKey(event: Pick<CallbackBatchEvent, "source" | "id" | "status">): string {
276
+ return `${event.source}\u0000${event.id}\u0000${event.status}`;
277
+ }
278
+
279
+ function eventDisposition(
280
+ event: Pick<CallbackBatchEvent, "isDelivered" | "getSuppressionReason">,
281
+ ): { kind: "deliver" } | { kind: "delivered" } | { kind: "suppressed"; reason: string } {
282
+ try {
283
+ if (event.isDelivered?.()) return { kind: "delivered" };
284
+ } catch {
285
+ return { kind: "suppressed", reason: "durable delivery state could not be verified" };
286
+ }
287
+ try {
288
+ const reason = event.getSuppressionReason?.();
289
+ return reason ? { kind: "suppressed", reason } : { kind: "deliver" };
290
+ } catch {
291
+ return { kind: "suppressed", reason: "callback ownership could not be verified" };
292
+ }
293
+ }
294
+
295
+ function invokeDelivered(
296
+ event: Pick<CallbackBatchEvent, "onDelivered">,
297
+ at: number,
298
+ ): void {
299
+ try { event.onDelivered?.(at); } catch { /* handoff already succeeded */ }
300
+ }
301
+
302
+ function invokeSuppressed(
303
+ event: Pick<CallbackBatchEvent, "onSuppressed">,
304
+ reason: string,
305
+ at: number,
306
+ ): void {
307
+ try { event.onSuppressed?.(reason, at); } catch { /* best effort durable suppression */ }
308
+ }
309
+
310
+ function boundedField(value: unknown, maxChars: number): string {
311
+ const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
312
+ if (oneLine.length <= maxChars) return oneLine;
313
+ return `${oneLine.slice(0, Math.max(0, maxChars - 3))}...`;
314
+ }
315
+
316
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
317
+ return (typeof value === "object" || typeof value === "function")
318
+ && value !== null
319
+ && typeof (value as PromiseLike<unknown>).then === "function";
320
+ }
@@ -54,6 +54,7 @@ export type BackgroundWorkProvider = {
54
54
  label: string;
55
55
  priority: number;
56
56
  visibleCount(): number;
57
+ parentRow?(now: number): BackgroundWorkRow | null;
57
58
  listRows(now: number): BackgroundWorkRow[];
58
59
  detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
59
60
  armCloseLabel(row: BackgroundWorkRow): string;
@@ -411,6 +412,16 @@ function buildMainListLines(
411
412
  const group = grouped.get(label)!;
412
413
  if (i > 0) lines.push("");
413
414
  lines.push(providerGroupLabel(label, fg));
415
+ const provider = state().providers.get(group[0]!.providerId);
416
+ let parentRow: BackgroundWorkRow | null = null;
417
+ try { parentRow = provider?.parentRow?.(Date.now()) ?? null; } catch { parentRow = null; }
418
+ if (parentRow) {
419
+ lines.push(formatMainListRow({
420
+ ...parentRow,
421
+ navigatorId: rowKey(parentRow.providerId, parentRow.id),
422
+ providerLabel: label,
423
+ }, false, fg, width));
424
+ }
414
425
  for (const row of group) {
415
426
  const selected = options.focused && row.navigatorId === options.selectedId;
416
427
  lines.push(formatMainListRow(row, selected === true, fg, width));
@@ -976,7 +987,10 @@ function buildDetailLines(
976
987
  } else {
977
988
  const evidenceLabel = /log/i.test(detail.evidence.label) ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
978
989
  lines.push(dim(section(evidenceLabel, width), fg));
979
- for (const raw of body.split(/\r?\n/)) lines.push(raw ? ` ${raw}` : " ");
990
+ const wrapped = /log/i.test(detail.evidence.label)
991
+ ? wrapLogText(body, width - 6)
992
+ : body.split(/\r?\n/);
993
+ for (const raw of wrapped) lines.push(raw ? ` ${raw}` : " ");
980
994
  }
981
995
  lines.push("");
982
996
  const footerLines = [dim(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
@@ -1042,6 +1056,82 @@ function wrapEvidenceText(text: string, width: number): string[] {
1042
1056
  return rows.length ? rows : ["(no output yet)"];
1043
1057
  }
1044
1058
 
1059
+ /**
1060
+ * Wrap terminal log rows without flattening entries together. Continuations
1061
+ * retain the source indentation, and long paths/JSON tokens are hard-wrapped
1062
+ * so the final width guard never has to discard their suffix.
1063
+ */
1064
+ export function wrapLogText(text: string, width: number): string[] {
1065
+ const max = Math.max(8, Math.floor(width));
1066
+ const rows: string[] = [];
1067
+ for (const source of String(text ?? "").split(/\r?\n/)) {
1068
+ if (!source) {
1069
+ rows.push("");
1070
+ continue;
1071
+ }
1072
+ const sourceIndent = source.match(/^[ \t]*/)?.[0] ?? "";
1073
+ const indent = sourceIndent.replace(/\t/g, " ");
1074
+ let remaining = source.slice(sourceIndent.length);
1075
+ const contentWidth = Math.max(1, max - visibleWidth(indent));
1076
+ if (!remaining) {
1077
+ rows.push(indent);
1078
+ continue;
1079
+ }
1080
+ while (visibleWidth(remaining) > contentWidth) {
1081
+ let [head, tail] = splitVisiblePrefix(remaining, contentWidth);
1082
+ const minSoftBreak = Math.floor(contentWidth * 0.45);
1083
+ const delimiterBreak = lastDelimiterBreak(head);
1084
+ const whitespace = head.match(/\s+\S*$/)?.index;
1085
+ if (delimiterBreak > minSoftBreak) {
1086
+ tail = head.slice(delimiterBreak) + tail;
1087
+ head = head.slice(0, delimiterBreak);
1088
+ } else if (whitespace !== undefined && whitespace > minSoftBreak) {
1089
+ tail = head.slice(whitespace).trimStart() + tail;
1090
+ head = head.slice(0, whitespace).trimEnd();
1091
+ }
1092
+ rows.push(indent + head);
1093
+ remaining = tail.trimStart();
1094
+ }
1095
+ rows.push(indent + remaining);
1096
+ }
1097
+ return rows.length ? rows : [""];
1098
+ }
1099
+
1100
+ function lastDelimiterBreak(value: string): number {
1101
+ let last = -1;
1102
+ for (const match of value.matchAll(/[\/,}:]/g)) last = match.index + 1;
1103
+ return last;
1104
+ }
1105
+
1106
+ function splitVisiblePrefix(value: string, width: number): [string, string] {
1107
+ const max = Math.max(0, Math.floor(width));
1108
+ let visible = 0;
1109
+ let index = 0;
1110
+ while (index < value.length && visible < max) {
1111
+ if (value[index] === "\u001b" || value[index] === "\u009b") {
1112
+ const match = value.slice(index).match(ANSI_RE);
1113
+ if (match?.index === 0) {
1114
+ index += match[0].length;
1115
+ continue;
1116
+ }
1117
+ }
1118
+ if (value[index] === "<") {
1119
+ const close = value.indexOf(">", index);
1120
+ if (close !== -1) {
1121
+ const tag = value.slice(index, close + 1);
1122
+ if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
1123
+ index = close + 1;
1124
+ continue;
1125
+ }
1126
+ }
1127
+ }
1128
+ const codePoint = value.codePointAt(index)!;
1129
+ index += codePoint > 0xFFFF ? 2 : 1;
1130
+ visible += 1;
1131
+ }
1132
+ return [value.slice(0, index), value.slice(index)];
1133
+ }
1134
+
1045
1135
  function cycleLogTailRows(current: number): number {
1046
1136
  const idx = LOG_TAIL_ROW_CHOICES.findIndex((value) => value === current);
1047
1137
  return LOG_TAIL_ROW_CHOICES[(idx + 1) % LOG_TAIL_ROW_CHOICES.length];
package/src/tools.ts CHANGED
@@ -2,6 +2,7 @@ import { Type } from "typebox";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { readLog } from "./logs.js";
4
4
  import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
5
+ import { cancelCallbackBatch } from "./shared-callback-batcher.js";
5
6
  import { listMetas, readMeta, writeMeta } from "./registry.js";
6
7
  import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
7
8
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
@@ -89,9 +90,11 @@ export function registerTools(pi: ExtensionAPI): void {
89
90
  });
90
91
  pi.on("session_before_switch", () => {
91
92
  activeSession = undefined;
93
+ cancelCallbackBatch(pi);
92
94
  });
93
95
  pi.on("session_shutdown", () => {
94
96
  activeSession = undefined;
97
+ cancelCallbackBatch(pi);
95
98
  });
96
99
 
97
100
  pi.registerTool({