pi-better-background-tasks 0.2.20 → 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.
@@ -16,6 +16,24 @@ export interface CallbackBatchEvent {
16
16
  status: string;
17
17
  detailTool: CallbackDetailTool;
18
18
  callback?: boolean;
19
+ /** Lifecycle/work outcome; independent of semantic task correctness. */
20
+ outcome?: string;
21
+ /**
22
+ * Legacy single-string failure summary already reduced by the observation
23
+ * owner. Prefer `failureRows` so shown/omitted incidents are counted exactly.
24
+ */
25
+ failure?: string;
26
+ /** One row per active incident, priority order (failure-observations formatFailureLines). */
27
+ failureRows?: string[];
28
+ /** Matched-condition, stop-error, or observation-gap facts. */
29
+ decision?: string;
30
+ /** Active incidents for this row. Defaults to `failureRows.length`. */
31
+ incidentCount?: number;
32
+ /**
33
+ * Legacy: incidents the caller already knows are not in `failure`. Ignored
34
+ * when `failureRows` is given, because the batch then counts rows it shows.
35
+ */
36
+ omittedIncidents?: number;
19
37
  isDelivered?: () => boolean;
20
38
  getSuppressionReason?: () => string | undefined;
21
39
  onDelivered?: (at: number) => void;
@@ -24,11 +42,21 @@ export interface CallbackBatchEvent {
24
42
 
25
43
  export interface UrgentCallbackEvent {
26
44
  source: CallbackSource;
45
+ /** Notification identity (dedupe/receipts). */
27
46
  id: string;
47
+ /** Retrieval identity for the inspect tool when it differs from `id`. */
48
+ inspectId?: string;
28
49
  label: string;
29
50
  status: "orphaned" | "lost" | string;
30
51
  customType: string;
52
+ /** Explanation text (health transition, attention reason). */
31
53
  content: string;
54
+ detailTool?: CallbackDetailTool;
55
+ /** One row per active incident, priority order. */
56
+ failureRows?: string[];
57
+ incidentCount?: number;
58
+ /** Legacy: ignored when `failureRows` is given. */
59
+ omittedIncidents?: number;
32
60
  isDelivered?: () => boolean;
33
61
  getSuppressionReason?: () => string | undefined;
34
62
  onDelivered?: (at: number) => void;
@@ -38,6 +66,8 @@ export interface UrgentCallbackEvent {
38
66
  export interface CallbackBatcherOptions {
39
67
  windowMs?: number;
40
68
  retryMs?: number;
69
+ /** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
70
+ maxBytes?: number;
41
71
  }
42
72
 
43
73
  export interface CallbackBatcher {
@@ -48,6 +78,16 @@ export interface CallbackBatcher {
48
78
  pendingCount(): number;
49
79
  }
50
80
 
81
+ export interface CallbackBatchFormatOptions {
82
+ maxBytes?: number;
83
+ }
84
+
85
+ export interface FormattedCallbackBatch {
86
+ text: string;
87
+ represented: CallbackBatchEvent[];
88
+ omitted: number;
89
+ }
90
+
51
91
  interface PendingEvent {
52
92
  event: CallbackBatchEvent;
53
93
  sequence: number;
@@ -60,9 +100,25 @@ interface SharedCallbackBatcherState {
60
100
  const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
61
101
  const DEFAULT_WINDOW_MS = 100;
62
102
  const DEFAULT_RETRY_MS = 1_000;
63
- const MAX_LABEL_CHARS = 160;
64
- const MAX_ID_CHARS = 200;
65
- const MAX_STATUS_CHARS = 80;
103
+ const MAX_LABEL_BYTES = 160;
104
+ const MAX_ID_BYTES = 200;
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;
110
+ const MAX_FAILURE_BYTES = 400;
111
+ const MAX_DECISION_BYTES = 400;
112
+ const encoder = new TextEncoder();
113
+ const decoder = new TextDecoder("utf-8");
114
+
115
+ /** OUTPUT-POLICY default: UTF-8 bytes of one model-facing callback batch. */
116
+ export const CALLBACK_BATCH_BUDGET_BYTES = 2 * 1024;
117
+ /** Documented hard cap. Explicit larger pages clamp here. */
118
+ export const CALLBACK_BATCH_MAX_BYTES = 8 * 1024;
119
+
120
+ const RETRIEVAL_FOOTER =
121
+ "Retrieve durable results/status with the listed tools using cursor/limit. Full results and logs are intentionally omitted.";
66
122
 
67
123
  export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
68
124
  export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
@@ -76,24 +132,293 @@ export function resolveCallbackBatchWindowMs(
76
132
  return Math.max(0, Math.min(5_000, Math.floor(parsed)));
77
133
  }
78
134
 
79
- export function formatCallbackBatch(events: readonly CallbackBatchEvent[]): string {
80
- const count = events.length;
135
+ export function utf8ByteLength(text: string): number {
136
+ return encoder.encode(text).byteLength;
137
+ }
138
+
139
+ export function callbackBatchBudget(requested?: unknown): number {
140
+ const parsed = typeof requested === "number" ? requested
141
+ : typeof requested === "string" && requested.trim() !== "" ? Number(requested)
142
+ : Number.NaN;
143
+ if (!Number.isFinite(parsed) || parsed <= 0) return CALLBACK_BATCH_BUDGET_BYTES;
144
+ return Math.min(Math.max(1, Math.floor(parsed)), CALLBACK_BATCH_MAX_BYTES);
145
+ }
146
+
147
+ function completeUtf8End(bytes: Uint8Array, to: number): number {
148
+ if (to <= 0) return 0;
149
+ if (to >= bytes.length) return bytes.length;
150
+ let seqStart = to - 1;
151
+ while (seqStart > 0 && (bytes[seqStart]! & 0xc0) === 0x80) seqStart -= 1;
152
+ if ((bytes[seqStart]! & 0xc0) === 0x80) return to;
153
+ const lead = bytes[seqStart]!;
154
+ const needed = lead <= 0x7f ? 1
155
+ : (lead & 0xe0) === 0xc0 ? 2
156
+ : (lead & 0xf0) === 0xe0 ? 3
157
+ : (lead & 0xf8) === 0xf0 ? 4
158
+ : 1;
159
+ return seqStart + needed > to ? seqStart : to;
160
+ }
161
+
162
+ function clipUtf8Prefix(text: string, maxBytes: number): string {
163
+ if (maxBytes <= 0) return "";
164
+ const bytes = encoder.encode(text);
165
+ if (bytes.byteLength <= maxBytes) return text;
166
+ return decoder.decode(bytes.subarray(0, completeUtf8End(bytes, Math.min(maxBytes, bytes.byteLength))));
167
+ }
168
+
169
+ function boundedField(value: unknown, maxBytes: number): string {
170
+ const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
171
+ if (utf8ByteLength(oneLine) <= maxBytes) return oneLine;
172
+ const ellipsis = "...";
173
+ return `${clipUtf8Prefix(oneLine, Math.max(0, maxBytes - utf8ByteLength(ellipsis)))}${ellipsis}`;
174
+ }
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
+
203
+ function inspectFor(event: Pick<CallbackBatchEvent, "id" | "detailTool">): string {
204
+ const id = boundedField(event.id, MAX_ID_BYTES);
205
+ return event.detailTool === "bg_task_status"
206
+ ? `bg_task_status id=${id}`
207
+ : `subagent_result id=${JSON.stringify(id)}`;
208
+ }
209
+
210
+ interface IncidentLines {
211
+ lines: string[];
212
+ shown: number;
213
+ total: number;
214
+ }
215
+
216
+ /**
217
+ * Whole incident rows that fit `maxBytes`, one per line. A first row that does
218
+ * not fit is shown as a clipped prefix and is NOT counted as shown.
219
+ */
220
+ function incidentLines(rows: readonly string[], total: number, maxBytes: number, indent: string): IncidentLines {
221
+ const lines: string[] = [];
222
+ let used = 0;
223
+ let shown = 0;
224
+ for (const row of rows) {
225
+ const line = `${indent}${String(row).replace(/\s+/g, " ").trim()}`;
226
+ const size = utf8ByteLength(line) + 1;
227
+ if (used + size <= maxBytes) {
228
+ lines.push(line);
229
+ used += size;
230
+ shown += 1;
231
+ continue;
232
+ }
233
+ if (lines.length === 0 && maxBytes - indent.length > 48) {
234
+ lines.push(`${indent}${boundedField(row, maxBytes - utf8ByteLength(indent) - 1)} (clipped)`);
235
+ }
236
+ break;
237
+ }
238
+ return { lines, shown, total: Math.max(total, rows.length) };
239
+ }
240
+
241
+ function countsLine(incidents: IncidentLines, inspect: string): string | undefined {
242
+ if (incidents.total <= 0) return undefined;
243
+ const omitted = Math.max(0, incidents.total - incidents.shown);
244
+ return ` incidents=${incidents.total} shown=${incidents.shown}` +
245
+ (omitted > 0 ? ` omittedIncidents=${omitted} retrieve: ${inspect} (incident pages via cursor)` : "");
246
+ }
247
+
248
+ function eventIncidents(event: CallbackBatchEvent, failureBytes: number): IncidentLines {
249
+ if (event.failureRows && event.failureRows.length) {
250
+ return incidentLines(event.failureRows, event.incidentCount ?? event.failureRows.length, failureBytes, " failure: ");
251
+ }
252
+ if (event.failure) {
253
+ const total = event.incidentCount ?? 1;
254
+ const whole = incidentLines([event.failure], 1, failureBytes, " failure: ");
255
+ const legacyShown = whole.shown ? Math.max(0, total - (event.omittedIncidents ?? 0)) : 0;
256
+ return { lines: whole.lines, shown: legacyShown, total };
257
+ }
258
+ const total = event.incidentCount ?? 0;
259
+ return { lines: [], shown: 0, total };
260
+ }
261
+
262
+ function formatRow(event: CallbackBatchEvent, detailBytes = MAX_FAILURE_BYTES + MAX_DECISION_BYTES): string {
263
+ const source = boundedField(event.source, 40);
264
+ const id = boundedField(event.id, MAX_ID_BYTES);
265
+ const label = boundedField(event.label, MAX_LABEL_BYTES);
266
+ const status = boundedStatus(event.status);
267
+ const inspect = inspectFor(event);
268
+ const lines = [
269
+ `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${inspect}`,
270
+ ];
271
+ if (event.outcome) {
272
+ const outcome = boundedField(event.outcome, 80);
273
+ if (outcome && outcome !== status) lines.push(` outcome=${outcome}`);
274
+ }
275
+ const decisionBytes = Math.min(MAX_DECISION_BYTES, Math.floor(detailBytes / 2));
276
+ if (event.decision && decisionBytes > 24) lines.push(` decision: ${boundedField(event.decision, decisionBytes)}`);
277
+ const incidents = eventIncidents(event, Math.max(0, Math.min(MAX_FAILURE_BYTES * 2, detailBytes - decisionBytes)));
278
+ lines.push(...incidents.lines);
279
+ const counts = countsLine(incidents, inspect);
280
+ if (counts) lines.push(counts);
281
+ return lines.join("\n");
282
+ }
283
+
284
+ function renderBatch(represented: readonly CallbackBatchEvent[], omitted: number, detailBytes?: number): string {
285
+ const count = represented.length;
81
286
  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");
287
+ const omittedLine = omitted > 0
288
+ ? `${omitted} more completion${omitted === 1 ? "" : "s"} omitted from this batch (not receipted; still queued).`
289
+ : undefined;
290
+ return [heading, ...represented.map((event) => formatRow(event, detailBytes)), omittedLine, RETRIEVAL_FOOTER]
291
+ .filter((line): line is string => Boolean(line))
292
+ .join("\n");
293
+ }
294
+
295
+ function clipRendered(text: string, maxBytes: number): string {
296
+ if (utf8ByteLength(text) <= maxBytes) return text;
297
+ const suffix = "\n[clipped to callback budget]";
298
+ const budget = maxBytes - utf8ByteLength(suffix);
299
+ if (budget < 24) return clipUtf8Prefix(text, maxBytes);
300
+ return `${clipUtf8Prefix(text, budget)}${suffix}`;
301
+ }
302
+
303
+ function eventPriority(event: CallbackBatchEvent): number {
304
+ if (event.failure || event.failureRows?.length || (event.omittedIncidents ?? 0) > 0 || (event.incidentCount ?? 0) > 0) return 0;
305
+ if (event.decision) return 1;
306
+ const status = String(event.status ?? "").toLowerCase();
307
+ if (/(?:fail|orphan|lost|timed_out|timeout|unresolved|incomplete|observation incomplete)/.test(status)) return 0;
308
+ return 2;
309
+ }
310
+
311
+ /**
312
+ * Urgent health/failure callback under the total budget. Order: header,
313
+ * explanation, whole incident rows that fit, the incident count line (total,
314
+ * shown, omitted, retrieval), and the inspect line. The count and inspect
315
+ * lines are never dropped; unshown explanation bytes are counted.
316
+ */
317
+ export function formatUrgentCallback(
318
+ event: UrgentCallbackEvent,
319
+ options: CallbackBatchFormatOptions = {},
320
+ ): string {
321
+ const maxBytes = callbackBatchBudget(options.maxBytes);
322
+ const target = event.inspectId ?? event.id;
323
+ const id = boundedField(target, MAX_ID_BYTES);
324
+ const label = boundedField(event.label, MAX_LABEL_BYTES);
325
+ const status = boundedStatus(event.status);
326
+ const tool = event.detailTool
327
+ ?? (event.source === "background-task" ? "bg_task_status" : "subagent_result");
328
+ const inspectTarget = tool === "bg_task_status" ? `bg_task_status id=${id}` : `subagent_result id=${JSON.stringify(id)}`;
329
+ const inspect = `Inspect: ${inspectTarget}`;
330
+ const header = `${boundedField(event.source, 40)} id=${id} label=${JSON.stringify(label)} status=${status}`;
331
+ const source = String(event.content ?? "").trim();
332
+ const rows = event.failureRows ?? [];
333
+ const total = rows.length ? Math.max(rows.length, event.incidentCount ?? 0) : event.incidentCount ?? 0;
334
+ const legacyOmitted = rows.length ? 0 : event.omittedIncidents ?? 0;
335
+ const counts = (shown: number): string | undefined => {
336
+ if (total <= 0) return undefined;
337
+ const omitted = rows.length ? total - shown : legacyOmitted;
338
+ const shownPart = rows.length ? ` shown=${shown}` : "";
339
+ return `incidents=${total}${shownPart}` +
340
+ (omitted > 0 ? ` omittedIncidents=${omitted} retrieve: ${inspectTarget} (incident pages via cursor)` : "");
341
+ };
342
+ const render = (body: string, note: string | undefined, shownRows: string[]): string =>
343
+ [header, body, note, ...shownRows, counts(shownRows.length), inspect]
344
+ .filter((part): part is string => Boolean(part && part.length > 0))
345
+ .join("\n");
346
+ const fixed = utf8ByteLength(render("", undefined, [])) + 16;
347
+ const room = Math.max(0, maxBytes - fixed);
348
+ // The explanation keeps at least half the room when incident rows compete.
349
+ const contentShare = rows.length ? Math.floor(room / 2) : room;
350
+ let body = source;
351
+ let note: string | undefined;
352
+ if (utf8ByteLength(source) > contentShare) {
353
+ const noteFor = (omitted: number) => `omittedBytes=${omitted} retrieve: ${inspectTarget}`;
354
+ const clipped = clipUtf8Prefix(source, Math.max(0, contentShare - utf8ByteLength(noteFor(utf8ByteLength(source))) - 1));
355
+ body = clipped;
356
+ note = noteFor(utf8ByteLength(source) - utf8ByteLength(clipped));
357
+ }
358
+ const shown: string[] = [];
359
+ for (const row of rows) {
360
+ const line = String(row).replace(/\s+/g, " ").trim();
361
+ if (utf8ByteLength(render(body, note, [...shown, line])) + 8 > maxBytes) break;
362
+ shown.push(line);
363
+ }
364
+ const rendered = render(body, note, shown);
365
+ if (utf8ByteLength(rendered) <= maxBytes) return rendered;
366
+ const minimal = render("", source ? `omittedBytes=${utf8ByteLength(source)} retrieve: ${inspectTarget}` : undefined, []);
367
+ return clipRendered(minimal, maxBytes);
368
+ }
369
+
370
+ export function packCallbackBatch(
371
+ events: readonly CallbackBatchEvent[],
372
+ options: CallbackBatchFormatOptions = {},
373
+ ): FormattedCallbackBatch {
374
+ const maxBytes = callbackBatchBudget(options.maxBytes);
375
+ if (events.length === 0) {
376
+ return { text: renderBatch([], 0), represented: [], omitted: 0 };
377
+ }
378
+
379
+ const ranked = events.map((event, index) => ({ event, index }))
380
+ .sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.index - b.index);
381
+
382
+ const selected = new Set<number>();
383
+ const renderSelected = (): string => {
384
+ const represented = events.filter((_, index) => selected.has(index));
385
+ return renderBatch(represented, events.length - selected.size);
386
+ };
387
+
388
+ for (const { index } of ranked) {
389
+ selected.add(index);
390
+ if (utf8ByteLength(renderSelected()) <= maxBytes) continue;
391
+ selected.delete(index);
392
+ if (selected.size === 0) {
393
+ // One row alone exceeds the budget: shrink its detail, never its counts.
394
+ const event = events[index]!;
395
+ for (const detail of [MAX_FAILURE_BYTES, 200, 0]) {
396
+ const text = renderBatch([event], events.length - 1, detail);
397
+ if (utf8ByteLength(text) <= maxBytes) {
398
+ return { text, represented: [event], omitted: events.length - 1 };
399
+ }
400
+ }
401
+ return {
402
+ text: clipRendered(renderBatch([event], events.length - 1, 0), maxBytes),
403
+ represented: [event],
404
+ omitted: events.length - 1,
405
+ };
406
+ }
407
+ }
408
+
409
+ const represented = events.filter((_, index) => selected.has(index));
410
+ return {
411
+ text: renderSelected(),
412
+ represented,
413
+ omitted: events.length - represented.length,
414
+ };
415
+ }
416
+
417
+ export function formatCallbackBatch(
418
+ events: readonly CallbackBatchEvent[],
419
+ options: CallbackBatchFormatOptions = {},
420
+ ): string {
421
+ return packCallbackBatch(events, options).text;
97
422
  }
98
423
 
99
424
  export function createCallbackBatcher(
@@ -102,6 +427,7 @@ export function createCallbackBatcher(
102
427
  ): CallbackBatcher {
103
428
  const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
104
429
  const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
430
+ const maxBytes = callbackBatchBudget(options.maxBytes);
105
431
  const pending = new Map<string, PendingEvent>();
106
432
  const inFlight = new Set<string>();
107
433
  const urgentInFlight = new Set<string>();
@@ -177,11 +503,16 @@ export function createCallbackBatcher(
177
503
  return !deferred;
178
504
  }
179
505
 
506
+ const packed = packCallbackBatch(deliverable.map(([, item]) => item.event), { maxBytes });
507
+ const representedSet = new Set(packed.represented);
508
+ const representedItems = deliverable.filter(([, item]) => representedSet.has(item.event));
509
+ const overflowItems = deliverable.filter(([, item]) => !representedSet.has(item.event));
510
+
180
511
  try {
181
512
  await host.sendMessage(
182
513
  {
183
514
  customType: "background-completion-batch",
184
- content: formatCallbackBatch(deliverable.map(([, item]) => item.event)),
515
+ content: packed.text,
185
516
  display: true,
186
517
  },
187
518
  { deliverAs: "followUp", triggerTurn: true },
@@ -199,7 +530,7 @@ export function createCallbackBatcher(
199
530
  }
200
531
 
201
532
  const deliveredAt = Date.now();
202
- for (const [key, item] of deliverable) {
533
+ for (const [key, item] of representedItems) {
203
534
  handedOff.set(key, deliveredAt);
204
535
  if (!invokeDelivered(item.event, deliveredAt)) {
205
536
  deferred = true;
@@ -207,6 +538,10 @@ export function createCallbackBatcher(
207
538
  }
208
539
  inFlight.delete(key);
209
540
  }
541
+ for (const [key, item] of overflowItems) {
542
+ pending.set(key, item);
543
+ inFlight.delete(key);
544
+ }
210
545
  if (pending.size > 0) schedule(deferred ? retryMs : windowMs);
211
546
  return !deferred;
212
547
  };
@@ -240,7 +575,7 @@ export function createCallbackBatcher(
240
575
  urgentInFlight.add(key);
241
576
  try {
242
577
  const handoff = host.sendMessage(
243
- { customType: event.customType, content: event.content, display: true },
578
+ { customType: event.customType, content: formatUrgentCallback(event, { maxBytes }), display: true },
244
579
  { deliverAs: "followUp", triggerTurn: true },
245
580
  );
246
581
  if (isPromiseLike(handoff)) {
@@ -333,12 +668,6 @@ function invokeSuppressed(
333
668
  try { event.onSuppressed?.(reason, at); } catch { /* best effort durable suppression */ }
334
669
  }
335
670
 
336
- function boundedField(value: unknown, maxChars: number): string {
337
- const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
338
- if (oneLine.length <= maxChars) return oneLine;
339
- return `${oneLine.slice(0, Math.max(0, maxChars - 3))}...`;
340
- }
341
-
342
671
  function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
343
672
  return (typeof value === "object" || typeof value === "function")
344
673
  && value !== null