sentinelayer-cli 0.30.3 → 0.30.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/package.json +1 -1
- package/src/commands/session.js +289 -24
- package/src/mcp/session-stdio-server.js +2 -15
- package/src/session/control-events.js +40 -0
- package/src/session/event-identity.js +22 -0
- package/src/session/listener.js +2 -9
- package/src/session/listeners.js +16 -1
- package/src/session/remote-hydrate.js +87 -17
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -65,6 +65,10 @@ import {
|
|
|
65
65
|
updateSessionTitle,
|
|
66
66
|
} from "../session/store.js";
|
|
67
67
|
import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
|
|
68
|
+
import {
|
|
69
|
+
filterSessionMaterialEvents,
|
|
70
|
+
isSessionControlEvent,
|
|
71
|
+
} from "../session/control-events.js";
|
|
68
72
|
import {
|
|
69
73
|
acceptSessionInvitation,
|
|
70
74
|
normalizeSessionOnboarding,
|
|
@@ -76,6 +80,8 @@ import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
|
76
80
|
import {
|
|
77
81
|
addSessionEventIdentityKeys,
|
|
78
82
|
dedupeSessionEvents,
|
|
83
|
+
sessionEventIdentityKeys,
|
|
84
|
+
sessionEventUpgradesExisting,
|
|
79
85
|
sessionEventHasKnownIdentity,
|
|
80
86
|
} from "../session/event-identity.js";
|
|
81
87
|
import { readSessionPreview } from "../session/preview.js";
|
|
@@ -701,6 +707,80 @@ function mergeSessionActionEvents(events = [], actionEvents = []) {
|
|
|
701
707
|
.map((entry) => entry.event);
|
|
702
708
|
}
|
|
703
709
|
|
|
710
|
+
async function pollSessionEventsBeforeTranscriptTail(sessionId, {
|
|
711
|
+
targetPath,
|
|
712
|
+
beforeSequence = null,
|
|
713
|
+
tail = 20,
|
|
714
|
+
includeControlEvents = false,
|
|
715
|
+
timeoutMs = 15_000,
|
|
716
|
+
maxPages = 10,
|
|
717
|
+
} = {}) {
|
|
718
|
+
const targetMaterialCount = Math.max(1, Math.floor(Number(tail) || 20));
|
|
719
|
+
const pageLimit = includeControlEvents
|
|
720
|
+
? Math.min(200, targetMaterialCount)
|
|
721
|
+
: 200;
|
|
722
|
+
const maxPageCount = Math.max(1, Math.min(25, Math.floor(Number(maxPages) || 10)));
|
|
723
|
+
let nextBeforeSequence = beforeSequence;
|
|
724
|
+
let latestResult = null;
|
|
725
|
+
let combinedEvents = [];
|
|
726
|
+
let pageCount = 0;
|
|
727
|
+
|
|
728
|
+
for (let page = 0; page < maxPageCount; page += 1) {
|
|
729
|
+
const result = await pollSessionEventsBefore(sessionId, {
|
|
730
|
+
targetPath,
|
|
731
|
+
beforeSequence: nextBeforeSequence,
|
|
732
|
+
limit: pageLimit,
|
|
733
|
+
timeoutMs,
|
|
734
|
+
});
|
|
735
|
+
pageCount += 1;
|
|
736
|
+
if (!result?.ok) {
|
|
737
|
+
if (!latestResult) return { ...result, pageCount };
|
|
738
|
+
latestResult = {
|
|
739
|
+
...latestResult,
|
|
740
|
+
reason: result?.reason || latestResult.reason || "partial_tail_fetch",
|
|
741
|
+
partial: true,
|
|
742
|
+
};
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
latestResult = result;
|
|
747
|
+
const pageEvents = Array.isArray(result.events) ? result.events : [];
|
|
748
|
+
combinedEvents = [...pageEvents, ...combinedEvents];
|
|
749
|
+
const materialCount = includeControlEvents
|
|
750
|
+
? combinedEvents.length
|
|
751
|
+
: filterSessionMaterialEvents(combinedEvents).length;
|
|
752
|
+
|
|
753
|
+
if (includeControlEvents || materialCount >= targetMaterialCount) break;
|
|
754
|
+
if (pageEvents.length < pageLimit) break;
|
|
755
|
+
|
|
756
|
+
const candidateBefore = Number(result.beforeSequence || 0);
|
|
757
|
+
if (!Number.isFinite(candidateBefore) || candidateBefore <= 0) break;
|
|
758
|
+
const currentBefore = Number(nextBeforeSequence || 0);
|
|
759
|
+
if (currentBefore > 0 && candidateBefore >= currentBefore) break;
|
|
760
|
+
nextBeforeSequence = candidateBefore;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (!latestResult) {
|
|
764
|
+
return {
|
|
765
|
+
ok: true,
|
|
766
|
+
reason: "",
|
|
767
|
+
events: [],
|
|
768
|
+
cursor: null,
|
|
769
|
+
beforeSequence: beforeSequence || null,
|
|
770
|
+
pageCount,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
...latestResult,
|
|
776
|
+
events: combinedEvents,
|
|
777
|
+
cursor: normalizeString(combinedEvents[combinedEvents.length - 1]?.cursor) || latestResult.cursor || null,
|
|
778
|
+
pageCount,
|
|
779
|
+
materialBackfillComplete:
|
|
780
|
+
includeControlEvents || filterSessionMaterialEvents(combinedEvents).length >= targetMaterialCount,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
704
784
|
async function appendActionEventIfMissing(sessionId, actionEvent, { targetPath } = {}) {
|
|
705
785
|
if (!actionEvent) return { appended: false, reason: "no_event", event: null };
|
|
706
786
|
const existingEvents = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
|
|
@@ -1602,6 +1682,54 @@ function compactPayload(record = {}) {
|
|
|
1602
1682
|
);
|
|
1603
1683
|
}
|
|
1604
1684
|
|
|
1685
|
+
function listenerPresenceHeartbeatFingerprint(lifecycle = {}) {
|
|
1686
|
+
return JSON.stringify(
|
|
1687
|
+
compactPayload({
|
|
1688
|
+
active: Boolean(lifecycle.active),
|
|
1689
|
+
state: normalizeString(lifecycle.state) || "heartbeat",
|
|
1690
|
+
transport: normalizeString(lifecycle.transport) || null,
|
|
1691
|
+
reason: normalizeString(lifecycle.reason) || null,
|
|
1692
|
+
idleIntervalSeconds: lifecycle.idleIntervalSeconds ?? null,
|
|
1693
|
+
activeIntervalSeconds: lifecycle.activeIntervalSeconds ?? null,
|
|
1694
|
+
activeWindowSeconds: lifecycle.activeWindowSeconds ?? null,
|
|
1695
|
+
lastHumanActivityAt: lifecycle.lastHumanActivityAt || null,
|
|
1696
|
+
}),
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
export function shouldPublishListenerPresenceHeartbeat({
|
|
1701
|
+
lifecycle = {},
|
|
1702
|
+
nowMs = Date.now(),
|
|
1703
|
+
lastHeartbeatMs = 0,
|
|
1704
|
+
lastFingerprint = "",
|
|
1705
|
+
presenceIntervalMs = 60_000,
|
|
1706
|
+
presenceKeepaliveMs = 180_000,
|
|
1707
|
+
} = {}) {
|
|
1708
|
+
const lifecycleType = normalizeString(lifecycle.type) || "heartbeat";
|
|
1709
|
+
if (lifecycleType !== "heartbeat") {
|
|
1710
|
+
return { publish: true, fingerprint: lastFingerprint, reason: "lifecycle" };
|
|
1711
|
+
}
|
|
1712
|
+
const fingerprint = listenerPresenceHeartbeatFingerprint(lifecycle);
|
|
1713
|
+
if (lifecycle.stopping) {
|
|
1714
|
+
return { publish: true, fingerprint, reason: "stopping" };
|
|
1715
|
+
}
|
|
1716
|
+
const currentMs = Number(nowMs) || Date.now();
|
|
1717
|
+
const previousMs = Number(lastHeartbeatMs) || 0;
|
|
1718
|
+
if (previousMs <= 0) {
|
|
1719
|
+
return { publish: true, fingerprint, reason: "first" };
|
|
1720
|
+
}
|
|
1721
|
+
if (fingerprint !== lastFingerprint) {
|
|
1722
|
+
return { publish: true, fingerprint, reason: "changed" };
|
|
1723
|
+
}
|
|
1724
|
+
const minIntervalMs = Math.max(1, Number(presenceIntervalMs) || 60_000);
|
|
1725
|
+
const keepaliveMs = Math.max(minIntervalMs, Number(presenceKeepaliveMs) || 180_000);
|
|
1726
|
+
const elapsedMs = currentMs - previousMs;
|
|
1727
|
+
if (elapsedMs >= keepaliveMs) {
|
|
1728
|
+
return { publish: true, fingerprint, reason: "keepalive" };
|
|
1729
|
+
}
|
|
1730
|
+
return { publish: false, fingerprint, reason: elapsedMs < minIntervalMs ? "interval" : "unchanged" };
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1605
1733
|
async function publishListenerPresenceEvent({
|
|
1606
1734
|
sessionId,
|
|
1607
1735
|
targetPath,
|
|
@@ -1651,6 +1779,8 @@ async function publishListenerPresenceEvent({
|
|
|
1651
1779
|
idleIntervalSeconds: lifecycle.idleIntervalSeconds,
|
|
1652
1780
|
activeIntervalSeconds: lifecycle.activeIntervalSeconds,
|
|
1653
1781
|
activeWindowSeconds: lifecycle.activeWindowSeconds,
|
|
1782
|
+
presenceIntervalSeconds: lifecycle.presenceIntervalSeconds,
|
|
1783
|
+
presenceKeepaliveSeconds: lifecycle.presenceKeepaliveSeconds,
|
|
1654
1784
|
lastHumanActivityAt: lifecycle.lastHumanActivityAt,
|
|
1655
1785
|
lastSleepMs: lifecycle.lastSleepMs,
|
|
1656
1786
|
nextPollMs: lifecycle.nextPollMs,
|
|
@@ -2115,13 +2245,6 @@ function formatEventLine(event = {}) {
|
|
|
2115
2245
|
return `${ts} ${agentId} ${type}`;
|
|
2116
2246
|
}
|
|
2117
2247
|
|
|
2118
|
-
function isSessionControlEvent(event = {}) {
|
|
2119
|
-
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
|
2120
|
-
const type = normalizeString(event.event || event.type).toLowerCase();
|
|
2121
|
-
if (type.startsWith("session_listener_")) return true;
|
|
2122
|
-
return normalizeString(payload.source).toLowerCase() === "session_listen";
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
2248
|
function summarizeSessionTailEvent(event = null) {
|
|
2126
2249
|
if (!event || typeof event !== "object") return null;
|
|
2127
2250
|
return {
|
|
@@ -2288,6 +2411,7 @@ async function hydrateJoinBriefingContext(sessionId, { targetPath, limit = 100 }
|
|
|
2288
2411
|
reason: "",
|
|
2289
2412
|
remoteEvents: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
|
|
2290
2413
|
appended: appended.appended,
|
|
2414
|
+
upgraded: appended.upgraded,
|
|
2291
2415
|
skipped: appended.skipped,
|
|
2292
2416
|
failed: appended.failed,
|
|
2293
2417
|
cursor: remoteTail.cursor || null,
|
|
@@ -2305,6 +2429,34 @@ async function hydrateJoinBriefingContext(sessionId, { targetPath, limit = 100 }
|
|
|
2305
2429
|
}
|
|
2306
2430
|
}
|
|
2307
2431
|
|
|
2432
|
+
function remoteSessionEventUpgradesLocal(localEvent = {}, remoteEvent = {}) {
|
|
2433
|
+
return sessionEventUpgradesExisting(localEvent, remoteEvent);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
function indexSessionEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
|
|
2437
|
+
if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
|
|
2438
|
+
for (const key of sessionEventIdentityKeys(event)) {
|
|
2439
|
+
const existingIndex = indexByKey.get(key);
|
|
2440
|
+
const existingEvent = Number.isInteger(existingIndex) && existingIndex >= 0
|
|
2441
|
+
? eventList[existingIndex]
|
|
2442
|
+
: null;
|
|
2443
|
+
if (!existingEvent || remoteSessionEventUpgradesLocal(existingEvent, event)) {
|
|
2444
|
+
indexByKey.set(key, index);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
function findSessionEventIdentityIndex(indexByKey, event = {}) {
|
|
2450
|
+
if (!indexByKey) return -1;
|
|
2451
|
+
for (const key of sessionEventIdentityKeys(event)) {
|
|
2452
|
+
const index = indexByKey.get(key);
|
|
2453
|
+
if (Number.isInteger(index) && index >= 0) {
|
|
2454
|
+
return index;
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
return -1;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2308
2460
|
async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetPath } = {}) {
|
|
2309
2461
|
const events = Array.isArray(remoteEvents) ? remoteEvents : [];
|
|
2310
2462
|
if (events.length === 0) {
|
|
@@ -2315,18 +2467,47 @@ async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetP
|
|
|
2315
2467
|
};
|
|
2316
2468
|
}
|
|
2317
2469
|
const knownKeys = new Set();
|
|
2470
|
+
const localIndexByKey = new Map();
|
|
2318
2471
|
const localEvents = await readStream(sessionId, {
|
|
2319
2472
|
targetPath,
|
|
2320
2473
|
tail: 0,
|
|
2321
2474
|
});
|
|
2322
|
-
for (const event of localEvents) {
|
|
2475
|
+
for (const [index, event] of localEvents.entries()) {
|
|
2323
2476
|
addSessionEventIdentityKeys(knownKeys, event);
|
|
2477
|
+
indexSessionEventIdentityKeys(localIndexByKey, localEvents, event, index);
|
|
2324
2478
|
}
|
|
2325
2479
|
|
|
2326
2480
|
let appended = 0;
|
|
2481
|
+
let upgraded = 0;
|
|
2327
2482
|
let skipped = 0;
|
|
2328
2483
|
let failed = 0;
|
|
2329
2484
|
for (const event of events) {
|
|
2485
|
+
const existingIndex = findSessionEventIdentityIndex(localIndexByKey, event);
|
|
2486
|
+
if (existingIndex >= 0) {
|
|
2487
|
+
const existing = localEvents[existingIndex];
|
|
2488
|
+
if (!remoteSessionEventUpgradesLocal(existing, event)) {
|
|
2489
|
+
skipped += 1;
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
// Keep the richer canonical API row in local history when it
|
|
2493
|
+
// upgrades an optimistic/local post that had no durable cursor yet.
|
|
2494
|
+
// Future reads can then page by sequence instead of rediscovering the
|
|
2495
|
+
// upgrade every time.
|
|
2496
|
+
try {
|
|
2497
|
+
const persisted = await appendToStream(sessionId, event, {
|
|
2498
|
+
targetPath,
|
|
2499
|
+
syncRemote: false,
|
|
2500
|
+
});
|
|
2501
|
+
localEvents.push(persisted);
|
|
2502
|
+
addSessionEventIdentityKeys(knownKeys, persisted);
|
|
2503
|
+
indexSessionEventIdentityKeys(localIndexByKey, localEvents, persisted, localEvents.length - 1);
|
|
2504
|
+
upgraded += 1;
|
|
2505
|
+
} catch {
|
|
2506
|
+
addSessionEventIdentityKeys(knownKeys, event);
|
|
2507
|
+
failed += 1;
|
|
2508
|
+
}
|
|
2509
|
+
continue;
|
|
2510
|
+
}
|
|
2330
2511
|
if (sessionEventHasKnownIdentity(event, knownKeys)) {
|
|
2331
2512
|
skipped += 1;
|
|
2332
2513
|
continue;
|
|
@@ -2345,6 +2526,7 @@ async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetP
|
|
|
2345
2526
|
}
|
|
2346
2527
|
return {
|
|
2347
2528
|
appended,
|
|
2529
|
+
upgraded,
|
|
2348
2530
|
skipped,
|
|
2349
2531
|
failed,
|
|
2350
2532
|
};
|
|
@@ -3998,6 +4180,11 @@ export function registerSessionCommand(program) {
|
|
|
3998
4180
|
"Minimum seconds between remote listener heartbeat events (default 60)",
|
|
3999
4181
|
"60",
|
|
4000
4182
|
)
|
|
4183
|
+
.option(
|
|
4184
|
+
"--presence-keepalive <seconds>",
|
|
4185
|
+
"Maximum seconds between unchanged remote listener heartbeat events (default 180)",
|
|
4186
|
+
"180",
|
|
4187
|
+
)
|
|
4001
4188
|
.option(
|
|
4002
4189
|
"--no-presence",
|
|
4003
4190
|
"Do not publish durable listener lifecycle/heartbeat events",
|
|
@@ -4050,6 +4237,11 @@ export function registerSessionCommand(program) {
|
|
|
4050
4237
|
"presence-interval",
|
|
4051
4238
|
60,
|
|
4052
4239
|
);
|
|
4240
|
+
const presenceKeepaliveSeconds = parsePositiveInteger(
|
|
4241
|
+
options.presenceKeepalive,
|
|
4242
|
+
"presence-keepalive",
|
|
4243
|
+
180,
|
|
4244
|
+
);
|
|
4053
4245
|
const listenerIdentity = inferSessionAgentIdentity({
|
|
4054
4246
|
agentId,
|
|
4055
4247
|
model: options.model,
|
|
@@ -4132,7 +4324,10 @@ export function registerSessionCommand(program) {
|
|
|
4132
4324
|
const durablePresenceEnabled = options.presence !== false;
|
|
4133
4325
|
const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
|
|
4134
4326
|
const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
|
|
4327
|
+
const presenceKeepaliveMs =
|
|
4328
|
+
Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds, 1) * 1000;
|
|
4135
4329
|
let lastPresenceHeartbeatMs = 0;
|
|
4330
|
+
let lastPresenceHeartbeatFingerprint = "";
|
|
4136
4331
|
|
|
4137
4332
|
// Periodic in-session success reminders (ack, claim work, reply
|
|
4138
4333
|
// in-thread). Long-running interactive listeners only — skipped under
|
|
@@ -4314,11 +4509,25 @@ export function registerSessionCommand(program) {
|
|
|
4314
4509
|
}
|
|
4315
4510
|
if (!publishPresence) return;
|
|
4316
4511
|
const lifecycleType = normalizeString(lifecycle?.type);
|
|
4512
|
+
const publishLifecycle = {
|
|
4513
|
+
...lifecycle,
|
|
4514
|
+
presenceIntervalSeconds,
|
|
4515
|
+
presenceKeepaliveSeconds: Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds),
|
|
4516
|
+
};
|
|
4317
4517
|
if (lifecycleType === "heartbeat") {
|
|
4318
4518
|
const nowMs = Date.now();
|
|
4319
|
-
|
|
4519
|
+
const heartbeatDecision = shouldPublishListenerPresenceHeartbeat({
|
|
4520
|
+
lifecycle: publishLifecycle,
|
|
4521
|
+
nowMs,
|
|
4522
|
+
lastHeartbeatMs: lastPresenceHeartbeatMs,
|
|
4523
|
+
lastFingerprint: lastPresenceHeartbeatFingerprint,
|
|
4524
|
+
presenceIntervalMs,
|
|
4525
|
+
presenceKeepaliveMs,
|
|
4526
|
+
});
|
|
4527
|
+
if (!heartbeatDecision.publish) {
|
|
4320
4528
|
return;
|
|
4321
4529
|
}
|
|
4530
|
+
lastPresenceHeartbeatFingerprint = heartbeatDecision.fingerprint;
|
|
4322
4531
|
lastPresenceHeartbeatMs = nowMs;
|
|
4323
4532
|
}
|
|
4324
4533
|
await publishListenerPresenceEvent({
|
|
@@ -4330,7 +4539,7 @@ export function registerSessionCommand(program) {
|
|
|
4330
4539
|
provider,
|
|
4331
4540
|
clientKind,
|
|
4332
4541
|
listenerId,
|
|
4333
|
-
lifecycle,
|
|
4542
|
+
lifecycle: publishLifecycle,
|
|
4334
4543
|
});
|
|
4335
4544
|
},
|
|
4336
4545
|
});
|
|
@@ -4853,9 +5062,6 @@ export function registerSessionCommand(program) {
|
|
|
4853
5062
|
const beforeSequence = parseOptionalPositiveInteger(options.beforeSequence, "before-sequence");
|
|
4854
5063
|
const emitJson = shouldEmitJson(options, command);
|
|
4855
5064
|
const includeControlEvents = Boolean(options.includeControlEvents);
|
|
4856
|
-
const remoteTailLimit = includeControlEvents
|
|
4857
|
-
? tail
|
|
4858
|
-
: Math.min(200, Math.max(tail, tail + 20));
|
|
4859
5065
|
const readAgentId = await defaultAgentId(options.agent, targetPath);
|
|
4860
5066
|
|
|
4861
5067
|
let hydration = null;
|
|
@@ -4874,11 +5080,13 @@ export function registerSessionCommand(program) {
|
|
|
4874
5080
|
hydration = await hydrateSessionFromRemote({
|
|
4875
5081
|
sessionId: normalizedSessionId,
|
|
4876
5082
|
targetPath,
|
|
5083
|
+
includeControlEvents,
|
|
4877
5084
|
});
|
|
4878
|
-
remoteTail = await
|
|
5085
|
+
remoteTail = await pollSessionEventsBeforeTranscriptTail(normalizedSessionId, {
|
|
4879
5086
|
targetPath,
|
|
4880
5087
|
beforeSequence,
|
|
4881
|
-
|
|
5088
|
+
tail,
|
|
5089
|
+
includeControlEvents,
|
|
4882
5090
|
timeoutMs: 15_000,
|
|
4883
5091
|
});
|
|
4884
5092
|
if (options.actions !== false) {
|
|
@@ -4954,12 +5162,29 @@ export function registerSessionCommand(program) {
|
|
|
4954
5162
|
const displayEvents = [...allEvents];
|
|
4955
5163
|
let remoteTailAppended = 0;
|
|
4956
5164
|
let remoteTailDisplayedOnly = 0;
|
|
4957
|
-
|
|
5165
|
+
let remoteTailUpgraded = 0;
|
|
5166
|
+
const remoteTailAppendEvents = includeControlEvents
|
|
5167
|
+
? remoteTail?.events
|
|
5168
|
+
: filterSessionMaterialEvents(remoteTail?.events || []);
|
|
5169
|
+
if (remoteTail?.ok && Array.isArray(remoteTailAppendEvents) && remoteTailAppendEvents.length > 0) {
|
|
4958
5170
|
const knownKeys = new Set();
|
|
4959
|
-
|
|
5171
|
+
const displayIndexByKey = new Map();
|
|
5172
|
+
for (const [index, event] of displayEvents.entries()) {
|
|
4960
5173
|
addSessionEventIdentityKeys(knownKeys, event);
|
|
5174
|
+
indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, index);
|
|
4961
5175
|
}
|
|
4962
|
-
for (const event of
|
|
5176
|
+
for (const event of remoteTailAppendEvents) {
|
|
5177
|
+
const existingIndex = findSessionEventIdentityIndex(displayIndexByKey, event);
|
|
5178
|
+
if (existingIndex >= 0) {
|
|
5179
|
+
const existing = displayEvents[existingIndex];
|
|
5180
|
+
if (remoteSessionEventUpgradesLocal(existing, event)) {
|
|
5181
|
+
displayEvents[existingIndex] = event;
|
|
5182
|
+
addSessionEventIdentityKeys(knownKeys, event);
|
|
5183
|
+
indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, existingIndex);
|
|
5184
|
+
remoteTailUpgraded += 1;
|
|
5185
|
+
}
|
|
5186
|
+
continue;
|
|
5187
|
+
}
|
|
4963
5188
|
if (sessionEventHasKnownIdentity(event, knownKeys)) {
|
|
4964
5189
|
continue;
|
|
4965
5190
|
}
|
|
@@ -4970,10 +5195,12 @@ export function registerSessionCommand(program) {
|
|
|
4970
5195
|
});
|
|
4971
5196
|
displayEvents.push(appended);
|
|
4972
5197
|
addSessionEventIdentityKeys(knownKeys, appended);
|
|
5198
|
+
indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, appended, displayEvents.length - 1);
|
|
4973
5199
|
remoteTailAppended += 1;
|
|
4974
5200
|
} catch {
|
|
4975
5201
|
displayEvents.push(event);
|
|
4976
5202
|
addSessionEventIdentityKeys(knownKeys, event);
|
|
5203
|
+
indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, displayEvents.length - 1);
|
|
4977
5204
|
remoteTailDisplayedOnly += 1;
|
|
4978
5205
|
}
|
|
4979
5206
|
}
|
|
@@ -4987,10 +5214,11 @@ export function registerSessionCommand(program) {
|
|
|
4987
5214
|
const recentHumanActivity = remoteActions?.ok
|
|
4988
5215
|
? readProjectionRecentHumanActivity(remoteActions)
|
|
4989
5216
|
: [];
|
|
5217
|
+
const dedupedDisplayEvents = dedupeSessionEvents(displayEvents);
|
|
4990
5218
|
const transcriptEvents = includeControlEvents
|
|
4991
|
-
?
|
|
4992
|
-
:
|
|
4993
|
-
const hiddenControlEventCount =
|
|
5219
|
+
? dedupedDisplayEvents
|
|
5220
|
+
: dedupedDisplayEvents.filter((event) => !isSessionControlEvent(event));
|
|
5221
|
+
const hiddenControlEventCount = dedupedDisplayEvents.length - transcriptEvents.length;
|
|
4994
5222
|
const events = mergeSessionActionEvents(transcriptEvents, actionEvents).slice(-tail);
|
|
4995
5223
|
const autoView = recordSessionReadViews(normalizedSessionId, events, {
|
|
4996
5224
|
targetPath,
|
|
@@ -5037,9 +5265,12 @@ export function registerSessionCommand(program) {
|
|
|
5037
5265
|
count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
|
|
5038
5266
|
cursor: remoteTail.cursor || null,
|
|
5039
5267
|
beforeSequence: remoteTail.beforeSequence || null,
|
|
5268
|
+
pageCount: Number(remoteTail.pageCount || 0),
|
|
5269
|
+
materialBackfillComplete: Boolean(remoteTail.materialBackfillComplete),
|
|
5040
5270
|
verified: Boolean(remoteTail.ok),
|
|
5041
5271
|
appended: remoteTailAppended,
|
|
5042
5272
|
displayedOnly: remoteTailDisplayedOnly,
|
|
5273
|
+
upgraded: remoteTailUpgraded,
|
|
5043
5274
|
controlEventCount: remoteTailStats?.controlEventCount || 0,
|
|
5044
5275
|
materialEventCount: remoteTailStats?.materialEventCount || 0,
|
|
5045
5276
|
latestEvent: remoteTailStats?.latestEvent || null,
|
|
@@ -5111,6 +5342,7 @@ export function registerSessionCommand(program) {
|
|
|
5111
5342
|
signal: ac.signal,
|
|
5112
5343
|
})) {
|
|
5113
5344
|
if (item.event) {
|
|
5345
|
+
if (!includeControlEvents && isSessionControlEvent(item.event)) continue;
|
|
5114
5346
|
if (emitJson) {
|
|
5115
5347
|
console.log(JSON.stringify({ source: item.source, event: item.event }));
|
|
5116
5348
|
} else {
|
|
@@ -5139,6 +5371,9 @@ export function registerSessionCommand(program) {
|
|
|
5139
5371
|
continue;
|
|
5140
5372
|
}
|
|
5141
5373
|
addSessionEventIdentityKeys(seenFollowEvents, event);
|
|
5374
|
+
if (!includeControlEvents && isSessionControlEvent(event)) {
|
|
5375
|
+
continue;
|
|
5376
|
+
}
|
|
5142
5377
|
if (emitJson) {
|
|
5143
5378
|
console.log(JSON.stringify(event));
|
|
5144
5379
|
} else {
|
|
@@ -5580,6 +5815,7 @@ export function registerSessionCommand(program) {
|
|
|
5580
5815
|
"Hydrate from the SentinelLayer API before exporting and include remote message actions",
|
|
5581
5816
|
)
|
|
5582
5817
|
.option("--no-actions", "Do not include remote message actions/replies/reactions")
|
|
5818
|
+
.option("--include-control-events", "Include listener/control-plane events in the exported transcript")
|
|
5583
5819
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
5584
5820
|
.action(async (sessionId, options) => {
|
|
5585
5821
|
const normalizedSessionId = normalizeString(sessionId);
|
|
@@ -5597,6 +5833,7 @@ export function registerSessionCommand(program) {
|
|
|
5597
5833
|
hydration = await hydrateSessionFromRemote({
|
|
5598
5834
|
sessionId: normalizedSessionId,
|
|
5599
5835
|
targetPath,
|
|
5836
|
+
includeControlEvents: Boolean(options.includeControlEvents),
|
|
5600
5837
|
}).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
|
|
5601
5838
|
if (options.actions !== false) {
|
|
5602
5839
|
remoteActions = await listSessionMessageActions(normalizedSessionId, {
|
|
@@ -5629,7 +5866,12 @@ export function registerSessionCommand(program) {
|
|
|
5629
5866
|
const actionEvents = remoteActions?.ok
|
|
5630
5867
|
? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
|
|
5631
5868
|
: [];
|
|
5632
|
-
const
|
|
5869
|
+
const includeControlEvents = Boolean(options.includeControlEvents);
|
|
5870
|
+
const transcriptBaseEvents = includeControlEvents
|
|
5871
|
+
? events
|
|
5872
|
+
: filterSessionMaterialEvents(events);
|
|
5873
|
+
const hiddenControlEventCount = events.length - transcriptBaseEvents.length;
|
|
5874
|
+
const exportEvents = mergeSessionActionEvents(transcriptBaseEvents, actionEvents);
|
|
5633
5875
|
const stats = computeTranscriptStats({
|
|
5634
5876
|
sessionMeta: sessionPayload,
|
|
5635
5877
|
events: exportEvents,
|
|
@@ -5643,6 +5885,15 @@ export function registerSessionCommand(program) {
|
|
|
5643
5885
|
if (format === "ndjson") {
|
|
5644
5886
|
const lines = [];
|
|
5645
5887
|
lines.push(JSON.stringify({ kind: "session", value: sessionPayload }));
|
|
5888
|
+
lines.push(JSON.stringify({
|
|
5889
|
+
kind: "export_metadata",
|
|
5890
|
+
value: {
|
|
5891
|
+
includeControlEvents,
|
|
5892
|
+
hiddenControlEventCount,
|
|
5893
|
+
rawEventCount: events.length,
|
|
5894
|
+
eventCount: exportEvents.length,
|
|
5895
|
+
},
|
|
5896
|
+
}));
|
|
5646
5897
|
for (const agent of agents) lines.push(JSON.stringify({ kind: "agent", value: agent }));
|
|
5647
5898
|
for (const participant of participants) {
|
|
5648
5899
|
lines.push(JSON.stringify({ kind: "participant", value: participant }));
|
|
@@ -5684,10 +5935,12 @@ export function registerSessionCommand(program) {
|
|
|
5684
5935
|
registeredAgents: agents.length,
|
|
5685
5936
|
events: exportEvents.length,
|
|
5686
5937
|
rawEvents: events.length,
|
|
5938
|
+
hiddenControlEvents: hiddenControlEventCount,
|
|
5687
5939
|
actions: Array.isArray(remoteActions?.actions) ? remoteActions.actions.length : 0,
|
|
5688
5940
|
actionEvents: actionEvents.length,
|
|
5689
5941
|
tasks: (tasks.tasks || []).length,
|
|
5690
5942
|
},
|
|
5943
|
+
includeControlEvents,
|
|
5691
5944
|
totals: stats.totals,
|
|
5692
5945
|
},
|
|
5693
5946
|
null,
|
|
@@ -5704,7 +5957,7 @@ export function registerSessionCommand(program) {
|
|
|
5704
5957
|
pc.gray(
|
|
5705
5958
|
`Exported ${exportEvents.length} events / ${participants.length} participants (${agents.length} registered agents) / ${
|
|
5706
5959
|
(tasks.tasks || []).length
|
|
5707
|
-
} tasks → ${outPath}`,
|
|
5960
|
+
} tasks${hiddenControlEventCount ? ` (${hiddenControlEventCount} control events omitted)` : ""} → ${outPath}`,
|
|
5708
5961
|
),
|
|
5709
5962
|
);
|
|
5710
5963
|
} else {
|
|
@@ -5727,6 +5980,7 @@ export function registerSessionCommand(program) {
|
|
|
5727
5980
|
"Hydrate from the SentinelLayer API before rendering (pulls web-posted messages into the local NDJSON)",
|
|
5728
5981
|
)
|
|
5729
5982
|
.option("--no-actions", "Do not include remote message actions/replies/reactions")
|
|
5983
|
+
.option("--include-control-events", "Include listener/control-plane events in the Markdown transcript")
|
|
5730
5984
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
5731
5985
|
.option("--json", "Emit machine-readable output")
|
|
5732
5986
|
.action(async (sessionId, options, command) => {
|
|
@@ -5743,6 +5997,7 @@ export function registerSessionCommand(program) {
|
|
|
5743
5997
|
hydration = await hydrateSessionFromRemote({
|
|
5744
5998
|
sessionId: normalizedSessionId,
|
|
5745
5999
|
targetPath,
|
|
6000
|
+
includeControlEvents: Boolean(options.includeControlEvents),
|
|
5746
6001
|
}).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
|
|
5747
6002
|
if (options.actions !== false) {
|
|
5748
6003
|
remoteActions = await listSessionMessageActions(normalizedSessionId, {
|
|
@@ -5765,7 +6020,12 @@ export function registerSessionCommand(program) {
|
|
|
5765
6020
|
const actionEvents = remoteActions?.ok
|
|
5766
6021
|
? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
|
|
5767
6022
|
: [];
|
|
5768
|
-
const
|
|
6023
|
+
const includeControlEvents = Boolean(options.includeControlEvents);
|
|
6024
|
+
const transcriptBaseEvents = includeControlEvents
|
|
6025
|
+
? events
|
|
6026
|
+
: filterSessionMaterialEvents(events);
|
|
6027
|
+
const hiddenControlEventCount = events.length - transcriptBaseEvents.length;
|
|
6028
|
+
const transcriptEvents = mergeSessionActionEvents(transcriptBaseEvents, actionEvents);
|
|
5769
6029
|
|
|
5770
6030
|
// Pull GitHub/Google avatar + display name from the active auth
|
|
5771
6031
|
// session so any human-id seen in the stream renders with the
|
|
@@ -5830,6 +6090,8 @@ export function registerSessionCommand(program) {
|
|
|
5830
6090
|
rawEventCount: events.length,
|
|
5831
6091
|
actionCount: Array.isArray(remoteActions?.actions) ? remoteActions.actions.length : 0,
|
|
5832
6092
|
actionEventCount: actionEvents.length,
|
|
6093
|
+
includeControlEvents,
|
|
6094
|
+
hiddenControlEventCount,
|
|
5833
6095
|
agentCount: participants.length,
|
|
5834
6096
|
participantCount: participants.length,
|
|
5835
6097
|
derivedAgentCount: stats.agents.length,
|
|
@@ -5861,6 +6123,9 @@ export function registerSessionCommand(program) {
|
|
|
5861
6123
|
`${transcriptEvents.length} events · ${participants.length} participants (${agents.length} registered agents) · actions=${payload.actionCount} · live ${stats.sessionLiveSeconds}s · senti=${stats.sentiActions} · tokens=${stats.totals.tokenTotal} · cost=$${stats.totals.costTotalUsd.toFixed(4)}`,
|
|
5862
6124
|
),
|
|
5863
6125
|
);
|
|
6126
|
+
if (hiddenControlEventCount > 0) {
|
|
6127
|
+
console.log(pc.gray(`${hiddenControlEventCount} control events omitted; rerun with --include-control-events to inspect them.`));
|
|
6128
|
+
}
|
|
5864
6129
|
});
|
|
5865
6130
|
|
|
5866
6131
|
session
|
|
@@ -5,6 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
5
5
|
import { createAgentEvent } from "../events/schema.js";
|
|
6
6
|
import { appendToStream } from "../session/stream.js";
|
|
7
7
|
import { eventMatchesAgent } from "../session/listener.js";
|
|
8
|
+
import { isSessionControlEvent } from "../session/control-events.js";
|
|
8
9
|
import {
|
|
9
10
|
listSessionMessageActions,
|
|
10
11
|
pollSessionEvents,
|
|
@@ -102,20 +103,6 @@ function eventAgentId(event = {}) {
|
|
|
102
103
|
return normalizeAgentId(event?.agent?.id || event?.agentId || event?.agent_id, "");
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
function eventName(event = {}) {
|
|
106
|
-
return normalizeString(event?.event || event?.type).toLowerCase();
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function isControlEvent(event = {}) {
|
|
110
|
-
const name = eventName(event);
|
|
111
|
-
const payload = isPlainObject(event?.payload) ? event.payload : {};
|
|
112
|
-
return (
|
|
113
|
-
name.startsWith("session_listen_") ||
|
|
114
|
-
name === "agent_heartbeat" ||
|
|
115
|
-
normalizeString(payload.source).toLowerCase() === "session_listen"
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
106
|
function safePayload(payload = {}) {
|
|
120
107
|
if (!isPlainObject(payload)) return {};
|
|
121
108
|
const next = { ...payload };
|
|
@@ -627,7 +614,7 @@ export function createSessionMcpToolHandlers({
|
|
|
627
614
|
}
|
|
628
615
|
|
|
629
616
|
const events = (Array.isArray(result.events) ? result.events : []).filter((event) => {
|
|
630
|
-
if (!includeControlEvents &&
|
|
617
|
+
if (!includeControlEvents && isSessionControlEvent(event)) return false;
|
|
631
618
|
if (!includeSelf && eventAgentId(event) === agentId) return false;
|
|
632
619
|
return eventMatchesAgent(event, agentId);
|
|
633
620
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const SESSION_CONTROL_EVENT_TYPES = new Set([
|
|
2
|
+
"listener_stop",
|
|
3
|
+
"session_coaching",
|
|
4
|
+
"session_listen_catchup",
|
|
5
|
+
"session_listen_error",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
function normalizeString(value) {
|
|
9
|
+
return String(value || "").trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function eventType(event = {}) {
|
|
13
|
+
return normalizeString(event?.event || event?.type).toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function payloadSource(event = {}) {
|
|
17
|
+
const payload = event?.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
|
|
18
|
+
? event.payload
|
|
19
|
+
: {};
|
|
20
|
+
return normalizeString(payload.source).toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isSessionListenerLifecycleEvent(event = {}) {
|
|
24
|
+
const type = eventType(event);
|
|
25
|
+
if (type.startsWith("session_listener_")) return true;
|
|
26
|
+
return payloadSource(event) === "session_listen";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isSessionControlEvent(event = {}) {
|
|
30
|
+
const type = eventType(event);
|
|
31
|
+
return (
|
|
32
|
+
isSessionListenerLifecycleEvent(event) ||
|
|
33
|
+
type.startsWith("session_listen_") ||
|
|
34
|
+
SESSION_CONTROL_EVENT_TYPES.has(type)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function filterSessionMaterialEvents(events = []) {
|
|
39
|
+
return (Array.isArray(events) ? events : []).filter((event) => !isSessionControlEvent(event));
|
|
40
|
+
}
|
|
@@ -15,6 +15,24 @@ function timestampKey(...values) {
|
|
|
15
15
|
return "";
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export function readSessionEventSequence(event = {}) {
|
|
19
|
+
const value = Number(event?.sequenceId ?? event?.sequence_id ?? event?.sequence);
|
|
20
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sessionEventHasDurableCursor(event = {}) {
|
|
24
|
+
return Boolean(keyString(event?.cursor));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sessionEventUpgradesExisting(existingEvent = {}, candidateEvent = {}) {
|
|
28
|
+
const existingSequence = readSessionEventSequence(existingEvent);
|
|
29
|
+
const candidateSequence = readSessionEventSequence(candidateEvent);
|
|
30
|
+
if (candidateSequence > 0 && existingSequence <= 0) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return sessionEventHasDurableCursor(candidateEvent) && !sessionEventHasDurableCursor(existingEvent);
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
function stableJsonValue(value) {
|
|
19
37
|
if (Array.isArray(value)) {
|
|
20
38
|
return value.map((item) => stableJsonValue(item));
|
|
@@ -51,6 +69,10 @@ export function sessionEventIdentityKeys(event = {}) {
|
|
|
51
69
|
keys.push(`idempotency:${event.idempotencyToken.trim()}`);
|
|
52
70
|
}
|
|
53
71
|
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
|
|
72
|
+
const clientMessageId = typeof payload.clientMessageId === "string" ? payload.clientMessageId.trim() : "";
|
|
73
|
+
if (clientMessageId) {
|
|
74
|
+
keys.push(`client-message:${clientMessageId}`);
|
|
75
|
+
}
|
|
54
76
|
const messageId = typeof payload.messageId === "string" ? payload.messageId.trim() : "";
|
|
55
77
|
if (messageId) {
|
|
56
78
|
keys.push(`message:${messageId}`);
|
package/src/session/listener.js
CHANGED
|
@@ -2,6 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
2
2
|
|
|
3
3
|
import { pollSessionEvents, pollSessionEventsBefore, streamSessionEvents } from "./sync.js";
|
|
4
4
|
import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
5
|
+
import { isSessionListenerLifecycleEvent } from "./control-events.js";
|
|
5
6
|
|
|
6
7
|
const BROADCAST_RECIPIENTS = new Set([
|
|
7
8
|
"*",
|
|
@@ -223,14 +224,6 @@ function isRecentActivity(activityMs, nowMs, windowMs) {
|
|
|
223
224
|
);
|
|
224
225
|
}
|
|
225
226
|
|
|
226
|
-
function isListenerLifecycleEvent(event = {}) {
|
|
227
|
-
if (!isPlainObject(event)) return false;
|
|
228
|
-
const payload = isPlainObject(event.payload) ? event.payload : {};
|
|
229
|
-
const eventType = normalizeString(event.event || event.type).toLowerCase();
|
|
230
|
-
if (eventType.startsWith("session_listener_")) return true;
|
|
231
|
-
return normalizeString(payload.source).toLowerCase() === "session_listen";
|
|
232
|
-
}
|
|
233
|
-
|
|
234
227
|
/**
|
|
235
228
|
* Poll session events in the background and emit only events addressed to
|
|
236
229
|
* the current agent or broadcast to everyone. The loop advances its cursor
|
|
@@ -409,7 +402,7 @@ export async function listenSessionEvents({
|
|
|
409
402
|
if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
|
|
410
403
|
lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
|
|
411
404
|
}
|
|
412
|
-
if (
|
|
405
|
+
if (isSessionListenerLifecycleEvent(event)) continue;
|
|
413
406
|
if (!eventMatchesAgent(event, normalizedAgentId)) continue;
|
|
414
407
|
visibleEvents.push(event);
|
|
415
408
|
}
|
package/src/session/listeners.js
CHANGED
|
@@ -56,6 +56,8 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
|
|
|
56
56
|
const active = Boolean(payload.active);
|
|
57
57
|
const idleIntervalSeconds = positiveInt(payload.idleIntervalSeconds);
|
|
58
58
|
const activeIntervalSeconds = positiveInt(payload.activeIntervalSeconds);
|
|
59
|
+
const presenceKeepaliveSeconds = positiveInt(payload.presenceKeepaliveSeconds);
|
|
60
|
+
const presenceIntervalSeconds = positiveInt(payload.presenceIntervalSeconds);
|
|
59
61
|
const nextPollSeconds = positiveInt(payload.nextPollMs)
|
|
60
62
|
? Math.round(positiveInt(payload.nextPollMs) / 1000)
|
|
61
63
|
: null;
|
|
@@ -64,9 +66,20 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
|
|
|
64
66
|
const cadenceSeconds = active
|
|
65
67
|
? activeIntervalSeconds || nextPollSeconds
|
|
66
68
|
: idleIntervalSeconds || nextPollSeconds;
|
|
69
|
+
const expectedPresenceSeconds =
|
|
70
|
+
presenceKeepaliveSeconds ||
|
|
71
|
+
presenceIntervalSeconds ||
|
|
72
|
+
cadenceSeconds ||
|
|
73
|
+
idleIntervalSeconds ||
|
|
74
|
+
activeIntervalSeconds ||
|
|
75
|
+
null;
|
|
76
|
+
const staleAfterForRowMs = Math.max(
|
|
77
|
+
staleAfterMs,
|
|
78
|
+
expectedPresenceSeconds ? expectedPresenceSeconds * 2_500 : 0,
|
|
79
|
+
);
|
|
67
80
|
let status;
|
|
68
81
|
if (stopped) status = "stopped";
|
|
69
|
-
else if (ageMs !== null && ageMs >
|
|
82
|
+
else if (ageMs !== null && ageMs > staleAfterForRowMs) status = "stale";
|
|
70
83
|
else status = active ? "active" : "idle";
|
|
71
84
|
|
|
72
85
|
rows.push({
|
|
@@ -78,6 +91,8 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
|
|
|
78
91
|
cadenceSeconds: cadenceSeconds ?? null,
|
|
79
92
|
idleIntervalSeconds,
|
|
80
93
|
activeIntervalSeconds,
|
|
94
|
+
presenceIntervalSeconds,
|
|
95
|
+
presenceKeepaliveSeconds,
|
|
81
96
|
nextPollSeconds,
|
|
82
97
|
lastSeenAt: epoch ? new Date(epoch).toISOString() : null,
|
|
83
98
|
lastSeenAgoSeconds: ageMs !== null ? Math.round(ageMs / 1000) : null,
|
|
@@ -29,20 +29,49 @@ import {
|
|
|
29
29
|
import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
30
30
|
import {
|
|
31
31
|
addSessionEventIdentityKeys,
|
|
32
|
+
sessionEventIdentityKeys,
|
|
32
33
|
sessionEventHasKnownIdentity,
|
|
34
|
+
sessionEventUpgradesExisting,
|
|
33
35
|
} from "./event-identity.js";
|
|
36
|
+
import { isSessionControlEvent } from "./control-events.js";
|
|
34
37
|
|
|
35
38
|
const EVENTS_CURSOR_SUFFIX = "events";
|
|
36
39
|
const DEFAULT_EVENT_PAGE_LIMIT = 200;
|
|
37
40
|
const DEFAULT_MAX_EVENT_PAGES = 25;
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
function indexRelayEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
|
|
43
|
+
if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
|
|
44
|
+
for (const key of sessionEventIdentityKeys(event)) {
|
|
45
|
+
const existingIndex = indexByKey.get(key);
|
|
46
|
+
const existingEvent = Number.isInteger(existingIndex) && existingIndex >= 0
|
|
47
|
+
? eventList[existingIndex]
|
|
48
|
+
: null;
|
|
49
|
+
if (!existingEvent || sessionEventUpgradesExisting(existingEvent, event)) {
|
|
50
|
+
indexByKey.set(key, index);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findRelayEventIdentityIndex(indexByKey, event = {}) {
|
|
56
|
+
if (!indexByKey) return -1;
|
|
57
|
+
for (const key of sessionEventIdentityKeys(event)) {
|
|
58
|
+
const index = indexByKey.get(key);
|
|
59
|
+
if (Number.isInteger(index) && index >= 0) {
|
|
60
|
+
return index;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return -1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readExistingRelayState(sessionId, { targetPath = process.cwd() } = {}) {
|
|
40
67
|
const knownKeys = new Set();
|
|
41
68
|
const events = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
|
|
42
|
-
|
|
69
|
+
const indexByKey = new Map();
|
|
70
|
+
for (const [index, event] of events.entries()) {
|
|
43
71
|
addSessionEventIdentityKeys(knownKeys, event);
|
|
72
|
+
indexRelayEventIdentityKeys(indexByKey, events, event, index);
|
|
44
73
|
}
|
|
45
|
-
return knownKeys;
|
|
74
|
+
return { knownKeys, events, indexByKey };
|
|
46
75
|
}
|
|
47
76
|
|
|
48
77
|
async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
|
|
@@ -92,10 +121,13 @@ async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() }
|
|
|
92
121
|
return { materialized: true, refreshed: false, session: created, remoteStatus };
|
|
93
122
|
}
|
|
94
123
|
|
|
95
|
-
function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
|
|
124
|
+
function sourceFullyRelayed(events = [], successfulKeys = new Set(), failedKeys = new Set()) {
|
|
96
125
|
const relayedEvents = Array.isArray(events) ? events : [];
|
|
97
126
|
if (relayedEvents.length === 0) return true;
|
|
98
|
-
return relayedEvents.every((event) =>
|
|
127
|
+
return relayedEvents.every((event) =>
|
|
128
|
+
!sessionEventHasKnownIdentity(event, failedKeys) &&
|
|
129
|
+
sessionEventHasKnownIdentity(event, successfulKeys)
|
|
130
|
+
);
|
|
99
131
|
}
|
|
100
132
|
|
|
101
133
|
function markPostKillEvent(event = {}) {
|
|
@@ -242,6 +274,7 @@ export async function hydrateSessionFromRemote({
|
|
|
242
274
|
probeOpenCircuit = true,
|
|
243
275
|
eventPageLimit = DEFAULT_EVENT_PAGE_LIMIT,
|
|
244
276
|
maxEventPages = DEFAULT_MAX_EVENT_PAGES,
|
|
277
|
+
includeControlEvents = false,
|
|
245
278
|
} = {}) {
|
|
246
279
|
if (!sessionId || typeof sessionId !== "string") {
|
|
247
280
|
return {
|
|
@@ -341,12 +374,37 @@ export async function hydrateSessionFromRemote({
|
|
|
341
374
|
let relayed = 0;
|
|
342
375
|
let materializedLocalSession = false;
|
|
343
376
|
let remoteStatus = "";
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
377
|
+
const relayState = merged.length > 0
|
|
378
|
+
? await readExistingRelayState(sessionId, { targetPath })
|
|
379
|
+
: { knownKeys: new Set(), events: [], indexByKey: new Map() };
|
|
380
|
+
const successfulRelayKeys = relayState.knownKeys;
|
|
381
|
+
const existingRelayEvents = relayState.events;
|
|
382
|
+
const existingRelayIndexByKey = relayState.indexByKey;
|
|
383
|
+
const newEvents = [];
|
|
384
|
+
for (const event of merged) {
|
|
385
|
+
const existingIndex = findRelayEventIdentityIndex(existingRelayIndexByKey, event);
|
|
386
|
+
if (existingIndex >= 0) {
|
|
387
|
+
const existing = existingRelayEvents[existingIndex];
|
|
388
|
+
if (sessionEventUpgradesExisting(existing, event)) {
|
|
389
|
+
newEvents.push(event);
|
|
390
|
+
}
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (!sessionEventHasKnownIdentity(event, successfulRelayKeys)) {
|
|
394
|
+
newEvents.push(event);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
let skippedControlEventCount = 0;
|
|
398
|
+
const materialNewEvents = [];
|
|
399
|
+
for (const event of newEvents) {
|
|
400
|
+
if (!includeControlEvents && isSessionControlEvent(event)) {
|
|
401
|
+
skippedControlEventCount += 1;
|
|
402
|
+
addSessionEventIdentityKeys(successfulRelayKeys, event);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
materialNewEvents.push(event);
|
|
406
|
+
}
|
|
407
|
+
if (materialNewEvents.length > 0) {
|
|
350
408
|
try {
|
|
351
409
|
const localSession = await _ensureLocalSession(sessionId, { targetPath });
|
|
352
410
|
materializedLocalSession = Boolean(localSession?.materialized);
|
|
@@ -357,24 +415,35 @@ export async function hydrateSessionFromRemote({
|
|
|
357
415
|
// returns a structured result.
|
|
358
416
|
}
|
|
359
417
|
}
|
|
418
|
+
|
|
360
419
|
const appendEvents =
|
|
361
420
|
remoteStatus && !["active", "pending"].includes(remoteStatus)
|
|
362
|
-
?
|
|
363
|
-
:
|
|
421
|
+
? materialNewEvents.map((event) => markPostKillEvent(event))
|
|
422
|
+
: materialNewEvents;
|
|
423
|
+
const failedRelayKeys = new Set();
|
|
364
424
|
for (const event of appendEvents) {
|
|
365
425
|
try {
|
|
366
|
-
await _append(sessionId, event, { targetPath, syncRemote: false });
|
|
426
|
+
const persisted = await _append(sessionId, event, { targetPath, syncRemote: false });
|
|
367
427
|
relayed += 1;
|
|
368
|
-
|
|
428
|
+
const persistedEvent = persisted && typeof persisted === "object" ? persisted : event;
|
|
429
|
+
existingRelayEvents.push(persistedEvent);
|
|
430
|
+
addSessionEventIdentityKeys(successfulRelayKeys, persistedEvent);
|
|
431
|
+
indexRelayEventIdentityKeys(
|
|
432
|
+
existingRelayIndexByKey,
|
|
433
|
+
existingRelayEvents,
|
|
434
|
+
persistedEvent,
|
|
435
|
+
existingRelayEvents.length - 1,
|
|
436
|
+
);
|
|
369
437
|
} catch {
|
|
438
|
+
addSessionEventIdentityKeys(failedRelayKeys, event);
|
|
370
439
|
// Append errors are observable via the stream but should not
|
|
371
440
|
// abort the rest of the batch — partial relay is still progress.
|
|
372
441
|
}
|
|
373
442
|
}
|
|
374
443
|
|
|
375
444
|
let persistedCursor = false;
|
|
376
|
-
const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys);
|
|
377
|
-
const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys);
|
|
445
|
+
const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys, failedRelayKeys);
|
|
446
|
+
const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys, failedRelayKeys);
|
|
378
447
|
if (humanCursorSafe && typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
|
|
379
448
|
const result = await writeSyncCursor(sessionId, humanResult.cursor, { targetPath }).catch(() => null);
|
|
380
449
|
persistedCursor = Boolean(result && result.written);
|
|
@@ -395,6 +464,7 @@ export async function hydrateSessionFromRemote({
|
|
|
395
464
|
persistedCursor,
|
|
396
465
|
humanRelayed: (humanResult?.events || []).length,
|
|
397
466
|
eventsRelayed: (eventsResult?.events || []).length,
|
|
467
|
+
controlEventsSkipped: skippedControlEventCount,
|
|
398
468
|
eventsCursor:
|
|
399
469
|
typeof eventsResult?.cursor === "string" ? eventsResult.cursor : eventsCursor || null,
|
|
400
470
|
eventsPageCount: Number(eventsResult?.pageCount || 0),
|