ace-wake 0.1.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.
@@ -0,0 +1,772 @@
1
+ import {
2
+ LOOP_SOURCE_PREFIX,
3
+ MAX_NAME_CHARS,
4
+ WATCH_SOURCE_PREFIX,
5
+ WakeError,
6
+ formatWatchDelta,
7
+ } from "./types.js";
8
+
9
+ /**
10
+ * Session-owned store of named loop and watch subscriptions.
11
+ *
12
+ * The registry owns definitions and their runtime handles. Host capabilities
13
+ * arrive as injected ports:
14
+ *
15
+ * - `loops` / `watches`: subscription ports owning native interval and
16
+ * filesystem-watcher handles (`start(def, handlers)`, `stop(name)`,
17
+ * `stopAll()`, `errorOf(name)`).
18
+ * - `state`: persistence port (`load()` / `save(snapshot)`).
19
+ * - `status`: reporting port (`render(snapshot)`).
20
+ * - `dispatcher`: wake dispatcher (queue-only delivery with coalescing).
21
+ *
22
+ * Reconciliation is idempotent: prior runtime handles are disposed first,
23
+ * then exactly one native handle is registered per persisted definition.
24
+ * Loops schedule their next interval from "now"; no missed ticks are replayed
25
+ * after reload or restart.
26
+ */
27
+ export class WakeRegistry {
28
+ /** @private @type {object} */
29
+ #loops;
30
+ /** @private @type {object} */
31
+ #watches;
32
+ /** @private @type {object} */
33
+ #state;
34
+ /** @private @type {object} */
35
+ #status;
36
+ /** @private @type {import("./wake-dispatcher.js").WakeDispatcher} */
37
+ #dispatcher;
38
+ /** @private @type {boolean} */
39
+ #retaining;
40
+ /** @private @type {boolean} */
41
+ #resuming;
42
+ /** @private @type {Map<string, {prefix: string, name: string, message: string}>} */
43
+ #retained;
44
+ /** @private @type {boolean} */
45
+ #dispatchPending;
46
+ /** @private @type {Timeout | undefined} */
47
+ #dispatchRecoveryTimer;
48
+ /** @private @type {number} */
49
+ #dispatchRecoveryMs;
50
+ /** @private @type {Timeout | undefined} */
51
+ #recoveryTimer;
52
+ /** @private @type {Timeout | undefined} */
53
+ #dirtyRetryTimer;
54
+ /** @private @type {boolean} */
55
+ #deliveryUnresolved;
56
+ /** @private @type {Set<string>} */
57
+ #inFlightWatchNames;
58
+ /** @private @type {(() => boolean) | undefined} */
59
+ #isHostQueued;
60
+ /** @private @type {(() => boolean) | undefined} */
61
+ #isHostIdle;
62
+ /** @private @type {boolean} */
63
+ #disposed;
64
+
65
+ /** @private @type {{retained: Array<{prefix: string, name: string, message: string}>, stranded: Array<[string, string]>} | undefined} */
66
+ #reconcileBatch;
67
+ /** @private @type {import("./types.js").WakeSnapshot | undefined} */
68
+ #loaded;
69
+
70
+ /**
71
+ * @param {object} ports
72
+ * @param {object} ports.loops
73
+ * @param {object} ports.watches
74
+ * @param {object} ports.state
75
+ * @param {object} ports.status
76
+ * @param {import("./wake-dispatcher.js").WakeDispatcher} ports.dispatcher
77
+ */
78
+ constructor({ loops, watches, state, status, dispatcher, dispatchRecoveryMs = 5000, isHostQueued, isHostIdle }) {
79
+ this.#loops = loops;
80
+ this.#watches = watches;
81
+ this.#state = state;
82
+ this.#status = status;
83
+ this.#dispatcher = dispatcher;
84
+ /** @private @type {boolean} */
85
+ this.#retaining = false;
86
+ /** @private @type {boolean} */
87
+ this.#resuming = false;
88
+ /** @private @type {Map<string, {prefix: string, name: string, message: string}>} */
89
+ this.#retained = new Map();
90
+ /** @private @type {boolean} */
91
+ this.#dispatchPending = false;
92
+ /** @private @type {Timeout | undefined} */
93
+ this.#dispatchRecoveryTimer;
94
+ /** @private @type {number} */
95
+ this.#dispatchRecoveryMs = dispatchRecoveryMs;
96
+ /** @private @type {boolean} */
97
+ this.#deliveryUnresolved = false;
98
+ /** @private @type {Timeout | undefined} */
99
+ this.#dirtyRetryTimer = undefined;
100
+ /** @private @type {Set<string>} */
101
+ this.#inFlightWatchNames = new Set();
102
+ /** @private @type {(() => boolean) | undefined} */
103
+ this.#isHostQueued = isHostQueued;
104
+ /** @private @type {(() => boolean) | undefined} */
105
+ this.#isHostIdle = isHostIdle;
106
+ /** @private @type {boolean} */
107
+ this.#disposed = false;
108
+ }
109
+
110
+ /**
111
+ * Register a named recurring timer.
112
+ *
113
+ * @param {{name: string, intervalSeconds: number, message: string}} input
114
+ * @returns {import("./types.js").LoopDefinition}
115
+ */
116
+ addLoop(input) {
117
+ const name = requireName(input.name, "loop");
118
+ const intervalSeconds = requireInterval(input.intervalSeconds);
119
+ const message = requireMessage(input.message);
120
+
121
+ if (this.#snapshot.loops.some((loop) => loop.name === name)) {
122
+ throw new WakeError(`loop "${name}" already exists; remove it before re-adding`);
123
+ }
124
+
125
+ const definition = { kind: "loop", name, intervalSeconds, message };
126
+ this.#snapshot.loops.push(definition);
127
+ this.#loops.start(definition, () => this.#fire(LOOP_SOURCE_PREFIX, name, definition.message));
128
+ this.#persist();
129
+ return definition;
130
+ }
131
+
132
+ /**
133
+ * Register a named filesystem watch.
134
+ *
135
+ * @param {{name: string, path: string, message: string}} input
136
+ * @returns {import("./types.js").WatchDefinition}
137
+ */
138
+ addWatch(input) {
139
+ const name = requireName(input.name, "watch");
140
+ const path = requireWatchPath(input.path);
141
+ const message = requireMessage(input.message);
142
+
143
+ if (this.#snapshot.watches.some((watch) => watch.name === name)) {
144
+ throw new WakeError(`watch "${name}" already exists; remove it before re-adding`);
145
+ }
146
+
147
+ const definition = { kind: "watch", name, path, message };
148
+ // A secret answer case (8xs.t.pu0.9): the wake carries the change
149
+ // event and its REFERENCE only — the delta's content excerpt is
150
+ // stripped so the value never passes through the model. The
151
+ // executor reads the value from the case file through its secure
152
+ // channel, never from the wake.
153
+ if (input.redact === true) {
154
+ definition.redact = true;
155
+ }
156
+ // The watch port validates readability, takes its baseline, and resolves
157
+ // the configured path to a canonical absolute path — an unreadable or
158
+ // invalid path fails the command loudly before anything is persisted. The
159
+ // delta argument carries the changed targets of each wake.
160
+ definition.path = this.#watches.start(
161
+ definition,
162
+ (watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, name, definition.message,
163
+ redacted(definition, delta)),
164
+ );
165
+
166
+ this.#snapshot.watches.push(definition);
167
+ this.#persist();
168
+ return definition;
169
+ }
170
+
171
+ /**
172
+ * Remove a loop and stop its future wakes.
173
+ *
174
+ * @param {string} name
175
+ */
176
+ removeLoop(name) {
177
+ const removed = removeByName(this.#snapshot.loops, name, "loop");
178
+ this.#loops.stop(name);
179
+ this.#dispatcher.settle(loopSourceKey(name));
180
+ this.#retained.delete(loopSourceKey(name));
181
+ this.#dropFromReconcileBatch(loopSourceKey(name));
182
+ this.#persist();
183
+ return removed;
184
+ }
185
+
186
+ /**
187
+ * Remove a watch and stop its future wakes.
188
+ *
189
+ * @param {string} name
190
+ */
191
+ removeWatch(name) {
192
+ const removed = removeByName(this.#snapshot.watches, name, "watch");
193
+ this.#watches.stop(name);
194
+ this.#dispatcher.settle(watchSourceKey(name));
195
+ this.#retained.delete(watchSourceKey(name));
196
+ this.#dropFromReconcileBatch(watchSourceKey(name));
197
+ this.#persist();
198
+ return removed;
199
+ }
200
+
201
+ /**
202
+ * Immutable view of definitions plus runtime state for commands and UI.
203
+ *
204
+ * @returns {Array<object>}
205
+ */
206
+ list() {
207
+ const entries = [];
208
+ for (const loop of this.#snapshot.loops) {
209
+ entries.push({
210
+ kind: "loop",
211
+ name: loop.name,
212
+ detail: `${loop.intervalSeconds}s`,
213
+ message: loop.message,
214
+ pending: this.#dispatcher.isPending(loopSourceKey(loop.name)),
215
+ error: this.#loops.errorOf(loop.name),
216
+ });
217
+ }
218
+ for (const watch of this.#snapshot.watches) {
219
+ entries.push({
220
+ kind: "watch",
221
+ name: watch.name,
222
+ detail: watch.path,
223
+ message: watch.message,
224
+ pending: this.#dispatcher.isPending(watchSourceKey(watch.name)),
225
+ error: this.#watches.errorOf(watch.name),
226
+ });
227
+ }
228
+ return entries;
229
+ }
230
+
231
+ /**
232
+ * Re-register exactly one native handle per persisted definition. Prior
233
+ * handles are disposed first, so reload or restart never duplicates timers
234
+ * and never replays missed ticks. A subscription that fails to restart
235
+ * (e.g. its watched path vanished) records a visible inactive-error state
236
+ * and never blocks the remaining subscriptions.
237
+ */
238
+ reconcile() {
239
+ this.#loops.stopAll();
240
+ this.#watches.stopAll();
241
+
242
+ for (const loop of this.#snapshot.loops) {
243
+ this.#loops.start(loop, () => this.#fire(LOOP_SOURCE_PREFIX, loop.name, loop.message));
244
+ }
245
+ for (const watch of this.#snapshot.watches) {
246
+ try {
247
+ this.#watches.start(
248
+ watch,
249
+ (definition, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message,
250
+ redacted(watch, delta)),
251
+ );
252
+ } catch (error) {
253
+ const message = error instanceof Error ? error.message : String(error);
254
+ this.#watches.fail(watch.name, `could not restart watch: ${message}`);
255
+ }
256
+ }
257
+ this.#refreshStatus();
258
+ }
259
+
260
+ /**
261
+ * Re-render the visible subscription status; ports invoke this when runtime
262
+ * state changes outside a registry call (e.g. a watch deactivates itself).
263
+ */
264
+ refreshStatus() {
265
+ this.#refreshStatus();
266
+ }
267
+
268
+ /** Dispose all runtime handles; persisted definitions remain for reload. */
269
+ dispose() {
270
+ this.#retaining = false;
271
+ this.#resuming = false;
272
+ this.#disposed = true;
273
+ this.#deliveryUnresolved = false;
274
+ this.#dispatchPending = false;
275
+ clearTimeout(this.#recoveryTimer);
276
+ clearTimeout(this.#dirtyRetryTimer);
277
+ this.#retained.clear();
278
+ this.#reconcileBatch = undefined;
279
+ this.#loops.stopAll();
280
+ this.#watches.stopAll();
281
+ this.#status.render([]);
282
+ }
283
+
284
+ /**
285
+ * Clear pending wake markers after the agent fully settled, then re-check
286
+ * watches whose change was absorbed by a pending wake: their state still
287
+ * differs from the last delivered fingerprint, so they wake again without
288
+ * needing another filesystem event.
289
+ */
290
+ settleAll() {
291
+ // A settlement is proof the host accepts prompts again; a manual
292
+ // compaction that is still resuming completes its boundary here so the
293
+ // stranded attempts reconcile instead of being silently cleared.
294
+ this.#deliveryUnresolved = false;
295
+ if (this.#resuming) {
296
+ this.completeResume();
297
+ } else {
298
+ this.#dispatcher.settleAll();
299
+ this.#closeDispatchWindow();
300
+ }
301
+ this.#drainRetained();
302
+ this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
303
+ this.#refreshStatus();
304
+ }
305
+
306
+ /**
307
+ * Retain wakes instead of dispatching while the host cannot accept them
308
+ * (manual compaction rejects sendUserMessage asynchronously — before, during,
309
+ * and after the compaction events — which would otherwise strand the source
310
+ * pending forever, since manual compaction never emits agent_settled).
311
+ */
312
+ pauseDelivery() {
313
+ this.#retaining = true;
314
+ }
315
+
316
+ /**
317
+ * Mark the compaction events as finished. Pi may still reject prompts while
318
+ * later session_compact handlers complete, so retention stays on and the
319
+ * boundary is taken by completeResume() — either when the readiness poll
320
+ * observes an idle context or at the next settlement.
321
+ */
322
+ resumeDelivery() {
323
+ this.#resuming = true;
324
+ }
325
+
326
+ /**
327
+ * Take the resume boundary atomically: every pending marker and retained
328
+ * wake at this moment is reconciled in one batch, and delivery returns to
329
+ * normal. Idempotent — without a pending boundary it is a no-op.
330
+ */
331
+ completeResume() {
332
+ if (!this.#resuming) {
333
+ return;
334
+ }
335
+ this.#resuming = false;
336
+ this.#retaining = false;
337
+ clearTimeout(this.#dispatchRecoveryTimer);
338
+ this.#dispatchPending = false;
339
+ // A stranded watch attempt must not replay as a bare message: its
340
+ // fingerprint already advanced past a send compaction preflight rejected,
341
+ // so a replay deferred behind another source's dispatch window would
342
+ // later read as already delivered and flushDirty() would discard the
343
+ // change. Stranded watches instead revert to their last delivered
344
+ // fingerprint and go dirty — the watch reconciliation path preserves
345
+ // every change until delivery is confirmed.
346
+ const strandedWatches = [];
347
+ const strandedOthers = [];
348
+ for (const entry of this.#dispatcher.pendingEntries()) {
349
+ if (entry[0].startsWith(WATCH_SOURCE_PREFIX)) {
350
+ strandedWatches.push(entry[0].slice(WATCH_SOURCE_PREFIX.length));
351
+ } else {
352
+ strandedOthers.push(entry);
353
+ }
354
+ }
355
+ this.#watches.revertToDelivered(strandedWatches);
356
+ this.#reconcileBatch = {
357
+ retained: [...this.#retained.values()],
358
+ stranded: strandedOthers,
359
+ };
360
+ this.#retained.clear();
361
+ this.#dispatcher.settleAll();
362
+ this.flushRetained();
363
+ this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
364
+ this.#refreshStatus();
365
+ }
366
+
367
+ /**
368
+ * Dispatch the reconciliation batch captured at the last resume boundary:
369
+ * retained wakes plus stranded pre-boundary attempts. Consumed exactly
370
+ * once — later calls are no-ops, so a flush can never replay wakes that
371
+ * were legitimately re-queued after the boundary.
372
+ */
373
+ flushRetained() {
374
+ const batch = this.#reconcileBatch;
375
+ if (!batch || (batch.retained.length === 0 && batch.stranded.length === 0)) {
376
+ return;
377
+ }
378
+ this.#reconcileBatch = undefined;
379
+ for (const wake of batch.retained) {
380
+ this.#fire(wake.prefix, wake.name, wake.message);
381
+ }
382
+ for (const [sourceKey, text] of batch.stranded) {
383
+ // Replay through #dispatch so concurrent stranded sources serialize
384
+ // across the idle-to-running transition instead of racing it.
385
+ const separator = sourceKey.indexOf(":");
386
+ this.#dispatch(sourceKey.slice(0, separator + 1), sourceKey.slice(separator + 1), text);
387
+ }
388
+ this.#refreshStatus();
389
+ }
390
+
391
+ /**
392
+ * A removed subscription's wake must never fire from a pending resume
393
+ * batch, so removal strips it from the reconciliation boundary.
394
+ *
395
+ * @param {string} sourceKey
396
+ */
397
+ #dropFromReconcileBatch(sourceKey) {
398
+ const batch = this.#reconcileBatch;
399
+ if (!batch) {
400
+ return;
401
+ }
402
+ batch.retained = batch.retained.filter((wake) => `${wake.prefix}${wake.name}` !== sourceKey);
403
+ batch.stranded = batch.stranded.filter(([key]) => key !== sourceKey);
404
+ }
405
+
406
+ /** Load persisted definitions from the session state port. */
407
+ load() {
408
+ this.#snapshot = this.#state.load();
409
+ }
410
+
411
+ #fire(sourcePrefix, name, message, delta) {
412
+ try {
413
+ const outcome = this.#dispatch(sourcePrefix, name, message, delta);
414
+ this.#refreshStatus();
415
+ return outcome;
416
+ } catch {
417
+ // Timer and watcher callbacks can outlive their host context (session
418
+ // replacement, reload, or disposal invalidates the captured context
419
+ // without running session_shutdown). A stale context must never crash
420
+ // the callback, and a runtime that can no longer deliver should not
421
+ // keep ticking: defuse all handles until the next session_start
422
+ // re-registers them.
423
+ this.#loops.stopAll();
424
+ this.#watches.stopAll();
425
+ return { delivered: false, reason: "stale" };
426
+ }
427
+ }
428
+
429
+ #dispatch(sourcePrefix, name, message, delta) {
430
+ const sourceKey = `${sourcePrefix}${name}`;
431
+ if (this.#dispatcher.isPending(sourceKey)) {
432
+ // Same-source repeats coalesce regardless of the transition window.
433
+ return { delivered: false, reason: "coalesced" };
434
+ }
435
+ // The serialization window guards only the idle-to-running transition;
436
+ // once the host reports an active run it is stale (agent_start may never
437
+ // fire to close it — queued follow-ups do not re-emit it), so only
438
+ // retention keeps blocking delivery then.
439
+ const windowBlocked = this.#dispatchPending && !this.#hostIsActive();
440
+ const blocked = this.#retaining || windowBlocked;
441
+ if (blocked && sourcePrefix === WATCH_SOURCE_PREFIX) {
442
+ // Watch changes must never queue as bare messages: marking the
443
+ // subscription dirty defers delivery to flushDirty(), which re-stats
444
+ // and wakes with the latest state at the next settlement boundary —
445
+ // no lost changes, no duplicate replays.
446
+ this.#watches.markDirty(name);
447
+ return { delivered: false, reason: "deferred" };
448
+ }
449
+ if (this.#retaining) {
450
+ this.#retained.set(sourceKey, { prefix: sourcePrefix, name, message });
451
+ return { delivered: false, reason: "retained" };
452
+ }
453
+ if (windowBlocked) {
454
+ // A previous wake is still crossing the idle-to-running transition;
455
+ // Pi rejects concurrent prompts asynchronously, so this source waits
456
+ // to enter the follow-up queue safely (agent_start) or until the
457
+ // transition window expires.
458
+ this.#retained.set(sourceKey, { prefix: sourcePrefix, name, message });
459
+ return { delivered: false, reason: "serialized" };
460
+ }
461
+ const text = sourcePrefix === WATCH_SOURCE_PREFIX && delta !== undefined
462
+ ? formatWatchDelta(message, delta)
463
+ : message;
464
+ const outcome = this.#dispatcher.wake(sourceKey, text);
465
+ if (!outcome.delivered) {
466
+ // A refused watch wake (no model selected, broken authentication) has
467
+ // no next tick, settlement, or lifecycle event to retry it: without a
468
+ // timer the change would wait for an unrelated filesystem event.
469
+ // Still-refused retries re-arm the timer when they fire.
470
+ if (sourcePrefix === WATCH_SOURCE_PREFIX && outcome.reason === "rejected") {
471
+ this.#scheduleDirtyWatchRetry();
472
+ }
473
+ return outcome;
474
+ }
475
+ if (this.#hostIsActive()) {
476
+ // A run is already active: deliverAs: "followUp" is queue-safe by Pi's
477
+ // contract, so no serialization window arms. The window exists only
478
+ // for the idle-to-running transition, where concurrent sends race
479
+ // Pi's asynchronous prompt preparation — and no agent_start ever
480
+ // fires for follow-ups queued behind an active run to close one.
481
+ // Unrelated sources deliver immediately instead of waiting for that
482
+ // run's settlement.
483
+ return outcome;
484
+ }
485
+ if (sourcePrefix === WATCH_SOURCE_PREFIX) {
486
+ this.#inFlightWatchNames.add(name);
487
+ }
488
+ this.#markDispatchInFlight();
489
+ return outcome;
490
+ }
491
+
492
+ /**
493
+ * Whether the host reports a run already in progress. The serialization
494
+ * window guards only the idle-to-running transition; delivery into an
495
+ * active run is queue-safe and needs no window and no recovery tracking.
496
+ */
497
+ #hostIsActive() {
498
+ if (!this.#isHostIdle) {
499
+ return false;
500
+ }
501
+ try {
502
+ return !this.#isHostIdle();
503
+ } catch {
504
+ return false;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Bounded retry for watch changes whose dispatch was refused: loops retry
510
+ * on their next tick, but a watch has no future trigger of its own, so
511
+ * this timer is the only thing standing between a refused change and its
512
+ * delivery once the refusal cause clears (e.g. a model gets selected).
513
+ * One attempt per recovery window, deduplicated; disposal cancels it. No
514
+ * duplicate risk: a refused wake was never accepted, so its fingerprint
515
+ * never advanced and the retry is a first delivery, not a replay.
516
+ */
517
+ #scheduleDirtyWatchRetry() {
518
+ if (this.#disposed || this.#dirtyRetryTimer) {
519
+ return;
520
+ }
521
+ this.#dirtyRetryTimer = setTimeout(() => {
522
+ this.#dirtyRetryTimer = undefined;
523
+ if (this.#disposed) {
524
+ return;
525
+ }
526
+ this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
527
+ this.#refreshStatus();
528
+ }, this.#dispatchRecoveryMs);
529
+ this.#dirtyRetryTimer.unref?.();
530
+ }
531
+
532
+ /**
533
+ * An unacknowledged watch attempt must not read as delivered: revert its
534
+ * fingerprint to the last delivered state and mark it dirty so the next
535
+ * settlement re-fires the change.
536
+ */
537
+ #revertInFlightWatchAttempts() {
538
+ if (this.#inFlightWatchNames.size === 0) {
539
+ return;
540
+ }
541
+ this.#watches.revertToDelivered([...this.#inFlightWatchNames]);
542
+ this.#inFlightWatchNames.clear();
543
+ }
544
+
545
+ #closeDispatchWindow() {
546
+ clearTimeout(this.#dispatchRecoveryTimer);
547
+ this.#dispatchPending = false;
548
+ this.#drainRetained();
549
+ }
550
+
551
+
552
+ /**
553
+ * The host started a run: concurrent sends now queue safely as follow-ups,
554
+ * so the serialization window closes and retained wakes drain immediately.
555
+ * Watches deferred by the closed window flush too — without a run to
556
+ * queue behind they would wait for a full settlement even though the
557
+ * follow-up queue is already safe.
558
+ */
559
+ noteRunStarted() {
560
+ this.#deliveryUnresolved = false;
561
+ this.#inFlightWatchNames.clear();
562
+ this.#dispatchPending = false;
563
+ this.#drainRetained();
564
+ this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
565
+ this.#refreshStatus();
566
+ }
567
+
568
+ #markDispatchInFlight() {
569
+ // The dispatch stays in flight until a lifecycle transition confirms
570
+ // delivery: agent_start proves the prompt entered a run (concurrent
571
+ // sends queue safely as follow-ups from then on), and any settlement
572
+ // proves the host accepts prompts again.
573
+ this.#dispatchPending = true;
574
+ clearTimeout(this.#recoveryTimer);
575
+ this.#recoveryTimer = setTimeout(() => this.recoverUnacknowledgedDispatch(), this.#dispatchRecoveryMs);
576
+ this.#recoveryTimer.unref?.();
577
+ }
578
+
579
+ /**
580
+ * Recovery for attempts no lifecycle transition ever acknowledged: Pi's
581
+ * void sendUserMessage API reports preflight failures (no model, broken
582
+ * authentication) only asynchronously, and no signal distinguishes a
583
+ * rejected attempt from one whose startup hooks are slow. Wake semantics
584
+ * prefer redelivery over loss — a duplicate is a repeated bounded message
585
+ * the agent can ignore, a lost duty wake is a silent miss — so recovery
586
+ * releases the in-flight state: serialization opens, pending markers
587
+ * clear, and watch changes re-fire from their latest state at the next
588
+ * settlement. Retry cadence stays bounded by the recovery window.
589
+ */
590
+ recoverUnacknowledgedDispatch() {
591
+ if (this.#disposed || !this.#dispatchPending) {
592
+ return;
593
+ }
594
+ let queued = true;
595
+ let idle = false;
596
+ try {
597
+ // The discriminators the void API still provides: an accepted wake
598
+ // sits in Pi's queue (pendingMessages true) until processing consumes
599
+ // it, and while anything runs the context is not idle. Both signals
600
+ // must agree that nothing is queued and nothing is running before an
601
+ // attempt is treated as rejected — an empty queue during active
602
+ // processing is a consumed follow-up, and an idle context during
603
+ // asynchronous startup hooks is an unresolved submission.
604
+ queued = this.#isHostQueued?.() ?? true;
605
+ idle = this.#isHostIdle?.() ?? false;
606
+ } catch {
607
+ queued = true;
608
+ idle = false;
609
+ }
610
+ if (queued || !idle) {
611
+ // Legitimate: queued for later or actively processing. Re-check after
612
+ // another window; the run's own settlement settles the marker.
613
+ this.#scheduleRecovery();
614
+ return;
615
+ }
616
+ this.#dispatchPending = false;
617
+ this.#dispatcher.settleAll();
618
+ this.#revertInFlightWatchAttempts();
619
+ this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
620
+ this.#refreshStatus();
621
+ }
622
+
623
+ #scheduleRecovery() {
624
+ clearTimeout(this.#dispatchRecoveryTimer);
625
+ this.#dispatchRecoveryTimer = setTimeout(() => this.recoverUnacknowledgedDispatch(), this.#dispatchRecoveryMs);
626
+ this.#dispatchRecoveryTimer.unref?.();
627
+ }
628
+
629
+ #drainRetained() {
630
+ const entries = [...this.#retained.values()];
631
+ this.#retained.clear();
632
+ for (const wake of entries) {
633
+ try {
634
+ this.#dispatcher.wake(`${wake.prefix}${wake.name}`, wake.message);
635
+ } catch {
636
+ // Draining must never crash the releasing callback.
637
+ }
638
+ }
639
+ }
640
+
641
+ #persist() {
642
+ this.#state.save({
643
+ loops: this.#snapshot.loops.map((loop) => ({ ...loop })),
644
+ watches: this.#snapshot.watches.map((watch) => ({ ...watch })),
645
+ });
646
+ this.#refreshStatus();
647
+ }
648
+
649
+ #refreshStatus() {
650
+ this.#status.render(this.list());
651
+ }
652
+
653
+ get #snapshot() {
654
+ if (this.#loaded === undefined) {
655
+ this.#loaded = this.#state.load();
656
+ }
657
+ return this.#loaded;
658
+ }
659
+
660
+ set #snapshot(value) {
661
+ this.#loaded = value;
662
+ }
663
+ }
664
+
665
+ /**
666
+ * @param {string} value
667
+ * @param {string} kind
668
+ * @returns {string}
669
+ */
670
+ export function requireName(value, kind) {
671
+ const name = typeof value === "string" ? value.trim() : "";
672
+ if (!name) {
673
+ throw new WakeError(`${kind} name must not be empty`);
674
+ }
675
+ if (/\s/.test(name)) {
676
+ throw new WakeError(`${kind} name must not contain whitespace`);
677
+ }
678
+ if (name.length > MAX_NAME_CHARS) {
679
+ throw new WakeError(`${kind} name must be at most ${MAX_NAME_CHARS} characters, got ${name.length}`);
680
+ }
681
+ return name;
682
+ }
683
+
684
+ /**
685
+ * Largest interval the host timer layer supports: node:timers clamps delays
686
+ * above 2^31 - 1 milliseconds down to 1 millisecond, which would turn an
687
+ * over-large interval into a wake storm, so it is rejected instead.
688
+ *
689
+ * @constant {number}
690
+ */
691
+ export const MAX_INTERVAL_SECONDS = (2 ** 31 - 1) / 1000;
692
+
693
+ /**
694
+ * @param {unknown} value
695
+ * @returns {number}
696
+ */
697
+ export function requireInterval(value) {
698
+ const interval = typeof value === "string" ? Number(value) : value;
699
+ if (typeof interval !== "number" || !Number.isFinite(interval) || interval <= 0) {
700
+ throw new WakeError(`interval must be a finite number of seconds greater than 0, got ${JSON.stringify(value ?? null)}`);
701
+ }
702
+ if (interval > MAX_INTERVAL_SECONDS) {
703
+ throw new WakeError(`interval must be at most ${MAX_INTERVAL_SECONDS} seconds (~24.9 days), got ${JSON.stringify(value)}`);
704
+ }
705
+ return interval;
706
+ }
707
+
708
+ /**
709
+ * @param {unknown} value
710
+ * @returns {string}
711
+ */
712
+ export function requireMessage(value) {
713
+ const message = typeof value === "string" ? value.trim() : "";
714
+ if (!message) {
715
+ throw new WakeError("message must not be empty");
716
+ }
717
+ return message;
718
+ }
719
+
720
+ /**
721
+ * @param {unknown} value
722
+ * @returns {string}
723
+ */
724
+ export function requireWatchPath(value) {
725
+ const path = typeof value === "string" ? value.trim() : "";
726
+ if (!path) {
727
+ throw new WakeError("path must not be empty");
728
+ }
729
+ return path;
730
+ }
731
+
732
+ /**
733
+ * @param {Array<{name: string}>} definitions
734
+ * @param {string} name
735
+ * @param {string} kind
736
+ */
737
+ function removeByName(definitions, name, kind) {
738
+ const index = definitions.findIndex((definition) => definition.name === name);
739
+ if (index === -1) {
740
+ throw new WakeError(`unknown ${kind}: ${name}`);
741
+ }
742
+ return definitions.splice(index, 1)[0];
743
+ }
744
+
745
+ /** @param {string} name */
746
+ export function loopSourceKey(name) {
747
+ return `${LOOP_SOURCE_PREFIX}${name}`;
748
+ }
749
+
750
+ /**
751
+ * Redacted delta for a secret watch (8xs.t.pu0.9): every entry keeps its
752
+ * path/kind/signature reference, the content excerpt is stripped.
753
+ *
754
+ * @param {{redact?: boolean}} definition
755
+ * @param {Array<object> | undefined} delta
756
+ * @returns {Array<object> | undefined}
757
+ */
758
+ function redacted(definition, delta) {
759
+ if (definition?.redact !== true || !Array.isArray(delta)) {
760
+ return delta;
761
+ }
762
+ return delta.map((entry) => {
763
+ const { excerpt, ...reference } = entry;
764
+ void excerpt;
765
+ return reference;
766
+ });
767
+ }
768
+
769
+ /** @param {string} name */
770
+ export function watchSourceKey(name) {
771
+ return `${WATCH_SOURCE_PREFIX}${name}`;
772
+ }