sentinelayer-cli 0.9.2 → 0.9.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.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -44,9 +44,11 @@ import {
44
44
  createSession,
45
45
  DEFAULT_TTL_SECONDS,
46
46
  getSession,
47
+ isSessionCacheExpired,
47
48
  listActiveSessions,
48
49
  listAllSessions,
49
50
  recordSessionProvisionedIdentities,
51
+ refreshSessionCacheForRemoteActivity,
50
52
  updateSessionTitle,
51
53
  } from "../session/store.js";
52
54
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
@@ -330,11 +332,44 @@ function formatRelativeAge(isoTimestamp) {
330
332
 
331
333
  async function ensureLocalSessionForRemoteCommand(
332
334
  sessionId,
333
- { targetPath, title = "", skipRemoteProbe = false } = {},
335
+ { targetPath, title = "", skipRemoteProbe = false, remoteSession = null } = {},
334
336
  ) {
335
337
  const existing = await getSession(sessionId, { targetPath });
336
338
  if (existing) {
337
- return { materialized: false, session: existing };
339
+ if (!isSessionCacheExpired(existing)) {
340
+ return { materialized: false, refreshed: false, session: existing };
341
+ }
342
+ const existingStatus = normalizeString(existing.status).toLowerCase();
343
+ const locallyClosedByStatus = existingStatus === "expired" || existingStatus === "archived";
344
+ if (locallyClosedByStatus && !skipRemoteProbe) {
345
+ throw new Error(
346
+ `Session '${sessionId}' is ${existingStatus} locally; run \`sl session join ${sessionId}\` to verify remote access before posting.`,
347
+ );
348
+ }
349
+
350
+ let access = { accessible: Boolean(skipRemoteProbe), reason: "" };
351
+ if (!skipRemoteProbe) {
352
+ access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
353
+ accessible: false,
354
+ reason: normalizeString(error?.message) || "probe_failed",
355
+ }));
356
+ }
357
+ if (!access?.accessible) {
358
+ throw new Error(
359
+ `Session '${sessionId}' is expired locally and remote access failed (${access?.reason || "unknown"}).`,
360
+ );
361
+ }
362
+
363
+ const refreshed = await refreshSessionCacheForRemoteActivity(sessionId, {
364
+ targetPath,
365
+ title,
366
+ lastInteractionAt:
367
+ normalizeString(remoteSession?.lastInteractionAt) ||
368
+ normalizeString(remoteSession?.lastActivityAt) ||
369
+ normalizeString(remoteSession?.updatedAt) ||
370
+ normalizeString(remoteSession?.createdAt),
371
+ });
372
+ return { materialized: false, refreshed: Boolean(refreshed), session: refreshed || existing };
338
373
  }
339
374
  // `skipRemoteProbe` is set by callers that have already verified the session
340
375
  // via `verifyRemoteSession` (the singleton GET) — re-probing the legacy
@@ -356,7 +391,7 @@ async function ensureLocalSessionForRemoteCommand(
356
391
  sessionId,
357
392
  title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
358
393
  });
359
- return { materialized: true, session: created };
394
+ return { materialized: true, refreshed: false, session: created };
360
395
  }
361
396
 
362
397
  async function ensureWorkspaceSession({
@@ -470,56 +505,16 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
470
505
  return normalized || fallbackValue;
471
506
  }
472
507
 
473
- // Derive a stable, human-friendly fallback agent id from the active auth
474
- // session `human-<github_username>` if logged in via GitHub, else
475
- // `human-<email-localpart>` as a last resort. We resolve this lazily and
476
- // cache per process so repeated `sl session say` calls don't churn auth.
477
- //
478
- // Carter's complaint: "we aren't auto naming these agents per joining,
479
- // we need to figure out a fingerprint for them somehow.. maybe at joining
480
- // we ask for name?" — auth-derived names are the cleanest deterministic
481
- // fingerprint we already have. Fall through to "cli-user" only if the
482
- // CLI is genuinely unauthenticated (CI fixture, fresh checkout).
483
- let _cachedAuthAgentId = undefined; // undefined = not yet resolved
484
- async function _resolveAuthAgentId(targetPath) {
485
- if (_cachedAuthAgentId !== undefined) return _cachedAuthAgentId;
486
- try {
487
- const auth = await resolveActiveAuthSession({
488
- cwd: targetPath || process.cwd(),
489
- env: process.env,
490
- autoRotate: false,
491
- });
492
- const username = normalizeString(auth?.user?.githubUsername).toLowerCase();
493
- if (username) {
494
- _cachedAuthAgentId = `human-${username.replace(/[^a-z0-9._-]+/g, "-")}`;
495
- return _cachedAuthAgentId;
496
- }
497
- const email = normalizeString(auth?.user?.email).toLowerCase();
498
- if (email) {
499
- const local = email.split("@")[0].replace(/[^a-z0-9._-]+/g, "-");
500
- if (local) {
501
- _cachedAuthAgentId = `human-${local}`;
502
- return _cachedAuthAgentId;
503
- }
504
- }
505
- } catch {
506
- /* unauthenticated → fall through */
507
- }
508
- _cachedAuthAgentId = "";
509
- return "";
508
+ // Preserve the literal default identity for `session say`. This command is
509
+ // often used by agents as a low-friction relay; silently rewriting the default
510
+ // `cli-user` to the authenticated human makes a forgotten --agent flag look
511
+ // like the workspace owner authored the message.
512
+ export function resolveSessionSayAgentId(value) {
513
+ return normalizeAgentId(value, "cli-user");
510
514
  }
511
515
 
512
- // Wrapper that prefers the auth-derived id over the literal `cli-user`
513
- // placeholder when the caller didn't pass --name/--agent. Callers that
514
- // supplied a name keep round-tripping verbatim.
515
- async function defaultAgentId(value, targetPath) {
516
- const explicit = normalizeString(value);
517
- if (explicit && explicit.toLowerCase() !== "cli-user") {
518
- return normalizeAgentId(value, "cli-user");
519
- }
520
- const authId = await _resolveAuthAgentId(targetPath);
521
- if (authId) return authId;
522
- return normalizeAgentId(value, "cli-user");
516
+ async function defaultAgentId(value, _targetPath) {
517
+ return resolveSessionSayAgentId(value);
523
518
  }
524
519
 
525
520
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
@@ -933,6 +928,7 @@ export function registerSessionCommand(program) {
933
928
  targetPath,
934
929
  title: normalizeString(remoteSession.title),
935
930
  skipRemoteProbe: true,
931
+ remoteSession,
936
932
  });
937
933
  const payload = {
938
934
  command: "session ensure",
@@ -942,6 +938,7 @@ export function registerSessionCommand(program) {
942
938
  resumed: true,
943
939
  attached: true,
944
940
  materializedLocalSession: localSession.materialized,
941
+ refreshedLocalSession: Boolean(localSession.refreshed),
945
942
  verificationSource: verification.source,
946
943
  dashboardUrl: buildDashboardUrl(explicitSessionId),
947
944
  };
@@ -1145,6 +1142,7 @@ export function registerSessionCommand(program) {
1145
1142
  targetPath,
1146
1143
  title: normalizeString(remoteSession.title),
1147
1144
  skipRemoteProbe: true,
1145
+ remoteSession,
1148
1146
  });
1149
1147
 
1150
1148
  const explicitAgent = normalizeString(options.agent);
@@ -1195,6 +1193,7 @@ export function registerSessionCommand(program) {
1195
1193
  status: joined.status,
1196
1194
  joinedAt: joined.joinedAt,
1197
1195
  materializedLocalSession: localSession.materialized,
1196
+ refreshedLocalSession: Boolean(localSession.refreshed),
1198
1197
  verificationSource: verification.source,
1199
1198
  eventCount: Number.isFinite(eventCount) ? eventCount : 0,
1200
1199
  agentCount: Number.isFinite(agentCount) ? agentCount : 0,
@@ -1277,6 +1276,7 @@ export function registerSessionCommand(program) {
1277
1276
  agentId,
1278
1277
  event: persisted,
1279
1278
  materializedLocalSession: localSession.materialized,
1279
+ refreshedLocalSession: Boolean(localSession.refreshed),
1280
1280
  remoteSync: remoteSync || undefined,
1281
1281
  };
1282
1282
  if (shouldEmitJson(options, command)) {
@@ -1364,6 +1364,7 @@ export function registerSessionCommand(program) {
1364
1364
  agentId,
1365
1365
  event: persisted,
1366
1366
  materializedLocalSession: localSession.materialized,
1367
+ refreshedLocalSession: Boolean(localSession.refreshed),
1367
1368
  remoteSync,
1368
1369
  };
1369
1370
  if (shouldEmitJson(options, command)) {
@@ -198,6 +198,35 @@ function buildRecapText({
198
198
  ).trim();
199
199
  }
200
200
 
201
+ // Multi-agent session etiquette + read-path rules surfaced in the
202
+ // context_briefing payload an agent receives on first join. Web
203
+ // renders this as markdown (see sentinelayer-web Session.tsx
204
+ // SessionMessage), so headers/lists/inline code are intentional.
205
+ //
206
+ // Keep this short and operationally actionable. Anything that's
207
+ // purely doctrinal belongs in AGENTS.md, not the per-join briefing.
208
+ const AGENT_JOIN_RULES = [
209
+ "**Welcome to this session.** Quick rules so we coordinate cleanly:",
210
+ "",
211
+ "**Reading the room** — When you join, the recap above summarizes activity since the last quiet stretch. To read further back, run `sl session read --remote --tail 50 --json` (bump `--tail` if you need more). Do this BEFORE responding so you don't repeat questions or miss a lock-and-claim someone else already opened.",
212
+ "",
213
+ "**Polling cadence** — Poll new events at most once per 60s (`sl session listen` or `sl session read --remote --tail N`). More frequent than that wastes budget and can hit per-user rate limits. Less frequent than ~5min and peers may think you went idle.",
214
+ "",
215
+ "**Writing back** — You can use **markdown**: bold, italic, lists, fenced code, and `inline code`. The web dashboard renders it. Plain text also works. Keep posts terse and technical — link to the work, don't recap it.",
216
+ "",
217
+ "**Coordination** — Lock-and-claim before you start a scope another agent could be on. If you push back on someone's approach, cite the specific assumption you disagree with and the file:line evidence.",
218
+ "",
219
+ "**Stop conditions** — If the human asks you to stop, stop. If 60+ minutes of total session silence, stop polling.",
220
+ ].join("\n");
221
+
222
+ function buildAgentJoinBriefingText({ recap = "", forAgent = "" } = {}) {
223
+ const trimmedRecap = normalizeString(recap);
224
+ const trimmedAgent = normalizeString(forAgent);
225
+ const greeting = trimmedAgent ? `**${trimmedAgent}** joined. ${trimmedRecap}` : trimmedRecap;
226
+ const recapBlock = greeting || "Welcome — no prior session activity to summarize yet.";
227
+ return `${recapBlock}\n\n---\n\n${AGENT_JOIN_RULES}`;
228
+ }
229
+
201
230
  function buildPeriodicText(recap = {}) {
202
231
  const summary = recap.summary && typeof recap.summary === "object" ? recap.summary : {};
203
232
  const elapsedMinutes = Number(summary.elapsedMinutes || 0);
@@ -303,6 +332,7 @@ export async function emitContextBriefing(
303
332
  maxEvents = DEFAULT_RECAP_MAX_EVENTS,
304
333
  targetPath = process.cwd(),
305
334
  nowIso = new Date().toISOString(),
335
+ includeJoinRules = true,
306
336
  } = {}
307
337
  ) {
308
338
  const recap = await buildSessionRecap(sessionId, {
@@ -311,6 +341,9 @@ export async function emitContextBriefing(
311
341
  targetPath,
312
342
  nowIso,
313
343
  });
344
+ const briefingMessage = includeJoinRules
345
+ ? buildAgentJoinBriefingText({ recap: recap.text, forAgent: forAgentId })
346
+ : recap.text;
314
347
  const event = createAgentEvent({
315
348
  event: "context_briefing",
316
349
  agentId: SENTI_AGENT_ID,
@@ -319,7 +352,9 @@ export async function emitContextBriefing(
319
352
  ts: recap.generatedAt,
320
353
  payload: {
321
354
  forAgent: normalizeString(forAgentId) || null,
355
+ message: briefingMessage,
322
356
  recap: recap.text,
357
+ rules: includeJoinRules ? AGENT_JOIN_RULES : null,
323
358
  ephemeral: true,
324
359
  style: RECAP_STYLE,
325
360
  generatedAt: recap.generatedAt,
@@ -20,7 +20,12 @@
20
20
 
21
21
  import { listSessionsFromApi, pollHumanMessages, pollSessionEvents } from "./sync.js";
22
22
  import { appendToStream, readStream } from "./stream.js";
23
- import { createSession, getSession } from "./store.js";
23
+ import {
24
+ createSession,
25
+ getSession,
26
+ isSessionCacheExpired,
27
+ refreshSessionCacheForRemoteActivity,
28
+ } from "./store.js";
24
29
  import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
25
30
  import {
26
31
  addSessionEventIdentityKeys,
@@ -42,10 +47,8 @@ async function readExistingRelayKeys(sessionId, { targetPath = process.cwd() } =
42
47
 
43
48
  async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
44
49
  const existing = await getSession(sessionId, { targetPath });
45
- if (existing) {
46
- return { materialized: false, session: existing };
47
- }
48
50
  let remoteStatus = "";
51
+ let remoteSession = null;
49
52
  const remoteList = await listSessionsFromApi({
50
53
  targetPath,
51
54
  includeArchived: true,
@@ -53,14 +56,40 @@ async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() }
53
56
  }).catch(() => null);
54
57
  if (remoteList?.ok) {
55
58
  const match = (remoteList.sessions || []).find((entry) => entry?.sessionId === sessionId);
59
+ remoteSession = match || null;
56
60
  remoteStatus = String(match?.archiveStatus || match?.status || "").trim().toLowerCase();
57
61
  }
62
+ if (existing) {
63
+ const existingStatus = String(existing.status || "").trim().toLowerCase();
64
+ const locallyClosedByStatus = existingStatus === "expired" || existingStatus === "archived";
65
+ const remoteAllowsRefresh =
66
+ ["active", "pending"].includes(remoteStatus) || (!remoteStatus && !locallyClosedByStatus);
67
+ if (isSessionCacheExpired(existing) && remoteAllowsRefresh) {
68
+ const refreshed = await refreshSessionCacheForRemoteActivity(sessionId, {
69
+ targetPath,
70
+ title: remoteSession?.title || "",
71
+ lastInteractionAt:
72
+ remoteSession?.lastInteractionAt ||
73
+ remoteSession?.lastActivityAt ||
74
+ remoteSession?.updatedAt ||
75
+ remoteSession?.createdAt ||
76
+ "",
77
+ });
78
+ return {
79
+ materialized: false,
80
+ refreshed: Boolean(refreshed),
81
+ session: refreshed || existing,
82
+ remoteStatus,
83
+ };
84
+ }
85
+ return { materialized: false, refreshed: false, session: existing, remoteStatus };
86
+ }
58
87
  const created = await createSession({
59
88
  targetPath,
60
89
  sessionId,
61
90
  title: `remote-${String(sessionId).slice(0, 8)}`,
62
91
  });
63
- return { materialized: true, session: created, remoteStatus };
92
+ return { materialized: true, refreshed: false, session: created, remoteStatus };
64
93
  }
65
94
 
66
95
  function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
@@ -388,7 +388,8 @@ function normalizeMetadata(raw = {}, { sessionId, targetPath, nowIso } = {}) {
388
388
  }
389
389
 
390
390
  function isExpired(metadata, nowIso = new Date().toISOString()) {
391
- if (!metadata || normalizeSessionStatus(metadata.status) === SESSION_STATUS_EXPIRED) {
391
+ const status = normalizeSessionStatus(metadata?.status);
392
+ if (!metadata || status === SESSION_STATUS_EXPIRED || status === SESSION_STATUS_ARCHIVED) {
392
393
  return true;
393
394
  }
394
395
  const expiryEpoch = Date.parse(normalizeIsoTimestamp(metadata.expiresAt, nowIso));
@@ -399,6 +400,10 @@ function isExpired(metadata, nowIso = new Date().toISOString()) {
399
400
  return nowEpoch >= expiryEpoch;
400
401
  }
401
402
 
403
+ export function isSessionCacheExpired(metadata, nowIso = new Date().toISOString()) {
404
+ return isExpired(metadata, nowIso);
405
+ }
406
+
402
407
  function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString()) {
403
408
  return {
404
409
  sessionId: metadata.sessionId,
@@ -526,6 +531,53 @@ export async function updateSessionTitle(
526
531
  return buildSessionPayload(saved, loaded.paths, nowIso);
527
532
  }
528
533
 
534
+ export async function refreshSessionCacheForRemoteActivity(
535
+ sessionId,
536
+ {
537
+ targetPath = process.cwd(),
538
+ title = "",
539
+ ttlSeconds = DEFAULT_TTL_SECONDS,
540
+ lastInteractionAt = "",
541
+ nowIso = new Date().toISOString(),
542
+ } = {}
543
+ ) {
544
+ const loaded = await loadMetadata(sessionId, { targetPath });
545
+ if (!loaded) {
546
+ return null;
547
+ }
548
+ const normalizedTtlSeconds = normalizePositiveInteger(ttlSeconds, DEFAULT_TTL_SECONDS);
549
+ const remoteInteractionIso = normalizeIsoTimestamp(
550
+ lastInteractionAt,
551
+ loaded.metadata.lastInteractionAt || loaded.metadata.createdAt || nowIso
552
+ );
553
+ const currentInteractionEpoch = Date.parse(
554
+ normalizeIsoTimestamp(loaded.metadata.lastInteractionAt, loaded.metadata.createdAt || nowIso)
555
+ );
556
+ const remoteInteractionEpoch = Date.parse(remoteInteractionIso);
557
+ const lastInteractionIso =
558
+ Number.isFinite(remoteInteractionEpoch) &&
559
+ (!Number.isFinite(currentInteractionEpoch) || remoteInteractionEpoch > currentInteractionEpoch)
560
+ ? remoteInteractionIso
561
+ : normalizeIsoTimestamp(loaded.metadata.lastInteractionAt, nowIso);
562
+
563
+ const metadata = {
564
+ ...loaded.metadata,
565
+ updatedAt: nowIso,
566
+ expiresAt: toIsoAfterSeconds(nowIso, normalizedTtlSeconds),
567
+ ttlSeconds: normalizedTtlSeconds,
568
+ status: SESSION_STATUS_ACTIVE,
569
+ expiredAt: null,
570
+ lastInteractionAt: lastInteractionIso,
571
+ };
572
+ const normalizedTitle = normalizeString(title);
573
+ if (normalizedTitle) {
574
+ metadata.title = normalizedTitle;
575
+ }
576
+
577
+ const saved = await saveMetadata(metadata, loaded.paths);
578
+ return buildSessionPayload(saved, loaded.paths, nowIso);
579
+ }
580
+
529
581
  export async function recordSessionRemoteTitleSync(
530
582
  sessionId,
531
583
  {