pi-better-subagents 0.1.16 → 0.1.18

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/finalization.ts CHANGED
@@ -62,6 +62,13 @@ export function finalizeRun(
62
62
  if (outcome.incomplete) meta.failureReason = "incomplete-stream";
63
63
  meta.exitCode = code;
64
64
  meta.endedAt = Date.now();
65
+ const callback = meta.callback !== false;
66
+ if (callback
67
+ && meta.completionCallbackPendingAt === undefined
68
+ && meta.completionCallbackSentAt === undefined
69
+ && meta.completionCallbackSuppressedAt === undefined) {
70
+ meta.completionCallbackPendingAt = meta.endedAt;
71
+ }
65
72
  writeMeta(meta);
66
73
 
67
74
  const label = meta.name ? `${meta.name} (${id})` : id;
@@ -84,10 +91,9 @@ export function finalizeRun(
84
91
  /* ignore */
85
92
  }
86
93
 
87
- const callback = meta.callback !== false; // default: trigger completion
88
- // buildCompletionDelivery is the single place sendMessage content/options are
89
- // assembled. resultText is accepted here so callers/tests can pass it without
90
- // breaking, but it is NEVER put into content — the result lives in subagent_result.
94
+ // buildCompletionDelivery remains the compatibility formatter for callers and
95
+ // direct finalizer tests. Production callback:true delivery is coalesced by the
96
+ // host wrapper; callback:false never invokes a model-message hook.
91
97
  const delivery = buildCompletionDelivery({
92
98
  id,
93
99
  label,
@@ -98,10 +104,12 @@ export function finalizeRun(
98
104
  lifecycleClassification: outcome.classification,
99
105
  resultText: r.finalText || r.lastActivity || "",
100
106
  });
101
- hooks.sendMessage?.(
102
- { customType: "subagent-complete", content: delivery.content, display: true },
103
- delivery.options,
104
- );
107
+ if (callback) {
108
+ hooks.sendMessage?.(
109
+ { customType: "subagent-complete", content: delivery.content, display: true },
110
+ delivery.options,
111
+ );
112
+ }
105
113
 
106
114
  return {
107
115
  applied: true,
package/index.ts CHANGED
@@ -84,6 +84,7 @@ import {
84
84
  getSharedCapacityGate,
85
85
  } from "./capacity.mjs";
86
86
  import { buildHealthCallbackDelivery } from "./completion.ts";
87
+ import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
87
88
  import {
88
89
  text,
89
90
  subagentListTool,
@@ -96,6 +97,7 @@ import {
96
97
  WIDGET_CLEAR,
97
98
  fmtElapsed,
98
99
  fmtSpend,
100
+ fmtTokens,
99
101
  shortModel,
100
102
  isSpendCacheFresh,
101
103
  resolveHealthLogExtraction,
@@ -236,12 +238,58 @@ function stopCurrentSessionSubagents(ctx: ExtensionContext): void {
236
238
 
237
239
  function markCompletionCallbackSuppressed(id: string, reason: string, now: number = Date.now()): void {
238
240
  const meta = readMeta(id);
239
- if (!meta || meta.completionCallbackSuppressedAt !== undefined) return;
241
+ if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
240
242
  meta.completionCallbackSuppressedAt = now;
241
243
  meta.completionCallbackSuppressedReason = reason;
242
244
  writeMeta(meta);
243
245
  }
244
246
 
247
+ function markCompletionCallbackSent(id: string, now: number): void {
248
+ const meta = readMeta(id);
249
+ if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
250
+ meta.completionCallbackSentAt = now;
251
+ writeMeta(meta);
252
+ }
253
+
254
+ /** Queue one durable ordinary terminal event on the host-shared batch. */
255
+ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
256
+ const meta = readMeta(id);
257
+ if (!meta
258
+ || meta.callback === false
259
+ || meta.completionCallbackPendingAt === undefined
260
+ || meta.completionCallbackSentAt !== undefined
261
+ || meta.completionCallbackSuppressedAt !== undefined) return;
262
+ const label = meta.name ? `${meta.name} (${id})` : id;
263
+ getCallbackBatcher(pi).enqueue({
264
+ source: "subagent",
265
+ id,
266
+ label,
267
+ status: meta.status,
268
+ detailTool: "subagent_result",
269
+ callback: true,
270
+ isDelivered: () => {
271
+ const current = readMeta(id);
272
+ return current?.completionCallbackSentAt !== undefined
273
+ || current?.completionCallbackSuppressedAt !== undefined;
274
+ },
275
+ getSuppressionReason: () => {
276
+ const current = readMeta(id);
277
+ if (!current) return "subagent metadata is unavailable";
278
+ return callbackSuppressionReason(current);
279
+ },
280
+ onDelivered: (at) => markCompletionCallbackSent(id, at),
281
+ onSuppressed: (reason, at) => markCompletionCallbackSuppressed(id, reason, at),
282
+ });
283
+ }
284
+
285
+ /** Recover only records explicitly marked pending; legacy terminal runs never replay. */
286
+ function recoverCompletionCallbacks(pi: ExtensionAPI): void {
287
+ for (const meta of listMetas()) {
288
+ if (!ownedByThisParent(meta)) continue;
289
+ enqueueCompletionCallback(pi, meta.id);
290
+ }
291
+ }
292
+
245
293
  function markHealthCallbackSuppressed(meta: RunMeta, status: "orphaned" | "lost", reason: string, now: number): void {
246
294
  if (status === "orphaned") {
247
295
  if (meta.orphanedCallbackSuppressedAt !== undefined) return;
@@ -408,12 +456,6 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
408
456
  if (!pi) return;
409
457
  if (isHealthCallbackHandled(meta, status)) return;
410
458
 
411
- const suppressionReason = callbackSuppressionReason(meta);
412
- if (suppressionReason) {
413
- markHealthCallbackSuppressed(meta, status, suppressionReason, now);
414
- return;
415
- }
416
-
417
459
  const callback = meta.callback !== false;
418
460
  const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
419
461
  const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
@@ -425,20 +467,35 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
425
467
  writeMeta(meta);
426
468
  return;
427
469
  }
428
- try {
429
- pi.sendMessage(
430
- { customType: "subagent-health", content: delivery.content, display: true },
431
- delivery.options,
432
- );
433
- } catch {
434
- // Handoff failed — leave marker unset so a later tick/reload can retry.
435
- // Never let a delivery failure break the health ticker.
436
- return;
437
- }
438
- // Marker = successful handoff (sendMessage returned), not mere attempt.
439
- if (status === "orphaned") meta.orphanedCallbackSentAt = now;
440
- else meta.lostCallbackSentAt = now;
441
- writeMeta(meta);
470
+ void getCallbackBatcher(pi).deliverUrgent({
471
+ source: "subagent",
472
+ id: meta.id,
473
+ label,
474
+ status,
475
+ customType: "subagent-health",
476
+ content: delivery.content,
477
+ isDelivered: () => {
478
+ const current = readMeta(meta.id);
479
+ return current ? isHealthCallbackHandled(current, status) : true;
480
+ },
481
+ getSuppressionReason: () => {
482
+ const current = readMeta(meta.id);
483
+ if (!current) return "subagent metadata is unavailable";
484
+ return callbackSuppressionReason(current);
485
+ },
486
+ onDelivered: (at) => {
487
+ const current = readMeta(meta.id);
488
+ if (!current || isHealthCallbackHandled(current, status)) return;
489
+ if (status === "orphaned") current.orphanedCallbackSentAt = at;
490
+ else current.lostCallbackSentAt = at;
491
+ writeMeta(current);
492
+ },
493
+ onSuppressed: (reason, at) => {
494
+ const current = readMeta(meta.id);
495
+ if (!current || isHealthCallbackHandled(current, status)) return;
496
+ markHealthCallbackSuppressed(current, status, reason, at);
497
+ },
498
+ });
442
499
  }
443
500
 
444
501
  /** One reconciliation + durable health-callback recovery pass. */
@@ -587,6 +644,8 @@ export function setIdentityProbeForTests(probe: ProcessProbe | undefined): void
587
644
 
588
645
  let unregisterSubagentProvider: (() => void) | undefined;
589
646
  const TERMINAL_NAVIGATOR_RETENTION_MS = 30_000;
647
+ let mainAgentStartedAt: number | undefined;
648
+ const mainAgentTools = new Map<string, string>();
590
649
 
591
650
  /**
592
651
  * Observe health for a navigator row/detail (#69). Reuses the size/mtime-gated
@@ -743,6 +802,37 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
743
802
  });
744
803
  }
745
804
 
805
+ function mainAgentWorkRow(now: number): BackgroundWorkRow {
806
+ let running = mainAgentStartedAt !== undefined;
807
+ try { running ||= uiCtx?.isIdle() === false; } catch { /* use event state */ }
808
+ const model = shortModel(uiCtx?.model?.id);
809
+ const effort = uiCtx?.thinkingLevel;
810
+ const tool = [...mainAgentTools.values()].at(-1);
811
+ let contextTokens: number | null | undefined;
812
+ try { contextTokens = uiCtx?.getContextUsage()?.tokens; } catch { contextTokens = undefined; }
813
+ const tokens = typeof contextTokens === "number" ? `${fmtTokens(contextTokens)} tok` : undefined;
814
+ const bits = [
815
+ effort ? `${model} ${effort}` : model,
816
+ tool ? `tool ${tool}` : undefined,
817
+ tokens,
818
+ ].filter((bit): bit is string => Boolean(bit));
819
+ return {
820
+ providerId: "subagents",
821
+ id: "main",
822
+ name: "main",
823
+ model,
824
+ effort,
825
+ tool,
826
+ tokens,
827
+ status: running ? "running" : "idle",
828
+ statusTone: running ? "running" : "muted",
829
+ kind: "main agent",
830
+ elapsed: running && mainAgentStartedAt !== undefined ? fmtElapsed(now - mainAgentStartedAt) : "idle",
831
+ primary: bits.join(" · "),
832
+ sortStartedAt: mainAgentStartedAt ?? now,
833
+ };
834
+ }
835
+
746
836
  function subagentWorkDetail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
747
837
  const detail = navigatorDetail(id, now);
748
838
  if (!detail) return null;
@@ -780,6 +870,7 @@ function ensureSubagentProvider(): void {
780
870
  label: "Subagents",
781
871
  priority: 10,
782
872
  visibleCount: () => navigatorRunningCount(),
873
+ parentRow: (now) => mainAgentWorkRow(now),
783
874
  listRows: (now) => subagentWorkRows(now),
784
875
  detail: (id, now, options) => subagentWorkDetail(id, now, options),
785
876
  armCloseLabel: (row) => row.status === "running" || row.status === "orphaned" ? "x again to stop" : "x again to dismiss",
@@ -900,16 +991,7 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
900
991
  notify: (message, level) => {
901
992
  try { ctx.ui.notify(message, level); } catch { /* ignore */ }
902
993
  },
903
- sendMessage: (message, options) => {
904
- const meta = readMeta(id);
905
- if (!meta) return;
906
- const suppressionReason = callbackSuppressionReason(meta);
907
- if (suppressionReason) {
908
- markCompletionCallbackSuppressed(id, suppressionReason);
909
- return;
910
- }
911
- pi.sendMessage(message, options);
912
- },
994
+ sendMessage: () => enqueueCompletionCallback(pi, id),
913
995
  });
914
996
  }
915
997
 
@@ -1322,12 +1404,51 @@ export default function (pi: ExtensionAPI) {
1322
1404
  pi.registerTool(subagentStopTool(Type, { onStopped: renderWidget }));
1323
1405
 
1324
1406
  // ---- live-status lifecycle -----------------------------------------
1407
+ pi.on("agent_start", async (_event, ctx) => {
1408
+ uiCtx = ctx;
1409
+ mainAgentStartedAt = Date.now();
1410
+ mainAgentTools.clear();
1411
+ refreshBackgroundWorkNavigator(ctx);
1412
+ });
1413
+
1414
+ pi.on("tool_execution_start", async (event, ctx) => {
1415
+ mainAgentTools.set(event.toolCallId, event.toolName);
1416
+ refreshBackgroundWorkNavigator(ctx);
1417
+ });
1418
+
1419
+ pi.on("tool_execution_end", async (event, ctx) => {
1420
+ mainAgentTools.delete(event.toolCallId);
1421
+ refreshBackgroundWorkNavigator(ctx);
1422
+ });
1423
+
1424
+ pi.on("message_end", async (event, ctx) => {
1425
+ if (event.message.role === "assistant") refreshBackgroundWorkNavigator(ctx);
1426
+ });
1427
+
1428
+ pi.on("model_select", async (_event, ctx) => {
1429
+ refreshBackgroundWorkNavigator(ctx);
1430
+ });
1431
+
1432
+ pi.on("thinking_level_select", async (_event, ctx) => {
1433
+ refreshBackgroundWorkNavigator(ctx);
1434
+ });
1435
+
1436
+ pi.on("agent_settled", async (_event, ctx) => {
1437
+ mainAgentStartedAt = undefined;
1438
+ mainAgentTools.clear();
1439
+ refreshBackgroundWorkNavigator(ctx);
1440
+ });
1441
+
1325
1442
  // Capture a UI-bearing context and, if runs from a prior session are still
1326
1443
  // alive, resume the ticking widget. Deferred out of the factory per pi's
1327
1444
  // "no background resources at load" rule.
1328
1445
  pi.on("session_start", async (_event, ctx) => {
1329
1446
  uiCtx = ctx;
1447
+ try { mainAgentStartedAt = ctx.isIdle() ? undefined : Date.now(); }
1448
+ catch { mainAgentStartedAt = undefined; }
1449
+ mainAgentTools.clear();
1330
1450
  activeCallbackOrigin = callbackOriginFromContext(ctx);
1451
+ recoverCompletionCallbacks(pi);
1331
1452
  // Reload / session switch hardening (#48):
1332
1453
  // - Drop any leftover overlay timers/confirm state from a prior session
1333
1454
  // (defensive if the host skipped session_shutdown before re-start).
@@ -1359,6 +1480,9 @@ export default function (pi: ExtensionAPI) {
1359
1480
 
1360
1481
  pi.on("session_before_switch", () => {
1361
1482
  activeCallbackOrigin = undefined;
1483
+ cancelCallbackBatch(pi);
1484
+ mainAgentStartedAt = undefined;
1485
+ mainAgentTools.clear();
1362
1486
  disposeBackgroundWorkNavigator();
1363
1487
  });
1364
1488
 
@@ -1369,6 +1493,9 @@ export default function (pi: ExtensionAPI) {
1369
1493
  // cannot become live process groups with no coordinator.
1370
1494
  stopCurrentSessionSubagents(ctx);
1371
1495
  activeCallbackOrigin = undefined;
1496
+ cancelCallbackBatch(pi);
1497
+ mainAgentStartedAt = undefined;
1498
+ mainAgentTools.clear();
1372
1499
  stopTicker();
1373
1500
  stopHealthTicker();
1374
1501
  spendCache.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/registry.ts CHANGED
@@ -102,6 +102,9 @@ export interface RunMeta {
102
102
  sessionId: string;
103
103
  /** Foreground session that is allowed to receive unsolicited callbacks. */
104
104
  callbackOrigin?: RunCallbackOrigin;
105
+ /** Durable ordinary-completion callback recovery and successful-handoff markers. */
106
+ completionCallbackPendingAt?: number;
107
+ completionCallbackSentAt?: number;
105
108
  completionCallbackSuppressedAt?: number;
106
109
  completionCallbackSuppressedReason?: string;
107
110
  orphanedCallbackSuppressedAt?: number;
@@ -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];