sentinelayer-cli 0.9.2 → 0.9.4

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.4",
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)) {
@@ -13,6 +13,14 @@ const DEFAULT_RECAP_MAX_EVENTS = 100;
13
13
  const DEFAULT_RECAP_INTERVAL_MS = 300_000;
14
14
  const DEFAULT_RECAP_INACTIVITY_MS = 600_000;
15
15
  const DEFAULT_RECAP_ACTIVITY_THRESHOLD = 5;
16
+ const DEFAULT_TASK_SUMMARY_LIMIT = 3;
17
+ const ACTIVE_TASK_STATUSES = new Set(["PENDING", "ACCEPTED", "BLOCKED"]);
18
+ const TASK_STATUS_KEYS = {
19
+ PENDING: "pending",
20
+ ACCEPTED: "accepted",
21
+ COMPLETED: "completed",
22
+ BLOCKED: "blocked",
23
+ };
16
24
 
17
25
  const ACTIVE_RECAP_EMITTERS = new Map();
18
26
 
@@ -160,6 +168,117 @@ async function readPendingTasks(sessionId, { forAgentId = "", targetPath = proce
160
168
  }
161
169
  }
162
170
 
171
+ function normalizeTaskStatus(value) {
172
+ const normalized = normalizeString(value).toUpperCase();
173
+ return Object.prototype.hasOwnProperty.call(TASK_STATUS_KEYS, normalized)
174
+ ? normalized
175
+ : "PENDING";
176
+ }
177
+
178
+ function taskOwner(task = {}) {
179
+ return (
180
+ normalizeString(task.toAgentId) ||
181
+ normalizeString(task.requestedToAgentId) ||
182
+ (normalizeString(task.roleFilter) ? `role:${normalizeString(task.roleFilter)}` : "") ||
183
+ "unassigned"
184
+ );
185
+ }
186
+
187
+ function shortTaskText(value) {
188
+ const text = normalizeString(value).replace(/\s+/g, " ");
189
+ if (text.length <= 80) {
190
+ return text;
191
+ }
192
+ return `${text.slice(0, 77)}...`;
193
+ }
194
+
195
+ function emptyTaskLedgerSummary() {
196
+ return {
197
+ total: 0,
198
+ active: 0,
199
+ pending: 0,
200
+ accepted: 0,
201
+ blocked: 0,
202
+ completed: 0,
203
+ owners: [],
204
+ recent: [],
205
+ };
206
+ }
207
+
208
+ function summarizeTaskLedger(tasks = [], { limit = DEFAULT_TASK_SUMMARY_LIMIT } = {}) {
209
+ const summary = emptyTaskLedgerSummary();
210
+ const owners = new Map();
211
+ const normalizedTasks = [];
212
+
213
+ for (const task of Array.isArray(tasks) ? tasks : []) {
214
+ if (!task || typeof task !== "object") {
215
+ continue;
216
+ }
217
+ const status = normalizeTaskStatus(task.status);
218
+ const statusKey = TASK_STATUS_KEYS[status];
219
+ const owner = taskOwner(task);
220
+ const priority = normalizeString(task.priority) || "when-free";
221
+ const taskId = normalizeString(task.taskId) || "task";
222
+ const updatedAt = normalizeIsoTimestamp(
223
+ task.updatedAt || task.completedAt || task.acceptedAt || task.createdAt,
224
+ new Date().toISOString(),
225
+ );
226
+ const record = {
227
+ taskId,
228
+ status,
229
+ priority,
230
+ owner,
231
+ task: shortTaskText(task.task),
232
+ updatedAt,
233
+ };
234
+ normalizedTasks.push(record);
235
+ summary.total += 1;
236
+ summary[statusKey] += 1;
237
+
238
+ if (ACTIVE_TASK_STATUSES.has(status)) {
239
+ summary.active += 1;
240
+ const ownerRecord = owners.get(owner) || {
241
+ agentId: owner,
242
+ active: 0,
243
+ pending: 0,
244
+ accepted: 0,
245
+ blocked: 0,
246
+ };
247
+ ownerRecord.active += 1;
248
+ ownerRecord[statusKey] += 1;
249
+ owners.set(owner, ownerRecord);
250
+ }
251
+ }
252
+
253
+ summary.owners = [...owners.values()]
254
+ .sort((left, right) => {
255
+ if (right.active !== left.active) return right.active - left.active;
256
+ return left.agentId.localeCompare(right.agentId);
257
+ })
258
+ .slice(0, Math.max(1, limit));
259
+ summary.recent = normalizedTasks
260
+ .sort((left, right) => toEpoch(right.updatedAt) - toEpoch(left.updatedAt))
261
+ .slice(0, Math.max(1, limit));
262
+ return summary;
263
+ }
264
+
265
+ async function readTaskLedgerSummary(
266
+ sessionId,
267
+ { targetPath = process.cwd(), limit = DEFAULT_TASK_SUMMARY_LIMIT } = {},
268
+ ) {
269
+ const paths = resolveSessionPaths(sessionId, { targetPath });
270
+ try {
271
+ const raw = await fsp.readFile(paths.tasksPath, "utf-8");
272
+ const parsed = JSON.parse(raw);
273
+ return summarizeTaskLedger(parsed?.tasks || [], { limit });
274
+ } catch (error) {
275
+ if (error && typeof error === "object" && error.code === "ENOENT") {
276
+ return emptyTaskLedgerSummary();
277
+ }
278
+ return emptyTaskLedgerSummary();
279
+ }
280
+ }
281
+
163
282
  function buildElapsedMinutes(events = [], nowIso = new Date().toISOString()) {
164
283
  if (!Array.isArray(events) || events.length === 0) {
165
284
  return 0;
@@ -181,6 +300,7 @@ function buildRecapText({
181
300
  totalFindings = 0,
182
301
  activeLocks = 0,
183
302
  pendingTasks = 0,
303
+ taskLedger = emptyTaskLedgerSummary(),
184
304
  snippets = [],
185
305
  } = {}) {
186
306
  const agentText =
@@ -191,13 +311,78 @@ function buildRecapText({
191
311
  const lockText = `${activeLocks} file lock${activeLocks === 1 ? "" : "s"} active`;
192
312
  const pendingText =
193
313
  pendingTasks > 0 ? `You have ${pendingTasks} pending task${pendingTasks === 1 ? "" : "s"}.` : "";
314
+ const taskText = buildTaskLedgerText(taskLedger);
194
315
  const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
195
- return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${snippetText}`.replace(
316
+ return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${taskText}. ${snippetText}`.replace(
196
317
  /\s+/g,
197
318
  " "
198
319
  ).trim();
199
320
  }
200
321
 
322
+ function buildTaskLedgerText(taskLedger = emptyTaskLedgerSummary()) {
323
+ const total = Number(taskLedger.total || 0);
324
+ if (!total) {
325
+ return "Tasks: none queued";
326
+ }
327
+ const active = Number(taskLedger.active || 0);
328
+ const counts = [
329
+ `${Number(taskLedger.pending || 0)} pending`,
330
+ `${Number(taskLedger.accepted || 0)} accepted`,
331
+ `${Number(taskLedger.blocked || 0)} blocked`,
332
+ `${Number(taskLedger.completed || 0)} done`,
333
+ ].join(", ");
334
+ const ownerText =
335
+ Array.isArray(taskLedger.owners) && taskLedger.owners.length > 0
336
+ ? `Owners: ${taskLedger.owners
337
+ .map((owner) => {
338
+ const parts = [];
339
+ if (owner.pending) parts.push(`${owner.pending} pending`);
340
+ if (owner.accepted) parts.push(`${owner.accepted} accepted`);
341
+ if (owner.blocked) parts.push(`${owner.blocked} blocked`);
342
+ return `${owner.agentId} (${parts.join("/") || `${owner.active || 0} active`})`;
343
+ })
344
+ .join("; ")}`
345
+ : "Owners: none active";
346
+ const recentText =
347
+ Array.isArray(taskLedger.recent) && taskLedger.recent.length > 0
348
+ ? `Recent tasks: ${taskLedger.recent
349
+ .map((task) => `${task.priority} ${task.status} ${task.owner}: ${task.task}`)
350
+ .join(" | ")}`
351
+ : "";
352
+ return [`Tasks: ${active} active of ${total} total (${counts})`, ownerText, recentText]
353
+ .filter(Boolean)
354
+ .join(". ");
355
+ }
356
+
357
+ // Multi-agent session etiquette + read-path rules surfaced in the
358
+ // context_briefing payload an agent receives on first join. Web
359
+ // renders this as markdown (see sentinelayer-web Session.tsx
360
+ // SessionMessage), so headers/lists/inline code are intentional.
361
+ //
362
+ // Keep this short and operationally actionable. Anything that's
363
+ // purely doctrinal belongs in AGENTS.md, not the per-join briefing.
364
+ const AGENT_JOIN_RULES = [
365
+ "**Welcome to this session.** Quick rules so we coordinate cleanly:",
366
+ "",
367
+ "**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.",
368
+ "",
369
+ "**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.",
370
+ "",
371
+ "**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.",
372
+ "",
373
+ "**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.",
374
+ "",
375
+ "**Stop conditions** — If the human asks you to stop, stop. If 60+ minutes of total session silence, stop polling.",
376
+ ].join("\n");
377
+
378
+ function buildAgentJoinBriefingText({ recap = "", forAgent = "" } = {}) {
379
+ const trimmedRecap = normalizeString(recap);
380
+ const trimmedAgent = normalizeString(forAgent);
381
+ const greeting = trimmedAgent ? `**${trimmedAgent}** joined. ${trimmedRecap}` : trimmedRecap;
382
+ const recapBlock = greeting || "Welcome — no prior session activity to summarize yet.";
383
+ return `${recapBlock}\n\n---\n\n${AGENT_JOIN_RULES}`;
384
+ }
385
+
201
386
  function buildPeriodicText(recap = {}) {
202
387
  const summary = recap.summary && typeof recap.summary === "object" ? recap.summary : {};
203
388
  const elapsedMinutes = Number(summary.elapsedMinutes || 0);
@@ -206,7 +391,8 @@ function buildPeriodicText(recap = {}) {
206
391
  const activeLocks = Number(summary.activeLocks || 0);
207
392
  const lastActor = normalizeString(summary.lastActorId);
208
393
  const actorText = lastActor ? `${lastActor} active` : "no active actor";
209
- return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${actorText}.`;
394
+ const taskText = buildTaskLedgerText(summary.taskLedger);
395
+ return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${taskText}. ${actorText}.`;
210
396
  }
211
397
 
212
398
  export async function buildSessionRecap(
@@ -259,6 +445,9 @@ export async function buildSessionRecap(
259
445
  forAgentId: normalizedForAgentId,
260
446
  targetPath: normalizedTargetPath,
261
447
  });
448
+ const taskLedger = await readTaskLedgerSummary(normalizedSessionId, {
449
+ targetPath: normalizedTargetPath,
450
+ });
262
451
  const snippets = summarizeRecentActivity(visibleEvents, {
263
452
  forAgentId: normalizedForAgentId,
264
453
  limit: 2,
@@ -270,6 +459,7 @@ export async function buildSessionRecap(
270
459
  totalFindings: totalFindingsCount,
271
460
  activeLocks,
272
461
  pendingTasks,
462
+ taskLedger,
273
463
  snippets,
274
464
  });
275
465
 
@@ -288,6 +478,7 @@ export async function buildSessionRecap(
288
478
  totalFindingsCount,
289
479
  activeLocks,
290
480
  pendingTasksForAgent: pendingTasks,
481
+ taskLedger,
291
482
  snippets,
292
483
  elapsedMinutes,
293
484
  lastActorId: normalizeString(latestEvent?.agent?.id || latestEvent?.agentId) || null,
@@ -303,6 +494,7 @@ export async function emitContextBriefing(
303
494
  maxEvents = DEFAULT_RECAP_MAX_EVENTS,
304
495
  targetPath = process.cwd(),
305
496
  nowIso = new Date().toISOString(),
497
+ includeJoinRules = true,
306
498
  } = {}
307
499
  ) {
308
500
  const recap = await buildSessionRecap(sessionId, {
@@ -311,6 +503,9 @@ export async function emitContextBriefing(
311
503
  targetPath,
312
504
  nowIso,
313
505
  });
506
+ const briefingMessage = includeJoinRules
507
+ ? buildAgentJoinBriefingText({ recap: recap.text, forAgent: forAgentId })
508
+ : recap.text;
314
509
  const event = createAgentEvent({
315
510
  event: "context_briefing",
316
511
  agentId: SENTI_AGENT_ID,
@@ -319,7 +514,9 @@ export async function emitContextBriefing(
319
514
  ts: recap.generatedAt,
320
515
  payload: {
321
516
  forAgent: normalizeString(forAgentId) || null,
517
+ message: briefingMessage,
322
518
  recap: recap.text,
519
+ rules: includeJoinRules ? AGENT_JOIN_RULES : null,
323
520
  ephemeral: true,
324
521
  style: RECAP_STYLE,
325
522
  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
  {