sentinelayer-cli 0.11.1 → 0.11.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1397,6 +1397,7 @@ export function registerSessionCommand(program) {
1397
1397
  }
1398
1398
  const persisted = await appendToStream(normalizedSessionId, event, {
1399
1399
  targetPath,
1400
+ syncRemote: !localSession.materialized,
1400
1401
  });
1401
1402
  const payload = {
1402
1403
  command: "session say",
@@ -1768,6 +1769,8 @@ export function registerSessionCommand(program) {
1768
1769
  tail: 0,
1769
1770
  });
1770
1771
  const displayEvents = [...allEvents];
1772
+ let remoteTailAppended = 0;
1773
+ let remoteTailDisplayedOnly = 0;
1771
1774
  if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
1772
1775
  const knownKeys = new Set();
1773
1776
  for (const event of allEvents) {
@@ -1784,13 +1787,19 @@ export function registerSessionCommand(program) {
1784
1787
  });
1785
1788
  displayEvents.push(appended);
1786
1789
  addSessionEventIdentityKeys(knownKeys, appended);
1790
+ remoteTailAppended += 1;
1787
1791
  } catch {
1788
1792
  displayEvents.push(event);
1789
1793
  addSessionEventIdentityKeys(knownKeys, event);
1794
+ remoteTailDisplayedOnly += 1;
1790
1795
  }
1791
1796
  }
1792
1797
  }
1793
1798
  const events = dedupeSessionEvents(displayEvents).slice(-tail);
1799
+ const remoteVerified = Boolean(
1800
+ options.remote &&
1801
+ ((hydration && hydration.ok) || (remoteTail && remoteTail.ok))
1802
+ );
1794
1803
  const payload = {
1795
1804
  command: "session read",
1796
1805
  targetPath,
@@ -1798,6 +1807,15 @@ export function registerSessionCommand(program) {
1798
1807
  tail,
1799
1808
  count: events.length,
1800
1809
  events,
1810
+ displaySource: !options.remote
1811
+ ? "local"
1812
+ : remoteTail?.ok
1813
+ ? "remote_verified_tail"
1814
+ : hydration?.ok
1815
+ ? "hydrated_local"
1816
+ : "local_only",
1817
+ remoteVerified,
1818
+ localEventCount: allEvents.length,
1801
1819
  remote: hydration
1802
1820
  ? {
1803
1821
  ...hydration,
@@ -1807,6 +1825,9 @@ export function registerSessionCommand(program) {
1807
1825
  reason: remoteTail.reason || "",
1808
1826
  count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
1809
1827
  cursor: remoteTail.cursor || null,
1828
+ verified: Boolean(remoteTail.ok),
1829
+ appended: remoteTailAppended,
1830
+ displayedOnly: remoteTailDisplayedOnly,
1810
1831
  }
1811
1832
  : null,
1812
1833
  }
@@ -1,7 +1,7 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
2
 
3
3
  import { pollSessionEvents } from "./sync.js";
4
- import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
4
+ import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
5
5
 
6
6
  const BROADCAST_RECIPIENTS = new Set([
7
7
  "*",
@@ -123,6 +123,19 @@ function cursorFromEvents(events = [], fallbackCursor = null) {
123
123
  return cursor;
124
124
  }
125
125
 
126
+ function eventIdentityKey(event = {}) {
127
+ const cursor = normalizeString(event?.cursor);
128
+ if (cursor) return `cursor:${cursor}`;
129
+ const sequence = normalizeString(event?.sequenceId || event?.sequence_id || event?.sequence);
130
+ if (sequence) return `sequence:${sequence}`;
131
+ return JSON.stringify({
132
+ event: normalizeString(event?.event),
133
+ agent: normalizeString(event?.agent?.id || event?.agentId),
134
+ ts: normalizeString(event?.ts || event?.timestamp || event?.createdAt || event?.at),
135
+ message: normalizeString(event?.payload?.message || event?.payload?.text || event?.payload?.detail),
136
+ });
137
+ }
138
+
126
139
  function eventTimestampMs(event = {}) {
127
140
  for (const key of ["ts", "timestamp", "createdAt", "at"]) {
128
141
  const epoch = Date.parse(normalizeString(event?.[key]));
@@ -238,6 +251,7 @@ export async function listenSessionEvents({
238
251
  let matched = 0;
239
252
  let persistedCursor = false;
240
253
  let lastReason = "";
254
+ const emittedKeys = new Set();
241
255
  const maxPollCount = normalizePositiveInteger(maxPolls, 0);
242
256
  const pollLimit = normalizePositiveInteger(limit, 200);
243
257
  const idleSleepMs = Math.max(1, normalizePositiveInteger(intervalSeconds, 60)) * 1000;
@@ -262,32 +276,47 @@ export async function listenSessionEvents({
262
276
  if (result?.ok) {
263
277
  lastReason = "";
264
278
  const events = Array.isArray(result.events) ? result.events : [];
265
- const observedAtMs = Number(_nowMs()) || Date.now();
266
- for (const event of events) {
267
- const activityMs = humanActivityTimestampMs(event, observedAtMs);
268
- if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
269
- lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
279
+ const nextCursor = normalizeString(result.cursor) || cursorFromEvents(events, cursor);
280
+ const cursorDidNotAdvance = Boolean(nextCursor && cursor && !cursorAdvances(nextCursor, cursor));
281
+ const cursorFault = cursorDidNotAdvance && (nextCursor !== cursor || events.length > 0);
282
+ if (cursorFault) {
283
+ lastReason = "cursor_not_advanced";
284
+ await onError({
285
+ ok: false,
286
+ reason: lastReason,
287
+ cursor: cursor || null,
288
+ candidateCursor: nextCursor,
289
+ });
290
+ } else {
291
+ const observedAtMs = Number(_nowMs()) || Date.now();
292
+ for (const event of events) {
293
+ const activityMs = humanActivityTimestampMs(event, observedAtMs);
294
+ if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
295
+ lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
296
+ }
297
+ }
298
+ const shouldEmitBatch = primed || Boolean(replay);
299
+ for (const event of events) {
300
+ if (!eventMatchesAgent(event, normalizedAgentId)) continue;
301
+ const key = eventIdentityKey(event);
302
+ if (emittedKeys.has(key)) continue;
303
+ matched += 1;
304
+ if (!shouldEmitBatch && eventTimestampMs(event) < startedAtMs) continue;
305
+ await onEvent(event);
306
+ emittedKeys.add(key);
307
+ emitted += 1;
270
308
  }
271
- }
272
- const shouldEmitBatch = primed || Boolean(replay);
273
- for (const event of events) {
274
- if (!eventMatchesAgent(event, normalizedAgentId)) continue;
275
- matched += 1;
276
- if (!shouldEmitBatch && eventTimestampMs(event) < startedAtMs) continue;
277
- await onEvent(event);
278
- emitted += 1;
279
- }
280
309
 
281
- const nextCursor = normalizeString(result.cursor) || cursorFromEvents(events, cursor);
282
- if (nextCursor && nextCursor !== cursor) {
283
- const writeResult = await _writeCursor(normalizedSessionId, nextCursor, {
284
- targetPath,
285
- suffix: cursorSuffix,
286
- }).catch(() => null);
287
- persistedCursor = Boolean(writeResult?.written) || persistedCursor;
288
- cursor = nextCursor;
310
+ if (nextCursor && nextCursor !== cursor) {
311
+ const writeResult = await _writeCursor(normalizedSessionId, nextCursor, {
312
+ targetPath,
313
+ suffix: cursorSuffix,
314
+ }).catch(() => null);
315
+ persistedCursor = Boolean(writeResult?.written) || persistedCursor;
316
+ cursor = nextCursor;
317
+ }
318
+ primed = true;
289
319
  }
290
- primed = true;
291
320
  } else {
292
321
  lastReason = normalizeString(result?.reason) || "poll_failed";
293
322
  await onError({
@@ -26,7 +26,7 @@ import {
26
26
  isSessionCacheExpired,
27
27
  refreshSessionCacheForRemoteActivity,
28
28
  } from "./store.js";
29
- import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
29
+ import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
30
30
  import {
31
31
  addSessionEventIdentityKeys,
32
32
  sessionEventHasKnownIdentity,
@@ -166,11 +166,15 @@ async function pollSessionEventPages({
166
166
  };
167
167
  }
168
168
 
169
- const pageEvents = Array.isArray(result.events) ? result.events : [];
170
- events.push(...pageEvents);
171
169
  const nextCursor =
172
170
  typeof result.cursor === "string" && result.cursor.trim() ? result.cursor.trim() : cursor;
173
- const progressed = nextCursor && nextCursor !== cursor;
171
+ const progressed = nextCursor && cursorAdvances(nextCursor, cursor);
172
+ if (nextCursor && cursor && !progressed) {
173
+ reason = "cursor_not_advanced";
174
+ break;
175
+ }
176
+ const pageEvents = Array.isArray(result.events) ? result.events : [];
177
+ events.push(...pageEvents);
174
178
  cursor = nextCursor || cursor;
175
179
 
176
180
  if (pageEvents.length < normalizedLimit) {
@@ -46,6 +46,62 @@ export async function readSyncCursor(sessionId, { targetPath, suffix = "" } = {}
46
46
  }
47
47
  }
48
48
 
49
+ function parseStableCursor(cursor) {
50
+ const normalized = typeof cursor === "string" ? cursor.trim() : "";
51
+ const match = /^(\d{10,}):([0-9a-fA-F]{1,16})$/.exec(normalized);
52
+ if (!match) return null;
53
+ const timeMs = Number(match[1]);
54
+ const sequence = Number.parseInt(match[2], 16);
55
+ if (!Number.isFinite(timeMs) || !Number.isFinite(sequence)) return null;
56
+ return { timeMs, sequence };
57
+ }
58
+
59
+ function parseIsoCursor(cursor) {
60
+ const normalized = typeof cursor === "string" ? cursor.trim() : "";
61
+ if (!normalized || normalized.includes(":") === false) return null;
62
+ const epoch = Date.parse(normalized);
63
+ return Number.isFinite(epoch) ? epoch : null;
64
+ }
65
+
66
+ export function compareSyncCursors(candidate, current) {
67
+ const next = typeof candidate === "string" ? candidate.trim() : "";
68
+ const previous = typeof current === "string" ? current.trim() : "";
69
+ if (!next) return null;
70
+ if (!previous) return 1;
71
+ if (next === previous) return 0;
72
+
73
+ const nextStable = parseStableCursor(next);
74
+ const previousStable = parseStableCursor(previous);
75
+ if (nextStable && previousStable) {
76
+ if (nextStable.sequence !== previousStable.sequence) {
77
+ return nextStable.sequence > previousStable.sequence ? 1 : -1;
78
+ }
79
+ if (nextStable.timeMs !== previousStable.timeMs) {
80
+ return nextStable.timeMs > previousStable.timeMs ? 1 : -1;
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ const nextIso = parseIsoCursor(next);
86
+ const previousIso = parseIsoCursor(previous);
87
+ if (nextIso !== null && previousIso !== null) {
88
+ if (nextIso === previousIso) return 0;
89
+ return nextIso > previousIso ? 1 : -1;
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ export function cursorAdvances(candidate, current) {
96
+ const comparison = compareSyncCursors(candidate, current);
97
+ if (comparison === null) {
98
+ const next = typeof candidate === "string" ? candidate.trim() : "";
99
+ const previous = typeof current === "string" ? current.trim() : "";
100
+ return Boolean(next && next !== previous);
101
+ }
102
+ return comparison > 0;
103
+ }
104
+
49
105
  /**
50
106
  * Persist the human-message cursor for a session. No-op when cursor is
51
107
  * empty so we never overwrite a real value with an empty one.
@@ -61,6 +117,26 @@ export async function writeSyncCursor(sessionId, cursor, { targetPath, suffix =
61
117
  if (!sessionId || !normalized) {
62
118
  return { written: false, path: filePath };
63
119
  }
120
+ const existing = await readSyncCursor(sessionId, { targetPath, suffix });
121
+ const comparison = compareSyncCursors(normalized, existing);
122
+ if (existing && comparison !== null && comparison < 0) {
123
+ return {
124
+ written: false,
125
+ path: filePath,
126
+ reason: "stale_cursor",
127
+ previousCursor: existing,
128
+ cursor: normalized,
129
+ };
130
+ }
131
+ if (existing && comparison === 0) {
132
+ return {
133
+ written: false,
134
+ path: filePath,
135
+ reason: "unchanged",
136
+ previousCursor: existing,
137
+ cursor: normalized,
138
+ };
139
+ }
64
140
  await fsp.mkdir(path.dirname(filePath), { recursive: true });
65
141
  const payload = { cursor: normalized, updatedAt: new Date().toISOString() };
66
142
  await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");