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 +1 -1
- package/src/runtime.ts +31 -17
- package/src/shared-callback-batcher.ts +320 -0
- package/src/shared-navigator.ts +91 -1
- package/src/tools.ts +3 -0
package/package.json
CHANGED
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
+
}
|
package/src/shared-navigator.ts
CHANGED
|
@@ -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
|
-
|
|
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({
|