@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 +1 -1
- package/package.json +7 -7
- package/src/agent-update-ui.ts +12 -2
- package/src/daemon-client.ts +21 -5
- package/src/index.ts +267 -46
- package/src/sanitize-text.ts +23 -0
- package/src/wake.ts +12 -4
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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,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
|
|
237
|
-
// failure but must not mark the daemon incompatible: an older
|
|
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())
|
|
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) =>
|
|
345
|
+
(outcome) =>
|
|
346
|
+
outcome.eventId > state.failedWakeThroughEventId &&
|
|
347
|
+
!state.presentedEventIds.has(outcome.eventId),
|
|
314
348
|
);
|
|
315
349
|
|
|
316
|
-
const wakeable = outcomes.filter((outcome) =>
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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) =>
|
|
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
|
|
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 = (
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
653
|
-
const nextAgents =
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
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
|
}
|