@dorokuma/herdsman-pi 0.14.1 → 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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +102 -211
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorokuma/herdsman-pi",
3
- "version": "0.14.1",
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;
@@ -207,18 +205,24 @@ type HerdsmanState = {
207
205
  */
208
206
  wakeSkipLogReasons: Map<number, string>;
209
207
  /**
210
- * Continuation drives already spent on the current unconsumed delivery.
208
+ * Continuation drives already spent, keyed by event id.
211
209
  *
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;
216
- /**
217
- * Event content queued for a busy orchestrator. Injected through the
218
- * `context` hook so the running turn sees the update without being
219
- * 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.
220
224
  */
221
- wakeContext: { content: string; eventIds: number[] } | undefined;
225
+ wakeContinuationDrives: Map<number, number>;
222
226
  wakeRequested: boolean;
223
227
  wakeRequestedThroughEventId: number;
224
228
  wakeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -285,18 +289,24 @@ export const WAKE_BUSY_SPIN_MS = 100;
285
289
  */
286
290
  export const WAKE_DEFERRED_TIMEOUT_MS = 5_000;
287
291
  /**
288
- * Upper bound on the continuation drives spent on one unconsumed wake delivery.
292
+ * Upper bound on the continuation drives spent on **one** unconsumed wake
293
+ * event id.
289
294
  *
290
295
  * Every drive costs a full agent run, so the budget is deliberately small: three
291
296
  * attempts already cover the drive that follows the settlement which missed Pi's
292
297
  * follow-up queue plus two retries after intervening runs that also ended before
293
298
  * their stop point, and five adds margin for a run that spent its stop point on
294
299
  * 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
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
298
303
  * continuation loop and the daemon's redelivery of the same event.
299
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.
309
+ *
300
310
  * The trade-off is deliberate and asymmetric: losing one agent update is
301
311
  * recoverable (the agent is still there and its transcript can be read
302
312
  * directly), while an unbounded continuation loop floods the orchestrator
@@ -390,7 +400,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
390
400
  launchIdentity: undefined,
391
401
  latestContext: undefined,
392
402
  pendingEvents: [],
393
- pinnedContext: undefined,
394
403
  presentedEventIds: new Set(),
395
404
  reconnectingFromOn: false,
396
405
  registrationInFlight: undefined,
@@ -406,8 +415,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
406
415
  wakeConsumptionObserved: new Set(),
407
416
  wakeSuppressedEventIds: new Set(),
408
417
  wakeSkipLogReasons: new Map(),
409
- wakeContinuationAttempts: 0,
410
- wakeContext: undefined,
418
+ wakeContinuationDrives: new Map(),
411
419
  wakeRequested: false,
412
420
  wakeRequestedThroughEventId: 0,
413
421
  wakeTimer: undefined,
@@ -458,15 +466,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
458
466
  cancelWakeTimer();
459
467
  state.wakeRequested = false;
460
468
  state.wakeRequestedThroughEventId = 0;
461
- // A wake queued for a busy orchestrator belongs to the role/scope that
462
- // queued it: dropping it here keeps a stale event body out of the context
463
- // of whatever session takes over next.
464
- state.wakeContext = undefined;
465
469
  };
466
470
 
467
471
  const clearAgentContext = () => {
468
472
  state.latestContext = undefined;
469
- state.pinnedContext = undefined;
470
473
  state.runActive = false;
471
474
  };
472
475
 
@@ -516,7 +519,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
516
519
  state.wakeAwaitingConsumption.delete(eventId);
517
520
  state.wakeConsumptionObserved.delete(eventId);
518
521
  state.wakeSuppressedEventIds.delete(eventId);
519
- 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);
520
524
  };
521
525
 
522
526
  /**
@@ -556,7 +560,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
556
560
  state.wakeAwaitingConsumption.clear();
557
561
  state.wakeConsumptionObserved.clear();
558
562
  state.wakeSkipLogReasons.clear();
559
- state.wakeContinuationAttempts = 0;
563
+ state.wakeContinuationDrives.clear();
560
564
  };
561
565
 
562
566
  const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
@@ -958,10 +962,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
958
962
  // of the same id out of the transcript.
959
963
  state.presentedEventIds.add(eventId);
960
964
  }
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
+ // 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);
965
971
  const stranded = eventIds
966
972
  .map((eventId) => state.unackedDelivered.get(eventId))
967
973
  .filter((event): event is AgentEventWireRecord => event !== undefined)
@@ -998,17 +1004,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
998
1004
  *
999
1005
  * Only consumption evidence writes a delivery off, so an intervening run that
1000
1006
  * 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
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
1005
1011
  * `writeOffStrandedWakeDelivery` for the trade-off.
1006
1012
  */
1007
1013
  const driveWakeContinuation = (ctx: PiContext) => {
1008
1014
  if (state.wakeAwaitingConsumption.size === 0) return;
1009
1015
  if (!pi.sendMessage) return;
1010
1016
  const eventIds = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
1011
- 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
+ }
1012
1026
  try {
1013
1027
  pi.sendMessage(
1014
1028
  {
@@ -1020,21 +1034,27 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1020
1034
  );
1021
1035
  logHerdsmanPi(
1022
1036
  "info",
1023
- `[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(",")}`,
1024
1038
  );
1025
1039
  } catch {
1026
1040
  logHerdsmanPi("warn", "[herdsman-pi] wake continuation refused by pi");
1027
1041
  }
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);
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);
1038
1058
  }
1039
1059
  };
1040
1060
 
@@ -1233,11 +1253,17 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1233
1253
  state.wakeTimer = undefined;
1234
1254
  state.wakeRequested = true;
1235
1255
  state.wakeRequestedThroughEventId = current.at(-1)?.eventId ?? 0;
1236
- // Dual-track injection: an idle orchestrator gets a triggered
1237
- // follow-up turn (immediate delivery), while a busy one is not
1238
- // interrupted — the same content is queued as a non-triggering
1239
- // follow-up and additionally exposed through the `context` hook so the
1240
- // 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.
1241
1267
  const orchestratorBusy = ctx.isIdle?.() === false;
1242
1268
  const wakeContent = formatAgentOutcomeUpdates(batchOutcomes);
1243
1269
  // Single line, injection path only: the decision that produced this
@@ -1278,17 +1304,17 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1278
1304
  // `driveWakeContinuation`), and until the content is seen in the
1279
1305
  // transcript the delivery is neither acknowledged nor injected again.
1280
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.
1281
1311
  for (const outcome of batchOutcomes) {
1282
1312
  state.wakeAwaitingConsumption.add(outcome.eventId);
1313
+ if (!state.wakeContinuationDrives.has(outcome.eventId)) {
1314
+ state.wakeContinuationDrives.set(outcome.eventId, 0);
1315
+ }
1283
1316
  }
1284
- state.wakeContinuationAttempts = 0;
1285
1317
  }
1286
- // A queued (non-triggering) delivery keeps the content available to
1287
- // the current turn through the context hook until it is settled or
1288
- // superseded by the next injection.
1289
- state.wakeContext = orchestratorBusy
1290
- ? { content: wakeContent, eventIds: batchOutcomes.map((outcome) => outcome.eventId) }
1291
- : undefined;
1292
1318
  state.wakeForcedRelease = false;
1293
1319
  state.wakeDeferredSince = undefined;
1294
1320
  // Only expose the batch after the hidden context was accepted by pi. This
@@ -1528,7 +1554,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1528
1554
  // already acked events are covered by the server cursor
1529
1555
  // (pruneAcknowledgedEvents below).
1530
1556
  state.deliveredBatch = undefined;
1531
- state.wakeContext = undefined;
1532
1557
  }
1533
1558
  // Otherwise the batch's wake turn is still in flight: keep it so the
1534
1559
  // settlement acknowledges it and the events are not re-presented.
@@ -1616,23 +1641,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1616
1641
  state.currentScope.workspaceId === message.params.workspaceId
1617
1642
  ) {
1618
1643
  const next = message.params.context ?? undefined;
1619
- const retain = (snapshot: AgentWorkspaceContextSnapshot | undefined) =>
1620
- snapshot
1621
- ? {
1622
- ...snapshot,
1623
- agents: snapshot.agents.flatMap((agent) => {
1624
- const nextAgents =
1625
- next?.agents.filter((candidate) => candidate.paneId === agent.paneId) ?? [];
1626
- if (nextAgents.length === 0) return [];
1627
- const nextAgent = nextAgents.find(
1628
- (candidate) => !agent.id || !candidate.id || agent.id === candidate.id,
1629
- );
1630
- return nextAgent ? [nextAgent] : [];
1631
- }),
1632
- }
1633
- : undefined;
1634
1644
  state.latestContext = next;
1635
- state.pinnedContext = retain(state.pinnedContext);
1636
1645
  }
1637
1646
  return;
1638
1647
  }
@@ -1836,6 +1845,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1836
1845
  }
1837
1846
  for (const eventId of consumedEventIds) {
1838
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);
1839
1851
  // The content reached the transcript, so this id counts as presented from
1840
1852
  // now on: a daemon redelivery of it (it is still unacknowledged whenever
1841
1853
  // its batch was already dropped) must not inject the same update again.
@@ -1847,7 +1859,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1847
1859
  // Only an id the daemon can still be told about is worth remembering.
1848
1860
  if (state.unackedDelivered.has(eventId)) state.wakeConsumptionObserved.add(eventId);
1849
1861
  }
1850
- if (state.wakeAwaitingConsumption.size === 0) state.wakeContinuationAttempts = 0;
1851
1862
  }
1852
1863
  if (message.role !== "assistant") return;
1853
1864
  const stopReason = stringValue(message.stopReason);
@@ -1886,63 +1897,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1886
1897
  // the next user message.
1887
1898
  if (state.runActive) return;
1888
1899
  state.runActive = true;
1889
- state.pinnedContext =
1890
- state.isOrchestrator && !state.deliveredBatch?.herdsmanTriggered
1891
- ? state.latestContext
1892
- : undefined;
1893
1900
  });
1894
1901
 
1895
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.
1896
1913
  const messages = event.messages.filter((message) => !isNormalHerdsmanContext(message));
1897
- const additions: PiAgentMessage[] = [];
1898
- const snapshot = state.pinnedContext;
1899
- if (snapshot && snapshot.agents.length > 0) {
1900
- additions.push({
1901
- content: formatHiddenAgentContext({
1902
- agents: snapshot.agents,
1903
- workspaceId: snapshot.workspaceId,
1904
- }),
1905
- customType: "herdsman-agent-context",
1906
- display: false,
1907
- role: "custom",
1908
- timestamp: Date.now(),
1909
- });
1910
- }
1911
- // A wake queued for a busy orchestrator is not allowed to interrupt the
1912
- // running tool chain, so its content is additionally pinned to the current
1913
- // context: the orchestrator sees the child-agent outcome in this turn
1914
- // without a triggered follow-up. The entry is dropped again by
1915
- // isNormalHerdsmanContext, so at most one copy is present per call.
1916
- //
1917
- // `eventIds` mirrors what this turn actually presents (the freshly injected
1918
- // outcomes): events carried over in the delivery queue were already shown
1919
- // to the orchestrator in the turn that presented them, so they are not
1920
- // re-listed here. The queued follow-up message itself keeps the wider
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.
1929
- const queuedWake = state.wakeContext;
1930
- if (queuedWake) {
1931
- additions.push({
1932
- content: queuedWake.content,
1933
- customType: "herdsman-wake-queued",
1934
- details: { eventIds: queuedWake.eventIds },
1935
- display: false,
1936
- role: "custom",
1937
- timestamp: Date.now(),
1938
- });
1939
- }
1940
- return additions.length === 0 ? { messages } : { messages: [...messages, ...additions] };
1914
+ return { messages };
1941
1915
  });
1942
1916
 
1943
1917
  pi.on("agent_settled", async (_event: unknown, ctx: PiContext) => {
1944
1918
  state.runActive = false;
1945
- state.pinnedContext = undefined;
1946
1919
  // A wake delivered as a queued follow-up is drained only when a run reaches
1947
1920
  // its stop point. If the run that received it settled without draining it,
1948
1921
  // no further run exists to carry the update out: drive a continuation (up to
@@ -1960,7 +1933,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1960
1933
  state.wakeDeferredUntilSettled = false;
1961
1934
  state.wakeDeferredSince = undefined;
1962
1935
  state.wakeForcedRelease = false;
1963
- state.wakeContext = undefined;
1964
1936
  setHerdsmanUi(ctx);
1965
1937
  scheduleWake(ctx);
1966
1938
  };
@@ -2028,62 +2000,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
2028
2000
 
2029
2001
  export default createHerdsmanPiExtension();
2030
2002
 
2031
- export function formatHiddenAgentContext(input: {
2032
- agents: AgentContextListItem[];
2033
- workspaceId: string;
2034
- }): string {
2035
- return [
2036
- "[HERDSMAN AGENT CONTEXT]",
2037
- `Current Herdr workspace: ${input.workspaceId}`,
2038
- ...input.agents.map((agent) => {
2039
- const history = agent.history ?? {};
2040
- const identity = agentIdentityLabel({
2041
- agent: agent.agent ?? "unknown",
2042
- name: agent.name,
2043
- });
2044
- const paneId = agent.paneId ?? "unknown";
2045
- const status = agent.agentStatus ?? "unknown";
2046
- const prefix = `- ${identity} ${paneId} ${status}`;
2047
-
2048
- const rawAgent = record(agent);
2049
- const tabTitleCandidate =
2050
- stringValue(rawAgent.terminalTitle) ?? stringValue(rawAgent.label);
2051
- const tabTitleCleaned =
2052
- tabTitleCandidate !== null ? sanitizeAndCleanContextText(tabTitleCandidate) : "";
2053
- const tabTitle =
2054
- tabTitleCleaned.length > 0 ? truncateSummary(tabTitleCleaned, 60) : null;
2055
-
2056
- const rawHistory = record(history);
2057
- const lastAssistantRecord = record(history.lastAssistantMessage);
2058
- const timeCandidate =
2059
- lastAssistantRecord.timestamp ??
2060
- history.updatedAt ??
2061
- rawHistory.updatedAt ??
2062
- rawAgent.updatedAt ??
2063
- rawAgent.time;
2064
- const formattedTime = formatTimestamp(timeCandidate);
2065
-
2066
- const assistantRaw = history.lastAssistantMessage?.text;
2067
- const assistantCleaned =
2068
- assistantRaw !== undefined && assistantRaw !== null
2069
- ? sanitizeAndCleanContextText(assistantRaw)
2070
- : "";
2071
- const assistantSummary =
2072
- assistantCleaned.length > 0 ? truncateSummary(assistantCleaned, 100) : null;
2073
-
2074
- const segments = [
2075
- prefix,
2076
- tabTitle,
2077
- formattedTime,
2078
- assistantSummary,
2079
- ].filter((segment): segment is string => segment !== null && segment.length > 0);
2080
-
2081
- return segments.join(" · ");
2082
- }),
2083
- "Use herdsman agent get/read if details are needed.",
2084
- ].join("\n");
2085
- }
2086
-
2087
2003
  export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string {
2088
2004
  return [
2089
2005
  "[HERDSMAN AGENT UPDATES]",
@@ -2106,6 +2022,10 @@ export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string
2106
2022
  function isNormalHerdsmanContext(message: PiAgentMessage): boolean {
2107
2023
  return (
2108
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).
2109
2029
  message.customType === "herdsman-wake-queued" ||
2110
2030
  contentIncludesMarker(message.content, "[HERDSMAN AGENT CONTEXT]")
2111
2031
  );
@@ -2185,32 +2105,3 @@ function sanitizeAndCleanContextText(value: string): string {
2185
2105
  return cleanContextText(sanitized);
2186
2106
  }
2187
2107
 
2188
- function truncateSummary(value: string, limit = 100): string {
2189
- if (value.length <= limit) return value;
2190
- const maxSearch = Math.min(value.length, limit + 8);
2191
- const searchSlice = value.slice(0, maxSearch);
2192
- const lastSpaceIndex = searchSlice.search(/\s[^\s]*$/);
2193
- if (lastSpaceIndex >= limit - 20) {
2194
- return `${value.slice(0, lastSpaceIndex)}…`;
2195
- }
2196
- return `${value.slice(0, limit)}…`;
2197
- }
2198
-
2199
- function formatTimestamp(value: unknown): string | null {
2200
- if (typeof value === "string") {
2201
- const trimmed = value.trim();
2202
- if (/^\d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
2203
- if (/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
2204
- }
2205
- if (!value) return null;
2206
- const date = value instanceof Date ? value : new Date(value as string | number);
2207
- if (Number.isNaN(date.getTime())) return null;
2208
- const pad = (n: number) => String(n).padStart(2, "0");
2209
- const month = pad(date.getMonth() + 1);
2210
- const day = pad(date.getDate());
2211
- const hours = pad(date.getHours());
2212
- const minutes = pad(date.getMinutes());
2213
- const seconds = pad(date.getSeconds());
2214
- return `${month}-${day} ${hours}:${minutes}:${seconds}`;
2215
- }
2216
-