pi-better-background-tasks 0.1.14 → 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/README.md CHANGED
@@ -16,6 +16,7 @@ Use `pi-better-background-tasks` when a command should keep running while the fo
16
16
  - Watch commands until success, failure, or timeout.
17
17
  - Keep task metadata and logs available across reloads.
18
18
  - Show active work in Pi's background-work navigator.
19
+ - Flag running tasks with no observable output or completed poll as stalled.
19
20
 
20
21
  ## Install
21
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.1.14",
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",
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { listMetas } from "./registry.js";
3
3
  import type { BackgroundTaskMeta } from "./types.js";
4
4
  import { isTerminalStatus } from "./types.js";
5
+ import { backgroundTaskProgressAt, observeBackgroundTaskStall } from "./stall.js";
5
6
 
6
7
  const GOAL_READY_EVENT = "pi-better-goal:ready";
7
8
  const GOAL_REGISTER_PROVIDER_EVENT = "pi-better-goal:register-provider";
@@ -92,11 +93,14 @@ function getGoalActivityOrigin(ctx: ExtensionContext) {
92
93
  function backgroundTaskToGoalItem(meta: BackgroundTaskMeta): GoalBackgroundWorkItem {
93
94
  const active = meta.status === "running";
94
95
  const terminal = isTerminalStatus(meta.status);
95
- const attention = terminal && meta.status !== "succeeded";
96
+ const stall = observeBackgroundTaskStall(meta);
97
+ const unhealthy = active && stall.state === "stalled";
98
+ const attention = (terminal && meta.status !== "succeeded") || unhealthy;
96
99
  const item: GoalBackgroundWorkItem = {
97
100
  id: meta.id,
98
101
  status: meta.status,
99
102
  active,
103
+ unhealthy,
100
104
  terminal,
101
105
  attention,
102
106
  startedAt: meta.startedAt,
@@ -108,6 +112,8 @@ function backgroundTaskToGoalItem(meta: BackgroundTaskMeta): GoalBackgroundWorkI
108
112
  argv: meta.argv,
109
113
  logPath: meta.logPath,
110
114
  spawnPid: meta.spawnPid,
115
+ lastProgressAt: backgroundTaskProgressAt(meta),
116
+ stall: stall.state,
111
117
  },
112
118
  };
113
119
  if (meta.name !== undefined) item.label = meta.name;
@@ -10,8 +10,9 @@ import {
10
10
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
12
  import { readLog } from "./logs.js";
13
- import { listMetas, readMeta, writeMeta } from "./registry.js";
13
+ import { listMetas, onMetaChanged, readMeta, writeMeta } from "./registry.js";
14
14
  import { stopTask } from "./runtime.js";
15
+ import { observeBackgroundTaskStall } from "./stall.js";
15
16
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
16
17
 
17
18
  let unregister: (() => void) | undefined;
@@ -62,6 +63,7 @@ const provider: BackgroundWorkProvider = {
62
63
  writeMeta(meta);
63
64
  return { action: "dismissed", providerId: "background-tasks", id, status: meta.status };
64
65
  },
66
+ onVisibleChanged: onMetaChanged,
65
67
  };
66
68
 
67
69
  function visibleMetas(now = Date.now()): BackgroundTaskMeta[] {
@@ -113,6 +115,9 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
113
115
  secondary: secondaryLabel(meta),
114
116
  facts: factsForMeta(meta, now),
115
117
  sortStartedAt: meta.startedAt,
118
+ expiresAt: meta.status === "running" || meta.endedAt === undefined
119
+ ? undefined
120
+ : meta.endedAt + TERMINAL_NAVIGATOR_RETENTION_MS,
116
121
  };
117
122
  }
118
123
 
@@ -131,6 +136,10 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
131
136
  ];
132
137
  if (meta.deadlineAt) metadata.push({ label: "deadline", value: formatDuration(meta.deadlineAt - now) });
133
138
  if (meta.lastCheckedAt) metadata.push({ label: "checked", value: `${formatDuration(now - meta.lastCheckedAt)} ago` });
139
+ if (meta.status === "running") {
140
+ const stall = observeBackgroundTaskStall(meta, now);
141
+ if (stall.state !== "healthy") metadata.push({ label: "activity", value: stall.state });
142
+ }
134
143
  if (meta.lastExitCode !== undefined) metadata.push({ label: "exit", value: String(meta.lastExitCode) });
135
144
  if (meta.error) metadata.push({ label: "error", value: meta.error });
136
145
  if (meta.logDiscardedBytes) {
@@ -194,6 +203,11 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
194
203
 
195
204
  function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
196
205
  const facts: string[] = [];
206
+ if (meta.status === "running") {
207
+ const stall = observeBackgroundTaskStall(meta, now);
208
+ if (stall.state === "stalled") facts.push("stalled");
209
+ else if (stall.state === "quiet") facts.push("quiet");
210
+ }
197
211
  if (meta.kind === "command_watch" && meta.intervalMs) facts.push(`every ${formatDuration(meta.intervalMs)}`);
198
212
  if (meta.deadlineAt && meta.status === "running") facts.push(`${formatDuration(meta.deadlineAt - now)} left`);
199
213
  if (meta.result && meta.status !== "running") {
package/src/registry.ts CHANGED
@@ -6,6 +6,7 @@ import { isTerminalStatus } from "./types.js";
6
6
 
7
7
  let seq = 0;
8
8
  const metaCache = new Map<string, BackgroundTaskMeta>();
9
+ const metaChangedListeners = new Set<() => void>();
9
10
 
10
11
  export function baseDir(): string {
11
12
  return join(tmpdir(), "pi-better-background-tasks");
@@ -40,6 +41,14 @@ export function writeMeta(meta: BackgroundTaskMeta): void {
40
41
  ensureTaskDir(meta.id);
41
42
  writeFileSync(metaPathFor(meta.id), JSON.stringify(meta, null, 2));
42
43
  metaCache.set(meta.id, meta);
44
+ for (const listener of metaChangedListeners) {
45
+ try { listener(); } catch { /* best effort */ }
46
+ }
47
+ }
48
+
49
+ export function onMetaChanged(listener: () => void): () => void {
50
+ metaChangedListeners.add(listener);
51
+ return () => metaChangedListeners.delete(listener);
43
52
  }
44
53
 
45
54
  export function readMeta(id: string): BackgroundTaskMeta | undefined {
package/src/runtime.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { statSync } from "node:fs";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import { appendLine, appendWatchResult, retainLogTail, resolveMaxLogBytes } from "./logs.js";
3
4
  import { evaluateCondition } from "./conditions.js";
4
5
  import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
5
6
  import { ensureTaskDir, logPathFor, nextTaskId, readMeta, writeMeta } from "./registry.js";
7
+ import { getCallbackBatcher } from "./shared-callback-batcher.js";
6
8
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, CommandSpec, Condition, TerminalResult } from "./types.js";
7
9
  import { isTerminalStatus } from "./types.js";
8
10
 
@@ -51,6 +53,7 @@ export function spawnTask(
51
53
  kind: "process",
52
54
  status: "running",
53
55
  startedAt: now,
56
+ lastProgressAt: now,
54
57
  deadlineAt: params.timeout_seconds ? now + params.timeout_seconds * 1000 : undefined,
55
58
  logPath,
56
59
  callback: params.callback,
@@ -103,6 +106,7 @@ export function startWatchTask(
103
106
  kind: "command_watch",
104
107
  status: "running",
105
108
  startedAt: now,
109
+ lastProgressAt: now,
106
110
  deadlineAt: timeoutSeconds ? now + timeoutSeconds * 1000 : undefined,
107
111
  intervalMs: Math.max(1, params.interval_seconds ?? 30) * 1000,
108
112
  logPath: logPathFor(id),
@@ -212,6 +216,7 @@ async function pollWatch(pi: ExtensionAPI, id: string, getActiveSession?: Active
212
216
  if (!latest || latest.status !== "running") return;
213
217
  enforceLogRetention(latest);
214
218
  latest.lastCheckedAt = Date.now();
219
+ latest.lastProgressAt = latest.lastCheckedAt;
215
220
  latest.lastExitCode = result.exitCode;
216
221
  latest.lastSignal = result.signal;
217
222
  latest.lastState = extractLastState(result);
@@ -300,24 +305,37 @@ async function notifyTerminal(
300
305
  if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
301
306
  const latest = readMeta(meta.id) ?? meta;
302
307
  if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
303
- const suppressionReason = getCallbackSuppressionReason(latest, getActiveSession?.());
304
- if (suppressionReason) {
305
- latest.callbackSuppressedAt = Date.now();
306
- latest.callbackSuppressedReason = suppressionReason;
307
- writeMeta(latest);
308
- return;
309
- }
310
308
  const label = latest.name ? `${latest.name} (${latest.id})` : latest.id;
311
- try {
312
- await pi.sendUserMessage(
313
- `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.`,
314
- { deliverAs: "followUp" },
315
- );
316
- latest.callbackSentAt = Date.now();
317
- writeMeta(latest);
318
- } catch {
319
- // Leave callbackSentAt unset so the originating session can attempt delivery once.
320
- }
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
+ });
321
339
  }
322
340
 
323
341
  function getCallbackSuppressionReason(
@@ -371,6 +389,15 @@ function stopLogRetention(id: string): void {
371
389
  }
372
390
 
373
391
  function enforceLogRetention(meta: BackgroundTaskMeta): void {
392
+ try {
393
+ const mtimeMs = Math.trunc(statSync(meta.logPath).mtimeMs);
394
+ if (mtimeMs > (meta.lastProgressAt ?? meta.startedAt)) {
395
+ meta.lastProgressAt = mtimeMs;
396
+ writeMeta(meta);
397
+ }
398
+ } catch {
399
+ // Logs are optional progress evidence; retention still proceeds if absent.
400
+ }
374
401
  const compacted = retainLogTail(meta.logPath, resolveMaxLogBytes(meta.maxLogBytes));
375
402
  if (!compacted) return;
376
403
  meta.logDiscardedBytes = (meta.logDiscardedBytes ?? 0) + compacted.discardedBytes;
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { Component } from "@earendil-works/pi-tui";
3
+ import { createRenderScheduler, type RenderScheduler } from "./shared-render-scheduler.ts";
3
4
 
4
5
  export default function navigatorExtension(): void {
5
6
  // Internal shared package. Pi may scan its symlink in the extension directory
@@ -25,6 +26,7 @@ export type BackgroundWorkRow = {
25
26
  secondary?: string;
26
27
  facts?: string[];
27
28
  sortStartedAt: number;
29
+ expiresAt?: number;
28
30
  };
29
31
 
30
32
  export type BackgroundWorkDetail = {
@@ -52,6 +54,7 @@ export type BackgroundWorkProvider = {
52
54
  label: string;
53
55
  priority: number;
54
56
  visibleCount(): number;
57
+ parentRow?(now: number): BackgroundWorkRow | null;
55
58
  listRows(now: number): BackgroundWorkRow[];
56
59
  detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
57
60
  armCloseLabel(row: BackgroundWorkRow): string;
@@ -79,7 +82,7 @@ type NavigatorState = {
79
82
  mainListFocused?: boolean;
80
83
  mainListCloseArm?: { id: string; armedAt: number };
81
84
  mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
82
- mainListTimer?: ReturnType<typeof setInterval>;
85
+ mainListDeadlineScheduler?: RenderScheduler;
83
86
  detailOverlayRows?: number;
84
87
  dispose?: () => void;
85
88
  };
@@ -91,15 +94,10 @@ const FACTORY_REFRESH = "__piBetterHarnessNavigatorRefresh";
91
94
  export const NAVIGATOR_STATUS_KEY = "background-work-nav";
92
95
  export const CLOSE_CONFIRM_STATUS_KEY = "background-work-close";
93
96
  export const MAIN_LIST_WIDGET_KEY = "background-work-list";
94
- export const DETAIL_TICK_MS = 1000;
97
+ export const DETAIL_TICK_MS = 10_000;
95
98
  export const CLOSE_ARM_MS = 3000;
96
99
  export const DEFAULT_LOG_TAIL_ROWS = 10;
97
100
  export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
98
- // Row rebuild cadence. Each tick asks every provider for rows, which stats and
99
- // parses run logs, so this is a per-provider I/O cadence and not a paint rate:
100
- // the widget repaints from render() whenever the TUI asks. 1 Hz matches
101
- // DETAIL_TICK_MS and the elapsed/spinner resolution the rows can actually show.
102
- const MAIN_LIST_TICK_MS = 1000;
103
101
  const MAIN_LIST_FALLBACK_WIDTH = 100;
104
102
  const DETAIL_OVERLAY_HEADER_MARGIN_ROWS = 5;
105
103
  const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
@@ -173,9 +171,10 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
173
171
  try { (ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
174
172
  s.mainListWidgetInstalled = false;
175
173
  s.mainListRequestRender = undefined;
174
+ s.mainListDeadlineScheduler?.dispose();
175
+ s.mainListDeadlineScheduler = createRenderScheduler(() => refreshMainListWidget());
176
176
  installNavigatorEditor(ctx.ui as any, deps);
177
177
  s.lastHint = undefined;
178
- startMainListWidget(ctx);
179
178
  refreshBackgroundWorkNavigator(ctx);
180
179
  refreshMainListWidget();
181
180
  }
@@ -193,6 +192,8 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
193
192
  }
194
193
  s.lastHint = undefined;
195
194
  s.lastMainListLines = undefined;
195
+ s.mainListDeadlineScheduler?.dispose();
196
+ s.mainListDeadlineScheduler = undefined;
196
197
  s.mainListWidgetInstalled = false;
197
198
  s.mainListRequestRender = undefined;
198
199
  s.detailOverlayRows = undefined;
@@ -226,17 +227,7 @@ function visibleCount(): number {
226
227
  }, 0);
227
228
  }
228
229
 
229
- function startMainListWidget(ctx: ExtensionContext): void {
230
- const s = state();
231
- if (s.mainListTimer || !isNavigatorUiAvailable(ctx)) return;
232
- s.mainListTimer = setInterval(() => refreshMainListWidget(), MAIN_LIST_TICK_MS);
233
- s.mainListTimer.unref?.();
234
- }
235
-
236
230
  function stopMainListWidget(): void {
237
- const s = state();
238
- if (s.mainListTimer) clearInterval(s.mainListTimer);
239
- s.mainListTimer = undefined;
240
231
  clearMainListCloseArm();
241
232
  }
242
233
 
@@ -254,7 +245,16 @@ function refreshMainListWidget(): void {
254
245
  const ctx = s.uiCtx;
255
246
  const deps = s.deps;
256
247
  if (!ctx || !deps || !isNavigatorUiAvailable(ctx)) return;
257
- const rows = listRows();
248
+ const now = Date.now();
249
+ const rows = listRows(now);
250
+ s.mainListDeadlineScheduler?.cancel();
251
+ const nextExpiry = rows.reduce<number | undefined>((next, row) => {
252
+ if (row.expiresAt === undefined || row.expiresAt <= now) return next;
253
+ return next === undefined ? row.expiresAt : Math.min(next, row.expiresAt);
254
+ }, undefined);
255
+ if (nextExpiry !== undefined) {
256
+ s.mainListDeadlineScheduler?.schedule(nextExpiry - now);
257
+ }
258
258
  syncMainListSelection(rows);
259
259
  s.lastMainListLines = rows.length ? buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
260
260
  selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
@@ -412,6 +412,16 @@ function buildMainListLines(
412
412
  const group = grouped.get(label)!;
413
413
  if (i > 0) lines.push("");
414
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
+ }
415
425
  for (const row of group) {
416
426
  const selected = options.focused && row.navigatorId === options.selectedId;
417
427
  lines.push(formatMainListRow(row, selected === true, fg, width));
@@ -462,7 +472,7 @@ function statusGlyph(row: InternalRow): string {
462
472
 
463
473
  function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; color: string } {
464
474
  if (row.statusTone !== "running") return { glyph: statusGlyph(row), color: toneColor(row.statusTone, row.status) };
465
- const frame = Math.floor(now / MAIN_LIST_TICK_MS) % RUNNING_DOT_FRAMES.length;
475
+ const frame = Math.floor(now / DETAIL_TICK_MS) % RUNNING_DOT_FRAMES.length;
466
476
  return { glyph: RUNNING_DOT_GLYPH, color: RUNNING_DOT_FRAMES[frame]! };
467
477
  }
468
478
 
@@ -676,7 +686,6 @@ function createOverlayComponent(
676
686
  const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
677
687
  if (idx >= 0) overlayState.selected = idx;
678
688
  }
679
- let detailTimer: ReturnType<typeof setInterval> | undefined;
680
689
  let closeArm: { id: string; armedAt: number } | undefined;
681
690
  let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
682
691
  let closed = false;
@@ -687,6 +696,13 @@ function createOverlayComponent(
687
696
  try { tui.requestRender(); } catch { /* ignore */ }
688
697
  }
689
698
 
699
+ const detailScheduler = createRenderScheduler(() => {
700
+ if (!detailId || mode !== "detail" || closed) return;
701
+ detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
702
+ requestRender();
703
+ startDetailTimer();
704
+ });
705
+
690
706
  function refreshRows(): void {
691
707
  const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
692
708
  overlayState.rows = listRows();
@@ -704,18 +720,14 @@ function createOverlayComponent(
704
720
  }
705
721
 
706
722
  function stopDetailTimer(): void {
707
- if (detailTimer) clearInterval(detailTimer);
708
- detailTimer = undefined;
723
+ detailScheduler.cancel();
709
724
  }
710
725
 
711
726
  function startDetailTimer(): void {
712
- stopDetailTimer();
713
- detailTimer = setInterval(() => {
714
- if (!detailId || mode !== "detail") return;
715
- detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
716
- requestRender();
717
- }, DETAIL_TICK_MS);
718
- detailTimer.unref?.();
727
+ detailScheduler.cancel();
728
+ if (detail?.status === "running" || detail?.status === "orphaned") {
729
+ detailScheduler.schedule(DETAIL_TICK_MS);
730
+ }
719
731
  }
720
732
 
721
733
  function selectedRow(): InternalRow | undefined {
@@ -759,7 +771,7 @@ function createOverlayComponent(
759
771
  if (closed) return;
760
772
  closed = true;
761
773
  clearCloseArm();
762
- stopDetailTimer();
774
+ detailScheduler.dispose();
763
775
  try { done(null); } catch { /* ignore */ }
764
776
  }
765
777
 
@@ -842,7 +854,7 @@ function createOverlayComponent(
842
854
  invalidate() {},
843
855
  dispose() {
844
856
  clearCloseArm();
845
- stopDetailTimer();
857
+ detailScheduler.dispose();
846
858
  },
847
859
  };
848
860
  }
@@ -975,7 +987,10 @@ function buildDetailLines(
975
987
  } else {
976
988
  const evidenceLabel = /log/i.test(detail.evidence.label) ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
977
989
  lines.push(dim(section(evidenceLabel, width), fg));
978
- 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}` : " ");
979
994
  }
980
995
  lines.push("");
981
996
  const footerLines = [dim(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
@@ -1041,6 +1056,82 @@ function wrapEvidenceText(text: string, width: number): string[] {
1041
1056
  return rows.length ? rows : ["(no output yet)"];
1042
1057
  }
1043
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
+
1044
1135
  function cycleLogTailRows(current: number): number {
1045
1136
  const idx = LOG_TAIL_ROW_CHOICES.findIndex((value) => value === current);
1046
1137
  return LOG_TAIL_ROW_CHOICES[(idx + 1) % LOG_TAIL_ROW_CHOICES.length];
@@ -0,0 +1,47 @@
1
+ // Generated from packages/render-scheduler/index.ts. Do not edit directly.
2
+ export interface RenderScheduler {
3
+ request(): void;
4
+ schedule(delayMs: number): void;
5
+ cancel(): void;
6
+ dispose(): void;
7
+ pending(): boolean;
8
+ }
9
+
10
+ /**
11
+ * Schedule TUI paints as replaceable one-shot deadlines. Callers reschedule
12
+ * only while visible state still changes; static and hidden UI stays idle.
13
+ */
14
+ export function createRenderScheduler(requestRender: () => void): RenderScheduler {
15
+ let timer: ReturnType<typeof setTimeout> | undefined;
16
+ let disposed = false;
17
+
18
+ const cancel = (): void => {
19
+ if (timer) clearTimeout(timer);
20
+ timer = undefined;
21
+ };
22
+
23
+ return {
24
+ request() {
25
+ if (disposed) return;
26
+ cancel();
27
+ requestRender();
28
+ },
29
+ schedule(delayMs: number) {
30
+ if (disposed) return;
31
+ cancel();
32
+ timer = setTimeout(() => {
33
+ timer = undefined;
34
+ if (!disposed) requestRender();
35
+ }, Math.max(0, delayMs));
36
+ timer.unref?.();
37
+ },
38
+ cancel,
39
+ dispose() {
40
+ disposed = true;
41
+ cancel();
42
+ },
43
+ pending() {
44
+ return timer !== undefined;
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,69 @@
1
+ // Generated from packages/stall-detector/index.ts. Do not edit directly.
2
+ export interface StallThresholds {
3
+ /** Age of observable progress before work becomes quiet. */
4
+ quietMs: number;
5
+ /** Age of observable progress before work becomes stalled. */
6
+ stallMs: number;
7
+ }
8
+
9
+ export type StallState = "healthy" | "quiet" | "stalled" | "unknown";
10
+
11
+ export interface StallObservation {
12
+ state: StallState;
13
+ /** Most recent meaningful evidence, falling back to the start time. */
14
+ observedAt?: number;
15
+ ageMs?: number;
16
+ thresholds: StallThresholds;
17
+ }
18
+
19
+ export interface ObserveStallInput {
20
+ now: number;
21
+ /** Explicit meaningful progress, never a timer tick or metadata write. */
22
+ lastProgressAt?: number;
23
+ /** Fallback anchor for work that has not produced progress yet. */
24
+ startedAt?: number;
25
+ /** Known active phases that explain a lack of observable progress. */
26
+ exempt?: boolean;
27
+ thresholds?: Partial<StallThresholds>;
28
+ }
29
+
30
+ export const DEFAULT_STALL_THRESHOLDS: Readonly<StallThresholds> = Object.freeze({
31
+ quietMs: 60_000,
32
+ stallMs: 5 * 60_000,
33
+ });
34
+
35
+ function positiveMs(value: unknown, fallback: number): number {
36
+ const parsed = Number(value);
37
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
38
+ }
39
+
40
+ /** Normalize caller config and ensure quiet never exceeds the stall threshold. */
41
+ export function resolveStallThresholds(partial?: Partial<StallThresholds> | null): StallThresholds {
42
+ const quietMs = positiveMs(partial?.quietMs, DEFAULT_STALL_THRESHOLDS.quietMs);
43
+ const stallMs = positiveMs(partial?.stallMs, DEFAULT_STALL_THRESHOLDS.stallMs);
44
+ return { quietMs: Math.min(quietMs, stallMs), stallMs: Math.max(quietMs, stallMs) };
45
+ }
46
+
47
+ /**
48
+ * Classify the age of observable progress. This is deliberately observational:
49
+ * it does not claim a live process is broken and never performs recovery.
50
+ */
51
+ export function observeStall(input: ObserveStallInput): StallObservation {
52
+ const thresholds = resolveStallThresholds(input.thresholds);
53
+ const observedAt = input.lastProgressAt ?? input.startedAt;
54
+ if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) {
55
+ return { state: "unknown", thresholds };
56
+ }
57
+
58
+ const ageMs = Math.max(0, input.now - observedAt);
59
+ if (input.exempt) {
60
+ return { state: ageMs >= thresholds.quietMs ? "quiet" : "healthy", observedAt, ageMs, thresholds };
61
+ }
62
+ if (ageMs >= thresholds.stallMs) {
63
+ return { state: "stalled", observedAt, ageMs, thresholds };
64
+ }
65
+ if (ageMs >= thresholds.quietMs) {
66
+ return { state: "quiet", observedAt, ageMs, thresholds };
67
+ }
68
+ return { state: "healthy", observedAt, ageMs, thresholds };
69
+ }
package/src/stall.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { statSync } from "node:fs";
2
+
3
+ import { observeStall, type StallObservation, type StallThresholds } from "./shared-stall-detector.js";
4
+ import type { BackgroundTaskMeta } from "./types.js";
5
+
6
+ function durationEnv(name: string): number | undefined {
7
+ const raw = process.env[name];
8
+ if (!raw || raw.trim() === "") return undefined;
9
+ const value = Number(raw);
10
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
11
+ }
12
+
13
+ export function backgroundTaskStallThresholds(): Partial<StallThresholds> {
14
+ return {
15
+ quietMs: durationEnv("PI_BETTER_STALL_QUIET_MS"),
16
+ stallMs: durationEnv("PI_BETTER_STALL_MS"),
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Process logs are the only external progress evidence available for detached
22
+ * commands. Watchers instead advance after a completed poll command.
23
+ */
24
+ export function backgroundTaskProgressAt(meta: BackgroundTaskMeta): number | undefined {
25
+ const recorded = Math.max(meta.lastProgressAt ?? 0, meta.lastCheckedAt ?? 0, meta.startedAt);
26
+ if (meta.kind !== "process") return recorded;
27
+ try {
28
+ return Math.max(recorded, Math.trunc(statSync(meta.logPath).mtimeMs));
29
+ } catch {
30
+ return recorded;
31
+ }
32
+ }
33
+
34
+ export function observeBackgroundTaskStall(meta: BackgroundTaskMeta, now = Date.now()): StallObservation {
35
+ return observeStall({
36
+ now,
37
+ lastProgressAt: backgroundTaskProgressAt(meta),
38
+ startedAt: meta.startedAt,
39
+ exempt: meta.status !== "running",
40
+ thresholds: backgroundTaskStallThresholds(),
41
+ });
42
+ }
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({
package/src/types.ts CHANGED
@@ -46,6 +46,8 @@ export interface BackgroundTaskMeta {
46
46
  deadlineAt?: number;
47
47
  intervalMs?: number;
48
48
  lastCheckedAt?: number;
49
+ /** Latest command completion or observed process log output. */
50
+ lastProgressAt?: number;
49
51
  lastExitCode?: number | null;
50
52
  lastSignal?: NodeJS.Signals | null;
51
53
  lastState?: unknown;