@dorokuma/herdsman-pi 0.14.0 → 0.14.2

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 +166 -225
  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.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Pi extension bridge for Herdsman agent history.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -7,7 +7,6 @@ export { logHerdsmanPi };
7
7
  export type { HerdsmanPiLogLevel } from "./logger.js";
8
8
 
9
9
  import {
10
- type AgentContextListItem,
11
10
  type AgentEventWireRecord,
12
11
  type AgentOrchestratorChanged,
13
12
  type AgentOrchestratorWireState,
@@ -105,7 +104,6 @@ type HerdsmanState = {
105
104
  launchIdentity: LaunchIdentity | undefined;
106
105
  latestContext: AgentWorkspaceContextSnapshot | undefined;
107
106
  pendingEvents: AgentEventWireRecord[];
108
- pinnedContext: AgentWorkspaceContextSnapshot | undefined;
109
107
  presentedEventIds: Set<number>;
110
108
  reconnectingFromOn: boolean;
111
109
  registrationInFlight: Promise<void> | undefined;
@@ -141,8 +139,12 @@ type HerdsmanState = {
141
139
  * delivery from being written off, and it carries three guarantees at once:
142
140
  *
143
141
  * - never lost: while it is non-empty the settlement drives a continuation
144
- * (bounded by MAX_WAKE_CONTINUATION_ATTEMPTS) so a later run drains the queue
145
- * and carries the update out;
142
+ * (bounded by MAX_WAKE_CONTINUATION_ATTEMPTS drives) so a later run drains
143
+ * the queue and carries the update out. The bound is a real ceiling, not a
144
+ * suggestion: once it is spent the ids are released from here and
145
+ * acknowledged (`writeOffStrandedWakeDelivery`), because an update that no
146
+ * turn will ever carry out must not keep a run loop — and the daemon's
147
+ * redelivery of it — alive forever;
146
148
  * - never acknowledged unseen: these ids are excluded from the acknowledgement
147
149
  * path, so the daemon keeps them pending and redelivers them when this
148
150
  * session never consumes them (the only way a delivery Pi itself dropped —
@@ -203,18 +205,24 @@ type HerdsmanState = {
203
205
  */
204
206
  wakeSkipLogReasons: Map<number, string>;
205
207
  /**
206
- * Continuation drives already spent on the current unconsumed delivery.
208
+ * Continuation drives already spent, keyed by event id.
207
209
  *
208
- * Reset when a delivery is handed over and when the unconsumed set empties, so
209
- * the bound applies per delivery instead of accumulating for the session.
210
- */
211
- wakeContinuationAttempts: number;
212
- /**
213
- * Event content queued for a busy orchestrator. Injected through the
214
- * `context` hook so the running turn sees the update without being
215
- * interrupted.
210
+ * The bound is per event, never per delivery and never per session: an
211
+ * unrelated event being injected must not hand a stuck one a fresh budget. A
212
+ * session that keeps receiving new updates used to reset a shared counter at
213
+ * every injection, so the one id no run ever drained drove on unboundedly and
214
+ * `MAX_WAKE_CONTINUATION_ATTEMPTS` never fired — the loop that flooded the
215
+ * orchestrator session with empty turns.
216
+ *
217
+ * `MAX_WAKE_CONTINUATION_ATTEMPTS` is therefore a hard ceiling per id: the
218
+ * drive that reaches it writes that id off
219
+ * (`writeOffStrandedWakeDelivery`), and an id that still has drives left keeps
220
+ * both its copy and its count even when a sibling is written off next to it.
221
+ *
222
+ * An entry leaves the map with the id itself: acknowledged, dead-lettered,
223
+ * consumed (its copy reached the transcript), or a role/scope reset.
216
224
  */
217
- wakeContext: { content: string; eventIds: number[] } | undefined;
225
+ wakeContinuationDrives: Map<number, number>;
218
226
  wakeRequested: boolean;
219
227
  wakeRequestedThroughEventId: number;
220
228
  wakeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -281,17 +289,31 @@ export const WAKE_BUSY_SPIN_MS = 100;
281
289
  */
282
290
  export const WAKE_DEFERRED_TIMEOUT_MS = 5_000;
283
291
  /**
284
- * Upper bound on the continuation drives spent on one unconsumed delivery.
292
+ * Upper bound on the continuation drives spent on **one** unconsumed wake
293
+ * event id.
294
+ *
295
+ * Every drive costs a full agent run, so the budget is deliberately small: three
296
+ * attempts already cover the drive that follows the settlement which missed Pi's
297
+ * follow-up queue plus two retries after intervening runs that also ended before
298
+ * their stop point, and five adds margin for a run that spent its stop point on
299
+ * a tool call. Beyond that the cause is systemic — the runs keep ending early, so
300
+ * starting another one could only produce another empty turn — and that id is
301
+ * written off instead (`writeOffStrandedWakeDelivery`): it leaves
302
+ * `wakeAwaitingConsumption` and is acknowledged, which stops both the
303
+ * continuation loop and the daemon's redelivery of the same event.
304
+ *
305
+ * The bound is per event id (`wakeContinuationDrives`) and is never reset by a
306
+ * later injection: a session that keeps receiving new updates must not hand an
307
+ * already-stuck id a fresh ceiling, or the loop it was meant to stop continues
308
+ * for exactly the events that are stuck.
285
309
  *
286
- * Every drive costs a full agent run, so three attempts cover the drive that
287
- * follows the settlement which missed Pi's follow-up queue plus two retries
288
- * after intervening runs that also ended before their stop point. Beyond that
289
- * the condition is systemic (Pi dropped the queue, the runs keep ending early)
290
- * and further attempts could only start runs without delivering anything: the
291
- * delivery stays unacknowledged, hence pending on the daemon, which redelivers
292
- * it to a later run or session.
310
+ * The trade-off is deliberate and asymmetric: losing one agent update is
311
+ * recoverable (the agent is still there and its transcript can be read
312
+ * directly), while an unbounded continuation loop floods the orchestrator
313
+ * session with empty turns and makes it unusable. A missed update is therefore
314
+ * strictly better than an endless one.
293
315
  */
294
- export const MAX_WAKE_CONTINUATION_ATTEMPTS = 3;
316
+ export const MAX_WAKE_CONTINUATION_ATTEMPTS = 5;
295
317
  /** `customType` of the hidden wake context this extension injects. */
296
318
  const WAKE_CONTEXT_CUSTOM_TYPE = "herdsman-wake-context";
297
319
  /** `customType` of the hidden marker that drives a missed wake continuation. */
@@ -378,7 +400,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
378
400
  launchIdentity: undefined,
379
401
  latestContext: undefined,
380
402
  pendingEvents: [],
381
- pinnedContext: undefined,
382
403
  presentedEventIds: new Set(),
383
404
  reconnectingFromOn: false,
384
405
  registrationInFlight: undefined,
@@ -394,8 +415,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
394
415
  wakeConsumptionObserved: new Set(),
395
416
  wakeSuppressedEventIds: new Set(),
396
417
  wakeSkipLogReasons: new Map(),
397
- wakeContinuationAttempts: 0,
398
- wakeContext: undefined,
418
+ wakeContinuationDrives: new Map(),
399
419
  wakeRequested: false,
400
420
  wakeRequestedThroughEventId: 0,
401
421
  wakeTimer: undefined,
@@ -446,15 +466,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
446
466
  cancelWakeTimer();
447
467
  state.wakeRequested = false;
448
468
  state.wakeRequestedThroughEventId = 0;
449
- // A wake queued for a busy orchestrator belongs to the role/scope that
450
- // queued it: dropping it here keeps a stale event body out of the context
451
- // of whatever session takes over next.
452
- state.wakeContext = undefined;
453
469
  };
454
470
 
455
471
  const clearAgentContext = () => {
456
472
  state.latestContext = undefined;
457
- state.pinnedContext = undefined;
458
473
  state.runActive = false;
459
474
  };
460
475
 
@@ -504,7 +519,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
504
519
  state.wakeAwaitingConsumption.delete(eventId);
505
520
  state.wakeConsumptionObserved.delete(eventId);
506
521
  state.wakeSuppressedEventIds.delete(eventId);
507
- if (state.wakeAwaitingConsumption.size === 0) state.wakeContinuationAttempts = 0;
522
+ // The id can no longer owe a drive, so its own budget leaves with it.
523
+ state.wakeContinuationDrives.delete(eventId);
508
524
  };
509
525
 
510
526
  /**
@@ -544,7 +560,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
544
560
  state.wakeAwaitingConsumption.clear();
545
561
  state.wakeConsumptionObserved.clear();
546
562
  state.wakeSkipLogReasons.clear();
547
- state.wakeContinuationAttempts = 0;
563
+ state.wakeContinuationDrives.clear();
548
564
  };
549
565
 
550
566
  const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
@@ -935,6 +951,44 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
935
951
  return { deliveryQueue, confirmablePrefix, blockedByMissingTurn, stillAwaitingConsumption };
936
952
  };
937
953
 
954
+ const writeOffStrandedWakeDelivery = (eventIds: number[], ctx: PiContext): void => {
955
+ // Release first, acknowledge second: the release is what breaks the loop
956
+ // synchronously, while the acknowledgement is an RPC that may fail.
957
+ for (const eventId of eventIds) {
958
+ state.wakeAwaitingConsumption.delete(eventId);
959
+ // This set only ever holds the id of a delivery that was already handed
960
+ // to Pi, so releasing it is not permission to present it again: the
961
+ // session-wide presented guard stays behind and keeps a daemon replay
962
+ // of the same id out of the transcript.
963
+ state.presentedEventIds.add(eventId);
964
+ }
965
+ // The written-off ids can no longer owe a drive, so their budgets leave
966
+ // with them. Every id that is still awaiting consumption keeps its own
967
+ // budget: it has not spent its own MAX_WAKE_CONTINUATION_ATTEMPTS drives
968
+ // yet, and resetting it here is what used to let a sibling's fresh budget
969
+ // cover for an unrelated stuck id.
970
+ for (const eventId of eventIds) state.wakeContinuationDrives.delete(eventId);
971
+ const stranded = eventIds
972
+ .map((eventId) => state.unackedDelivered.get(eventId))
973
+ .filter((event): event is AgentEventWireRecord => event !== undefined)
974
+ .sort((left, right) => left.id - right.id);
975
+ logHerdsmanPi(
976
+ "warn",
977
+ `[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`,
978
+ );
979
+ ctx.ui.notify?.(
980
+ `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`,
981
+ "warning",
982
+ );
983
+ if (stranded.length === 0) return;
984
+ void acknowledgeEventIds(stranded, { notify: false }, ctx).catch((error: unknown) => {
985
+ logHerdsmanPi(
986
+ "warn",
987
+ `[herdsman-pi] wake write-off acknowledgement failed eventIds=${stranded.map((event) => event.id).join(",")} · ${String(error)}`,
988
+ );
989
+ });
990
+ };
991
+
938
992
  /**
939
993
  * Drives one continuation that carries out a wake Pi has not drained.
940
994
  *
@@ -950,17 +1004,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
950
1004
  *
951
1005
  * Only consumption evidence writes a delivery off, so an intervening run that
952
1006
  * ends before its stop point (error, user abort, a refused tool) leaves the
953
- * next settlement driving again. That is bounded by
954
- * MAX_WAKE_CONTINUATION_ATTEMPTS: starting runs cannot fix a cause that is
955
- * not about the queue, and once the budget is spent the delivery stays
956
- * unacknowledged and therefore pending on the daemon.
1007
+ * next settlement driving again. That bound is per event id and nothing
1008
+ * resets it: starting runs cannot fix a cause that is not about the queue, so
1009
+ * once an id's own budget is spent the id is written off (released and
1010
+ * acknowledged) instead of looping forever — see
1011
+ * `writeOffStrandedWakeDelivery` for the trade-off.
957
1012
  */
958
1013
  const driveWakeContinuation = (ctx: PiContext) => {
959
1014
  if (state.wakeAwaitingConsumption.size === 0) return;
960
- if (state.wakeContinuationAttempts >= MAX_WAKE_CONTINUATION_ATTEMPTS) return;
961
1015
  if (!pi.sendMessage) return;
962
1016
  const eventIds = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
963
- state.wakeContinuationAttempts += 1;
1017
+ // Every id that is still waiting spends one drive of its own budget. The
1018
+ // counters are keyed by event id and are never reset by an injection, so a
1019
+ // delivery handed over later cannot buy an already-stuck id a fresh budget.
1020
+ const spent: number[] = [];
1021
+ for (const eventId of eventIds) {
1022
+ const drives = (state.wakeContinuationDrives.get(eventId) ?? 0) + 1;
1023
+ state.wakeContinuationDrives.set(eventId, drives);
1024
+ spent.push(drives);
1025
+ }
964
1026
  try {
965
1027
  pi.sendMessage(
966
1028
  {
@@ -972,29 +1034,27 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
972
1034
  );
973
1035
  logHerdsmanPi(
974
1036
  "info",
975
- `[herdsman-pi] wake continuation driven eventIds=${eventIds[0] ?? 0}-${eventIds.at(-1) ?? 0} count=${eventIds.length} attempt=${state.wakeContinuationAttempts}`,
1037
+ `[herdsman-pi] wake continuation driven eventIds=${eventIds[0] ?? 0}-${eventIds.at(-1) ?? 0} count=${eventIds.length} drives=${spent.join(",")}`,
976
1038
  );
977
1039
  } catch {
978
1040
  logHerdsmanPi("warn", "[herdsman-pi] wake continuation refused by pi");
979
1041
  }
980
- if (state.wakeContinuationAttempts >= MAX_WAKE_CONTINUATION_ATTEMPTS) {
981
- logHerdsmanPi(
982
- "warn",
983
- `[herdsman-pi] wake continuation limit reached awaiting=${eventIds.join(",")} attempts=${state.wakeContinuationAttempts} · delivery stays unacknowledged for daemon redelivery`,
984
- );
985
- // Bounded retries cannot deliver this update, and the daemon does not
986
- // redeliver while this terminal still holds the scope
987
- // (`nextDeliverableAfter` skips events already delivered to the same
988
- // owner), so the session is where it has to be visible. The notice must
989
- // not promise what the extension cannot do: a copy that Pi dropped cannot
990
- // be drained by "the next turn", it is simply gone here — so it says what
991
- // is true (unconfirmed, possibly dropped) and what the user can actually
992
- // do about it (a later turn still drains a queued copy, and handing the
993
- // workspace to another terminal makes the daemon redeliver the update).
994
- ctx.ui.notify?.(
995
- `Herdsman · ${eventIds.length} agent update${eventIds.length === 1 ? "" : "s"} could not be delivered by a wake turn · unconfirmed and possibly dropped: keep working here so a later turn drains a queued copy, or hand this workspace to another terminal so the daemon redelivers it`,
996
- "warning",
997
- );
1042
+ // Only the ids whose own budget this drive spent are written off. A sibling
1043
+ // that still has drives left keeps its copy in the queue and its count, so
1044
+ // it is neither lost nor granted a fresh ceiling.
1045
+ const exhausted = eventIds.filter(
1046
+ (_eventId, index) => (spent[index] ?? 0) >= MAX_WAKE_CONTINUATION_ATTEMPTS,
1047
+ );
1048
+ if (exhausted.length > 0) {
1049
+ // This was the last drive the id's own budget allows, and the queued copy
1050
+ // is still undrained: the id is written off instead of being left
1051
+ // pending forever. The ids are released from
1052
+ // `wakeAwaitingConsumption` (no further run is started for them) and
1053
+ // acknowledged, which is what stops the daemon from redelivering them
1054
+ // every freshness window — the loop that used to flood the orchestrator
1055
+ // session until manual sqlite surgery. Losing one update is the accepted
1056
+ // cost; see `writeOffStrandedWakeDelivery`.
1057
+ writeOffStrandedWakeDelivery(exhausted, ctx);
998
1058
  }
999
1059
  };
1000
1060
 
@@ -1193,11 +1253,17 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1193
1253
  state.wakeTimer = undefined;
1194
1254
  state.wakeRequested = true;
1195
1255
  state.wakeRequestedThroughEventId = current.at(-1)?.eventId ?? 0;
1196
- // Dual-track injection: an idle orchestrator gets a triggered
1197
- // follow-up turn (immediate delivery), while a busy one is not
1198
- // interrupted — the same content is queued as a non-triggering
1199
- // follow-up and additionally exposed through the `context` hook so the
1200
- // running turn can already see it.
1256
+ // Single-track injection: the wake content is handed to Pi exactly
1257
+ // once, as the follow-up message below. An idle orchestrator gets a
1258
+ // triggered follow-up turn (immediate delivery); a busy one is not
1259
+ // interrupted and receives the same content as a queued,
1260
+ // non-triggering follow-up that a later run drains out of the
1261
+ // follow-up queue. There is deliberately no second copy on the
1262
+ // `context` hook: a run that is busy cannot act on an early copy
1263
+ // anyway, while a second copy is exactly what puts the same update
1264
+ // into the session twice. So the transcript holds one entry per event
1265
+ // id, and the continuation drive (see `driveWakeContinuation`) is what
1266
+ // carries a queued copy that no run drained into the transcript.
1201
1267
  const orchestratorBusy = ctx.isIdle?.() === false;
1202
1268
  const wakeContent = formatAgentOutcomeUpdates(batchOutcomes);
1203
1269
  // Single line, injection path only: the decision that produced this
@@ -1238,17 +1304,17 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1238
1304
  // `driveWakeContinuation`), and until the content is seen in the
1239
1305
  // transcript the delivery is neither acknowledged nor injected again.
1240
1306
  if (orchestratorBusy) {
1307
+ // A freshly queued id starts its own continuation budget; an id that
1308
+ // was already awaiting consumption (merged into this batch from an
1309
+ // earlier injection) keeps the drives it already spent. Only a new
1310
+ // id may get a fresh ceiling — never an unrelated one.
1241
1311
  for (const outcome of batchOutcomes) {
1242
1312
  state.wakeAwaitingConsumption.add(outcome.eventId);
1313
+ if (!state.wakeContinuationDrives.has(outcome.eventId)) {
1314
+ state.wakeContinuationDrives.set(outcome.eventId, 0);
1315
+ }
1243
1316
  }
1244
- state.wakeContinuationAttempts = 0;
1245
1317
  }
1246
- // A queued (non-triggering) delivery keeps the content available to
1247
- // the current turn through the context hook until it is settled or
1248
- // superseded by the next injection.
1249
- state.wakeContext = orchestratorBusy
1250
- ? { content: wakeContent, eventIds: batchOutcomes.map((outcome) => outcome.eventId) }
1251
- : undefined;
1252
1318
  state.wakeForcedRelease = false;
1253
1319
  state.wakeDeferredSince = undefined;
1254
1320
  // Only expose the batch after the hidden context was accepted by pi. This
@@ -1488,7 +1554,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1488
1554
  // already acked events are covered by the server cursor
1489
1555
  // (pruneAcknowledgedEvents below).
1490
1556
  state.deliveredBatch = undefined;
1491
- state.wakeContext = undefined;
1492
1557
  }
1493
1558
  // Otherwise the batch's wake turn is still in flight: keep it so the
1494
1559
  // settlement acknowledges it and the events are not re-presented.
@@ -1576,23 +1641,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1576
1641
  state.currentScope.workspaceId === message.params.workspaceId
1577
1642
  ) {
1578
1643
  const next = message.params.context ?? undefined;
1579
- const retain = (snapshot: AgentWorkspaceContextSnapshot | undefined) =>
1580
- snapshot
1581
- ? {
1582
- ...snapshot,
1583
- agents: snapshot.agents.flatMap((agent) => {
1584
- const nextAgents =
1585
- next?.agents.filter((candidate) => candidate.paneId === agent.paneId) ?? [];
1586
- if (nextAgents.length === 0) return [];
1587
- const nextAgent = nextAgents.find(
1588
- (candidate) => !agent.id || !candidate.id || agent.id === candidate.id,
1589
- );
1590
- return nextAgent ? [nextAgent] : [];
1591
- }),
1592
- }
1593
- : undefined;
1594
1644
  state.latestContext = next;
1595
- state.pinnedContext = retain(state.pinnedContext);
1596
1645
  }
1597
1646
  return;
1598
1647
  }
@@ -1796,6 +1845,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1796
1845
  }
1797
1846
  for (const eventId of consumedEventIds) {
1798
1847
  state.wakeAwaitingConsumption.delete(eventId);
1848
+ // Its copy reached the transcript, so the id owes no drive any more and
1849
+ // its budget leaves with it.
1850
+ state.wakeContinuationDrives.delete(eventId);
1799
1851
  // The content reached the transcript, so this id counts as presented from
1800
1852
  // now on: a daemon redelivery of it (it is still unacknowledged whenever
1801
1853
  // its batch was already dropped) must not inject the same update again.
@@ -1807,7 +1859,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1807
1859
  // Only an id the daemon can still be told about is worth remembering.
1808
1860
  if (state.unackedDelivered.has(eventId)) state.wakeConsumptionObserved.add(eventId);
1809
1861
  }
1810
- if (state.wakeAwaitingConsumption.size === 0) state.wakeContinuationAttempts = 0;
1811
1862
  }
1812
1863
  if (message.role !== "assistant") return;
1813
1864
  const stopReason = stringValue(message.stopReason);
@@ -1846,63 +1897,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1846
1897
  // the next user message.
1847
1898
  if (state.runActive) return;
1848
1899
  state.runActive = true;
1849
- state.pinnedContext =
1850
- state.isOrchestrator && !state.deliveredBatch?.herdsmanTriggered
1851
- ? state.latestContext
1852
- : undefined;
1853
1900
  });
1854
1901
 
1855
1902
  pi.on("context", (event: { messages: PiAgentMessage[] }) => {
1903
+ // The wake content rides the single follow-up track and is never pinned
1904
+ // into the context here, so this hook injects nothing at all. What it
1905
+ // still does is drop herdsman's own context/wake entries from the
1906
+ // incoming list: a session replayed from disk can carry an old
1907
+ // `[HERDSMAN AGENT CONTEXT]` preview (its truncated one-line reports
1908
+ // repeatedly read as cut-off reports, and live board state is queried
1909
+ // through herdr), so those entries are filtered out instead of being
1910
+ // re-served to the model. The wake message itself
1911
+ // (`herdsman-wake-context`, the full report text) is not filtered - it is
1912
+ // the evidence the turn acts on.
1856
1913
  const messages = event.messages.filter((message) => !isNormalHerdsmanContext(message));
1857
- const additions: PiAgentMessage[] = [];
1858
- const snapshot = state.pinnedContext;
1859
- if (snapshot && snapshot.agents.length > 0) {
1860
- additions.push({
1861
- content: formatHiddenAgentContext({
1862
- agents: snapshot.agents,
1863
- workspaceId: snapshot.workspaceId,
1864
- }),
1865
- customType: "herdsman-agent-context",
1866
- display: false,
1867
- role: "custom",
1868
- timestamp: Date.now(),
1869
- });
1870
- }
1871
- // A wake queued for a busy orchestrator is not allowed to interrupt the
1872
- // running tool chain, so its content is additionally pinned to the current
1873
- // context: the orchestrator sees the child-agent outcome in this turn
1874
- // without a triggered follow-up. The entry is dropped again by
1875
- // isNormalHerdsmanContext, so at most one copy is present per call.
1876
- //
1877
- // `eventIds` mirrors what this turn actually presents (the freshly injected
1878
- // outcomes): events carried over in the delivery queue were already shown
1879
- // to the orchestrator in the turn that presented them, so they are not
1880
- // re-listed here. The queued follow-up message itself keeps the wider
1881
- // `details.eventIds` set (everything still unconfirmed).
1882
- //
1883
- // The pin is not a second delivery of the update: it makes the same queued
1884
- // copy visible early to the turn that is running, it is not written to the
1885
- // transcript, it is never acknowledged on its own, and it is not consumption
1886
- // evidence — only the hidden wake message's `message_end` is. So it may not be
1887
- // dropped in the name of "one copy only" either: it is the only way this turn
1888
- // ever sees the update.
1889
- const queuedWake = state.wakeContext;
1890
- if (queuedWake) {
1891
- additions.push({
1892
- content: queuedWake.content,
1893
- customType: "herdsman-wake-queued",
1894
- details: { eventIds: queuedWake.eventIds },
1895
- display: false,
1896
- role: "custom",
1897
- timestamp: Date.now(),
1898
- });
1899
- }
1900
- return additions.length === 0 ? { messages } : { messages: [...messages, ...additions] };
1914
+ return { messages };
1901
1915
  });
1902
1916
 
1903
1917
  pi.on("agent_settled", async (_event: unknown, ctx: PiContext) => {
1904
1918
  state.runActive = false;
1905
- state.pinnedContext = undefined;
1906
1919
  // A wake delivered as a queued follow-up is drained only when a run reaches
1907
1920
  // its stop point. If the run that received it settled without draining it,
1908
1921
  // no further run exists to carry the update out: drive a continuation (up to
@@ -1920,7 +1933,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1920
1933
  state.wakeDeferredUntilSettled = false;
1921
1934
  state.wakeDeferredSince = undefined;
1922
1935
  state.wakeForcedRelease = false;
1923
- state.wakeContext = undefined;
1924
1936
  setHerdsmanUi(ctx);
1925
1937
  scheduleWake(ctx);
1926
1938
  };
@@ -1988,62 +2000,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1988
2000
 
1989
2001
  export default createHerdsmanPiExtension();
1990
2002
 
1991
- export function formatHiddenAgentContext(input: {
1992
- agents: AgentContextListItem[];
1993
- workspaceId: string;
1994
- }): string {
1995
- return [
1996
- "[HERDSMAN AGENT CONTEXT]",
1997
- `Current Herdr workspace: ${input.workspaceId}`,
1998
- ...input.agents.map((agent) => {
1999
- const history = agent.history ?? {};
2000
- const identity = agentIdentityLabel({
2001
- agent: agent.agent ?? "unknown",
2002
- name: agent.name,
2003
- });
2004
- const paneId = agent.paneId ?? "unknown";
2005
- const status = agent.agentStatus ?? "unknown";
2006
- const prefix = `- ${identity} ${paneId} ${status}`;
2007
-
2008
- const rawAgent = record(agent);
2009
- const tabTitleCandidate =
2010
- stringValue(rawAgent.terminalTitle) ?? stringValue(rawAgent.label);
2011
- const tabTitleCleaned =
2012
- tabTitleCandidate !== null ? sanitizeAndCleanContextText(tabTitleCandidate) : "";
2013
- const tabTitle =
2014
- tabTitleCleaned.length > 0 ? truncateSummary(tabTitleCleaned, 60) : null;
2015
-
2016
- const rawHistory = record(history);
2017
- const lastAssistantRecord = record(history.lastAssistantMessage);
2018
- const timeCandidate =
2019
- lastAssistantRecord.timestamp ??
2020
- history.updatedAt ??
2021
- rawHistory.updatedAt ??
2022
- rawAgent.updatedAt ??
2023
- rawAgent.time;
2024
- const formattedTime = formatTimestamp(timeCandidate);
2025
-
2026
- const assistantRaw = history.lastAssistantMessage?.text;
2027
- const assistantCleaned =
2028
- assistantRaw !== undefined && assistantRaw !== null
2029
- ? sanitizeAndCleanContextText(assistantRaw)
2030
- : "";
2031
- const assistantSummary =
2032
- assistantCleaned.length > 0 ? truncateSummary(assistantCleaned, 100) : null;
2033
-
2034
- const segments = [
2035
- prefix,
2036
- tabTitle,
2037
- formattedTime,
2038
- assistantSummary,
2039
- ].filter((segment): segment is string => segment !== null && segment.length > 0);
2040
-
2041
- return segments.join(" · ");
2042
- }),
2043
- "Use herdsman agent get/read if details are needed.",
2044
- ].join("\n");
2045
- }
2046
-
2047
2003
  export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string {
2048
2004
  return [
2049
2005
  "[HERDSMAN AGENT UPDATES]",
@@ -2066,6 +2022,10 @@ export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string
2066
2022
  function isNormalHerdsmanContext(message: PiAgentMessage): boolean {
2067
2023
  return (
2068
2024
  message.customType === "herdsman-agent-context" ||
2025
+ // Defensive for session replay: older versions pinned a queued wake to the
2026
+ // context hook under this customType. A replayed history may still carry
2027
+ // those entries, and they are herdsman's own, so they are dropped from the
2028
+ // incoming list like the agent-context ones (see the context hook).
2069
2029
  message.customType === "herdsman-wake-queued" ||
2070
2030
  contentIncludesMarker(message.content, "[HERDSMAN AGENT CONTEXT]")
2071
2031
  );
@@ -2119,12 +2079,22 @@ function record(value: unknown): Record<string, unknown> {
2119
2079
  }
2120
2080
 
2121
2081
  function cleanContextText(value: string): string {
2082
+ // Same newline-preserving scheme as `normalizeExcerpt` in wake.ts: which byte a
2083
+ // line break is (CRLF, CR, U+2028/U+2029, NEL) is normalised to LF, trailing
2084
+ // whitespace is dropped per line, 3+ newlines collapse to a blank line, and
2085
+ // leading indentation plus inline runs of whitespace are kept as they are.
2086
+ // NEL (U+0085) is excluded from the control-character clearing regex below,
2087
+ // which would otherwise delete it as an unprintable byte; the rest of that
2088
+ // regex is left intact.
2122
2089
  return stripVTControlCharacters(value)
2123
2090
  .replace(
2124
- /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
2091
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0084\u0086-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
2125
2092
  "",
2126
2093
  )
2127
- .replace(/\s+/g, " ")
2094
+ .replace(/\r\n?/g, "\n")
2095
+ .replace(/[\u2028\u2029\u0085]/g, "\n")
2096
+ .replace(/[^\S\n]+$/gm, "")
2097
+ .replace(/\n{3,}/g, "\n\n")
2128
2098
  .trim();
2129
2099
  }
2130
2100
 
@@ -2135,32 +2105,3 @@ function sanitizeAndCleanContextText(value: string): string {
2135
2105
  return cleanContextText(sanitized);
2136
2106
  }
2137
2107
 
2138
- function truncateSummary(value: string, limit = 100): string {
2139
- if (value.length <= limit) return value;
2140
- const maxSearch = Math.min(value.length, limit + 8);
2141
- const searchSlice = value.slice(0, maxSearch);
2142
- const lastSpaceIndex = searchSlice.search(/\s[^\s]*$/);
2143
- if (lastSpaceIndex >= limit - 20) {
2144
- return `${value.slice(0, lastSpaceIndex)}…`;
2145
- }
2146
- return `${value.slice(0, limit)}…`;
2147
- }
2148
-
2149
- function formatTimestamp(value: unknown): string | null {
2150
- if (typeof value === "string") {
2151
- const trimmed = value.trim();
2152
- if (/^\d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
2153
- if (/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
2154
- }
2155
- if (!value) return null;
2156
- const date = value instanceof Date ? value : new Date(value as string | number);
2157
- if (Number.isNaN(date.getTime())) return null;
2158
- const pad = (n: number) => String(n).padStart(2, "0");
2159
- const month = pad(date.getMonth() + 1);
2160
- const day = pad(date.getDate());
2161
- const hours = pad(date.getHours());
2162
- const minutes = pad(date.getMinutes());
2163
- const seconds = pad(date.getSeconds());
2164
- return `${month}-${day} ${hours}:${minutes}:${seconds}`;
2165
- }
2166
-
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;