@dorokuma/herdsman-pi 0.9.0 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ pi install npm:@dorokuma/herdsman-pi
12
12
  herdsman daemon start
13
13
  ```
14
14
 
15
- When Pi runs inside Herdr, this extension connects to the Herdsman daemon and registers its exact Pi session path as presence identity. It does not send per-turn tool-result or final-message telemetry.
15
+ When Pi runs inside Herdr, this extension connects to the Herdsman daemon and registers its exact Pi session path as presence identity. After register it sends `agent.ping` at least every 30 seconds so an idle owner is not dropped by the daemon heartbeat. It does not send per-turn tool-result or final-message telemetry.
16
16
 
17
17
  Enter these commands in Pi, not in a shell:
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorokuma/herdsman-pi",
3
- "version": "0.9.0",
3
+ "version": "0.11.5",
4
4
  "description": "Pi extension bridge for Herdsman agent history. Forked from @ryonakae/herdsman.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -11,7 +11,7 @@
11
11
  "license": "MIT",
12
12
  "files": [
13
13
  "src",
14
- "../LICENSE"
14
+ "LICENSE"
15
15
  ],
16
16
  "publishConfig": {
17
17
  "access": "public"
@@ -21,6 +21,9 @@
21
21
  "url": "git+https://github.com/dorokuma/herdsman.git",
22
22
  "directory": "packages/herdsman-pi"
23
23
  },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit -p tsconfig.json"
26
+ },
24
27
  "pi": {
25
28
  "extensions": [
26
29
  "./src/index.ts"
@@ -37,8 +40,5 @@
37
40
  "bugs": {
38
41
  "url": "https://github.com/dorokuma/herdsman/issues"
39
42
  },
40
- "homepage": "https://github.com/dorokuma/herdsman#readme",
41
- "scripts": {
42
- "typecheck": "tsc --noEmit -p tsconfig.json"
43
- }
44
- }
43
+ "homepage": "https://github.com/dorokuma/herdsman#readme"
44
+ }
@@ -39,11 +39,14 @@ function isAgentOutcome(value: unknown): value is AgentOutcome {
39
39
  return (
40
40
  typeof candidate.agent === "string" &&
41
41
  typeof candidate.eventId === "number" &&
42
- (candidate.kind === "blocked" || candidate.kind === "completed") &&
42
+ (candidate.kind === "blocked" ||
43
+ candidate.kind === "completed" ||
44
+ candidate.kind === "failed") &&
43
45
  (candidate.name === undefined ||
44
46
  candidate.name === null ||
45
47
  typeof candidate.name === "string") &&
46
48
  (candidate.paneId === null || typeof candidate.paneId === "string") &&
49
+ (candidate.reason === undefined || typeof candidate.reason === "string") &&
47
50
  typeof candidate.terminalId === "string" &&
48
51
  typeof candidate.text === "string"
49
52
  );
@@ -106,7 +109,14 @@ export function renderAgentUpdateMessage(
106
109
  ].join(" ");
107
110
  if (!options.expanded) return [summary];
108
111
  const cleanedResponse = cleanDisplayText(outcome.text);
109
- const response = cleanedResponse.length > 0 ? cleanedResponse : "No final response";
112
+ const cleanedReason =
113
+ outcome.kind === "failed" ? cleanDisplayText(outcome.reason ?? "") : "";
114
+ const response =
115
+ cleanedResponse.length > 0
116
+ ? cleanedResponse
117
+ : cleanedReason.length > 0
118
+ ? cleanedReason
119
+ : "No final response";
110
120
  return [summary, theme.fg("muted", ` Last response ${response}`)];
111
121
  });
112
122
  const hiddenCount = details.outcomes.length - visibleOutcomes.length;
@@ -87,6 +87,7 @@ type PendingRequest = {
87
87
  method: string;
88
88
  reject(error: Error): void;
89
89
  resolve(value: unknown): void;
90
+ timer: ReturnType<typeof setTimeout>;
90
91
  };
91
92
 
92
93
  type RpcMessage = {
@@ -98,6 +99,7 @@ type RpcMessage = {
98
99
  };
99
100
 
100
101
  const DEFAULT_RECONNECT_DELAYS_MS = [100, 250, 500, 1_000] as const;
102
+ const REQUEST_TIMEOUT_MS = 15_000;
101
103
 
102
104
  export class ReconnectingDaemonClient {
103
105
  onConnected: (() => Promise<void> | void) | undefined;
@@ -152,7 +154,16 @@ export class ReconnectingDaemonClient {
152
154
  const id = `pi-${this.#nextId}`;
153
155
  this.#nextId += 1;
154
156
  return new Promise((resolve, reject) => {
155
- this.#pending.set(id, { method, reject, resolve });
157
+ // A request that gets no reply within the timeout is rejected and its
158
+ // pending slot is removed; a response arriving later is ignored because
159
+ // the id is no longer tracked. This keeps acks from hanging forever when
160
+ // the socket stays open but the daemon is unresponsive.
161
+ const timer = setTimeout(() => {
162
+ if (this.#pending.delete(id)) {
163
+ reject(new Error("Herdsman daemon request timed out"));
164
+ }
165
+ }, REQUEST_TIMEOUT_MS);
166
+ this.#pending.set(id, { method, reject, resolve, timer });
156
167
  this.#socket?.write(`${JSON.stringify({ id, method, params })}\n`);
157
168
  });
158
169
  }
@@ -228,16 +239,18 @@ export class ReconnectingDaemonClient {
228
239
  const pending = this.#pending.get(String(message.id));
229
240
  if (!pending) return;
230
241
  this.#pending.delete(String(message.id));
242
+ clearTimeout(pending.timer);
231
243
  if (message.error) {
232
244
  const error = Object.assign(new Error(message.error.message ?? "Herdsman RPC failed"), {
233
245
  code: message.error.code,
234
246
  });
235
247
  pending.reject(error);
236
- // Optional methods (turn completion signaling) are rejected like any other
237
- // failure but must not mark the daemon incompatible: an older daemon
238
- // answers "Unknown method" and the connection has to stay alive.
248
+ // Optional methods (turn completion signaling, keepalive ping) are rejected
249
+ // like any other failure but must not mark the daemon incompatible: an older
250
+ // daemon answers "Unknown method" and the connection has to stay alive.
239
251
  if (
240
252
  pending.method !== "agent.turn.completed" &&
253
+ pending.method !== "agent.ping" &&
241
254
  /unknown|not found|unsupported|method/i.test(error.message)
242
255
  ) {
243
256
  const incompatible = new Error("Herdsman daemon version is incompatible; restart the session");
@@ -252,7 +265,10 @@ export class ReconnectingDaemonClient {
252
265
  }
253
266
 
254
267
  #rejectAll(error: Error): void {
255
- for (const pending of this.#pending.values()) pending.reject(error);
268
+ for (const pending of this.#pending.values()) {
269
+ clearTimeout(pending.timer);
270
+ pending.reject(error);
271
+ }
256
272
  this.#pending.clear();
257
273
  }
258
274
  }
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { agentIdentityLabel } from "./agent-display.js";
2
+ import { sanitizeText } from "./sanitize-text.js";
2
3
  import { appendFileSync, mkdirSync } from "node:fs";
3
4
  import { homedir } from "node:os";
4
5
  import { dirname, isAbsolute, join } from "node:path";
6
+ import { stripVTControlCharacters } from "node:util";
5
7
 
6
8
  export type HerdsmanPiLogLevel = "info" | "warn" | "error";
7
9
 
@@ -110,12 +112,14 @@ type HerdsmanState = {
110
112
  connected: boolean;
111
113
  currentScope: CurrentScope | undefined;
112
114
  deliveredBatch: DeliveredBatch | undefined;
115
+ ackInFlight: boolean;
113
116
  failedWakeThroughEventId: number;
114
117
  isOrchestrator: boolean;
115
118
  launchIdentity: LaunchIdentity | undefined;
116
119
  latestContext: AgentWorkspaceContextSnapshot | undefined;
117
120
  pendingEvents: AgentEventWireRecord[];
118
121
  pinnedContext: AgentWorkspaceContextSnapshot | undefined;
122
+ presentedEventIds: Set<number>;
119
123
  reconnectingFromOn: boolean;
120
124
  registrationInFlight: Promise<void> | undefined;
121
125
  runActive: boolean;
@@ -168,6 +172,7 @@ type PiApi = {
168
172
  type ExtensionOptions = {
169
173
  clientFactory?: () => HerdsmanDaemonClient;
170
174
  onTurnCompletionSignal?: (completion: Promise<void>) => void;
175
+ onStateExposed?: (state: HerdsmanState) => void;
171
176
  };
172
177
 
173
178
  const DEFAULT_HOME_NAME = ".herdsman";
@@ -176,6 +181,7 @@ const HERDR_REQUIRED_MESSAGE = "Herdsman requires a Herdr workspace";
176
181
  const RECONNECTING_MESSAGE = "Herdsman is reconnecting · try again shortly";
177
182
  export const MAX_ACK_ATTEMPTS = 5;
178
183
  export const ACK_BACKOFF_CAP_MS = 30_000;
184
+ const KEEPALIVE_INTERVAL_MS = 30_000;
179
185
 
180
186
  type AckFailureClass = "terminal" | "resync" | "transient";
181
187
 
@@ -248,6 +254,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
248
254
  latestContext: undefined,
249
255
  pendingEvents: [],
250
256
  pinnedContext: undefined,
257
+ presentedEventIds: new Set(),
251
258
  reconnectingFromOn: false,
252
259
  registrationInFlight: undefined,
253
260
  roleMutationInFlight: false,
@@ -258,10 +265,27 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
258
265
  wakeRequested: false,
259
266
  wakeRequestedThroughEventId: 0,
260
267
  wakeTimer: undefined,
268
+ ackInFlight: false,
261
269
  };
270
+ options.onStateExposed?.(state);
262
271
  let activeContext: PiContext | undefined;
272
+ let keepaliveTimer: ReturnType<typeof setInterval> | undefined;
263
273
  let wakeGeneration = 0;
264
274
 
275
+ const stopKeepalive = () => {
276
+ if (!keepaliveTimer) return;
277
+ clearInterval(keepaliveTimer);
278
+ keepaliveTimer = undefined;
279
+ };
280
+
281
+ const startKeepalive = (client: HerdsmanDaemonClient) => {
282
+ stopKeepalive();
283
+ keepaliveTimer = setInterval(() => {
284
+ void client.request("agent.ping", {}).catch(() => undefined);
285
+ }, KEEPALIVE_INTERVAL_MS);
286
+ keepaliveTimer.unref?.();
287
+ };
288
+
265
289
  const setHerdsmanUi = (ctx: PiContext | undefined) => {
266
290
  if (!ctx) return;
267
291
  const footerState: HerdsmanFooterState = state.reconnectingFromOn
@@ -288,9 +312,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
288
312
  state.wakeRequestedThroughEventId = 0;
289
313
  };
290
314
 
291
- const wakeLabel = (count: number) =>
292
- `Herdsman received ${count} agent update${count === 1 ? "" : "s"}.`;
293
-
294
315
  const clearAgentContext = () => {
295
316
  state.latestContext = undefined;
296
317
  state.pinnedContext = undefined;
@@ -299,9 +320,20 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
299
320
 
300
321
  const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
301
322
  if (ackedEventId === undefined) return;
323
+ // Acknowledged events leave the pending projection but intentionally stay
324
+ // in presentedEventIds. The daemon never re-lists acked events (acked
325
+ // rows are excluded from pending discovery), so retaining the ids cannot
326
+ // suppress a legitimate future delivery; it only guarantees that an event
327
+ // already presented in this scope session is never presented again, even
328
+ // if a reconnect, an ack retry, or a daemon redelivery replays it. The
329
+ // set is bounded by the number of events presented per session and is
330
+ // cleared on role loss, scope change, and shutdown.
302
331
  state.pendingEvents = state.pendingEvents.filter((event) => event.id > ackedEventId);
303
332
  };
304
333
 
334
+ const isWakeableEvent = (event: AgentEventWireRecord | undefined) =>
335
+ !event?.nextAttemptAt || event.nextAttemptAt <= Date.now();
336
+
305
337
  const applyOwnerContext = (response: ConnectionStateResponse) => {
306
338
  state.latestContext = isLocalOwner(response) ? response.context ?? undefined : undefined;
307
339
  };
@@ -310,13 +342,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
310
342
  if (!ctx || !state.isOrchestrator || !state.currentScope || !pi.sendMessage) return;
311
343
  if (state.wakeTimer || state.wakeRequested) return;
312
344
  const outcomes = projectAgentOutcomes(state.pendingEvents).outcomes.filter(
313
- (outcome) => outcome.eventId > state.failedWakeThroughEventId,
345
+ (outcome) =>
346
+ outcome.eventId > state.failedWakeThroughEventId &&
347
+ !state.presentedEventIds.has(outcome.eventId),
314
348
  );
315
349
 
316
- const wakeable = outcomes.filter((outcome) => {
317
- const event = state.pendingEvents.find((pending) => pending.id === outcome.eventId);
318
- return !event?.nextAttemptAt || event.nextAttemptAt <= Date.now();
319
- });
350
+ const wakeable = outcomes.filter((outcome) =>
351
+ isWakeableEvent(state.pendingEvents.find((pending) => pending.id === outcome.eventId)),
352
+ );
320
353
  if (wakeable.length === 0) {
321
354
  const nextAttemptAt = outcomes
322
355
  .map((outcome) => state.pendingEvents.find((event) => event.id === outcome.eventId)?.nextAttemptAt)
@@ -331,7 +364,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
331
364
  return;
332
365
  }
333
366
 
334
- if (state.deliveredBatch || ctx.isIdle?.() === false) {
367
+ if (state.deliveredBatch || state.ackInFlight || ctx.isIdle?.() === false) {
335
368
  state.wakeDeferredUntilSettled = true;
336
369
  return;
337
370
  }
@@ -357,7 +390,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
357
390
  return;
358
391
  }
359
392
 
360
- const requestedThroughEventId = wakeable.at(-1)?.eventId ?? 0;
361
393
  try {
362
394
  const response = (await state.client?.request(
363
395
  "agent.orchestrator.get",
@@ -370,10 +402,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
370
402
  applyConnectionStateResponse(response, ctx);
371
403
  } catch {
372
404
  state.wakeTimer = undefined;
373
- state.failedWakeThroughEventId = Math.max(
374
- state.failedWakeThroughEventId,
375
- requestedThroughEventId,
376
- );
405
+ // A failed load is only temporary: the batch stays pending and is
406
+ // retried on the next wake instead of being permanently suppressed.
377
407
  ctx.ui.notify?.(
378
408
  "Herdsman couldn’t load agent updates · updates remain pending",
379
409
  "warning",
@@ -399,7 +429,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
399
429
 
400
430
  const batchEvents = [...state.pendingEvents].sort((left, right) => left.id - right.id);
401
431
  const batchOutcomes = projectAgentOutcomes(batchEvents).outcomes.filter(
402
- (outcome) => outcome.eventId > state.failedWakeThroughEventId,
432
+ (outcome) =>
433
+ outcome.eventId > state.failedWakeThroughEventId &&
434
+ !state.presentedEventIds.has(outcome.eventId) &&
435
+ isWakeableEvent(batchEvents.find((event) => event.id === outcome.eventId)),
403
436
  );
404
437
  if (batchOutcomes.length === 0) {
405
438
  state.wakeTimer = undefined;
@@ -428,26 +461,23 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
428
461
  details: { eventIds: batchEvents.map((event) => event.id) },
429
462
  display: false,
430
463
  },
431
- { deliverAs: "followUp" },
432
- );
433
- pi.sendMessage?.(
434
- {
435
- content: wakeLabel(current.length),
436
- customType: "herdsman-wake",
437
- details: {
438
- eventIds: current.map((outcome) => outcome.eventId),
439
- outcomes: current,
440
- } satisfies AgentUpdateMessageDetails,
441
- display: true,
442
- },
443
464
  { deliverAs: "followUp", triggerTurn: true },
444
465
  );
445
- // Only expose the batch after both messages were accepted by pi. This
466
+ // Only expose the batch after the hidden context was accepted by pi. This
446
467
  // keeps an injection failure eligible for daemon redelivery.
447
468
  state.deliveredBatch = deliveredBatch;
448
469
  state.wakeRequested = false;
449
470
  state.wakeRequestedThroughEventId = 0;
471
+ // Record the presentation so a reclaim redelivery of the same id is
472
+ // never presented twice. The set is monotonic for the lifetime of
473
+ // the current orchestrator scope: ids are only removed on role
474
+ // loss, scope change, or shutdown (pruneAcknowledgedEvents keeps
475
+ // acknowledged ids on purpose).
476
+ for (const outcome of batchOutcomes) {
477
+ state.presentedEventIds.add(outcome.eventId);
478
+ }
450
479
  } catch {
480
+ state.deliveredBatch = undefined;
451
481
  state.wakeRequested = false;
452
482
  }
453
483
  };
@@ -455,8 +485,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
455
485
  }, WAKE_SETTLE_MS);
456
486
  };
457
487
 
458
- const loseRole = (ctx: PiContext | undefined, options: { abort?: boolean } = {}) => {
459
- if (state.deliveredBatch) {
488
+ const loseRole = (
489
+ ctx: PiContext | undefined,
490
+ options: { abort?: boolean; preservePresented?: boolean } = {},
491
+ ) => {
492
+ if (state.deliveredBatch && !options.preservePresented) {
460
493
  state.deliveredBatch.invalidated = true;
461
494
  const abortedBatch = state.deliveredBatch;
462
495
  if (options.abort) {
@@ -486,13 +519,20 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
486
519
  clearAgentContext();
487
520
  state.isOrchestrator = false;
488
521
  state.pendingEvents = [];
522
+ // A transient disconnect (reconnect) keeps the presentation guard so an
523
+ // event already presented in this scope session is not presented again;
524
+ // only a genuine role/scope loss or shutdown resets it.
525
+ if (!options.preservePresented) state.presentedEventIds.clear();
489
526
  state.reconnectingFromOn = false;
490
527
  setHerdsmanUi(ctx);
491
528
  };
492
529
 
493
530
  const markDisconnected = (ctx: PiContext | undefined) => {
494
531
  const reconnectingFromOn = state.reconnectingFromOn || state.isOrchestrator;
495
- loseRole(ctx, { abort: false });
532
+ // Reconnects are not scope resets: keep the presentation guard and the
533
+ // in-flight batch so a settled turn can still be acknowledged and no
534
+ // already-presented event is injected twice.
535
+ loseRole(ctx, { abort: false, preservePresented: true });
496
536
  state.reconnectingFromOn = reconnectingFromOn;
497
537
  setHerdsmanUi(ctx);
498
538
  };
@@ -512,6 +552,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
512
552
  cancelWake();
513
553
  state.failedWakeThroughEventId = 0;
514
554
  state.pendingEvents = [];
555
+ state.presentedEventIds.clear();
515
556
  setHerdsmanUi(ctx);
516
557
  };
517
558
 
@@ -520,7 +561,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
520
561
  let addedNewEvent = false;
521
562
  for (const event of events) {
522
563
  const previous = byId.get(event.id);
523
- if (!previous) addedNewEvent = true;
564
+ // Only an event that survives the dead-letter barrier counts as new.
565
+ // The server keeps re-listing dead-lettered events (id <= failedWakeThroughEventId)
566
+ // on every orchestrator.get; treating one as new would cancel the in-flight
567
+ // wake (wakeGeneration++) on every get and stall the whole stream forever.
568
+ if (!previous && event.id > state.failedWakeThroughEventId) addedNewEvent = true;
524
569
  byId.set(event.id, previous ? { ...event, ...previous } : event);
525
570
  }
526
571
  state.pendingEvents = [...byId.values()]
@@ -531,6 +576,58 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
531
576
  scheduleWake(ctx);
532
577
  };
533
578
 
579
+ const syncPendingEventsFromServer = (
580
+ serverEvents: AgentEventWireRecord[],
581
+ ctx: PiContext | undefined,
582
+ ) => {
583
+ const serverEventById = new Map(serverEvents.map((event) => [event.id, event]));
584
+ const inFlightIds = new Set(state.deliveredBatch?.events.map((event) => event.id) ?? []);
585
+ const previousById = new Map(state.pendingEvents.map((event) => [event.id, event]));
586
+ const isTruncated = serverEvents.length >= 100;
587
+ const maxServerEventId = serverEvents.reduce((max, event) => Math.max(max, event.id), 0);
588
+
589
+ let addedNewEvent = false;
590
+ const nextPending: AgentEventWireRecord[] = [];
591
+
592
+ for (const serverEvent of serverEvents) {
593
+ const previous = previousById.get(serverEvent.id);
594
+ if (!previous && serverEvent.id > state.failedWakeThroughEventId) addedNewEvent = true;
595
+ nextPending.push(previous ? { ...serverEvent, ...previous } : serverEvent);
596
+ }
597
+
598
+ for (const [previousId, previousEvent] of previousById) {
599
+ if (serverEventById.has(previousId)) continue;
600
+ if (inFlightIds.has(previousId)) {
601
+ nextPending.push(previousEvent);
602
+ } else if (isTruncated && previousId > maxServerEventId) {
603
+ nextPending.push(previousEvent);
604
+ }
605
+ }
606
+
607
+ for (const inFlightId of inFlightIds) {
608
+ if (!serverEventById.has(inFlightId) && !previousById.has(inFlightId)) {
609
+ const inFlightEvent = state.deliveredBatch?.events.find(
610
+ (event) => event.id === inFlightId,
611
+ );
612
+ if (inFlightEvent) {
613
+ nextPending.push(inFlightEvent);
614
+ }
615
+ }
616
+ }
617
+
618
+ state.pendingEvents = nextPending
619
+ .filter((event) => event.id > state.failedWakeThroughEventId)
620
+ .sort((left, right) => left.id - right.id);
621
+
622
+ setHerdsmanUi(ctx);
623
+ if (state.pendingEvents.length === 0 && state.wakeTimer) {
624
+ cancelWakeTimer();
625
+ } else if (addedNewEvent && state.wakeTimer) {
626
+ cancelWakeTimer();
627
+ }
628
+ scheduleWake(ctx);
629
+ };
630
+
534
631
  const applyConnectionStateResponse = (
535
632
  response: ConnectionStateResponse,
536
633
  ctx: PiContext | undefined,
@@ -563,10 +660,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
563
660
  }
564
661
  state.isOrchestrator = true;
565
662
  state.reconnectingFromOn = false;
663
+ if (state.deliveredBatch) {
664
+ if (state.deliveredBatch.invalidated || ctx?.isIdle?.() !== false) {
665
+ // A batch invalidated by a genuine role/scope reset, or a batch whose
666
+ // wake turn is no longer running (it never started, or it already
667
+ // settled during the disconnect), can no longer be acknowledged by a
668
+ // future settlement. Clear it so the still-pending events can be
669
+ // re-woken on this fresh connection instead of being gated forever;
670
+ // already acked events are covered by the server cursor
671
+ // (pruneAcknowledgedEvents below).
672
+ state.deliveredBatch = undefined;
673
+ }
674
+ // Otherwise the batch's wake turn is still in flight: keep it so the
675
+ // settlement acknowledges it and the events are not re-presented.
676
+ }
566
677
  applyOwnerContext(response);
567
678
  setHerdsmanUi(ctx);
568
- addPendingEvents(response.events ?? [], ctx);
679
+ syncPendingEventsFromServer(response.events ?? [], ctx);
569
680
  pruneAcknowledgedEvents(response.state?.ackedEventId ?? response.ackedEventId);
681
+ if (state.pendingEvents.length === 0 && state.wakeTimer) cancelWakeTimer();
570
682
  setHerdsmanUi(ctx);
571
683
  scheduleWake(ctx);
572
684
  };
@@ -649,12 +761,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
649
761
  snapshot
650
762
  ? {
651
763
  ...snapshot,
652
- agents: snapshot.agents.filter((agent) => {
653
- const nextAgents = next?.agents.filter((candidate) => candidate.paneId === agent.paneId) ?? [];
654
- if (nextAgents.length === 0) return false;
655
- return nextAgents.some(
656
- (nextAgent) => !agent.id || !nextAgent.id || agent.id === nextAgent.id,
764
+ agents: snapshot.agents.flatMap((agent) => {
765
+ const nextAgents =
766
+ next?.agents.filter((candidate) => candidate.paneId === agent.paneId) ?? [];
767
+ if (nextAgents.length === 0) return [];
768
+ const nextAgent = nextAgents.find(
769
+ (candidate) => !agent.id || !candidate.id || agent.id === candidate.id,
657
770
  );
771
+ return nextAgent ? [nextAgent] : [];
658
772
  }),
659
773
  }
660
774
  : undefined;
@@ -687,11 +801,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
687
801
  })
688
802
  .then((response) => {
689
803
  state.connected = true;
804
+ startKeepalive(client);
690
805
  applyConnectionStateResponse(response as ConnectionStateResponse, ctx, {
691
806
  notifyReconnectLoss: true,
692
807
  });
693
808
  })
694
809
  .catch((error) => {
810
+ stopKeepalive();
695
811
  state.connected = false;
696
812
  const incompatibleMessage =
697
813
  error instanceof Error && /incompatible/i.test(error.message)
@@ -770,12 +886,19 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
770
886
  loseRole(ctx);
771
887
  return;
772
888
  }
889
+ stopKeepalive();
773
890
  state.client?.close();
774
891
  const client = options.clientFactory?.() ?? new ReconnectingDaemonClient({ socketPath: defaultSocketPath() });
775
892
  client.resetForSession?.();
893
+ const closeClient = client.close.bind(client);
894
+ client.close = () => {
895
+ stopKeepalive();
896
+ closeClient();
897
+ };
776
898
  state.client = client;
777
899
  client.onConnected = () => registerPresence(ctx);
778
900
  client.onDisconnected = () => {
901
+ stopKeepalive();
779
902
  state.connected = false;
780
903
  markDisconnected(activeContext);
781
904
  };
@@ -783,9 +906,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
783
906
  });
784
907
 
785
908
  pi.on("session_shutdown", () => {
909
+ stopKeepalive();
786
910
  state.connected = false;
787
911
  loseRole(activeContext);
788
912
  state.deliveredBatch = undefined;
913
+ state.presentedEventIds.clear();
789
914
  state.client?.close();
790
915
  state.client = undefined;
791
916
  activeContext = undefined;
@@ -906,6 +1031,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
906
1031
  return;
907
1032
  }
908
1033
  state.deliveredBatch = undefined;
1034
+ state.ackInFlight = true;
909
1035
  const stillOwner =
910
1036
  state.isOrchestrator && state.currentScope?.terminalId === batch.ownerTerminalId;
911
1037
  const failBatch = () => {
@@ -915,6 +1041,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
915
1041
  );
916
1042
  };
917
1043
  const finishBatch = () => {
1044
+ state.ackInFlight = false;
918
1045
  state.wakeDeferredUntilSettled = false;
919
1046
  setHerdsmanUi(ctx);
920
1047
  scheduleWake(ctx);
@@ -939,6 +1066,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
939
1066
  })) as { ackedEventId?: number; state?: { ackedEventId?: number } };
940
1067
  pruneAcknowledgedEvents(ackResponse?.ackedEventId ?? ackResponse?.state?.ackedEventId);
941
1068
  state.pendingEvents = state.pendingEvents.filter((pending) => pending.id !== event.id);
1069
+ // The id intentionally stays in presentedEventIds: the event was
1070
+ // already presented this session and must not be injected again even
1071
+ // if the daemon replays it (for example after a reconnect
1072
+ // redelivery). The set is cleared only on role loss, scope change,
1073
+ // or shutdown.
942
1074
  state.failedWakeThroughEventId = Math.max(state.failedWakeThroughEventId, event.id);
943
1075
  setHerdsmanUi(ctx);
944
1076
  } catch (error) {
@@ -977,6 +1109,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
977
1109
 
978
1110
  if (attempts >= MAX_ACK_ATTEMPTS) {
979
1111
  state.pendingEvents = state.pendingEvents.filter((pending) => pending.id !== event.id);
1112
+ state.failedWakeThroughEventId = Math.max(state.failedWakeThroughEventId, event.id);
980
1113
  logHerdsmanPi(
981
1114
  "warn",
982
1115
  `[herdsman-pi] acknowledgement moved to dead-letter eventId=${event.id} attempts=${attempts} code=${failureCode}`,
@@ -1012,7 +1145,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1012
1145
  `[herdsman-pi] acknowledgement resync failed eventId=${event.id} attempts=${attempts} code=${ackFailureCode(resyncError)}`,
1013
1146
  );
1014
1147
  }
1015
- break;
1148
+ // The event stays pending with a backoff so the ack cursor can
1149
+ // sweep it later, but it was already presented this session and is
1150
+ // not re-presented: presentedEventIds keeps the guard until scope
1151
+ // reset. Continue so one failed event does not block the rest of
1152
+ // the batch.
1153
+ continue;
1016
1154
  }
1017
1155
 
1018
1156
  state.pendingEvents = state.pendingEvents.map((pending) =>
@@ -1025,7 +1163,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1025
1163
  "warning",
1026
1164
  );
1027
1165
  setHerdsmanUi(ctx);
1028
- break;
1166
+ // The event stays pending with a backoff so the ack cursor can sweep
1167
+ // it later, but it was already presented this session and is not
1168
+ // re-presented: presentedEventIds keeps the guard until scope reset.
1169
+ continue;
1029
1170
  }
1030
1171
  }
1031
1172
  finishBatch();
@@ -1049,11 +1190,44 @@ export function formatHiddenAgentContext(input: {
1049
1190
  agent: agent.agent ?? "unknown",
1050
1191
  name: agent.name,
1051
1192
  });
1052
- return [
1053
- `- ${identity} ${agent.paneId ?? "unknown"} ${agent.agentStatus ?? "unknown"}`,
1054
- ` last user: ${oneLine(history.lastUserMessage?.text ?? "")}`,
1055
- ` last assistant: ${oneLine(history.lastAssistantMessage?.text ?? "")}`,
1056
- ].join("\n");
1193
+ const paneId = agent.paneId ?? "unknown";
1194
+ const status = agent.agentStatus ?? "unknown";
1195
+ const prefix = `- ${identity} ${paneId} ${status}`;
1196
+
1197
+ const rawAgent = record(agent);
1198
+ const tabTitleCandidate =
1199
+ stringValue(rawAgent.terminalTitle) ?? stringValue(rawAgent.label);
1200
+ const tabTitleCleaned =
1201
+ tabTitleCandidate !== null ? sanitizeAndCleanContextText(tabTitleCandidate) : "";
1202
+ const tabTitle =
1203
+ tabTitleCleaned.length > 0 ? truncateSummary(tabTitleCleaned, 60) : null;
1204
+
1205
+ const rawHistory = record(history);
1206
+ const lastAssistantRecord = record(history.lastAssistantMessage);
1207
+ const timeCandidate =
1208
+ lastAssistantRecord.timestamp ??
1209
+ history.updatedAt ??
1210
+ rawHistory.updatedAt ??
1211
+ rawAgent.updatedAt ??
1212
+ rawAgent.time;
1213
+ const formattedTime = formatTimestamp(timeCandidate);
1214
+
1215
+ const assistantRaw = history.lastAssistantMessage?.text;
1216
+ const assistantCleaned =
1217
+ assistantRaw !== undefined && assistantRaw !== null
1218
+ ? sanitizeAndCleanContextText(assistantRaw)
1219
+ : "";
1220
+ const assistantSummary =
1221
+ assistantCleaned.length > 0 ? truncateSummary(assistantCleaned, 100) : null;
1222
+
1223
+ const segments = [
1224
+ prefix,
1225
+ tabTitle,
1226
+ formattedTime,
1227
+ assistantSummary,
1228
+ ].filter((segment): segment is string => segment !== null && segment.length > 0);
1229
+
1230
+ return segments.join(" · ");
1057
1231
  }),
1058
1232
  "Use herdsman agent get/read if details are needed.",
1059
1233
  ].join("\n");
@@ -1071,7 +1245,7 @@ export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string
1071
1245
  });
1072
1246
  return [
1073
1247
  `- ${event.type} ${identity} ${event.paneId ?? "unknown"}`,
1074
- ` last assistant: ${oneLine(history.lastAssistantMessage?.text ?? "")}`,
1248
+ ` last assistant: ${oneLine(sanitizeText(history.lastAssistantMessage?.text ?? "").text)}`,
1075
1249
  ` event: ${event.id}`,
1076
1250
  ].join("\n");
1077
1251
  }),
@@ -1135,3 +1309,50 @@ function record(value: unknown): Record<string, unknown> {
1135
1309
  function oneLine(value: string): string {
1136
1310
  return value.replace(/\s+/g, " ");
1137
1311
  }
1312
+
1313
+ function cleanContextText(value: string): string {
1314
+ return stripVTControlCharacters(value)
1315
+ .replace(
1316
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
1317
+ "",
1318
+ )
1319
+ .replace(/\s+/g, " ")
1320
+ .trim();
1321
+ }
1322
+
1323
+ function sanitizeAndCleanContextText(value: string): string {
1324
+ const preCleaned = cleanContextText(value);
1325
+ if (!preCleaned) return "";
1326
+ const sanitized = sanitizeText(preCleaned).text;
1327
+ return cleanContextText(sanitized);
1328
+ }
1329
+
1330
+ function truncateSummary(value: string, limit = 100): string {
1331
+ if (value.length <= limit) return value;
1332
+ const maxSearch = Math.min(value.length, limit + 8);
1333
+ const searchSlice = value.slice(0, maxSearch);
1334
+ const lastSpaceIndex = searchSlice.search(/\s[^\s]*$/);
1335
+ if (lastSpaceIndex >= limit - 20) {
1336
+ return `${value.slice(0, lastSpaceIndex)}…`;
1337
+ }
1338
+ return `${value.slice(0, limit)}…`;
1339
+ }
1340
+
1341
+ function formatTimestamp(value: unknown): string | null {
1342
+ if (typeof value === "string") {
1343
+ const trimmed = value.trim();
1344
+ if (/^\d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
1345
+ if (/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
1346
+ }
1347
+ if (!value) return null;
1348
+ const date = value instanceof Date ? value : new Date(value as string | number);
1349
+ if (Number.isNaN(date.getTime())) return null;
1350
+ const pad = (n: number) => String(n).padStart(2, "0");
1351
+ const month = pad(date.getMonth() + 1);
1352
+ const day = pad(date.getDate());
1353
+ const hours = pad(date.getHours());
1354
+ const minutes = pad(date.getMinutes());
1355
+ const seconds = pad(date.getSeconds());
1356
+ return `${month}-${day} ${hours}:${minutes}:${seconds}`;
1357
+ }
1358
+
@@ -0,0 +1,23 @@
1
+ export function sanitizeText(value: unknown): { redacted: boolean; text: string } {
2
+ let text = typeof value === "string" ? value : JSON.stringify(value);
3
+ if (text === undefined) text = String(value);
4
+ let redacted = false;
5
+ for (const pattern of [
6
+ /(Authorization:\s*Bearer\s+)[^\s]+/gi,
7
+ /\b(Bearer\s+)[A-Za-z0-9._\-+=/]+/gi,
8
+ /\b(token=)[^\s&]+/gi,
9
+ /\b(password=)[^\s&]+/gi,
10
+ /\b(secret=)[^\s&]+/gi,
11
+ /\b(api_key=)[^\s&]+/gi,
12
+ ]) {
13
+ text = text.replace(pattern, (_match, prefix: string) => {
14
+ redacted = true;
15
+ return `${prefix}[REDACTED]`;
16
+ });
17
+ }
18
+ text = text.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, () => {
19
+ redacted = true;
20
+ return "sk-[REDACTED]";
21
+ });
22
+ return { redacted, text };
23
+ }
package/src/wake.ts CHANGED
@@ -7,9 +7,10 @@ export const WAKE_SETTLE_MS = 500;
7
7
  export type AgentOutcome = {
8
8
  agent: string;
9
9
  eventId: number;
10
- kind: "blocked" | "completed";
10
+ kind: "blocked" | "completed" | "failed";
11
11
  name?: string | null;
12
12
  paneId: string | null;
13
+ reason?: string;
13
14
  terminalId: string;
14
15
  text: string;
15
16
  };
@@ -31,6 +32,7 @@ function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefi
31
32
  if (!event.terminalId) return undefined;
32
33
  if (event.type === "agent.done") return "completed";
33
34
  if (event.type === "agent.blocked") return "blocked";
35
+ if (event.type === "agent.failed") return "failed";
34
36
  const payload = asRecord(event.payload);
35
37
  if (event.type === "agent.idle" && payload.from === "working") return "completed";
36
38
  return undefined;
@@ -45,7 +47,8 @@ function project(events: AgentEventWireRecord[], seen: Set<number>): AgentOutcom
45
47
  const payload = asRecord(event.payload);
46
48
  const paneId = event.paneId ?? null;
47
49
  const text = normalizeExcerpt(event.compactHistory?.lastAssistantMessage?.text);
48
- return [{ agent: stringValue(payload.agent) ?? stringValue(event.agentId) ?? paneId ?? event.terminalId, eventId: event.id, kind, name: stringValue(payload.name) ?? null, paneId, terminalId: event.terminalId, text }];
50
+ const reason = kind === "failed" ? normalizeExcerpt(payload.reason) : undefined;
51
+ return [{ agent: stringValue(payload.agent) ?? stringValue(event.agentId) ?? paneId ?? event.terminalId, eventId: event.id, kind, name: stringValue(payload.name) ?? null, paneId, ...(reason ? { reason } : {}), terminalId: event.terminalId, text }];
49
52
  });
50
53
  for (const outcome of outcomes) seen.add(outcome.eventId);
51
54
  return { outcomes, rawEvents };
@@ -54,9 +57,14 @@ export function projectAgentOutcomes(events: AgentEventWireRecord[]): AgentOutco
54
57
  export function createAgentOutcomeProjector(): (events: AgentEventWireRecord[]) => AgentOutcomeProjection { const seen = new Set<number>(); return (events) => project(events, seen); }
55
58
  export function formatAgentOutcomeUpdates(outcomes: AgentOutcome[]): string {
56
59
  const updates = outcomes.map((outcome) => {
57
- const excerpt = outcome.text.length > 0 ? outcome.text : "(no assistant message)";
58
60
  const identity = agentIdentityLabel({ agent: outcome.agent, name: outcome.name });
59
- return `- ${outcome.kind} ${identity} ${outcome.paneId ?? "unknown"}\n last assistant: ${excerpt}\n event: ${outcome.eventId}`;
61
+ const pane = outcome.paneId ?? "unknown";
62
+ if (outcome.kind === "failed") {
63
+ const reason = outcome.reason && outcome.reason.length > 0 ? outcome.reason : "(unknown)";
64
+ return `- failed ${identity} ${pane}\n reason: ${reason}`;
65
+ }
66
+ const excerpt = outcome.text.length > 0 ? outcome.text : "(no assistant message)";
67
+ return `- ${outcome.kind} ${identity} ${pane}\n last assistant: ${excerpt}\n event: ${outcome.eventId}`;
60
68
  }).join("\n");
61
69
  return `${WAKE_POLICY}\n\n[HERDSMAN AGENT UPDATES]\n${updates}`;
62
70
  }