@dorokuma/herdsman-pi 0.14.1 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +102 -211
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,7 +7,6 @@ export { logHerdsmanPi };
|
|
|
7
7
|
export type { HerdsmanPiLogLevel } from "./logger.js";
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
type AgentContextListItem,
|
|
11
10
|
type AgentEventWireRecord,
|
|
12
11
|
type AgentOrchestratorChanged,
|
|
13
12
|
type AgentOrchestratorWireState,
|
|
@@ -105,7 +104,6 @@ type HerdsmanState = {
|
|
|
105
104
|
launchIdentity: LaunchIdentity | undefined;
|
|
106
105
|
latestContext: AgentWorkspaceContextSnapshot | undefined;
|
|
107
106
|
pendingEvents: AgentEventWireRecord[];
|
|
108
|
-
pinnedContext: AgentWorkspaceContextSnapshot | undefined;
|
|
109
107
|
presentedEventIds: Set<number>;
|
|
110
108
|
reconnectingFromOn: boolean;
|
|
111
109
|
registrationInFlight: Promise<void> | undefined;
|
|
@@ -207,18 +205,24 @@ type HerdsmanState = {
|
|
|
207
205
|
*/
|
|
208
206
|
wakeSkipLogReasons: Map<number, string>;
|
|
209
207
|
/**
|
|
210
|
-
* Continuation drives already spent
|
|
208
|
+
* Continuation drives already spent, keyed by event id.
|
|
211
209
|
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
210
|
+
* The bound is per event, never per delivery and never per session: an
|
|
211
|
+
* unrelated event being injected must not hand a stuck one a fresh budget. A
|
|
212
|
+
* session that keeps receiving new updates used to reset a shared counter at
|
|
213
|
+
* every injection, so the one id no run ever drained drove on unboundedly and
|
|
214
|
+
* `MAX_WAKE_CONTINUATION_ATTEMPTS` never fired — the loop that flooded the
|
|
215
|
+
* orchestrator session with empty turns.
|
|
216
|
+
*
|
|
217
|
+
* `MAX_WAKE_CONTINUATION_ATTEMPTS` is therefore a hard ceiling per id: the
|
|
218
|
+
* drive that reaches it writes that id off
|
|
219
|
+
* (`writeOffStrandedWakeDelivery`), and an id that still has drives left keeps
|
|
220
|
+
* both its copy and its count even when a sibling is written off next to it.
|
|
221
|
+
*
|
|
222
|
+
* An entry leaves the map with the id itself: acknowledged, dead-lettered,
|
|
223
|
+
* consumed (its copy reached the transcript), or a role/scope reset.
|
|
220
224
|
*/
|
|
221
|
-
|
|
225
|
+
wakeContinuationDrives: Map<number, number>;
|
|
222
226
|
wakeRequested: boolean;
|
|
223
227
|
wakeRequestedThroughEventId: number;
|
|
224
228
|
wakeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -285,18 +289,24 @@ export const WAKE_BUSY_SPIN_MS = 100;
|
|
|
285
289
|
*/
|
|
286
290
|
export const WAKE_DEFERRED_TIMEOUT_MS = 5_000;
|
|
287
291
|
/**
|
|
288
|
-
* Upper bound on the continuation drives spent on one unconsumed wake
|
|
292
|
+
* Upper bound on the continuation drives spent on **one** unconsumed wake
|
|
293
|
+
* event id.
|
|
289
294
|
*
|
|
290
295
|
* Every drive costs a full agent run, so the budget is deliberately small: three
|
|
291
296
|
* attempts already cover the drive that follows the settlement which missed Pi's
|
|
292
297
|
* follow-up queue plus two retries after intervening runs that also ended before
|
|
293
298
|
* their stop point, and five adds margin for a run that spent its stop point on
|
|
294
299
|
* a tool call. Beyond that the cause is systemic — the runs keep ending early, so
|
|
295
|
-
* starting another one could only produce another empty turn — and
|
|
296
|
-
*
|
|
297
|
-
* `wakeAwaitingConsumption` and
|
|
300
|
+
* starting another one could only produce another empty turn — and that id is
|
|
301
|
+
* written off instead (`writeOffStrandedWakeDelivery`): it leaves
|
|
302
|
+
* `wakeAwaitingConsumption` and is acknowledged, which stops both the
|
|
298
303
|
* continuation loop and the daemon's redelivery of the same event.
|
|
299
304
|
*
|
|
305
|
+
* The bound is per event id (`wakeContinuationDrives`) and is never reset by a
|
|
306
|
+
* later injection: a session that keeps receiving new updates must not hand an
|
|
307
|
+
* already-stuck id a fresh ceiling, or the loop it was meant to stop continues
|
|
308
|
+
* for exactly the events that are stuck.
|
|
309
|
+
*
|
|
300
310
|
* The trade-off is deliberate and asymmetric: losing one agent update is
|
|
301
311
|
* recoverable (the agent is still there and its transcript can be read
|
|
302
312
|
* directly), while an unbounded continuation loop floods the orchestrator
|
|
@@ -390,7 +400,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
390
400
|
launchIdentity: undefined,
|
|
391
401
|
latestContext: undefined,
|
|
392
402
|
pendingEvents: [],
|
|
393
|
-
pinnedContext: undefined,
|
|
394
403
|
presentedEventIds: new Set(),
|
|
395
404
|
reconnectingFromOn: false,
|
|
396
405
|
registrationInFlight: undefined,
|
|
@@ -406,8 +415,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
406
415
|
wakeConsumptionObserved: new Set(),
|
|
407
416
|
wakeSuppressedEventIds: new Set(),
|
|
408
417
|
wakeSkipLogReasons: new Map(),
|
|
409
|
-
|
|
410
|
-
wakeContext: undefined,
|
|
418
|
+
wakeContinuationDrives: new Map(),
|
|
411
419
|
wakeRequested: false,
|
|
412
420
|
wakeRequestedThroughEventId: 0,
|
|
413
421
|
wakeTimer: undefined,
|
|
@@ -458,15 +466,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
458
466
|
cancelWakeTimer();
|
|
459
467
|
state.wakeRequested = false;
|
|
460
468
|
state.wakeRequestedThroughEventId = 0;
|
|
461
|
-
// A wake queued for a busy orchestrator belongs to the role/scope that
|
|
462
|
-
// queued it: dropping it here keeps a stale event body out of the context
|
|
463
|
-
// of whatever session takes over next.
|
|
464
|
-
state.wakeContext = undefined;
|
|
465
469
|
};
|
|
466
470
|
|
|
467
471
|
const clearAgentContext = () => {
|
|
468
472
|
state.latestContext = undefined;
|
|
469
|
-
state.pinnedContext = undefined;
|
|
470
473
|
state.runActive = false;
|
|
471
474
|
};
|
|
472
475
|
|
|
@@ -516,7 +519,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
516
519
|
state.wakeAwaitingConsumption.delete(eventId);
|
|
517
520
|
state.wakeConsumptionObserved.delete(eventId);
|
|
518
521
|
state.wakeSuppressedEventIds.delete(eventId);
|
|
519
|
-
|
|
522
|
+
// The id can no longer owe a drive, so its own budget leaves with it.
|
|
523
|
+
state.wakeContinuationDrives.delete(eventId);
|
|
520
524
|
};
|
|
521
525
|
|
|
522
526
|
/**
|
|
@@ -556,7 +560,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
556
560
|
state.wakeAwaitingConsumption.clear();
|
|
557
561
|
state.wakeConsumptionObserved.clear();
|
|
558
562
|
state.wakeSkipLogReasons.clear();
|
|
559
|
-
state.
|
|
563
|
+
state.wakeContinuationDrives.clear();
|
|
560
564
|
};
|
|
561
565
|
|
|
562
566
|
const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
|
|
@@ -958,10 +962,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
958
962
|
// of the same id out of the transcript.
|
|
959
963
|
state.presentedEventIds.add(eventId);
|
|
960
964
|
}
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
|
|
965
|
+
// The written-off ids can no longer owe a drive, so their budgets leave
|
|
966
|
+
// with them. Every id that is still awaiting consumption keeps its own
|
|
967
|
+
// budget: it has not spent its own MAX_WAKE_CONTINUATION_ATTEMPTS drives
|
|
968
|
+
// yet, and resetting it here is what used to let a sibling's fresh budget
|
|
969
|
+
// cover for an unrelated stuck id.
|
|
970
|
+
for (const eventId of eventIds) state.wakeContinuationDrives.delete(eventId);
|
|
965
971
|
const stranded = eventIds
|
|
966
972
|
.map((eventId) => state.unackedDelivered.get(eventId))
|
|
967
973
|
.filter((event): event is AgentEventWireRecord => event !== undefined)
|
|
@@ -998,17 +1004,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
998
1004
|
*
|
|
999
1005
|
* Only consumption evidence writes a delivery off, so an intervening run that
|
|
1000
1006
|
* ends before its stop point (error, user abort, a refused tool) leaves the
|
|
1001
|
-
* next settlement driving again. That is
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1007
|
+
* next settlement driving again. That bound is per event id and nothing
|
|
1008
|
+
* resets it: starting runs cannot fix a cause that is not about the queue, so
|
|
1009
|
+
* once an id's own budget is spent the id is written off (released and
|
|
1010
|
+
* acknowledged) instead of looping forever — see
|
|
1005
1011
|
* `writeOffStrandedWakeDelivery` for the trade-off.
|
|
1006
1012
|
*/
|
|
1007
1013
|
const driveWakeContinuation = (ctx: PiContext) => {
|
|
1008
1014
|
if (state.wakeAwaitingConsumption.size === 0) return;
|
|
1009
1015
|
if (!pi.sendMessage) return;
|
|
1010
1016
|
const eventIds = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
|
|
1011
|
-
|
|
1017
|
+
// Every id that is still waiting spends one drive of its own budget. The
|
|
1018
|
+
// counters are keyed by event id and are never reset by an injection, so a
|
|
1019
|
+
// delivery handed over later cannot buy an already-stuck id a fresh budget.
|
|
1020
|
+
const spent: number[] = [];
|
|
1021
|
+
for (const eventId of eventIds) {
|
|
1022
|
+
const drives = (state.wakeContinuationDrives.get(eventId) ?? 0) + 1;
|
|
1023
|
+
state.wakeContinuationDrives.set(eventId, drives);
|
|
1024
|
+
spent.push(drives);
|
|
1025
|
+
}
|
|
1012
1026
|
try {
|
|
1013
1027
|
pi.sendMessage(
|
|
1014
1028
|
{
|
|
@@ -1020,21 +1034,27 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1020
1034
|
);
|
|
1021
1035
|
logHerdsmanPi(
|
|
1022
1036
|
"info",
|
|
1023
|
-
`[herdsman-pi] wake continuation driven eventIds=${eventIds[0] ?? 0}-${eventIds.at(-1) ?? 0} count=${eventIds.length}
|
|
1037
|
+
`[herdsman-pi] wake continuation driven eventIds=${eventIds[0] ?? 0}-${eventIds.at(-1) ?? 0} count=${eventIds.length} drives=${spent.join(",")}`,
|
|
1024
1038
|
);
|
|
1025
1039
|
} catch {
|
|
1026
1040
|
logHerdsmanPi("warn", "[herdsman-pi] wake continuation refused by pi");
|
|
1027
1041
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
|
|
1042
|
+
// Only the ids whose own budget this drive spent are written off. A sibling
|
|
1043
|
+
// that still has drives left keeps its copy in the queue and its count, so
|
|
1044
|
+
// it is neither lost nor granted a fresh ceiling.
|
|
1045
|
+
const exhausted = eventIds.filter(
|
|
1046
|
+
(_eventId, index) => (spent[index] ?? 0) >= MAX_WAKE_CONTINUATION_ATTEMPTS,
|
|
1047
|
+
);
|
|
1048
|
+
if (exhausted.length > 0) {
|
|
1049
|
+
// This was the last drive the id's own budget allows, and the queued copy
|
|
1050
|
+
// is still undrained: the id is written off instead of being left
|
|
1051
|
+
// pending forever. The ids are released from
|
|
1052
|
+
// `wakeAwaitingConsumption` (no further run is started for them) and
|
|
1053
|
+
// acknowledged, which is what stops the daemon from redelivering them
|
|
1054
|
+
// every freshness window — the loop that used to flood the orchestrator
|
|
1055
|
+
// session until manual sqlite surgery. Losing one update is the accepted
|
|
1056
|
+
// cost; see `writeOffStrandedWakeDelivery`.
|
|
1057
|
+
writeOffStrandedWakeDelivery(exhausted, ctx);
|
|
1038
1058
|
}
|
|
1039
1059
|
};
|
|
1040
1060
|
|
|
@@ -1233,11 +1253,17 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1233
1253
|
state.wakeTimer = undefined;
|
|
1234
1254
|
state.wakeRequested = true;
|
|
1235
1255
|
state.wakeRequestedThroughEventId = current.at(-1)?.eventId ?? 0;
|
|
1236
|
-
//
|
|
1237
|
-
// follow-up
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1256
|
+
// Single-track injection: the wake content is handed to Pi exactly
|
|
1257
|
+
// once, as the follow-up message below. An idle orchestrator gets a
|
|
1258
|
+
// triggered follow-up turn (immediate delivery); a busy one is not
|
|
1259
|
+
// interrupted and receives the same content as a queued,
|
|
1260
|
+
// non-triggering follow-up that a later run drains out of the
|
|
1261
|
+
// follow-up queue. There is deliberately no second copy on the
|
|
1262
|
+
// `context` hook: a run that is busy cannot act on an early copy
|
|
1263
|
+
// anyway, while a second copy is exactly what puts the same update
|
|
1264
|
+
// into the session twice. So the transcript holds one entry per event
|
|
1265
|
+
// id, and the continuation drive (see `driveWakeContinuation`) is what
|
|
1266
|
+
// carries a queued copy that no run drained into the transcript.
|
|
1241
1267
|
const orchestratorBusy = ctx.isIdle?.() === false;
|
|
1242
1268
|
const wakeContent = formatAgentOutcomeUpdates(batchOutcomes);
|
|
1243
1269
|
// Single line, injection path only: the decision that produced this
|
|
@@ -1278,17 +1304,17 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1278
1304
|
// `driveWakeContinuation`), and until the content is seen in the
|
|
1279
1305
|
// transcript the delivery is neither acknowledged nor injected again.
|
|
1280
1306
|
if (orchestratorBusy) {
|
|
1307
|
+
// A freshly queued id starts its own continuation budget; an id that
|
|
1308
|
+
// was already awaiting consumption (merged into this batch from an
|
|
1309
|
+
// earlier injection) keeps the drives it already spent. Only a new
|
|
1310
|
+
// id may get a fresh ceiling — never an unrelated one.
|
|
1281
1311
|
for (const outcome of batchOutcomes) {
|
|
1282
1312
|
state.wakeAwaitingConsumption.add(outcome.eventId);
|
|
1313
|
+
if (!state.wakeContinuationDrives.has(outcome.eventId)) {
|
|
1314
|
+
state.wakeContinuationDrives.set(outcome.eventId, 0);
|
|
1315
|
+
}
|
|
1283
1316
|
}
|
|
1284
|
-
state.wakeContinuationAttempts = 0;
|
|
1285
1317
|
}
|
|
1286
|
-
// A queued (non-triggering) delivery keeps the content available to
|
|
1287
|
-
// the current turn through the context hook until it is settled or
|
|
1288
|
-
// superseded by the next injection.
|
|
1289
|
-
state.wakeContext = orchestratorBusy
|
|
1290
|
-
? { content: wakeContent, eventIds: batchOutcomes.map((outcome) => outcome.eventId) }
|
|
1291
|
-
: undefined;
|
|
1292
1318
|
state.wakeForcedRelease = false;
|
|
1293
1319
|
state.wakeDeferredSince = undefined;
|
|
1294
1320
|
// Only expose the batch after the hidden context was accepted by pi. This
|
|
@@ -1528,7 +1554,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1528
1554
|
// already acked events are covered by the server cursor
|
|
1529
1555
|
// (pruneAcknowledgedEvents below).
|
|
1530
1556
|
state.deliveredBatch = undefined;
|
|
1531
|
-
state.wakeContext = undefined;
|
|
1532
1557
|
}
|
|
1533
1558
|
// Otherwise the batch's wake turn is still in flight: keep it so the
|
|
1534
1559
|
// settlement acknowledges it and the events are not re-presented.
|
|
@@ -1616,23 +1641,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1616
1641
|
state.currentScope.workspaceId === message.params.workspaceId
|
|
1617
1642
|
) {
|
|
1618
1643
|
const next = message.params.context ?? undefined;
|
|
1619
|
-
const retain = (snapshot: AgentWorkspaceContextSnapshot | undefined) =>
|
|
1620
|
-
snapshot
|
|
1621
|
-
? {
|
|
1622
|
-
...snapshot,
|
|
1623
|
-
agents: snapshot.agents.flatMap((agent) => {
|
|
1624
|
-
const nextAgents =
|
|
1625
|
-
next?.agents.filter((candidate) => candidate.paneId === agent.paneId) ?? [];
|
|
1626
|
-
if (nextAgents.length === 0) return [];
|
|
1627
|
-
const nextAgent = nextAgents.find(
|
|
1628
|
-
(candidate) => !agent.id || !candidate.id || agent.id === candidate.id,
|
|
1629
|
-
);
|
|
1630
|
-
return nextAgent ? [nextAgent] : [];
|
|
1631
|
-
}),
|
|
1632
|
-
}
|
|
1633
|
-
: undefined;
|
|
1634
1644
|
state.latestContext = next;
|
|
1635
|
-
state.pinnedContext = retain(state.pinnedContext);
|
|
1636
1645
|
}
|
|
1637
1646
|
return;
|
|
1638
1647
|
}
|
|
@@ -1836,6 +1845,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1836
1845
|
}
|
|
1837
1846
|
for (const eventId of consumedEventIds) {
|
|
1838
1847
|
state.wakeAwaitingConsumption.delete(eventId);
|
|
1848
|
+
// Its copy reached the transcript, so the id owes no drive any more and
|
|
1849
|
+
// its budget leaves with it.
|
|
1850
|
+
state.wakeContinuationDrives.delete(eventId);
|
|
1839
1851
|
// The content reached the transcript, so this id counts as presented from
|
|
1840
1852
|
// now on: a daemon redelivery of it (it is still unacknowledged whenever
|
|
1841
1853
|
// its batch was already dropped) must not inject the same update again.
|
|
@@ -1847,7 +1859,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1847
1859
|
// Only an id the daemon can still be told about is worth remembering.
|
|
1848
1860
|
if (state.unackedDelivered.has(eventId)) state.wakeConsumptionObserved.add(eventId);
|
|
1849
1861
|
}
|
|
1850
|
-
if (state.wakeAwaitingConsumption.size === 0) state.wakeContinuationAttempts = 0;
|
|
1851
1862
|
}
|
|
1852
1863
|
if (message.role !== "assistant") return;
|
|
1853
1864
|
const stopReason = stringValue(message.stopReason);
|
|
@@ -1886,63 +1897,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1886
1897
|
// the next user message.
|
|
1887
1898
|
if (state.runActive) return;
|
|
1888
1899
|
state.runActive = true;
|
|
1889
|
-
state.pinnedContext =
|
|
1890
|
-
state.isOrchestrator && !state.deliveredBatch?.herdsmanTriggered
|
|
1891
|
-
? state.latestContext
|
|
1892
|
-
: undefined;
|
|
1893
1900
|
});
|
|
1894
1901
|
|
|
1895
1902
|
pi.on("context", (event: { messages: PiAgentMessage[] }) => {
|
|
1903
|
+
// The wake content rides the single follow-up track and is never pinned
|
|
1904
|
+
// into the context here, so this hook injects nothing at all. What it
|
|
1905
|
+
// still does is drop herdsman's own context/wake entries from the
|
|
1906
|
+
// incoming list: a session replayed from disk can carry an old
|
|
1907
|
+
// `[HERDSMAN AGENT CONTEXT]` preview (its truncated one-line reports
|
|
1908
|
+
// repeatedly read as cut-off reports, and live board state is queried
|
|
1909
|
+
// through herdr), so those entries are filtered out instead of being
|
|
1910
|
+
// re-served to the model. The wake message itself
|
|
1911
|
+
// (`herdsman-wake-context`, the full report text) is not filtered - it is
|
|
1912
|
+
// the evidence the turn acts on.
|
|
1896
1913
|
const messages = event.messages.filter((message) => !isNormalHerdsmanContext(message));
|
|
1897
|
-
|
|
1898
|
-
const snapshot = state.pinnedContext;
|
|
1899
|
-
if (snapshot && snapshot.agents.length > 0) {
|
|
1900
|
-
additions.push({
|
|
1901
|
-
content: formatHiddenAgentContext({
|
|
1902
|
-
agents: snapshot.agents,
|
|
1903
|
-
workspaceId: snapshot.workspaceId,
|
|
1904
|
-
}),
|
|
1905
|
-
customType: "herdsman-agent-context",
|
|
1906
|
-
display: false,
|
|
1907
|
-
role: "custom",
|
|
1908
|
-
timestamp: Date.now(),
|
|
1909
|
-
});
|
|
1910
|
-
}
|
|
1911
|
-
// A wake queued for a busy orchestrator is not allowed to interrupt the
|
|
1912
|
-
// running tool chain, so its content is additionally pinned to the current
|
|
1913
|
-
// context: the orchestrator sees the child-agent outcome in this turn
|
|
1914
|
-
// without a triggered follow-up. The entry is dropped again by
|
|
1915
|
-
// isNormalHerdsmanContext, so at most one copy is present per call.
|
|
1916
|
-
//
|
|
1917
|
-
// `eventIds` mirrors what this turn actually presents (the freshly injected
|
|
1918
|
-
// outcomes): events carried over in the delivery queue were already shown
|
|
1919
|
-
// to the orchestrator in the turn that presented them, so they are not
|
|
1920
|
-
// re-listed here. The queued follow-up message itself keeps the wider
|
|
1921
|
-
// `details.eventIds` set (everything still unconfirmed).
|
|
1922
|
-
//
|
|
1923
|
-
// The pin is not a second delivery of the update: it makes the same queued
|
|
1924
|
-
// copy visible early to the turn that is running, it is not written to the
|
|
1925
|
-
// transcript, it is never acknowledged on its own, and it is not consumption
|
|
1926
|
-
// evidence — only the hidden wake message's `message_end` is. So it may not be
|
|
1927
|
-
// dropped in the name of "one copy only" either: it is the only way this turn
|
|
1928
|
-
// ever sees the update.
|
|
1929
|
-
const queuedWake = state.wakeContext;
|
|
1930
|
-
if (queuedWake) {
|
|
1931
|
-
additions.push({
|
|
1932
|
-
content: queuedWake.content,
|
|
1933
|
-
customType: "herdsman-wake-queued",
|
|
1934
|
-
details: { eventIds: queuedWake.eventIds },
|
|
1935
|
-
display: false,
|
|
1936
|
-
role: "custom",
|
|
1937
|
-
timestamp: Date.now(),
|
|
1938
|
-
});
|
|
1939
|
-
}
|
|
1940
|
-
return additions.length === 0 ? { messages } : { messages: [...messages, ...additions] };
|
|
1914
|
+
return { messages };
|
|
1941
1915
|
});
|
|
1942
1916
|
|
|
1943
1917
|
pi.on("agent_settled", async (_event: unknown, ctx: PiContext) => {
|
|
1944
1918
|
state.runActive = false;
|
|
1945
|
-
state.pinnedContext = undefined;
|
|
1946
1919
|
// A wake delivered as a queued follow-up is drained only when a run reaches
|
|
1947
1920
|
// its stop point. If the run that received it settled without draining it,
|
|
1948
1921
|
// no further run exists to carry the update out: drive a continuation (up to
|
|
@@ -1960,7 +1933,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1960
1933
|
state.wakeDeferredUntilSettled = false;
|
|
1961
1934
|
state.wakeDeferredSince = undefined;
|
|
1962
1935
|
state.wakeForcedRelease = false;
|
|
1963
|
-
state.wakeContext = undefined;
|
|
1964
1936
|
setHerdsmanUi(ctx);
|
|
1965
1937
|
scheduleWake(ctx);
|
|
1966
1938
|
};
|
|
@@ -2028,62 +2000,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
2028
2000
|
|
|
2029
2001
|
export default createHerdsmanPiExtension();
|
|
2030
2002
|
|
|
2031
|
-
export function formatHiddenAgentContext(input: {
|
|
2032
|
-
agents: AgentContextListItem[];
|
|
2033
|
-
workspaceId: string;
|
|
2034
|
-
}): string {
|
|
2035
|
-
return [
|
|
2036
|
-
"[HERDSMAN AGENT CONTEXT]",
|
|
2037
|
-
`Current Herdr workspace: ${input.workspaceId}`,
|
|
2038
|
-
...input.agents.map((agent) => {
|
|
2039
|
-
const history = agent.history ?? {};
|
|
2040
|
-
const identity = agentIdentityLabel({
|
|
2041
|
-
agent: agent.agent ?? "unknown",
|
|
2042
|
-
name: agent.name,
|
|
2043
|
-
});
|
|
2044
|
-
const paneId = agent.paneId ?? "unknown";
|
|
2045
|
-
const status = agent.agentStatus ?? "unknown";
|
|
2046
|
-
const prefix = `- ${identity} ${paneId} ${status}`;
|
|
2047
|
-
|
|
2048
|
-
const rawAgent = record(agent);
|
|
2049
|
-
const tabTitleCandidate =
|
|
2050
|
-
stringValue(rawAgent.terminalTitle) ?? stringValue(rawAgent.label);
|
|
2051
|
-
const tabTitleCleaned =
|
|
2052
|
-
tabTitleCandidate !== null ? sanitizeAndCleanContextText(tabTitleCandidate) : "";
|
|
2053
|
-
const tabTitle =
|
|
2054
|
-
tabTitleCleaned.length > 0 ? truncateSummary(tabTitleCleaned, 60) : null;
|
|
2055
|
-
|
|
2056
|
-
const rawHistory = record(history);
|
|
2057
|
-
const lastAssistantRecord = record(history.lastAssistantMessage);
|
|
2058
|
-
const timeCandidate =
|
|
2059
|
-
lastAssistantRecord.timestamp ??
|
|
2060
|
-
history.updatedAt ??
|
|
2061
|
-
rawHistory.updatedAt ??
|
|
2062
|
-
rawAgent.updatedAt ??
|
|
2063
|
-
rawAgent.time;
|
|
2064
|
-
const formattedTime = formatTimestamp(timeCandidate);
|
|
2065
|
-
|
|
2066
|
-
const assistantRaw = history.lastAssistantMessage?.text;
|
|
2067
|
-
const assistantCleaned =
|
|
2068
|
-
assistantRaw !== undefined && assistantRaw !== null
|
|
2069
|
-
? sanitizeAndCleanContextText(assistantRaw)
|
|
2070
|
-
: "";
|
|
2071
|
-
const assistantSummary =
|
|
2072
|
-
assistantCleaned.length > 0 ? truncateSummary(assistantCleaned, 100) : null;
|
|
2073
|
-
|
|
2074
|
-
const segments = [
|
|
2075
|
-
prefix,
|
|
2076
|
-
tabTitle,
|
|
2077
|
-
formattedTime,
|
|
2078
|
-
assistantSummary,
|
|
2079
|
-
].filter((segment): segment is string => segment !== null && segment.length > 0);
|
|
2080
|
-
|
|
2081
|
-
return segments.join(" · ");
|
|
2082
|
-
}),
|
|
2083
|
-
"Use herdsman agent get/read if details are needed.",
|
|
2084
|
-
].join("\n");
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
2003
|
export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string {
|
|
2088
2004
|
return [
|
|
2089
2005
|
"[HERDSMAN AGENT UPDATES]",
|
|
@@ -2106,6 +2022,10 @@ export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string
|
|
|
2106
2022
|
function isNormalHerdsmanContext(message: PiAgentMessage): boolean {
|
|
2107
2023
|
return (
|
|
2108
2024
|
message.customType === "herdsman-agent-context" ||
|
|
2025
|
+
// Defensive for session replay: older versions pinned a queued wake to the
|
|
2026
|
+
// context hook under this customType. A replayed history may still carry
|
|
2027
|
+
// those entries, and they are herdsman's own, so they are dropped from the
|
|
2028
|
+
// incoming list like the agent-context ones (see the context hook).
|
|
2109
2029
|
message.customType === "herdsman-wake-queued" ||
|
|
2110
2030
|
contentIncludesMarker(message.content, "[HERDSMAN AGENT CONTEXT]")
|
|
2111
2031
|
);
|
|
@@ -2185,32 +2105,3 @@ function sanitizeAndCleanContextText(value: string): string {
|
|
|
2185
2105
|
return cleanContextText(sanitized);
|
|
2186
2106
|
}
|
|
2187
2107
|
|
|
2188
|
-
function truncateSummary(value: string, limit = 100): string {
|
|
2189
|
-
if (value.length <= limit) return value;
|
|
2190
|
-
const maxSearch = Math.min(value.length, limit + 8);
|
|
2191
|
-
const searchSlice = value.slice(0, maxSearch);
|
|
2192
|
-
const lastSpaceIndex = searchSlice.search(/\s[^\s]*$/);
|
|
2193
|
-
if (lastSpaceIndex >= limit - 20) {
|
|
2194
|
-
return `${value.slice(0, lastSpaceIndex)}…`;
|
|
2195
|
-
}
|
|
2196
|
-
return `${value.slice(0, limit)}…`;
|
|
2197
|
-
}
|
|
2198
|
-
|
|
2199
|
-
function formatTimestamp(value: unknown): string | null {
|
|
2200
|
-
if (typeof value === "string") {
|
|
2201
|
-
const trimmed = value.trim();
|
|
2202
|
-
if (/^\d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
|
|
2203
|
-
if (/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
|
|
2204
|
-
}
|
|
2205
|
-
if (!value) return null;
|
|
2206
|
-
const date = value instanceof Date ? value : new Date(value as string | number);
|
|
2207
|
-
if (Number.isNaN(date.getTime())) return null;
|
|
2208
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
2209
|
-
const month = pad(date.getMonth() + 1);
|
|
2210
|
-
const day = pad(date.getDate());
|
|
2211
|
-
const hours = pad(date.getHours());
|
|
2212
|
-
const minutes = pad(date.getMinutes());
|
|
2213
|
-
const seconds = pad(date.getSeconds());
|
|
2214
|
-
return `${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
2215
|
-
}
|
|
2216
|
-
|