sentinelayer-cli 0.11.0 → 0.11.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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
  }
@@ -2488,6 +2509,8 @@ export function registerSessionCommand(program) {
2488
2509
  count: remote.count,
2489
2510
  nextCursor: remote.nextCursor || null,
2490
2511
  hasMore: Boolean(remote.hasMore),
2512
+ truncated: Boolean(remote.truncated),
2513
+ warnings: Array.isArray(remote.warnings) ? remote.warnings : [],
2491
2514
  sessions: trimmed,
2492
2515
  };
2493
2516
  if (emitJson) {
@@ -2531,6 +2554,13 @@ export function registerSessionCommand(program) {
2531
2554
  ),
2532
2555
  );
2533
2556
  }
2557
+ if (remote.truncated) {
2558
+ console.log(
2559
+ pc.yellow(
2560
+ "Remote session listing is truncated by the page cap; JSON output includes nextCursor for resume.",
2561
+ ),
2562
+ );
2563
+ }
2534
2564
  return;
2535
2565
  }
2536
2566
 
@@ -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,46 @@ 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 cursorMovedBackward = Boolean(nextCursor && cursor && !cursorAdvances(nextCursor, cursor));
281
+ if (cursorMovedBackward) {
282
+ lastReason = "cursor_not_advanced";
283
+ await onError({
284
+ ok: false,
285
+ reason: lastReason,
286
+ cursor: cursor || null,
287
+ candidateCursor: nextCursor,
288
+ });
289
+ } else {
290
+ const observedAtMs = Number(_nowMs()) || Date.now();
291
+ for (const event of events) {
292
+ const activityMs = humanActivityTimestampMs(event, observedAtMs);
293
+ if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
294
+ lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
295
+ }
296
+ }
297
+ const shouldEmitBatch = primed || Boolean(replay);
298
+ for (const event of events) {
299
+ if (!eventMatchesAgent(event, normalizedAgentId)) continue;
300
+ const key = eventIdentityKey(event);
301
+ if (emittedKeys.has(key)) continue;
302
+ matched += 1;
303
+ if (!shouldEmitBatch && eventTimestampMs(event) < startedAtMs) continue;
304
+ await onEvent(event);
305
+ emittedKeys.add(key);
306
+ emitted += 1;
270
307
  }
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
308
 
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;
309
+ if (nextCursor && nextCursor !== cursor) {
310
+ const writeResult = await _writeCursor(normalizedSessionId, nextCursor, {
311
+ targetPath,
312
+ suffix: cursorSuffix,
313
+ }).catch(() => null);
314
+ persistedCursor = Boolean(writeResult?.written) || persistedCursor;
315
+ cursor = nextCursor;
316
+ }
317
+ primed = true;
289
318
  }
290
- primed = true;
291
319
  } else {
292
320
  lastReason = normalizeString(result?.reason) || "poll_failed";
293
321
  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");
@@ -1255,7 +1255,7 @@ export async function pollSessionEventsBefore(
1255
1255
  * @param {number} [options.maxPages]
1256
1256
  * @param {Function} [options.resolveAuthSession]
1257
1257
  * @param {Function} [options.fetchImpl]
1258
- * @returns {Promise<{ok: boolean, reason: string, sessions: Array<object>, count: number, nextCursor: string|null, hasMore: boolean}>}
1258
+ * @returns {Promise<{ok: boolean, reason: string, sessions: Array<object>, count: number, nextCursor: string|null, hasMore: boolean, truncated: boolean, warnings: Array<object>}>}
1259
1259
  */
1260
1260
  export async function listSessionsFromApi({
1261
1261
  targetPath = process.cwd(),
@@ -1283,6 +1283,8 @@ export async function listSessionsFromApi({
1283
1283
  count: 0,
1284
1284
  nextCursor: null,
1285
1285
  hasMore: false,
1286
+ truncated: false,
1287
+ warnings: [],
1286
1288
  };
1287
1289
  }
1288
1290
  if (!session || !session.token) {
@@ -1293,6 +1295,8 @@ export async function listSessionsFromApi({
1293
1295
  count: 0,
1294
1296
  nextCursor: null,
1295
1297
  hasMore: false,
1298
+ truncated: false,
1299
+ warnings: [],
1296
1300
  };
1297
1301
  }
1298
1302
 
@@ -1330,6 +1334,8 @@ export async function listSessionsFromApi({
1330
1334
  count: 0,
1331
1335
  nextCursor: null,
1332
1336
  hasMore: false,
1337
+ truncated: false,
1338
+ warnings: [],
1333
1339
  };
1334
1340
  }
1335
1341
  if (!response || !response.ok) {
@@ -1340,6 +1346,8 @@ export async function listSessionsFromApi({
1340
1346
  count: 0,
1341
1347
  nextCursor: null,
1342
1348
  hasMore: false,
1349
+ truncated: false,
1350
+ warnings: [],
1343
1351
  };
1344
1352
  }
1345
1353
  const payload = await response.json().catch(() => ({}));
@@ -1356,6 +1364,18 @@ export async function listSessionsFromApi({
1356
1364
  if (!fetchAll || !hasMore) break;
1357
1365
  }
1358
1366
 
1367
+ const truncated = Boolean(fetchAll && hasMore && nextCursor);
1368
+ const warnings = truncated
1369
+ ? [
1370
+ {
1371
+ code: "SESSION_LIST_MAX_PAGES_REACHED",
1372
+ message: `Session list stopped after ${normalizedMaxPages} pages; output is partial and nextCursor can resume the listing.`,
1373
+ maxPages: normalizedMaxPages,
1374
+ nextCursor,
1375
+ },
1376
+ ]
1377
+ : [];
1378
+
1359
1379
  return {
1360
1380
  ok: true,
1361
1381
  reason: "",
@@ -1363,6 +1383,8 @@ export async function listSessionsFromApi({
1363
1383
  count,
1364
1384
  nextCursor: nextCursor || null,
1365
1385
  hasMore,
1386
+ truncated,
1387
+ warnings,
1366
1388
  };
1367
1389
  }
1368
1390