sentinelayer-cli 0.10.0 → 0.10.1

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.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -347,14 +347,37 @@ async function ensureLocalSessionForRemoteCommand(
347
347
  }
348
348
  const existingStatus = normalizeString(existing.status).toLowerCase();
349
349
  const locallyClosedByStatus = existingStatus === "expired" || existingStatus === "archived";
350
+ let verifiedRemoteSession = remoteSession;
351
+ let verifiedRemoteStatus = normalizeString(
352
+ verifiedRemoteSession?.archiveStatus || verifiedRemoteSession?.status,
353
+ ).toLowerCase();
350
354
  if (locallyClosedByStatus && !skipRemoteProbe) {
355
+ const verification = await verifyRemoteSession(sessionId, { targetPath }).catch((error) => ({
356
+ ok: false,
357
+ reason: normalizeString(error?.message) || "verify_failed",
358
+ }));
359
+ if (!verification?.ok) {
360
+ throw new Error(
361
+ `Session '${sessionId}' is ${existingStatus} locally and remote verification failed (${verification?.reason || "unknown"}).`,
362
+ );
363
+ }
364
+ verifiedRemoteSession = verification.session || verifiedRemoteSession;
365
+ verifiedRemoteStatus = normalizeString(
366
+ verifiedRemoteSession?.archiveStatus || verifiedRemoteSession?.status,
367
+ ).toLowerCase();
368
+ }
369
+ if (
370
+ locallyClosedByStatus &&
371
+ verifiedRemoteStatus &&
372
+ !["active", "pending"].includes(verifiedRemoteStatus)
373
+ ) {
351
374
  throw new Error(
352
- `Session '${sessionId}' is ${existingStatus} locally; run \`sl session join ${sessionId}\` to verify remote access before posting.`,
375
+ `Session '${sessionId}' is ${existingStatus} locally and remote status is ${verifiedRemoteStatus}; refusing to reopen a closed session.`,
353
376
  );
354
377
  }
355
378
 
356
- let access = { accessible: Boolean(skipRemoteProbe), reason: "" };
357
- if (!skipRemoteProbe) {
379
+ let access = { accessible: Boolean(skipRemoteProbe || locallyClosedByStatus), reason: "" };
380
+ if (!skipRemoteProbe && !locallyClosedByStatus) {
358
381
  access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
359
382
  accessible: false,
360
383
  reason: normalizeString(error?.message) || "probe_failed",
@@ -368,12 +391,13 @@ async function ensureLocalSessionForRemoteCommand(
368
391
 
369
392
  const refreshed = await refreshSessionCacheForRemoteActivity(sessionId, {
370
393
  targetPath,
371
- title,
394
+ title: title || normalizeString(verifiedRemoteSession?.title),
395
+ expiresAt: normalizeString(verifiedRemoteSession?.expiresAt),
372
396
  lastInteractionAt:
373
- normalizeString(remoteSession?.lastInteractionAt) ||
374
- normalizeString(remoteSession?.lastActivityAt) ||
375
- normalizeString(remoteSession?.updatedAt) ||
376
- normalizeString(remoteSession?.createdAt),
397
+ normalizeString(verifiedRemoteSession?.lastInteractionAt) ||
398
+ normalizeString(verifiedRemoteSession?.lastActivityAt) ||
399
+ normalizeString(verifiedRemoteSession?.updatedAt) ||
400
+ normalizeString(verifiedRemoteSession?.createdAt),
377
401
  });
378
402
  return { materialized: false, refreshed: Boolean(refreshed), session: refreshed || existing };
379
403
  }
@@ -396,6 +420,13 @@ async function ensureLocalSessionForRemoteCommand(
396
420
  targetPath,
397
421
  sessionId,
398
422
  title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
423
+ createdAt: normalizeString(remoteSession?.createdAt),
424
+ expiresAt: normalizeString(remoteSession?.expiresAt),
425
+ lastInteractionAt:
426
+ normalizeString(remoteSession?.lastInteractionAt) ||
427
+ normalizeString(remoteSession?.lastActivityAt) ||
428
+ normalizeString(remoteSession?.updatedAt) ||
429
+ normalizeString(remoteSession?.createdAt),
399
430
  });
400
431
  return { materialized: true, refreshed: false, session: created };
401
432
  }
@@ -537,6 +537,7 @@ export async function refreshSessionCacheForRemoteActivity(
537
537
  targetPath = process.cwd(),
538
538
  title = "",
539
539
  ttlSeconds = DEFAULT_TTL_SECONDS,
540
+ expiresAt = "",
540
541
  lastInteractionAt = "",
541
542
  nowIso = new Date().toISOString(),
542
543
  } = {}
@@ -563,7 +564,7 @@ export async function refreshSessionCacheForRemoteActivity(
563
564
  const metadata = {
564
565
  ...loaded.metadata,
565
566
  updatedAt: nowIso,
566
- expiresAt: toIsoAfterSeconds(nowIso, normalizedTtlSeconds),
567
+ expiresAt: normalizeIsoTimestamp(expiresAt, toIsoAfterSeconds(nowIso, normalizedTtlSeconds)),
567
568
  ttlSeconds: normalizedTtlSeconds,
568
569
  status: SESSION_STATUS_ACTIVE,
569
570
  expiredAt: null,
@@ -6,13 +6,15 @@ import { createAgentEvent, normalizeAgentEvent } from "../events/schema.js";
6
6
  import { enrichEventWithMentions } from "./mentions.js";
7
7
  import { resolveSessionPaths } from "./paths.js";
8
8
  import { redactEventPayload } from "./redact.js";
9
- import { syncSessionEventToApi } from "./sync.js";
9
+ import { fetchSessionFromApi, listSessionsFromApi, syncSessionEventToApi } from "./sync.js";
10
10
 
11
11
  const DEFAULT_POLL_MS = 500;
12
12
  const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
13
13
  const DEFAULT_LOCK_STALE_MS = 30_000;
14
14
  const DEFAULT_LOCK_POLL_MS = 25;
15
15
  const DEFAULT_MAX_STREAM_EVENTS = 10_000;
16
+ const DEFAULT_REMOTE_REFRESH_TTL_SECONDS = 24 * 60 * 60;
17
+ const REMOTE_REFRESH_TIMEOUT_MS = 2_500;
16
18
 
17
19
  function normalizeString(value) {
18
20
  return String(value || "").trim();
@@ -41,6 +43,15 @@ function normalizePositiveInteger(value, fallbackValue) {
41
43
  return Math.floor(normalized);
42
44
  }
43
45
 
46
+ function toIsoAfterSeconds(baseIso, seconds) {
47
+ const baseEpoch = Date.parse(normalizeIsoTimestamp(baseIso, new Date().toISOString()));
48
+ const normalizedSeconds = normalizePositiveInteger(
49
+ seconds,
50
+ DEFAULT_REMOTE_REFRESH_TTL_SECONDS,
51
+ );
52
+ return new Date(baseEpoch + normalizedSeconds * 1000).toISOString();
53
+ }
54
+
44
55
  async function readSessionMetadata(paths) {
45
56
  try {
46
57
  const raw = await fsp.readFile(paths.metadataPath, "utf-8");
@@ -64,6 +75,108 @@ async function writeSessionMetadata(paths, metadata = {}) {
64
75
  await fsp.rename(tmpPath, paths.metadataPath);
65
76
  }
66
77
 
78
+ function remoteStatusAllowsLocalRefresh(remoteSession = {}) {
79
+ const status = normalizeString(remoteSession.archiveStatus || remoteSession.status).toLowerCase();
80
+ return !status || status === "active" || status === "pending";
81
+ }
82
+
83
+ function resolveRemoteSessionStatus(remoteSession = {}) {
84
+ return normalizeString(remoteSession.archiveStatus || remoteSession.status).toLowerCase();
85
+ }
86
+
87
+ async function resolveRemoteRefreshCandidate(sessionId, { targetPath = process.cwd() } = {}) {
88
+ const singletonResult = await fetchSessionFromApi(sessionId, {
89
+ targetPath,
90
+ timeoutMs: REMOTE_REFRESH_TIMEOUT_MS,
91
+ }).catch((error) => ({
92
+ ok: false,
93
+ reason: normalizeString(error?.message) || "fetch_failed",
94
+ session: null,
95
+ }));
96
+ if (singletonResult?.ok && singletonResult.session) {
97
+ if (!remoteStatusAllowsLocalRefresh(singletonResult.session)) {
98
+ return {
99
+ ok: false,
100
+ reason: `remote_${resolveRemoteSessionStatus(singletonResult.session) || "closed"}`,
101
+ remoteSession: singletonResult.session,
102
+ };
103
+ }
104
+ return { ok: true, reason: "", remoteSession: singletonResult.session };
105
+ }
106
+ if (singletonResult?.status && singletonResult.status !== 404) {
107
+ return { ok: false, reason: singletonResult.reason || `api_${singletonResult.status}` };
108
+ }
109
+
110
+ const listResult = await listSessionsFromApi({
111
+ targetPath,
112
+ includeArchived: true,
113
+ limit: 200,
114
+ timeoutMs: REMOTE_REFRESH_TIMEOUT_MS,
115
+ }).catch((error) => ({
116
+ ok: false,
117
+ reason: normalizeString(error?.message) || "list_failed",
118
+ sessions: [],
119
+ }));
120
+ if (listResult?.ok) {
121
+ const remoteSession = (listResult.sessions || []).find(
122
+ (entry) => normalizeString(entry?.sessionId) === sessionId,
123
+ );
124
+ if (remoteSession) {
125
+ if (!remoteStatusAllowsLocalRefresh(remoteSession)) {
126
+ return {
127
+ ok: false,
128
+ reason: `remote_${resolveRemoteSessionStatus(remoteSession) || "closed"}`,
129
+ remoteSession,
130
+ };
131
+ }
132
+ return { ok: true, reason: "", remoteSession };
133
+ }
134
+ }
135
+
136
+ return { ok: false, reason: listResult?.reason || singletonResult?.reason || "remote_unavailable" };
137
+ }
138
+
139
+ async function refreshExpiredMetadataFromRemote(paths, metadata = {}, { targetPath = process.cwd() } = {}) {
140
+ const candidate = await resolveRemoteRefreshCandidate(paths.sessionId, { targetPath });
141
+ if (!candidate?.ok) {
142
+ return { refreshed: false, reason: candidate?.reason || "remote_unavailable", metadata };
143
+ }
144
+ const remoteSession = candidate.remoteSession || {};
145
+ const nowIso = new Date().toISOString();
146
+ const ttlSeconds = normalizePositiveInteger(
147
+ metadata.ttlSeconds,
148
+ DEFAULT_REMOTE_REFRESH_TTL_SECONDS,
149
+ );
150
+ const remoteExpiresAt = normalizeString(remoteSession.expiresAt);
151
+ const remoteExpiryEpoch = Date.parse(remoteExpiresAt);
152
+ const nowEpoch = Date.parse(nowIso);
153
+ const expiresAt =
154
+ Number.isFinite(remoteExpiryEpoch) && Number.isFinite(nowEpoch) && remoteExpiryEpoch > nowEpoch
155
+ ? new Date(remoteExpiryEpoch).toISOString()
156
+ : toIsoAfterSeconds(nowIso, ttlSeconds);
157
+ const refreshed = {
158
+ ...metadata,
159
+ updatedAt: nowIso,
160
+ expiresAt,
161
+ ttlSeconds,
162
+ status: "active",
163
+ expiredAt: null,
164
+ archivedAt: null,
165
+ archiveStatus: resolveRemoteSessionStatus(remoteSession) || "active",
166
+ title: normalizeString(remoteSession.title) || metadata.title || null,
167
+ lastInteractionAt: normalizeIsoTimestamp(
168
+ remoteSession.lastInteractionAt ||
169
+ remoteSession.lastActivityAt ||
170
+ remoteSession.updatedAt ||
171
+ remoteSession.createdAt ||
172
+ metadata.lastInteractionAt,
173
+ metadata.lastInteractionAt || metadata.createdAt || nowIso,
174
+ ),
175
+ };
176
+ await writeSessionMetadata(paths, refreshed);
177
+ return { refreshed: true, reason: "", metadata: refreshed };
178
+ }
179
+
67
180
  function isSessionExpired(metadata = {}, nowIso = new Date().toISOString()) {
68
181
  const status = normalizeString(metadata.status).toLowerCase();
69
182
  if (status === "expired" || status === "archived") {
@@ -227,12 +340,18 @@ export async function appendToStream(
227
340
  { targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS, syncRemote = true } = {}
228
341
  ) {
229
342
  const paths = resolveSessionPaths(sessionId, { targetPath });
230
- const metadata = await readSessionMetadata(paths);
343
+ let metadata = await readSessionMetadata(paths);
231
344
  if (!metadata) {
232
345
  throw new Error(`Session '${paths.sessionId}' was not found.`);
233
346
  }
234
347
  if (isSessionExpired(metadata)) {
235
- throw new Error(`Session '${paths.sessionId}' is expired and does not accept new events.`);
348
+ const refresh = await refreshExpiredMetadataFromRemote(paths, metadata, { targetPath });
349
+ if (!refresh.refreshed) {
350
+ throw new Error(
351
+ `Session '${paths.sessionId}' is expired and does not accept new events (remote refresh failed: ${refresh.reason}).`,
352
+ );
353
+ }
354
+ metadata = refresh.metadata;
236
355
  }
237
356
 
238
357
  const rawEvent = materializeCanonicalEvent(paths.sessionId, event);
@@ -1319,6 +1319,91 @@ export async function listSessionsFromApi({
1319
1319
  };
1320
1320
  }
1321
1321
 
1322
+ /**
1323
+ * Fetch one visible session row by id via `GET /api/v1/sessions/{id}`.
1324
+ *
1325
+ * The session-events read probe can prove membership, but it cannot prove the
1326
+ * remote session is still active. Callers that need to refresh local closed
1327
+ * metadata should use this status-bearing endpoint first, then fall back to
1328
+ * `listSessionsFromApi` only for older API deployments without singleton read.
1329
+ *
1330
+ * @param {string} sessionId
1331
+ * @param {object} [options]
1332
+ * @param {string} [options.targetPath]
1333
+ * @param {Function} [options.resolveAuthSession]
1334
+ * @param {Function} [options.fetchImpl]
1335
+ * @param {number} [options.timeoutMs]
1336
+ * @returns {Promise<{ok: boolean, reason: string, session: object|null, status?: number}>}
1337
+ */
1338
+ export async function fetchSessionFromApi(
1339
+ sessionId,
1340
+ {
1341
+ targetPath = process.cwd(),
1342
+ resolveAuthSession = resolveActiveAuthSession,
1343
+ fetchImpl = fetchWithTimeout,
1344
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1345
+ } = {},
1346
+ ) {
1347
+ const normalizedSessionId = normalizeString(sessionId);
1348
+ if (!normalizedSessionId) {
1349
+ return { ok: false, reason: "invalid_session_id", session: null };
1350
+ }
1351
+
1352
+ let session;
1353
+ try {
1354
+ session = await resolveAuthSession({
1355
+ cwd: targetPath,
1356
+ env: process.env,
1357
+ autoRotate: false,
1358
+ });
1359
+ } catch {
1360
+ return { ok: false, reason: "no_session", session: null };
1361
+ }
1362
+ if (!session || !session.token) {
1363
+ return { ok: false, reason: "not_authenticated", session: null, status: 401 };
1364
+ }
1365
+
1366
+ const apiBaseUrl = resolveApiBaseUrl(session);
1367
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
1368
+
1369
+ let response;
1370
+ try {
1371
+ response = await fetchImpl(
1372
+ endpoint,
1373
+ {
1374
+ method: "GET",
1375
+ headers: { Authorization: `Bearer ${session.token}` },
1376
+ },
1377
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1378
+ );
1379
+ } catch (err) {
1380
+ return {
1381
+ ok: false,
1382
+ reason: normalizeString(err?.message) || "fetch_failed",
1383
+ session: null,
1384
+ };
1385
+ }
1386
+ if (!response) {
1387
+ return { ok: false, reason: "no_response", session: null };
1388
+ }
1389
+ if (response.ok) {
1390
+ const body = await response.json().catch(() => ({}));
1391
+ const sessionPayload = body && body.session && typeof body.session === "object"
1392
+ ? body.session
1393
+ : body && typeof body === "object"
1394
+ ? body
1395
+ : null;
1396
+ return { ok: true, reason: "", session: sessionPayload, status: response.status };
1397
+ }
1398
+ if (response.status === 403) {
1399
+ return { ok: false, reason: "not_a_member", session: null, status: 403 };
1400
+ }
1401
+ if (response.status === 404) {
1402
+ return { ok: false, reason: "session_not_found", session: null, status: 404 };
1403
+ }
1404
+ return { ok: false, reason: `api_${response.status}`, session: null, status: response.status };
1405
+ }
1406
+
1322
1407
  /**
1323
1408
  * Probe whether a single session is visible to the active user.
1324
1409
  *