@dorokuma/herdsman-pi 0.9.0 → 0.11.4
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/package.json +6 -6
- package/src/agent-update-ui.ts +12 -2
- package/src/daemon-client.ts +17 -2
- package/src/index.ts +150 -38
- package/src/sanitize-text.ts +23 -0
- package/src/wake.ts +12 -4
- package/LICENSE +0 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dorokuma/herdsman-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Pi extension bridge for Herdsman agent history. Forked from @ryonakae/herdsman.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -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
|
-
|
|
42
|
-
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
43
|
-
}
|
|
44
|
-
}
|
|
43
|
+
"homepage": "https://github.com/dorokuma/herdsman#readme"
|
|
44
|
+
}
|
package/src/agent-update-ui.ts
CHANGED
|
@@ -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" ||
|
|
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
|
|
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;
|
package/src/daemon-client.ts
CHANGED
|
@@ -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
|
-
|
|
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,6 +239,7 @@ 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,
|
|
@@ -252,7 +264,10 @@ export class ReconnectingDaemonClient {
|
|
|
252
264
|
}
|
|
253
265
|
|
|
254
266
|
#rejectAll(error: Error): void {
|
|
255
|
-
for (const pending of this.#pending.values())
|
|
267
|
+
for (const pending of this.#pending.values()) {
|
|
268
|
+
clearTimeout(pending.timer);
|
|
269
|
+
pending.reject(error);
|
|
270
|
+
}
|
|
256
271
|
this.#pending.clear();
|
|
257
272
|
}
|
|
258
273
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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";
|
|
@@ -110,12 +111,14 @@ type HerdsmanState = {
|
|
|
110
111
|
connected: boolean;
|
|
111
112
|
currentScope: CurrentScope | undefined;
|
|
112
113
|
deliveredBatch: DeliveredBatch | undefined;
|
|
114
|
+
ackInFlight: boolean;
|
|
113
115
|
failedWakeThroughEventId: number;
|
|
114
116
|
isOrchestrator: boolean;
|
|
115
117
|
launchIdentity: LaunchIdentity | undefined;
|
|
116
118
|
latestContext: AgentWorkspaceContextSnapshot | undefined;
|
|
117
119
|
pendingEvents: AgentEventWireRecord[];
|
|
118
120
|
pinnedContext: AgentWorkspaceContextSnapshot | undefined;
|
|
121
|
+
presentedEventIds: Set<number>;
|
|
119
122
|
reconnectingFromOn: boolean;
|
|
120
123
|
registrationInFlight: Promise<void> | undefined;
|
|
121
124
|
runActive: boolean;
|
|
@@ -168,6 +171,7 @@ type PiApi = {
|
|
|
168
171
|
type ExtensionOptions = {
|
|
169
172
|
clientFactory?: () => HerdsmanDaemonClient;
|
|
170
173
|
onTurnCompletionSignal?: (completion: Promise<void>) => void;
|
|
174
|
+
onStateExposed?: (state: HerdsmanState) => void;
|
|
171
175
|
};
|
|
172
176
|
|
|
173
177
|
const DEFAULT_HOME_NAME = ".herdsman";
|
|
@@ -248,6 +252,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
248
252
|
latestContext: undefined,
|
|
249
253
|
pendingEvents: [],
|
|
250
254
|
pinnedContext: undefined,
|
|
255
|
+
presentedEventIds: new Set(),
|
|
251
256
|
reconnectingFromOn: false,
|
|
252
257
|
registrationInFlight: undefined,
|
|
253
258
|
roleMutationInFlight: false,
|
|
@@ -258,7 +263,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
258
263
|
wakeRequested: false,
|
|
259
264
|
wakeRequestedThroughEventId: 0,
|
|
260
265
|
wakeTimer: undefined,
|
|
266
|
+
ackInFlight: false,
|
|
261
267
|
};
|
|
268
|
+
options.onStateExposed?.(state);
|
|
262
269
|
let activeContext: PiContext | undefined;
|
|
263
270
|
let wakeGeneration = 0;
|
|
264
271
|
|
|
@@ -288,9 +295,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
288
295
|
state.wakeRequestedThroughEventId = 0;
|
|
289
296
|
};
|
|
290
297
|
|
|
291
|
-
const wakeLabel = (count: number) =>
|
|
292
|
-
`Herdsman received ${count} agent update${count === 1 ? "" : "s"}.`;
|
|
293
|
-
|
|
294
298
|
const clearAgentContext = () => {
|
|
295
299
|
state.latestContext = undefined;
|
|
296
300
|
state.pinnedContext = undefined;
|
|
@@ -299,9 +303,20 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
299
303
|
|
|
300
304
|
const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
|
|
301
305
|
if (ackedEventId === undefined) return;
|
|
306
|
+
// Acknowledged events leave the pending projection but intentionally stay
|
|
307
|
+
// in presentedEventIds. The daemon never re-lists acked events (acked
|
|
308
|
+
// rows are excluded from pending discovery), so retaining the ids cannot
|
|
309
|
+
// suppress a legitimate future delivery; it only guarantees that an event
|
|
310
|
+
// already presented in this scope session is never presented again, even
|
|
311
|
+
// if a reconnect, an ack retry, or a daemon redelivery replays it. The
|
|
312
|
+
// set is bounded by the number of events presented per session and is
|
|
313
|
+
// cleared on role loss, scope change, and shutdown.
|
|
302
314
|
state.pendingEvents = state.pendingEvents.filter((event) => event.id > ackedEventId);
|
|
303
315
|
};
|
|
304
316
|
|
|
317
|
+
const isWakeableEvent = (event: AgentEventWireRecord | undefined) =>
|
|
318
|
+
!event?.nextAttemptAt || event.nextAttemptAt <= Date.now();
|
|
319
|
+
|
|
305
320
|
const applyOwnerContext = (response: ConnectionStateResponse) => {
|
|
306
321
|
state.latestContext = isLocalOwner(response) ? response.context ?? undefined : undefined;
|
|
307
322
|
};
|
|
@@ -310,13 +325,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
310
325
|
if (!ctx || !state.isOrchestrator || !state.currentScope || !pi.sendMessage) return;
|
|
311
326
|
if (state.wakeTimer || state.wakeRequested) return;
|
|
312
327
|
const outcomes = projectAgentOutcomes(state.pendingEvents).outcomes.filter(
|
|
313
|
-
(outcome) =>
|
|
328
|
+
(outcome) =>
|
|
329
|
+
outcome.eventId > state.failedWakeThroughEventId &&
|
|
330
|
+
!state.presentedEventIds.has(outcome.eventId),
|
|
314
331
|
);
|
|
315
332
|
|
|
316
|
-
const wakeable = outcomes.filter((outcome) =>
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
});
|
|
333
|
+
const wakeable = outcomes.filter((outcome) =>
|
|
334
|
+
isWakeableEvent(state.pendingEvents.find((pending) => pending.id === outcome.eventId)),
|
|
335
|
+
);
|
|
320
336
|
if (wakeable.length === 0) {
|
|
321
337
|
const nextAttemptAt = outcomes
|
|
322
338
|
.map((outcome) => state.pendingEvents.find((event) => event.id === outcome.eventId)?.nextAttemptAt)
|
|
@@ -331,7 +347,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
331
347
|
return;
|
|
332
348
|
}
|
|
333
349
|
|
|
334
|
-
if (state.deliveredBatch || ctx.isIdle?.() === false) {
|
|
350
|
+
if (state.deliveredBatch || state.ackInFlight || ctx.isIdle?.() === false) {
|
|
335
351
|
state.wakeDeferredUntilSettled = true;
|
|
336
352
|
return;
|
|
337
353
|
}
|
|
@@ -357,7 +373,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
357
373
|
return;
|
|
358
374
|
}
|
|
359
375
|
|
|
360
|
-
const requestedThroughEventId = wakeable.at(-1)?.eventId ?? 0;
|
|
361
376
|
try {
|
|
362
377
|
const response = (await state.client?.request(
|
|
363
378
|
"agent.orchestrator.get",
|
|
@@ -370,10 +385,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
370
385
|
applyConnectionStateResponse(response, ctx);
|
|
371
386
|
} catch {
|
|
372
387
|
state.wakeTimer = undefined;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
requestedThroughEventId,
|
|
376
|
-
);
|
|
388
|
+
// A failed load is only temporary: the batch stays pending and is
|
|
389
|
+
// retried on the next wake instead of being permanently suppressed.
|
|
377
390
|
ctx.ui.notify?.(
|
|
378
391
|
"Herdsman couldn’t load agent updates · updates remain pending",
|
|
379
392
|
"warning",
|
|
@@ -399,7 +412,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
399
412
|
|
|
400
413
|
const batchEvents = [...state.pendingEvents].sort((left, right) => left.id - right.id);
|
|
401
414
|
const batchOutcomes = projectAgentOutcomes(batchEvents).outcomes.filter(
|
|
402
|
-
(outcome) =>
|
|
415
|
+
(outcome) =>
|
|
416
|
+
outcome.eventId > state.failedWakeThroughEventId &&
|
|
417
|
+
!state.presentedEventIds.has(outcome.eventId) &&
|
|
418
|
+
isWakeableEvent(batchEvents.find((event) => event.id === outcome.eventId)),
|
|
403
419
|
);
|
|
404
420
|
if (batchOutcomes.length === 0) {
|
|
405
421
|
state.wakeTimer = undefined;
|
|
@@ -428,26 +444,23 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
428
444
|
details: { eventIds: batchEvents.map((event) => event.id) },
|
|
429
445
|
display: false,
|
|
430
446
|
},
|
|
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
447
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
444
448
|
);
|
|
445
|
-
// Only expose the batch after
|
|
449
|
+
// Only expose the batch after the hidden context was accepted by pi. This
|
|
446
450
|
// keeps an injection failure eligible for daemon redelivery.
|
|
447
451
|
state.deliveredBatch = deliveredBatch;
|
|
448
452
|
state.wakeRequested = false;
|
|
449
453
|
state.wakeRequestedThroughEventId = 0;
|
|
454
|
+
// Record the presentation so a reclaim redelivery of the same id is
|
|
455
|
+
// never presented twice. The set is monotonic for the lifetime of
|
|
456
|
+
// the current orchestrator scope: ids are only removed on role
|
|
457
|
+
// loss, scope change, or shutdown (pruneAcknowledgedEvents keeps
|
|
458
|
+
// acknowledged ids on purpose).
|
|
459
|
+
for (const outcome of batchOutcomes) {
|
|
460
|
+
state.presentedEventIds.add(outcome.eventId);
|
|
461
|
+
}
|
|
450
462
|
} catch {
|
|
463
|
+
state.deliveredBatch = undefined;
|
|
451
464
|
state.wakeRequested = false;
|
|
452
465
|
}
|
|
453
466
|
};
|
|
@@ -455,8 +468,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
455
468
|
}, WAKE_SETTLE_MS);
|
|
456
469
|
};
|
|
457
470
|
|
|
458
|
-
const loseRole = (
|
|
459
|
-
|
|
471
|
+
const loseRole = (
|
|
472
|
+
ctx: PiContext | undefined,
|
|
473
|
+
options: { abort?: boolean; preservePresented?: boolean } = {},
|
|
474
|
+
) => {
|
|
475
|
+
if (state.deliveredBatch && !options.preservePresented) {
|
|
460
476
|
state.deliveredBatch.invalidated = true;
|
|
461
477
|
const abortedBatch = state.deliveredBatch;
|
|
462
478
|
if (options.abort) {
|
|
@@ -486,13 +502,20 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
486
502
|
clearAgentContext();
|
|
487
503
|
state.isOrchestrator = false;
|
|
488
504
|
state.pendingEvents = [];
|
|
505
|
+
// A transient disconnect (reconnect) keeps the presentation guard so an
|
|
506
|
+
// event already presented in this scope session is not presented again;
|
|
507
|
+
// only a genuine role/scope loss or shutdown resets it.
|
|
508
|
+
if (!options.preservePresented) state.presentedEventIds.clear();
|
|
489
509
|
state.reconnectingFromOn = false;
|
|
490
510
|
setHerdsmanUi(ctx);
|
|
491
511
|
};
|
|
492
512
|
|
|
493
513
|
const markDisconnected = (ctx: PiContext | undefined) => {
|
|
494
514
|
const reconnectingFromOn = state.reconnectingFromOn || state.isOrchestrator;
|
|
495
|
-
|
|
515
|
+
// Reconnects are not scope resets: keep the presentation guard and the
|
|
516
|
+
// in-flight batch so a settled turn can still be acknowledged and no
|
|
517
|
+
// already-presented event is injected twice.
|
|
518
|
+
loseRole(ctx, { abort: false, preservePresented: true });
|
|
496
519
|
state.reconnectingFromOn = reconnectingFromOn;
|
|
497
520
|
setHerdsmanUi(ctx);
|
|
498
521
|
};
|
|
@@ -512,6 +535,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
512
535
|
cancelWake();
|
|
513
536
|
state.failedWakeThroughEventId = 0;
|
|
514
537
|
state.pendingEvents = [];
|
|
538
|
+
state.presentedEventIds.clear();
|
|
515
539
|
setHerdsmanUi(ctx);
|
|
516
540
|
};
|
|
517
541
|
|
|
@@ -520,7 +544,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
520
544
|
let addedNewEvent = false;
|
|
521
545
|
for (const event of events) {
|
|
522
546
|
const previous = byId.get(event.id);
|
|
523
|
-
|
|
547
|
+
// Only an event that survives the dead-letter barrier counts as new.
|
|
548
|
+
// The server keeps re-listing dead-lettered events (id <= failedWakeThroughEventId)
|
|
549
|
+
// on every orchestrator.get; treating one as new would cancel the in-flight
|
|
550
|
+
// wake (wakeGeneration++) on every get and stall the whole stream forever.
|
|
551
|
+
if (!previous && event.id > state.failedWakeThroughEventId) addedNewEvent = true;
|
|
524
552
|
byId.set(event.id, previous ? { ...event, ...previous } : event);
|
|
525
553
|
}
|
|
526
554
|
state.pendingEvents = [...byId.values()]
|
|
@@ -531,6 +559,58 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
531
559
|
scheduleWake(ctx);
|
|
532
560
|
};
|
|
533
561
|
|
|
562
|
+
const syncPendingEventsFromServer = (
|
|
563
|
+
serverEvents: AgentEventWireRecord[],
|
|
564
|
+
ctx: PiContext | undefined,
|
|
565
|
+
) => {
|
|
566
|
+
const serverEventById = new Map(serverEvents.map((event) => [event.id, event]));
|
|
567
|
+
const inFlightIds = new Set(state.deliveredBatch?.events.map((event) => event.id) ?? []);
|
|
568
|
+
const previousById = new Map(state.pendingEvents.map((event) => [event.id, event]));
|
|
569
|
+
const isTruncated = serverEvents.length >= 100;
|
|
570
|
+
const maxServerEventId = serverEvents.reduce((max, event) => Math.max(max, event.id), 0);
|
|
571
|
+
|
|
572
|
+
let addedNewEvent = false;
|
|
573
|
+
const nextPending: AgentEventWireRecord[] = [];
|
|
574
|
+
|
|
575
|
+
for (const serverEvent of serverEvents) {
|
|
576
|
+
const previous = previousById.get(serverEvent.id);
|
|
577
|
+
if (!previous && serverEvent.id > state.failedWakeThroughEventId) addedNewEvent = true;
|
|
578
|
+
nextPending.push(previous ? { ...serverEvent, ...previous } : serverEvent);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
for (const [previousId, previousEvent] of previousById) {
|
|
582
|
+
if (serverEventById.has(previousId)) continue;
|
|
583
|
+
if (inFlightIds.has(previousId)) {
|
|
584
|
+
nextPending.push(previousEvent);
|
|
585
|
+
} else if (isTruncated && previousId > maxServerEventId) {
|
|
586
|
+
nextPending.push(previousEvent);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
for (const inFlightId of inFlightIds) {
|
|
591
|
+
if (!serverEventById.has(inFlightId) && !previousById.has(inFlightId)) {
|
|
592
|
+
const inFlightEvent = state.deliveredBatch?.events.find(
|
|
593
|
+
(event) => event.id === inFlightId,
|
|
594
|
+
);
|
|
595
|
+
if (inFlightEvent) {
|
|
596
|
+
nextPending.push(inFlightEvent);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
state.pendingEvents = nextPending
|
|
602
|
+
.filter((event) => event.id > state.failedWakeThroughEventId)
|
|
603
|
+
.sort((left, right) => left.id - right.id);
|
|
604
|
+
|
|
605
|
+
setHerdsmanUi(ctx);
|
|
606
|
+
if (state.pendingEvents.length === 0 && state.wakeTimer) {
|
|
607
|
+
cancelWakeTimer();
|
|
608
|
+
} else if (addedNewEvent && state.wakeTimer) {
|
|
609
|
+
cancelWakeTimer();
|
|
610
|
+
}
|
|
611
|
+
scheduleWake(ctx);
|
|
612
|
+
};
|
|
613
|
+
|
|
534
614
|
const applyConnectionStateResponse = (
|
|
535
615
|
response: ConnectionStateResponse,
|
|
536
616
|
ctx: PiContext | undefined,
|
|
@@ -563,10 +643,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
563
643
|
}
|
|
564
644
|
state.isOrchestrator = true;
|
|
565
645
|
state.reconnectingFromOn = false;
|
|
646
|
+
if (state.deliveredBatch) {
|
|
647
|
+
if (state.deliveredBatch.invalidated || ctx?.isIdle?.() !== false) {
|
|
648
|
+
// A batch invalidated by a genuine role/scope reset, or a batch whose
|
|
649
|
+
// wake turn is no longer running (it never started, or it already
|
|
650
|
+
// settled during the disconnect), can no longer be acknowledged by a
|
|
651
|
+
// future settlement. Clear it so the still-pending events can be
|
|
652
|
+
// re-woken on this fresh connection instead of being gated forever;
|
|
653
|
+
// already acked events are covered by the server cursor
|
|
654
|
+
// (pruneAcknowledgedEvents below).
|
|
655
|
+
state.deliveredBatch = undefined;
|
|
656
|
+
}
|
|
657
|
+
// Otherwise the batch's wake turn is still in flight: keep it so the
|
|
658
|
+
// settlement acknowledges it and the events are not re-presented.
|
|
659
|
+
}
|
|
566
660
|
applyOwnerContext(response);
|
|
567
661
|
setHerdsmanUi(ctx);
|
|
568
|
-
|
|
662
|
+
syncPendingEventsFromServer(response.events ?? [], ctx);
|
|
569
663
|
pruneAcknowledgedEvents(response.state?.ackedEventId ?? response.ackedEventId);
|
|
664
|
+
if (state.pendingEvents.length === 0 && state.wakeTimer) cancelWakeTimer();
|
|
570
665
|
setHerdsmanUi(ctx);
|
|
571
666
|
scheduleWake(ctx);
|
|
572
667
|
};
|
|
@@ -786,6 +881,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
786
881
|
state.connected = false;
|
|
787
882
|
loseRole(activeContext);
|
|
788
883
|
state.deliveredBatch = undefined;
|
|
884
|
+
state.presentedEventIds.clear();
|
|
789
885
|
state.client?.close();
|
|
790
886
|
state.client = undefined;
|
|
791
887
|
activeContext = undefined;
|
|
@@ -906,6 +1002,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
906
1002
|
return;
|
|
907
1003
|
}
|
|
908
1004
|
state.deliveredBatch = undefined;
|
|
1005
|
+
state.ackInFlight = true;
|
|
909
1006
|
const stillOwner =
|
|
910
1007
|
state.isOrchestrator && state.currentScope?.terminalId === batch.ownerTerminalId;
|
|
911
1008
|
const failBatch = () => {
|
|
@@ -915,6 +1012,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
915
1012
|
);
|
|
916
1013
|
};
|
|
917
1014
|
const finishBatch = () => {
|
|
1015
|
+
state.ackInFlight = false;
|
|
918
1016
|
state.wakeDeferredUntilSettled = false;
|
|
919
1017
|
setHerdsmanUi(ctx);
|
|
920
1018
|
scheduleWake(ctx);
|
|
@@ -939,6 +1037,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
939
1037
|
})) as { ackedEventId?: number; state?: { ackedEventId?: number } };
|
|
940
1038
|
pruneAcknowledgedEvents(ackResponse?.ackedEventId ?? ackResponse?.state?.ackedEventId);
|
|
941
1039
|
state.pendingEvents = state.pendingEvents.filter((pending) => pending.id !== event.id);
|
|
1040
|
+
// The id intentionally stays in presentedEventIds: the event was
|
|
1041
|
+
// already presented this session and must not be injected again even
|
|
1042
|
+
// if the daemon replays it (for example after a reconnect
|
|
1043
|
+
// redelivery). The set is cleared only on role loss, scope change,
|
|
1044
|
+
// or shutdown.
|
|
942
1045
|
state.failedWakeThroughEventId = Math.max(state.failedWakeThroughEventId, event.id);
|
|
943
1046
|
setHerdsmanUi(ctx);
|
|
944
1047
|
} catch (error) {
|
|
@@ -977,6 +1080,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
977
1080
|
|
|
978
1081
|
if (attempts >= MAX_ACK_ATTEMPTS) {
|
|
979
1082
|
state.pendingEvents = state.pendingEvents.filter((pending) => pending.id !== event.id);
|
|
1083
|
+
state.failedWakeThroughEventId = Math.max(state.failedWakeThroughEventId, event.id);
|
|
980
1084
|
logHerdsmanPi(
|
|
981
1085
|
"warn",
|
|
982
1086
|
`[herdsman-pi] acknowledgement moved to dead-letter eventId=${event.id} attempts=${attempts} code=${failureCode}`,
|
|
@@ -1012,7 +1116,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1012
1116
|
`[herdsman-pi] acknowledgement resync failed eventId=${event.id} attempts=${attempts} code=${ackFailureCode(resyncError)}`,
|
|
1013
1117
|
);
|
|
1014
1118
|
}
|
|
1015
|
-
|
|
1119
|
+
// The event stays pending with a backoff so the ack cursor can
|
|
1120
|
+
// sweep it later, but it was already presented this session and is
|
|
1121
|
+
// not re-presented: presentedEventIds keeps the guard until scope
|
|
1122
|
+
// reset. Continue so one failed event does not block the rest of
|
|
1123
|
+
// the batch.
|
|
1124
|
+
continue;
|
|
1016
1125
|
}
|
|
1017
1126
|
|
|
1018
1127
|
state.pendingEvents = state.pendingEvents.map((pending) =>
|
|
@@ -1025,7 +1134,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1025
1134
|
"warning",
|
|
1026
1135
|
);
|
|
1027
1136
|
setHerdsmanUi(ctx);
|
|
1028
|
-
|
|
1137
|
+
// The event stays pending with a backoff so the ack cursor can sweep
|
|
1138
|
+
// it later, but it was already presented this session and is not
|
|
1139
|
+
// re-presented: presentedEventIds keeps the guard until scope reset.
|
|
1140
|
+
continue;
|
|
1029
1141
|
}
|
|
1030
1142
|
}
|
|
1031
1143
|
finishBatch();
|
|
@@ -1051,8 +1163,8 @@ export function formatHiddenAgentContext(input: {
|
|
|
1051
1163
|
});
|
|
1052
1164
|
return [
|
|
1053
1165
|
`- ${identity} ${agent.paneId ?? "unknown"} ${agent.agentStatus ?? "unknown"}`,
|
|
1054
|
-
` last user: ${oneLine(history.lastUserMessage?.text ?? "")}`,
|
|
1055
|
-
` last assistant: ${oneLine(history.lastAssistantMessage?.text ?? "")}`,
|
|
1166
|
+
` last user: ${oneLine(sanitizeText(history.lastUserMessage?.text ?? "").text)}`,
|
|
1167
|
+
` last assistant: ${oneLine(sanitizeText(history.lastAssistantMessage?.text ?? "").text)}`,
|
|
1056
1168
|
].join("\n");
|
|
1057
1169
|
}),
|
|
1058
1170
|
"Use herdsman agent get/read if details are needed.",
|
|
@@ -1071,7 +1183,7 @@ export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string
|
|
|
1071
1183
|
});
|
|
1072
1184
|
return [
|
|
1073
1185
|
`- ${event.type} ${identity} ${event.paneId ?? "unknown"}`,
|
|
1074
|
-
` last assistant: ${oneLine(history.lastAssistantMessage?.text ?? "")}`,
|
|
1186
|
+
` last assistant: ${oneLine(sanitizeText(history.lastAssistantMessage?.text ?? "").text)}`,
|
|
1075
1187
|
` event: ${event.id}`,
|
|
1076
1188
|
].join("\n");
|
|
1077
1189
|
}),
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/LICENSE
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Ryo Nakae
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
22
|
-
|
|
23
|
-
Copyright (c) 2026 dorokuma (herdsman)
|