@dorokuma/herdsman-pi 0.13.6 → 0.14.1

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +610 -64
  3. package/src/wake.ts +20 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorokuma/herdsman-pi",
3
- "version": "0.13.6",
3
+ "version": "0.14.1",
4
4
  "description": "Pi extension bridge for Herdsman agent history.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -130,6 +130,89 @@ type HerdsmanState = {
130
130
  * from the current state instead of deferring again.
131
131
  */
132
132
  wakeForcedRelease: boolean;
133
+ /**
134
+ * Event ids handed to Pi as a *queued* (non-triggering) follow-up whose
135
+ * content has not been seen entering the transcript yet.
136
+ *
137
+ * A wake injected while the orchestrator streams is parked in the agent's
138
+ * follow-up queue, and Pi only drains that queue when a run reaches its stop
139
+ * point. When the run it rode on already passed that point, the update sits in
140
+ * the queue until the next user message. This set is what keeps such a
141
+ * delivery from being written off, and it carries three guarantees at once:
142
+ *
143
+ * - never lost: while it is non-empty the settlement drives a continuation
144
+ * (bounded by MAX_WAKE_CONTINUATION_ATTEMPTS drives) so a later run drains
145
+ * the queue and carries the update out. The bound is a real ceiling, not a
146
+ * suggestion: once it is spent the ids are released from here and
147
+ * acknowledged (`writeOffStrandedWakeDelivery`), because an update that no
148
+ * turn will ever carry out must not keep a run loop — and the daemon's
149
+ * redelivery of it — alive forever;
150
+ * - never acknowledged unseen: these ids are excluded from the acknowledgement
151
+ * path, so the daemon keeps them pending and redelivers them when this
152
+ * session never consumes them (the only way a delivery Pi itself dropped —
153
+ * clearQueue / restoreQueuedMessagesToEditor — can still be recovered);
154
+ * - never duplicated: they are excluded from every later injection, so a
155
+ * redelivery of the same id cannot put the same content into the transcript
156
+ * twice.
157
+ *
158
+ * Ids leave the set when their content reaches the transcript (consumption
159
+ * evidence: the hidden wake message's `message_end`, which then moves them to
160
+ * `wakeConsumptionObserved`) or when the event leaves the delivery queue for
161
+ * good (acknowledged, covered by the acknowledgement watermark,
162
+ * dead-lettered), and with the delivery queue on a role/scope reset.
163
+ */
164
+ wakeAwaitingConsumption: Set<number>;
165
+ /**
166
+ * Ids whose content was observed in the transcript and which are therefore
167
+ * confirmed on the evidence alone, whatever the turn that carried them did.
168
+ *
169
+ * The daemon confirms by a monotonic watermark (`where id <= ?`), so an id that
170
+ * stays unacknowledged blocks every later one: a turn that ends in error after
171
+ * the content already reached the orchestrator must not pin that watermark, so
172
+ * the evidence — not the turn outcome — authorises these ids.
173
+ */
174
+ wakeConsumptionObserved: Set<number>;
175
+ /**
176
+ * Ids already handed to the orchestrator in this session that must never be
177
+ * injected again, because the copy that carries them can outlive the bookkeeping
178
+ * that knew about it.
179
+ *
180
+ * Pi's follow-up queue is process-wide and the extension has no API to query or
181
+ * clear it, so an id can become "un-presented" again while its content is still
182
+ * on its way: a role/scope reset clears the presentation guard (and the delivery
183
+ * queue), and a consumed id leaves the queue without ever being acknowledged (its
184
+ * batch is gone), so the daemon keeps redelivering it. This set is the guard that
185
+ * survives all of that:
186
+ *
187
+ * - an unconsumed delivery carried over a reset lands here (see
188
+ * `clearDeliveryBookkeeping`), which deliberately trades away the redelivery
189
+ * remedy for it (logged there) in exchange for never presenting a duplicate;
190
+ * - a consumed id lands here too, so a redelivery after the scope that consumed
191
+ * it is gone still cannot inject it a second time (the id also re-enters the
192
+ * current scope's `presentedEventIds`, see the consumption evidence handler).
193
+ *
194
+ * Ids leave the set when the daemon has confirmed them (their own
195
+ * acknowledgement, the acknowledgement watermark, dead-lettering) or when they
196
+ * leave the delivery queue for good — the daemon then holds nothing that could be
197
+ * redelivered, so there is nothing left to block.
198
+ */
199
+ wakeSuppressedEventIds: Set<number>;
200
+ /**
201
+ * Last reason an event id was skipped for injection, for the rate-limited
202
+ * diagnostic in `noteSkippedWakeInjection`: one line per id and reason.
203
+ *
204
+ * Skipping a redelivery is intentional but invisible, so without this the log
205
+ * could not tell "no update arrived" from "updates were suppressed"; with it a
206
+ * daemon that redelivers the same event in a loop still cannot flood the file.
207
+ */
208
+ wakeSkipLogReasons: Map<number, string>;
209
+ /**
210
+ * Continuation drives already spent on the current unconsumed delivery.
211
+ *
212
+ * Reset when a delivery is handed over and when the unconsumed set empties, so
213
+ * the bound applies per delivery instead of accumulating for the session.
214
+ */
215
+ wakeContinuationAttempts: number;
133
216
  /**
134
217
  * Event content queued for a busy orchestrator. Injected through the
135
218
  * `context` hook so the running turn sees the update without being
@@ -201,6 +284,38 @@ export const WAKE_BUSY_SPIN_MS = 100;
201
284
  * parked forever.
202
285
  */
203
286
  export const WAKE_DEFERRED_TIMEOUT_MS = 5_000;
287
+ /**
288
+ * Upper bound on the continuation drives spent on one unconsumed wake delivery.
289
+ *
290
+ * Every drive costs a full agent run, so the budget is deliberately small: three
291
+ * attempts already cover the drive that follows the settlement which missed Pi's
292
+ * follow-up queue plus two retries after intervening runs that also ended before
293
+ * their stop point, and five adds margin for a run that spent its stop point on
294
+ * a tool call. Beyond that the cause is systemic — the runs keep ending early, so
295
+ * starting another one could only produce another empty turn — and the delivery
296
+ * is written off instead (`writeOffStrandedWakeDelivery`): the ids leave
297
+ * `wakeAwaitingConsumption` and are acknowledged, which stops both the
298
+ * continuation loop and the daemon's redelivery of the same event.
299
+ *
300
+ * The trade-off is deliberate and asymmetric: losing one agent update is
301
+ * recoverable (the agent is still there and its transcript can be read
302
+ * directly), while an unbounded continuation loop floods the orchestrator
303
+ * session with empty turns and makes it unusable. A missed update is therefore
304
+ * strictly better than an endless one.
305
+ */
306
+ export const MAX_WAKE_CONTINUATION_ATTEMPTS = 5;
307
+ /** `customType` of the hidden wake context this extension injects. */
308
+ const WAKE_CONTEXT_CUSTOM_TYPE = "herdsman-wake-context";
309
+ /** `customType` of the hidden marker that drives a missed wake continuation. */
310
+ const WAKE_CONTINUATION_CUSTOM_TYPE = "herdsman-wake-continuation";
311
+ /**
312
+ * Content of the continuation marker. Its only job is to start a run
313
+ * (`triggerTurn: true`) so the run's loop drains the queued follow-up that was
314
+ * never delivered; the wake content itself is not repeated here, so the
315
+ * evidence is not presented twice.
316
+ */
317
+ const WAKE_CONTINUATION_CONTENT =
318
+ "[HERDSMAN WAKE CONTINUATION]\nA queued Herdsman agent update was not delivered by the previous turn; it follows this message. Handle it, and do not start unrelated work.";
204
319
 
205
320
  type AckFailureClass = "terminal" | "resync" | "transient";
206
321
 
@@ -287,6 +402,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
287
402
  wakeDeferredUntilSettled: false,
288
403
  wakeDeferredSince: undefined,
289
404
  wakeForcedRelease: false,
405
+ wakeAwaitingConsumption: new Set(),
406
+ wakeConsumptionObserved: new Set(),
407
+ wakeSuppressedEventIds: new Set(),
408
+ wakeSkipLogReasons: new Map(),
409
+ wakeContinuationAttempts: 0,
290
410
  wakeContext: undefined,
291
411
  wakeRequested: false,
292
412
  wakeRequestedThroughEventId: 0,
@@ -376,13 +496,67 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
376
496
  };
377
497
 
378
498
  /**
379
- * Drops one event from the delivery queue. The only callers are the two
380
- * acknowledgement outcomes (accepted, or terminally refused by the daemon)
381
- * and the role/scope reset, which clears the whole stream together with
382
- * `presentedEventIds`.
499
+ * Drops one event from the delivery queue. The callers are the two
500
+ * acknowledgement outcomes (accepted, or terminally refused by the daemon),
501
+ * the acknowledgement watermark that covers a whole range of ids, and the
502
+ * role/scope reset, which clears the entire stream through
503
+ * `clearDeliveryBookkeeping`.
504
+ *
505
+ * An event that leaves the queue can no longer be awaiting consumption: its
506
+ * acknowledgement cursor covered it, or it is dead-lettered and will never be
507
+ * redelivered. The awaiting set therefore follows the queue here — without
508
+ * that, a dead-lettered id would keep the settlement driving continuations
509
+ * (and holding its batch open) for content that can never arrive. The other
510
+ * two sets describe the same ids and follow the queue just as well: a
511
+ * confirmed id needs no evidence flag, and an id that is gone from the queue
512
+ * cannot be re-injected, so it needs no suppression either.
383
513
  */
384
514
  const dropUnackedDelivered = (eventId: number): void => {
385
515
  state.unackedDelivered.delete(eventId);
516
+ state.wakeAwaitingConsumption.delete(eventId);
517
+ state.wakeConsumptionObserved.delete(eventId);
518
+ state.wakeSuppressedEventIds.delete(eventId);
519
+ if (state.wakeAwaitingConsumption.size === 0) state.wakeContinuationAttempts = 0;
520
+ };
521
+
522
+ /**
523
+ * Drops the presentation guard, the delivery queue, and the unconsumed
524
+ * bookkeeping together.
525
+ *
526
+ * These describe the same events — handed to Pi, not yet confirmed by the
527
+ * daemon — so they are only ever cleared together, and only by a reset that
528
+ * also discards the pending projection: a genuine role/scope loss and
529
+ * shutdown. A transient disconnect keeps all of them (`preservePresented`) so
530
+ * an in-flight batch can still be settled and acknowledged after the
531
+ * reconnect, and so no already-presented event is presented again.
532
+ *
533
+ * One thing survives the reset (`wakeSuppressedEventIds`), because nothing here
534
+ * can invalidate it: an id handed to Pi may still sit in Pi's process-wide
535
+ * follow-up queue (no API to query or clear it), so dropping it together with
536
+ * the queue would let a daemon redelivery present the same update a second
537
+ * time in the scope that takes over. Unconsumed ids are carried over for that
538
+ * reason; ids consumed *after* their scope was reset join the same set from the
539
+ * consumption handler. The trade-off is explicit: for a carried-over id the
540
+ * redelivery remedy is given up on purpose (see the log line) until the daemon
541
+ * confirms it some other way.
542
+ */
543
+ const clearDeliveryBookkeeping = () => {
544
+ const carriedOver = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
545
+ for (const eventId of carriedOver) state.wakeSuppressedEventIds.add(eventId);
546
+ if (carriedOver.length > 0) {
547
+ logHerdsmanPi(
548
+ "info",
549
+ `[herdsman-pi] keeping ${carriedOver.length} unconsumed wake event id(s) suppressed across the scope change eventIds=${carriedOver.join(",")} · daemon redelivery for them is traded away to keep the transcript single-copy`,
550
+ );
551
+ }
552
+ state.presentedEventIds.clear();
553
+ // Once the guard is gone the daemon's pending events can be presented (and
554
+ // acknowledged) again, so keeping the old queue would only risk a stale id.
555
+ state.unackedDelivered.clear();
556
+ state.wakeAwaitingConsumption.clear();
557
+ state.wakeConsumptionObserved.clear();
558
+ state.wakeSkipLogReasons.clear();
559
+ state.wakeContinuationAttempts = 0;
386
560
  };
387
561
 
388
562
  const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
@@ -401,8 +575,69 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
401
575
  // superseded (the daemon acknowledges by watermark), so it leaves the
402
576
  // queue and is never re-acknowledged.
403
577
  for (const eventId of [...state.unackedDelivered.keys()]) {
404
- if (eventId <= ackedEventId) state.unackedDelivered.delete(eventId);
578
+ if (eventId <= ackedEventId) dropUnackedDelivered(eventId);
579
+ }
580
+ // The watermark also covers ids that survived a reset as suppression
581
+ // entries: the daemon considers them confirmed, so they will not be
582
+ // redelivered and the suppression has nothing left to block.
583
+ for (const eventId of [...state.wakeSuppressedEventIds]) {
584
+ if (eventId <= ackedEventId) state.wakeSuppressedEventIds.delete(eventId);
585
+ }
586
+ for (const eventId of [...state.wakeSkipLogReasons.keys()]) {
587
+ if (eventId <= ackedEventId) state.wakeSkipLogReasons.delete(eventId);
588
+ }
589
+ };
590
+
591
+ /**
592
+ * Records, once per id and reason, that an event which is already in the
593
+ * orchestrator's hands was not injected again.
594
+ *
595
+ * Skipping a redelivery is deliberate — the content must not enter the
596
+ * transcript twice — but it is also invisible: a daemon that keeps redelivering
597
+ * one event would otherwise leave no trace at all, and "an update never
598
+ * arrived" could not be told apart from "an update was suppressed" in the log.
599
+ * The line is emitted once per id and reason (a change of reason is logged
600
+ * again: `presented`, `awaiting` and `suppressed` describe different ownership
601
+ * of the same id), so a redelivery loop cannot flood the file.
602
+ */
603
+ const noteSkippedWakeInjection = (eventId: number, reason: string): false => {
604
+ if (state.wakeSkipLogReasons.get(eventId) !== reason) {
605
+ state.wakeSkipLogReasons.set(eventId, reason);
606
+ logHerdsmanPi(
607
+ "info",
608
+ `[herdsman-pi] wake injection skipped eventId=${eventId} reason=${reason} · update already presented (or still in flight) in this session, so a daemon redelivery is not shown twice`,
609
+ );
405
610
  }
611
+ return false;
612
+ };
613
+
614
+ /**
615
+ * Whether an event was already handed to the orchestrator in this session and
616
+ * therefore must not be injected again (logging why, at most once per reason).
617
+ *
618
+ * - `awaiting`: a copy of it was handed over as a queued follow-up that no run
619
+ * has drained yet, and the continuation is what carries it out (checked
620
+ * first: for a queued copy this is the state that explains the redelivery);
621
+ * - `presented`: it was injected before (or its content has since been observed
622
+ * in the transcript), so a redelivery would duplicate it;
623
+ * - `suppressed`: its copy may still sit in Pi's process-wide follow-up queue
624
+ * after a role/scope reset cleared the local guards, so the redelivery is the
625
+ * only one that must not be shown (see `wakeSuppressedEventIds`).
626
+ */
627
+ const alreadyPresented = (eventId: number): boolean => {
628
+ if (state.wakeAwaitingConsumption.has(eventId)) {
629
+ noteSkippedWakeInjection(eventId, "awaiting");
630
+ return true;
631
+ }
632
+ if (state.presentedEventIds.has(eventId)) {
633
+ noteSkippedWakeInjection(eventId, "presented");
634
+ return true;
635
+ }
636
+ if (state.wakeSuppressedEventIds.has(eventId)) {
637
+ noteSkippedWakeInjection(eventId, "suppressed");
638
+ return true;
639
+ }
640
+ return false;
406
641
  };
407
642
 
408
643
  const isWakeableEvent = (event: AgentEventWireRecord | undefined) =>
@@ -607,6 +842,202 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
607
842
  }, WAKE_SETTLE_MS);
608
843
  };
609
844
 
845
+ /**
846
+ * Ends the current wake-deferral episode without injecting.
847
+ *
848
+ * `wakeForcedRelease` and `wakeDeferredSince` describe *one* bounded
849
+ * deferral: the released deadline is re-derived from `wakeDeferredSince`
850
+ * every time `scheduleDeferredWake` runs, so a stale pair would let an
851
+ * unrelated later wake bypass the busy gate. Every path that ends a wake
852
+ * pass without injecting clears both, which gives the next deferral its own
853
+ * full `WAKE_DEFERRED_TIMEOUT_MS` budget. The 5s hard deadline itself is
854
+ * unchanged: it is measured inside a single episode, and an episode that
855
+ * reaches it still force-releases the batch on its next pass.
856
+ */
857
+ const endWakeDeferral = () => {
858
+ state.wakeForcedRelease = false;
859
+ state.wakeDeferredSince = undefined;
860
+ };
861
+
862
+ /**
863
+ * Event ids a hidden wake message proves to have reached the transcript.
864
+ *
865
+ * A wake injection names the outcomes it presents in
866
+ * `details.presentedEventIds`, and Pi writes those details onto the session
867
+ * entry it emits on `message_end` (both for a triggered turn and when a run
868
+ * finally drains the queued follow-up). That emission is the only consumption
869
+ * evidence there is: nothing else tells us that the content — not merely the
870
+ * request that carried it — reached the orchestrator.
871
+ *
872
+ * The wider `details.eventIds` list is deliberately not evidence: it names
873
+ * every id that was pending at injection time, including copies that an
874
+ * earlier injection presented (and that may have been dropped by Pi), so using
875
+ * it would confirm content nobody ever saw. Only real, numeric ids count, and
876
+ * a message without readable ones proves nothing: the caller logs that instead
877
+ * of confirming anything.
878
+ */
879
+ const wakeConsumedEventIds = (message: Record<string, unknown>): number[] => {
880
+ const presented = record(message.details).presentedEventIds;
881
+ if (!Array.isArray(presented)) return [];
882
+ return presented.filter((eventId): eventId is number => typeof eventId === "number");
883
+ };
884
+
885
+ /**
886
+ * The contiguous prefix of the delivery queue that may be acknowledged now.
887
+ *
888
+ * The delivery queue — not the injection snapshot — is what gets acknowledged:
889
+ * it holds every event handed to Pi that is still unconfirmed (a release merges
890
+ * batches instead of replacing them), is id-ascending, and an id leaves it only
891
+ * when the daemon accepts it.
892
+ *
893
+ * An acknowledgement tells the daemon "the orchestrator has this", and the
894
+ * daemon confirms by a monotonic watermark (`update agent_events set status =
895
+ * 'acked' where id <= ?`), so acking a larger id confirms every smaller one with
896
+ * it. The queue is therefore walked in ascending id order and only its
897
+ * *contiguous* confirmable prefix is returned: the walk stops at the first id
898
+ * that is not confirmable. Skipping an unconfirmed id would hand it to the
899
+ * watermark, which swallows it for good.
900
+ *
901
+ * An id is confirmable when its content is known to have reached the transcript
902
+ * (`wakeConsumptionObserved`), or — for a delivery that was never queued, i.e. a
903
+ * triggered prompt — when the turn that received it produced a final response or
904
+ * was aborted by the user. An id still awaiting consumption has no evidence and
905
+ * blocks the prefix: leaving it unacknowledged keeps it pending, which is what
906
+ * lets the daemon redeliver the one copy that never arrived.
907
+ *
908
+ * An event whose own acknowledgement already failed is left out (the daemon's
909
+ * cursor advance sweeps it, and a retry would reset its attempt/backoff
910
+ * accounting), but it must not hold the prefix back: it stays in the queue until
911
+ * that cursor or a scope reset confirms it, so a failed or dead-lettered
912
+ * acknowledgement never depends on the timing of the release to stay
913
+ * recoverable. Restricting to a *live* row with `attempts === 0` has two holes
914
+ * on purpose: `?? 0` covers an event the live projection no longer holds at all
915
+ * (the server stopped listing it, or `failedWakeThroughEventId` filters it out),
916
+ * and the projection cannot tell us it already failed — so the event gets one
917
+ * more attempt, a deliberate self-healing opportunity that then accumulates on
918
+ * the queue copy's counter and can reach MAX_ACK_ATTEMPTS instead of restarting
919
+ * at 1 every round.
920
+ */
921
+ const confirmableDeliveryPrefix = (turnProducedFinalResponse: boolean) => {
922
+ const deliveryQueue = unackedDeliveredAscending();
923
+ const confirmablePrefix: AgentEventWireRecord[] = [];
924
+ let blockedByMissingTurn = false;
925
+ for (const event of deliveryQueue) {
926
+ if (state.wakeAwaitingConsumption.has(event.id)) break;
927
+ if (!state.wakeConsumptionObserved.has(event.id) && !turnProducedFinalResponse) {
928
+ blockedByMissingTurn = true;
929
+ break;
930
+ }
931
+ // An event whose own acknowledgement already failed is left to the
932
+ // daemon's cursor sweep (see above); it must not hold the prefix back.
933
+ if ((state.pendingEvents.find((pending) => pending.id === event.id)?.attempts ?? 0) > 0) {
934
+ continue;
935
+ }
936
+ confirmablePrefix.push(event);
937
+ }
938
+ // Any id still awaiting consumption keeps the batch in flight: it owns the
939
+ // acknowledgement cursor, so the settlement of the run that finally drains
940
+ // the queued copy (the continuation driven by the settle handler) confirms it
941
+ // then. Reading the queue directly — instead of comparing two filtered lists —
942
+ // keeps that decision independent of the `attempts` exclusion above, which
943
+ // would otherwise hide an unconsumed id and drop the batch too early.
944
+ const stillAwaitingConsumption = deliveryQueue.some((event) =>
945
+ state.wakeAwaitingConsumption.has(event.id),
946
+ );
947
+ return { deliveryQueue, confirmablePrefix, blockedByMissingTurn, stillAwaitingConsumption };
948
+ };
949
+
950
+ const writeOffStrandedWakeDelivery = (eventIds: number[], ctx: PiContext): void => {
951
+ // Release first, acknowledge second: the release is what breaks the loop
952
+ // synchronously, while the acknowledgement is an RPC that may fail.
953
+ for (const eventId of eventIds) {
954
+ state.wakeAwaitingConsumption.delete(eventId);
955
+ // This set only ever holds the id of a delivery that was already handed
956
+ // to Pi, so releasing it is not permission to present it again: the
957
+ // session-wide presented guard stays behind and keeps a daemon replay
958
+ // of the same id out of the transcript.
959
+ state.presentedEventIds.add(eventId);
960
+ }
961
+ // Fresh budget for whatever is still awaiting consumption: the ids just
962
+ // written off are gone, and the ones that remain have not had their own
963
+ // MAX_WAKE_CONTINUATION_ATTEMPTS drives spent on them yet.
964
+ state.wakeContinuationAttempts = 0;
965
+ const stranded = eventIds
966
+ .map((eventId) => state.unackedDelivered.get(eventId))
967
+ .filter((event): event is AgentEventWireRecord => event !== undefined)
968
+ .sort((left, right) => left.id - right.id);
969
+ logHerdsmanPi(
970
+ "warn",
971
+ `[herdsman-pi] wake continuation gave up eventIds=${eventIds.join(",")} drives=${MAX_WAKE_CONTINUATION_ATTEMPTS} · released and acknowledged as consumed so the daemon stops redelivering them`,
972
+ );
973
+ ctx.ui.notify?.(
974
+ `Herdsman · ${eventIds.length} agent update${eventIds.length === 1 ? "" : "s"} could not be delivered by a wake turn · given up on (possibly never seen): read the agent directly for the details, or hand this workspace to another terminal so the daemon delivers it there`,
975
+ "warning",
976
+ );
977
+ if (stranded.length === 0) return;
978
+ void acknowledgeEventIds(stranded, { notify: false }, ctx).catch((error: unknown) => {
979
+ logHerdsmanPi(
980
+ "warn",
981
+ `[herdsman-pi] wake write-off acknowledgement failed eventIds=${stranded.map((event) => event.id).join(",")} · ${String(error)}`,
982
+ );
983
+ });
984
+ };
985
+
986
+ /**
987
+ * Drives one continuation that carries out a wake Pi has not drained.
988
+ *
989
+ * A wake injected while the orchestrator streams is delivered as a queued
990
+ * follow-up (`triggerTurn: false`), and Pi only drains that queue when a run
991
+ * reaches its stop point (`agent-loop` "Agent would stop here. Check for
992
+ * follow-up messages."). When the run it rode on already passed that point,
993
+ * the update sits in the queue until the next user message. At settlement the
994
+ * orchestrator is no longer streaming, so `triggerTurn: true` starts a real
995
+ * run (`_runAgentPrompt`) and that run's loop drains the queued follow-up.
996
+ * The marker carries no wake content: the queued follow-up is what delivers
997
+ * the evidence, exactly once.
998
+ *
999
+ * Only consumption evidence writes a delivery off, so an intervening run that
1000
+ * ends before its stop point (error, user abort, a refused tool) leaves the
1001
+ * next settlement driving again. That is bounded by
1002
+ * MAX_WAKE_CONTINUATION_ATTEMPTS: starting runs cannot fix a cause that is
1003
+ * not about the queue, so once the budget is spent the delivery is written
1004
+ * off (released and acknowledged) instead of looping forever — see
1005
+ * `writeOffStrandedWakeDelivery` for the trade-off.
1006
+ */
1007
+ const driveWakeContinuation = (ctx: PiContext) => {
1008
+ if (state.wakeAwaitingConsumption.size === 0) return;
1009
+ if (!pi.sendMessage) return;
1010
+ const eventIds = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
1011
+ state.wakeContinuationAttempts += 1;
1012
+ try {
1013
+ pi.sendMessage(
1014
+ {
1015
+ content: WAKE_CONTINUATION_CONTENT,
1016
+ customType: WAKE_CONTINUATION_CUSTOM_TYPE,
1017
+ display: false,
1018
+ },
1019
+ { deliverAs: "followUp", triggerTurn: true },
1020
+ );
1021
+ logHerdsmanPi(
1022
+ "info",
1023
+ `[herdsman-pi] wake continuation driven eventIds=${eventIds[0] ?? 0}-${eventIds.at(-1) ?? 0} count=${eventIds.length} attempt=${state.wakeContinuationAttempts}`,
1024
+ );
1025
+ } catch {
1026
+ logHerdsmanPi("warn", "[herdsman-pi] wake continuation refused by pi");
1027
+ }
1028
+ if (state.wakeContinuationAttempts >= MAX_WAKE_CONTINUATION_ATTEMPTS) {
1029
+ // This was the last drive the budget allows, and the queued copy is still
1030
+ // undrained: the delivery is written off instead of being left pending
1031
+ // forever. The ids are released from `wakeAwaitingConsumption` (no further
1032
+ // run is started for them) and acknowledged, which is what stops the
1033
+ // daemon from redelivering them every freshness window — the loop that
1034
+ // used to flood the orchestrator session until manual sqlite surgery.
1035
+ // Losing one update is the accepted cost; see
1036
+ // `writeOffStrandedWakeDelivery`.
1037
+ writeOffStrandedWakeDelivery([...state.wakeAwaitingConsumption], ctx);
1038
+ }
1039
+ };
1040
+
610
1041
  /**
611
1042
  * Arms the bounded deferral for a wake that cannot be injected right now.
612
1043
  *
@@ -639,12 +1070,19 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
639
1070
 
640
1071
  const scheduleWake = (ctx: PiContext | undefined) => {
641
1072
  if (!ctx || !state.isOrchestrator || !state.currentScope || !pi.sendMessage) return;
642
- if (state.wakeTimer || state.wakeRequested) return;
1073
+ if (state.wakeTimer || state.wakeRequested) {
1074
+ // A pending wake owns the release; this branch is also hit re-entrantly by
1075
+ // a running pass (its fired settle timer is still set), where clearing
1076
+ // `wakeForcedRelease` would re-defer a batch the deadline just released.
1077
+ return;
1078
+ }
643
1079
  const projection = projectAgentOutcomes(state.pendingEvents, wakeFilter);
644
1080
  const outcomes = projection.outcomes.filter(
645
1081
  (outcome) =>
646
1082
  outcome.eventId > state.failedWakeThroughEventId &&
647
- !state.presentedEventIds.has(outcome.eventId),
1083
+ // Already in the orchestrator's hands: injecting it again would put the
1084
+ // same content into the transcript twice. See `alreadyPresented`.
1085
+ !alreadyPresented(outcome.eventId),
648
1086
  );
649
1087
  const suppressedEvents = projection.suppressedUpstreamErrorEventIds
650
1088
  .filter(
@@ -659,6 +1097,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
659
1097
  isWakeableEvent(state.pendingEvents.find((pending) => pending.id === outcome.eventId)),
660
1098
  );
661
1099
  if (wakeable.length === 0) {
1100
+ // Nothing is wakeable right now, so the deferral that led here is over:
1101
+ // its released deadline must not let a later, unrelated wake bypass the
1102
+ // busy gate.
1103
+ endWakeDeferral();
662
1104
  // A suppressed upstream error that is now due must be silently
663
1105
  // acknowledged before any backoff timer is planted: planting the timer
664
1106
  // first would make scheduleSilentUpstreamErrorAck's entry guard
@@ -712,10 +1154,16 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
712
1154
  state.currentScope?.workspaceId !== ownerWorkspaceId
713
1155
  ) {
714
1156
  state.wakeTimer = undefined;
1157
+ // The pass belongs to a stale generation or scope, so its deferral
1158
+ // episode ends here (a scope reset usually got there first through
1159
+ // `cancelWakeTimer`).
1160
+ endWakeDeferral();
715
1161
  return;
716
1162
  }
717
1163
  if (ctx.isIdle?.() === false && !state.wakeForcedRelease) {
718
1164
  state.wakeTimer = undefined;
1165
+ // Still busy: this pass re-defers, so the deferral episode (and with
1166
+ // it the 5s deadline) must keep running instead of restarting.
719
1167
  scheduleDeferredWake(ctx);
720
1168
  return;
721
1169
  }
@@ -727,11 +1175,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
727
1175
  )) as ConnectionStateResponse | undefined;
728
1176
  if (!response) {
729
1177
  state.wakeTimer = undefined;
1178
+ endWakeDeferral();
730
1179
  return;
731
1180
  }
732
1181
  applyConnectionStateResponse(response, ctx);
733
1182
  } catch {
734
1183
  state.wakeTimer = undefined;
1184
+ endWakeDeferral();
735
1185
  // A failed load is only temporary: the batch stays pending and is
736
1186
  // retried on the next wake instead of being permanently suppressed.
737
1187
  ctx.ui.notify?.(
@@ -749,10 +1199,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
749
1199
  state.currentScope?.workspaceId !== ownerWorkspaceId
750
1200
  ) {
751
1201
  state.wakeTimer = undefined;
1202
+ // See the earlier generation re-check: the episode ends with it.
1203
+ endWakeDeferral();
752
1204
  return;
753
1205
  }
754
1206
  if (ctx.isIdle?.() === false && !state.wakeForcedRelease) {
755
1207
  state.wakeTimer = undefined;
1208
+ // See the earlier re-check: re-deferring keeps this episode's 5s
1209
+ // deadline intact.
756
1210
  scheduleDeferredWake(ctx);
757
1211
  return;
758
1212
  }
@@ -763,11 +1217,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
763
1217
  const batchOutcomes = batchProjection.outcomes.filter(
764
1218
  (outcome) =>
765
1219
  outcome.eventId > state.failedWakeThroughEventId &&
766
- !state.presentedEventIds.has(outcome.eventId) &&
1220
+ !alreadyPresented(outcome.eventId) &&
767
1221
  isWakeableEvent(batchEvents.find((event) => event.id === outcome.eventId)),
768
1222
  );
769
1223
  if (batchOutcomes.length === 0) {
770
1224
  state.wakeTimer = undefined;
1225
+ endWakeDeferral();
771
1226
  return;
772
1227
  }
773
1228
  const current = batchOutcomes;
@@ -785,11 +1240,18 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
785
1240
  // running turn can already see it.
786
1241
  const orchestratorBusy = ctx.isIdle?.() === false;
787
1242
  const wakeContent = formatAgentOutcomeUpdates(batchOutcomes);
1243
+ // Single line, injection path only: the decision that produced this
1244
+ // batch plus the signals it came from, so a wake that still arrives
1245
+ // late can be told apart from one parked by a stale gate.
1246
+ logHerdsmanPi(
1247
+ "info",
1248
+ `[herdsman-pi] wake inject deliverAs=followUp triggerTurn=${String(!orchestratorBusy)} forced=${state.wakeForcedRelease} runActive=${String(state.runActive)} isIdle=${ctx.isIdle === undefined ? "unknown" : String(ctx.isIdle())} eventIds=${batchOutcomes[0]?.eventId ?? 0}-${batchOutcomes.at(-1)?.eventId ?? 0} count=${batchOutcomes.length}`,
1249
+ );
788
1250
  try {
789
1251
  pi.sendMessage?.(
790
1252
  {
791
1253
  content: wakeContent,
792
- customType: "herdsman-wake-context",
1254
+ customType: WAKE_CONTEXT_CUSTOM_TYPE,
793
1255
  // Suppressed upstream errors are dropped from the injected
794
1256
  // context, but every other pending id stays listed so the
795
1257
  // evidence trail for the decision still names what was pending.
@@ -797,6 +1259,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
797
1259
  eventIds: batchEvents
798
1260
  .filter((event) => !batchSuppressedIds.has(event.id))
799
1261
  .map((event) => event.id),
1262
+ // The evidence channel: the ids whose content this message
1263
+ // actually carries (`eventIds` above lists everything that was
1264
+ // pending, including copies an earlier injection presented).
1265
+ // Only these prove consumption when a run drains this message.
1266
+ presentedEventIds: batchOutcomes.map((outcome) => outcome.eventId),
800
1267
  },
801
1268
  display: false,
802
1269
  },
@@ -804,6 +1271,18 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
804
1271
  ? { deliverAs: "followUp", triggerTurn: false }
805
1272
  : { deliverAs: "followUp", triggerTurn: true },
806
1273
  );
1274
+ // A queued (non-triggering) delivery rides the running turn: Pi parks it
1275
+ // in the agent's follow-up queue, which a run only drains when it reaches
1276
+ // its stop point. If the run it rode on already passed that point, nothing
1277
+ // drains it — the settlement drives a continuation instead (see
1278
+ // `driveWakeContinuation`), and until the content is seen in the
1279
+ // transcript the delivery is neither acknowledged nor injected again.
1280
+ if (orchestratorBusy) {
1281
+ for (const outcome of batchOutcomes) {
1282
+ state.wakeAwaitingConsumption.add(outcome.eventId);
1283
+ }
1284
+ state.wakeContinuationAttempts = 0;
1285
+ }
807
1286
  // A queued (non-triggering) delivery keeps the content available to
808
1287
  // the current turn through the context hook until it is settled or
809
1288
  // superseded by the next injection.
@@ -851,6 +1330,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
851
1330
  } catch {
852
1331
  state.deliveredBatch = undefined;
853
1332
  state.wakeRequested = false;
1333
+ // Nothing was queued by the refused injection, so nothing is awaiting
1334
+ // consumption on its account either: the ids only enter the awaiting set
1335
+ // once Pi accepted the message (see the queued branch above). The
1336
+ // refused injection consumed this deferral, so clearing it keeps the
1337
+ // elapsed deadline from letting a later, unrelated wake bypass the busy
1338
+ // gate.
1339
+ endWakeDeferral();
854
1340
  }
855
1341
  };
856
1342
  void startWake();
@@ -894,13 +1380,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
894
1380
  // A transient disconnect (reconnect) keeps the presentation guard so an
895
1381
  // event already presented in this scope session is not presented again;
896
1382
  // only a genuine role/scope loss or shutdown resets it.
897
- if (!options.preservePresented) {
898
- state.presentedEventIds.clear();
899
- // The delivery queue dies with the presentation guard: once the guard is
900
- // gone the daemon's pending events can be presented (and acknowledged)
901
- // again, so keeping the old queue would only risk a stale id.
902
- state.unackedDelivered.clear();
903
- }
1383
+ if (!options.preservePresented) clearDeliveryBookkeeping();
904
1384
  state.reconnectingFromOn = false;
905
1385
  setHerdsmanUi(ctx);
906
1386
  };
@@ -930,8 +1410,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
930
1410
  cancelWake();
931
1411
  state.failedWakeThroughEventId = 0;
932
1412
  state.pendingEvents = [];
933
- state.presentedEventIds.clear();
934
- state.unackedDelivered.clear();
1413
+ clearDeliveryBookkeeping();
935
1414
  setHerdsmanUi(ctx);
936
1415
  };
937
1416
 
@@ -1337,6 +1816,39 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1337
1816
 
1338
1817
  pi.on("message_end", (event: Record<string, unknown>) => {
1339
1818
  const message = record(event.message);
1819
+ if (message.role === "custom" && message.customType === WAKE_CONTEXT_CUSTOM_TYPE) {
1820
+ // Consumption evidence: the wake content reached the transcript, so some
1821
+ // run drained the queued follow-up and carried the update out. The ids the
1822
+ // message presented are confirmed — no further continuation is owed for
1823
+ // them, and they are now authorised for acknowledgement on their own,
1824
+ // whatever conclusion the turn reached.
1825
+ const consumedEventIds = wakeConsumedEventIds(message);
1826
+ if (consumedEventIds.length === 0) {
1827
+ // Nothing may be confirmed from a message whose evidence cannot be read
1828
+ // (a shape this extension never emits), and that must not pass silently:
1829
+ // the ids that are still waiting for their evidence must be named so the
1830
+ // stuck delivery is traceable.
1831
+ const awaiting = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
1832
+ logHerdsmanPi(
1833
+ "warn",
1834
+ `[herdsman-pi] wake consumption evidence unusable customType=${String(message.customType)} awaiting=${awaiting.length === 0 ? "none" : awaiting.join(",")} detailsKeys=${Object.keys(record(message.details)).join(",") || "none"} · no event confirmed by this message`,
1835
+ );
1836
+ }
1837
+ for (const eventId of consumedEventIds) {
1838
+ state.wakeAwaitingConsumption.delete(eventId);
1839
+ // The content reached the transcript, so this id counts as presented from
1840
+ // now on: a daemon redelivery of it (it is still unacknowledged whenever
1841
+ // its batch was already dropped) must not inject the same update again.
1842
+ // The scope's own guard may be gone — the copy can be drained after a
1843
+ // role/scope reset cleared it — so the session-wide suppression set keeps
1844
+ // the id as well; it is released when the daemon confirms the id.
1845
+ state.presentedEventIds.add(eventId);
1846
+ state.wakeSuppressedEventIds.add(eventId);
1847
+ // Only an id the daemon can still be told about is worth remembering.
1848
+ if (state.unackedDelivered.has(eventId)) state.wakeConsumptionObserved.add(eventId);
1849
+ }
1850
+ if (state.wakeAwaitingConsumption.size === 0) state.wakeContinuationAttempts = 0;
1851
+ }
1340
1852
  if (message.role !== "assistant") return;
1341
1853
  const stopReason = stringValue(message.stopReason);
1342
1854
  if (state.deliveredBatch) {
@@ -1365,6 +1877,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1365
1877
  });
1366
1878
 
1367
1879
  pi.on("agent_start", () => {
1880
+ // Deliberately no clearing here: a starting run does drain Pi's follow-up
1881
+ // queue, but that is not evidence that the content reached the transcript —
1882
+ // a run can end in error or be aborted before its stop point and leave the
1883
+ // queue untouched. Only the consumption signal (the hidden wake message's
1884
+ // `message_end`) writes a delivery off, so an update a run did not take
1885
+ // along is still driven out at the next settlement instead of waiting for
1886
+ // the next user message.
1368
1887
  if (state.runActive) return;
1369
1888
  state.runActive = true;
1370
1889
  state.pinnedContext =
@@ -1400,6 +1919,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1400
1919
  // to the orchestrator in the turn that presented them, so they are not
1401
1920
  // re-listed here. The queued follow-up message itself keeps the wider
1402
1921
  // `details.eventIds` set (everything still unconfirmed).
1922
+ //
1923
+ // The pin is not a second delivery of the update: it makes the same queued
1924
+ // copy visible early to the turn that is running, it is not written to the
1925
+ // transcript, it is never acknowledged on its own, and it is not consumption
1926
+ // evidence — only the hidden wake message's `message_end` is. So it may not be
1927
+ // dropped in the name of "one copy only" either: it is the only way this turn
1928
+ // ever sees the update.
1403
1929
  const queuedWake = state.wakeContext;
1404
1930
  if (queuedWake) {
1405
1931
  additions.push({
@@ -1417,16 +1943,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1417
1943
  pi.on("agent_settled", async (_event: unknown, ctx: PiContext) => {
1418
1944
  state.runActive = false;
1419
1945
  state.pinnedContext = undefined;
1420
- const batch = state.deliveredBatch;
1421
- if (!batch) {
1422
- state.wakeDeferredUntilSettled = false;
1423
- scheduleWake(ctx);
1424
- return;
1425
- }
1426
- state.deliveredBatch = undefined;
1427
- state.ackInFlight = true;
1428
- const stillOwner =
1429
- state.isOrchestrator && state.currentScope?.terminalId === batch.ownerTerminalId;
1946
+ // A wake delivered as a queued follow-up is drained only when a run reaches
1947
+ // its stop point. If the run that received it settled without draining it,
1948
+ // no further run exists to carry the update out: drive a continuation (up to
1949
+ // the per-delivery bound, see `driveWakeContinuation`), which is what
1950
+ // surfaces the queued update.
1951
+ driveWakeContinuation(ctx);
1430
1952
  const failBatch = () => {
1431
1953
  ctx.ui.notify?.(
1432
1954
  "Herdsman couldn’t acknowledge agent updates · updates remain pending",
@@ -1443,47 +1965,61 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1443
1965
  scheduleWake(ctx);
1444
1966
  };
1445
1967
 
1446
- // The delivery queue — not the injection snapshot — is what gets
1447
- // acknowledged: it holds every event handed to Pi that is still
1448
- // unconfirmed (a release merges batches instead of replacing them), is
1449
- // id-ascending, and an id leaves it only when the daemon accepts it.
1450
- //
1451
- // An event whose own acknowledgement already failed is left out here: it is
1452
- // never retried by this path (the daemon's cursor advance sweeps it, and a
1453
- // retry would reset its attempt/backoff accounting), but it stays in the
1454
- // queue until that cursor or a scope reset confirms it. A failed or
1455
- // dead-lettered acknowledgement thus never depends on the timing of the
1456
- // release to stay recoverable.
1457
- //
1458
- // Restricting to a *live* row with `attempts === 0` has two holes on
1459
- // purpose. `?? 0` covers an event the live projection no longer holds at
1460
- // all (the server stopped listing it, or `failedWakeThroughEventId`
1461
- // filters it out): the projection cannot tell us it already failed, so the
1462
- // event gets one more attempt — a deliberate self-healing opportunity that
1463
- // then accumulates on the queue copy's counter and can reach
1464
- // MAX_ACK_ATTEMPTS instead of restarting at 1 every round.
1465
- const ackable = unackedDeliveredAscending().filter(
1466
- (event) =>
1467
- (state.pendingEvents.find((pending) => pending.id === event.id)?.attempts ?? 0) === 0,
1468
- );
1469
- if (ackable.length === 0) {
1470
- finishBatch();
1968
+ const batch = state.deliveredBatch;
1969
+ if (!batch) {
1970
+ state.wakeDeferredUntilSettled = false;
1971
+ // A queue can outlive its batch: the batch record is dropped as soon as
1972
+ // nothing awaits consumption, while an acknowledgement it still owed can be
1973
+ // missing — most visibly when the daemon was unreachable while the content
1974
+ // was consumed. Without this pass those ids would pin the watermark until an
1975
+ // unrelated event happened to form a new batch, and a session that receives
1976
+ // no further update would never confirm what its transcript already holds.
1977
+ // With no batch left, no turn can vouch for an untracked delivery, so only
1978
+ // consumption evidence confirms an id.
1979
+ const stranded = confirmableDeliveryPrefix(false);
1980
+ if (
1981
+ stranded.confirmablePrefix.length > 0 &&
1982
+ state.isOrchestrator &&
1983
+ state.client !== undefined &&
1984
+ state.connected
1985
+ ) {
1986
+ const resumeAckInFlight = state.ackInFlight;
1987
+ state.ackInFlight = true;
1988
+ try {
1989
+ await acknowledgeEventIds(stranded.confirmablePrefix, { notify: true }, ctx);
1990
+ } finally {
1991
+ state.ackInFlight = resumeAckInFlight;
1992
+ }
1993
+ }
1994
+ scheduleWake(ctx);
1471
1995
  return;
1472
1996
  }
1473
1997
 
1474
- if (
1475
- (!batch.assistantFinalSucceeded && !batch.abortedByUser) ||
1476
- batch.invalidated ||
1477
- !stillOwner ||
1478
- !state.client ||
1479
- !state.connected
1480
- ) {
1481
- failBatch();
1998
+ const stillOwner =
1999
+ state.isOrchestrator && state.currentScope?.terminalId === batch.ownerTerminalId;
2000
+ const { deliveryQueue, confirmablePrefix, blockedByMissingTurn, stillAwaitingConsumption } =
2001
+ confirmableDeliveryPrefix(batch.assistantFinalSucceeded || batch.abortedByUser);
2002
+ const reachable = stillOwner && state.client !== undefined && state.connected;
2003
+ if (!stillAwaitingConsumption) state.deliveredBatch = undefined;
2004
+ state.ackInFlight = true;
2005
+ if (!reachable) {
2006
+ // Unreachable (ownership gone, or a disconnect): attempting an
2007
+ // acknowledgement now would only be refused and would burn the event's
2008
+ // attempt budget, while the queue keeps every event for the next
2009
+ // settlement on a live connection.
2010
+ if (deliveryQueue.length > 0) failBatch();
2011
+ finishBatch();
2012
+ return;
2013
+ }
2014
+ if (blockedByMissingTurn) failBatch();
2015
+ if (confirmablePrefix.length === 0) {
2016
+ // Nothing to confirm: an empty queue (the batch is settled) or a prefix
2017
+ // blocked by unconsumed content, which must not be confirmed yet.
1482
2018
  finishBatch();
1483
2019
  return;
1484
2020
  }
1485
2021
 
1486
- await acknowledgeEventIds(ackable, { notify: true }, ctx);
2022
+ await acknowledgeEventIds(confirmablePrefix, { notify: true }, ctx);
1487
2023
  finishBatch();
1488
2024
  });
1489
2025
 
@@ -1623,12 +2159,22 @@ function record(value: unknown): Record<string, unknown> {
1623
2159
  }
1624
2160
 
1625
2161
  function cleanContextText(value: string): string {
2162
+ // Same newline-preserving scheme as `normalizeExcerpt` in wake.ts: which byte a
2163
+ // line break is (CRLF, CR, U+2028/U+2029, NEL) is normalised to LF, trailing
2164
+ // whitespace is dropped per line, 3+ newlines collapse to a blank line, and
2165
+ // leading indentation plus inline runs of whitespace are kept as they are.
2166
+ // NEL (U+0085) is excluded from the control-character clearing regex below,
2167
+ // which would otherwise delete it as an unprintable byte; the rest of that
2168
+ // regex is left intact.
1626
2169
  return stripVTControlCharacters(value)
1627
2170
  .replace(
1628
- /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
2171
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0084\u0086-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
1629
2172
  "",
1630
2173
  )
1631
- .replace(/\s+/g, " ")
2174
+ .replace(/\r\n?/g, "\n")
2175
+ .replace(/[\u2028\u2029\u0085]/g, "\n")
2176
+ .replace(/[^\S\n]+$/gm, "")
2177
+ .replace(/\n{3,}/g, "\n\n")
1632
2178
  .trim();
1633
2179
  }
1634
2180
 
package/src/wake.ts CHANGED
@@ -34,7 +34,26 @@ function asRecord(value: unknown): Record<string, unknown> {
34
34
  function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; }
35
35
  function normalizeExcerpt(value: unknown): string {
36
36
  const raw = stringValue(value) ?? "";
37
- return stripVTControlCharacters(raw).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "").replace(/\s+/g, " ").trim();
37
+ // Line structure is evidence, not noise: a code block or a markdown heading
38
+ // only reads as one while its newlines survive, so this chain normalises
39
+ // *which byte* a line break is and where whitespace sits inside a line, and
40
+ // never folds a newline into a space:
41
+ // - CRLF/CR and the Unicode line separators (U+2028, U+2029, NEL) become LF,
42
+ // so "a line" means one thing everywhere downstream (NEL is excluded from
43
+ // the control-character regex, which would otherwise delete it);
44
+ // - trailing whitespace is dropped per line, so a "blank" line padded with
45
+ // spaces still collapses (it would otherwise defeat the 3+ newline rule);
46
+ // - three or more newlines collapse to a single blank line;
47
+ // - leading indentation and inline runs of whitespace are kept as they are,
48
+ // because they are what keeps a code block readable.
49
+ // No length cap is applied here.
50
+ return stripVTControlCharacters(raw)
51
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0084\u0086-\u009f]/g, "")
52
+ .replace(/\r\n?/g, "\n")
53
+ .replace(/[\u2028\u2029\u0085]/g, "\n")
54
+ .replace(/[^\S\n]+$/gm, "")
55
+ .replace(/\n{3,}/g, "\n\n")
56
+ .trim();
38
57
  }
39
58
  function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefined {
40
59
  if (!event.terminalId) return undefined;