sentinelayer-cli 0.9.0 → 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.
@@ -29,7 +29,7 @@ import {
29
29
  registerAgent,
30
30
  unregisterAgent,
31
31
  } from "../session/agent-registry.js";
32
- import { stopSenti } from "../session/daemon.js";
32
+ import { startSenti, stopSenti } from "../session/daemon.js";
33
33
  import { listRuntimeRuns } from "../session/runtime-bridge.js";
34
34
  import {
35
35
  listFileLocks,
@@ -44,22 +44,32 @@ 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";
55
+ import {
56
+ addSessionEventIdentityKeys,
57
+ dedupeSessionEvents,
58
+ sessionEventHasKnownIdentity,
59
+ } from "../session/event-identity.js";
53
60
  import { readSessionPreview } from "../session/preview.js";
54
61
  import {
55
62
  listSessionsFromApi,
56
63
  probeSessionAccess,
64
+ pollSessionEventsBefore,
65
+ syncSessionEventToApi,
57
66
  syncSessionMetadataToApi,
58
67
  } from "../session/sync.js";
59
68
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
60
69
  import { mergeLiveSources } from "../session/live-source.js";
61
70
  import { listenSessionEvents } from "../session/listener.js";
62
71
  import { deriveSessionTitle } from "../session/senti-naming.js";
72
+ import { pushSessionTitleToApi } from "../session/title-sync.js";
63
73
  import {
64
74
  buildDashboardUrl,
65
75
  buildTemplateLaunchPlan,
@@ -111,6 +121,10 @@ function remoteSessionLookupDisabled() {
111
121
  return String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
112
122
  }
113
123
 
124
+ function sentiAutostartDisabled() {
125
+ return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
126
+ }
127
+
114
128
  function mergeResumeCandidate(existing, incoming) {
115
129
  if (!existing) return incoming;
116
130
  const existingActivity = Number(existing._activityMs || 0);
@@ -194,29 +208,190 @@ async function findReusableSessionCandidate({
194
208
  return candidates[0] || null;
195
209
  }
196
210
 
197
- async function pushSessionTitleToApi(sessionId, title, { targetPath } = {}) {
198
- const normalizedTitle = normalizeString(title);
199
- if (!normalizedTitle || remoteSessionLookupDisabled()) return;
211
+ // Verify that a session id is reachable for the active user via the API
212
+ // singleton endpoint added in API PR #483 (`GET /api/v1/sessions/{id}`).
213
+ //
214
+ // Carter's complaint: "I can't create a session from the web and still have
215
+ // it available for you guys in CLI" — the historical CLI flow assumed the
216
+ // session was created locally first, so attaching to a web/peer-created
217
+ // session left the agent guessing about access. Singleton GET resolves
218
+ // that with one round-trip and gives us metadata for friendly output.
219
+ //
220
+ // Behaviour contract:
221
+ // - Returns `{ ok: true, source, session, status }` on success.
222
+ // - Returns `{ ok: false, reason: "not_found", status: 404 }` when the
223
+ // session genuinely isn't visible to the caller (404 + list fallback
224
+ // also empty). Callers should map this to a friendly "not found" exit.
225
+ // - Returns `{ ok: false, reason: "forbidden", status: 403 }` for explicit
226
+ // deny (caller is authenticated but not a member).
227
+ // - On 5xx: retries ONCE, then surfaces `{ ok: false, reason: "api_5xx" }`.
228
+ // - On 404 from the singleton: falls back to filtering the list endpoint
229
+ // so users on stale prod servers (pre-#483) aren't blocked. If the list
230
+ // contains the session id we treat it as success and return that row.
231
+ // - When `SENTINELAYER_SKIP_REMOTE_SYNC=1` (test bootstrap), short-circuits
232
+ // to `{ ok: true, source: "skipped", session: null }` so unit tests
233
+ // can exercise the local materialization path without a real API.
234
+ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
235
+ const normalizedSessionId = normalizeString(sessionId);
236
+ if (!normalizedSessionId) {
237
+ return { ok: false, reason: "invalid_session_id" };
238
+ }
239
+ if (remoteSessionLookupDisabled()) {
240
+ return { ok: true, source: "skipped", session: null };
241
+ }
242
+ let auth;
200
243
  try {
201
- const session = await resolveActiveAuthSession({
202
- cwd: targetPath,
244
+ auth = await resolveActiveAuthSession({
245
+ cwd: targetPath || process.cwd(),
203
246
  env: process.env,
204
247
  autoRotate: false,
205
248
  });
206
- if (!session?.token || !session?.apiUrl) return;
207
- const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
208
- await requestJsonMutation(
209
- `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/title`,
210
- {
211
- method: "POST",
212
- operationName: "session.set_title",
213
- headers: { Authorization: `Bearer ${session.token}` },
214
- body: { title: normalizedTitle },
215
- },
216
- );
217
249
  } catch {
218
- /* best-effort */
250
+ return { ok: false, reason: "no_session" };
251
+ }
252
+ if (!auth || !auth.token) {
253
+ return { ok: false, reason: "not_authenticated", status: 401 };
254
+ }
255
+ const apiUrl = String(auth.apiUrl || "").replace(/\/+$/, "");
256
+ if (!apiUrl) {
257
+ return { ok: false, reason: "no_api_url" };
258
+ }
259
+ const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
260
+ const headers = { Authorization: `Bearer ${auth.token}` };
261
+ let lastReason = "unknown";
262
+ for (let attempt = 0; attempt < 2; attempt += 1) {
263
+ let response;
264
+ try {
265
+ response = await fetch(endpoint, { method: "GET", headers });
266
+ } catch (err) {
267
+ lastReason = normalizeString(err?.message) || "fetch_failed";
268
+ continue;
269
+ }
270
+ if (response && response.ok) {
271
+ const body = await response.json().catch(() => ({}));
272
+ const sessionPayload = body && body.session && typeof body.session === "object"
273
+ ? body.session
274
+ : body && typeof body === "object"
275
+ ? body
276
+ : null;
277
+ return {
278
+ ok: true,
279
+ source: "singleton",
280
+ session: sessionPayload,
281
+ status: response.status,
282
+ };
283
+ }
284
+ if (!response) {
285
+ lastReason = "no_response";
286
+ continue;
287
+ }
288
+ if (response.status === 404) {
289
+ // Pre-#483 fallback: scan the list endpoint once for the same id.
290
+ const listResult = await listSessionsFromApi({
291
+ targetPath,
292
+ includeArchived: false,
293
+ limit: 50,
294
+ }).catch(() => null);
295
+ if (listResult && listResult.ok) {
296
+ const found = (listResult.sessions || []).find(
297
+ (entry) => normalizeString(entry?.sessionId) === normalizedSessionId,
298
+ );
299
+ if (found) {
300
+ return { ok: true, source: "list_fallback", session: found, status: 200 };
301
+ }
302
+ }
303
+ return { ok: false, reason: "not_found", status: 404 };
304
+ }
305
+ if (response.status === 403) {
306
+ return { ok: false, reason: "forbidden", status: 403 };
307
+ }
308
+ if (response.status >= 500 && response.status < 600) {
309
+ lastReason = `api_${response.status}`;
310
+ continue; // retry once on 5xx
311
+ }
312
+ return { ok: false, reason: `api_${response.status}`, status: response.status };
219
313
  }
314
+ return { ok: false, reason: lastReason };
315
+ }
316
+
317
+ // Render an absolute ISO timestamp as a coarse "Nm ago" / "Nh ago" / "Nd ago"
318
+ // label for human-readable join output. Returns `"never"` for missing input
319
+ // and `"just now"` for sub-minute deltas.
320
+ function formatRelativeAge(isoTimestamp) {
321
+ const epoch = Date.parse(normalizeString(isoTimestamp));
322
+ if (!Number.isFinite(epoch)) return "never";
323
+ const deltaMs = Date.now() - epoch;
324
+ if (deltaMs < 60_000) return "just now";
325
+ const minutes = Math.floor(deltaMs / 60_000);
326
+ if (minutes < 60) return `${minutes}m ago`;
327
+ const hours = Math.floor(minutes / 60);
328
+ if (hours < 24) return `${hours}h ago`;
329
+ const days = Math.floor(hours / 24);
330
+ return `${days}d ago`;
331
+ }
332
+
333
+ async function ensureLocalSessionForRemoteCommand(
334
+ sessionId,
335
+ { targetPath, title = "", skipRemoteProbe = false, remoteSession = null } = {},
336
+ ) {
337
+ const existing = await getSession(sessionId, { targetPath });
338
+ if (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 };
373
+ }
374
+ // `skipRemoteProbe` is set by callers that have already verified the session
375
+ // via `verifyRemoteSession` (the singleton GET) — re-probing the legacy
376
+ // `/events?limit=1` endpoint here would be a redundant round-trip and, for
377
+ // tests that mock only the singleton, would spuriously 404.
378
+ if (!skipRemoteProbe) {
379
+ const access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
380
+ accessible: false,
381
+ reason: normalizeString(error?.message) || "probe_failed",
382
+ }));
383
+ if (!access?.accessible) {
384
+ throw new Error(
385
+ `Session '${sessionId}' was not found locally and remote access failed (${access?.reason || "unknown"}).`,
386
+ );
387
+ }
388
+ }
389
+ const created = await createSession({
390
+ targetPath,
391
+ sessionId,
392
+ title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
393
+ });
394
+ return { materialized: true, refreshed: false, session: created };
220
395
  }
221
396
 
222
397
  async function ensureWorkspaceSession({
@@ -296,13 +471,16 @@ async function ensureWorkspaceSession({
296
471
 
297
472
  const effectiveTitle = titleArg || normalizeString(created.title) || fallbackTitle;
298
473
  const titleAuto = !titleArg && !resumedCandidate;
474
+ const pendingTitleSync = Boolean(created.remoteTitleSync?.pending && effectiveTitle);
299
475
  const shouldPushTitle = Boolean(
300
476
  titleArg ||
301
477
  titleAuto ||
478
+ pendingTitleSync ||
302
479
  (resumedCandidate && effectiveTitle && !normalizeString(resumedCandidate.title))
303
480
  );
481
+ let titleSync = null;
304
482
  if (shouldPushTitle) {
305
- void pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
483
+ titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
306
484
  }
307
485
 
308
486
  return {
@@ -315,6 +493,7 @@ async function ensureWorkspaceSession({
315
493
  durationMs: Date.now() - startedAt,
316
494
  title: effectiveTitle || null,
317
495
  titleAuto,
496
+ titleSync,
318
497
  };
319
498
  }
320
499
 
@@ -326,6 +505,18 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
326
505
  return normalized || fallbackValue;
327
506
  }
328
507
 
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");
514
+ }
515
+
516
+ async function defaultAgentId(value, _targetPath) {
517
+ return resolveSessionSayAgentId(value);
518
+ }
519
+
329
520
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
330
521
  const normalizedItems = Array.isArray(items) ? items : [];
331
522
  const normalizedConcurrency = Math.max(
@@ -579,6 +770,7 @@ export function registerSessionCommand(program) {
579
770
  resumed,
580
771
  title: effectiveTitle || null,
581
772
  titleAuto: Boolean(ensured.titleAuto),
773
+ titleSync: ensured.titleSync || undefined,
582
774
  };
583
775
 
584
776
  // Best-effort admin visibility sync. Session creation remains local-first.
@@ -594,6 +786,20 @@ export function registerSessionCommand(program) {
594
786
  codebaseContext: created.codebaseContext,
595
787
  }).catch(() => {});
596
788
 
789
+ // Auto-start the Senti orchestrator daemon. Without this, every
790
+ // session ran with `Senti actions: 1` (just the welcome alert)
791
+ // because nothing kicked the daemon ticking — agents joining
792
+ // never got greeted, mentions never routed, recaps never fired.
793
+ // Best-effort + non-blocking: the daemon registers itself in an
794
+ // in-memory map keyed by (sessionId, targetPath) and tolerates
795
+ // being started for an already-active session (returns the
796
+ // existing handle). If the daemon fails to start (unauth env,
797
+ // missing model proxy), the session keeps working — Senti just
798
+ // stays quiet, same as before this change.
799
+ if (!sentiAutostartDisabled()) {
800
+ void startSenti(created.sessionId, { targetPath }).catch(() => {});
801
+ }
802
+
597
803
  if (shouldEmitJson(options, command)) {
598
804
  console.log(JSON.stringify(payload, null, 2));
599
805
  return;
@@ -653,6 +859,10 @@ export function registerSessionCommand(program) {
653
859
  .command("ensure")
654
860
  .description("Join or create the canonical session for this workspace and emit JSON")
655
861
  .option("--path <path>", "Workspace path for the session", ".")
862
+ .option(
863
+ "--session <id>",
864
+ "Attach to an explicit remote-created session id (verifies + materializes local state, like `session join`).",
865
+ )
656
866
  .option("--title <title>", "Title applied if a new or unnamed resumed session needs one")
657
867
  .option(
658
868
  "--ttl-seconds <seconds>",
@@ -686,6 +896,56 @@ export function registerSessionCommand(program) {
686
896
  "reuse-window-seconds",
687
897
  3600,
688
898
  );
899
+
900
+ // --session <id> short-circuit: behave like `session join`. This is the
901
+ // path Carter cared about — "create on web, share id, attach in CLI".
902
+ // We verify the session is reachable, materialize a minimal local
903
+ // NDJSON if missing, and emit the same `{sessionId, title, resumed}`
904
+ // contract callers already consume from `ensure`.
905
+ const explicitSessionId = normalizeString(options.session);
906
+ if (explicitSessionId) {
907
+ const verification = await verifyRemoteSession(explicitSessionId, { targetPath });
908
+ if (!verification.ok) {
909
+ if (verification.status === 404 || verification.reason === "not_found") {
910
+ throw new Error(
911
+ `Session not found, archived, or not accessible to your account. (id=${explicitSessionId})`,
912
+ );
913
+ }
914
+ if (verification.status === 403 || verification.reason === "forbidden") {
915
+ throw new Error(
916
+ `Session '${explicitSessionId}' exists but your account is not a member.`,
917
+ );
918
+ }
919
+ if (verification.reason === "not_authenticated") {
920
+ throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
921
+ }
922
+ throw new Error(
923
+ `Failed to verify session '${explicitSessionId}' (${verification.reason || "unknown"}). Try again in a moment.`,
924
+ );
925
+ }
926
+ const remoteSession = verification.session || {};
927
+ const localSession = await ensureLocalSessionForRemoteCommand(explicitSessionId, {
928
+ targetPath,
929
+ title: normalizeString(remoteSession.title),
930
+ skipRemoteProbe: true,
931
+ remoteSession,
932
+ });
933
+ const payload = {
934
+ command: "session ensure",
935
+ targetPath,
936
+ sessionId: explicitSessionId,
937
+ title: normalizeString(remoteSession.title) || localSession?.session?.title || null,
938
+ resumed: true,
939
+ attached: true,
940
+ materializedLocalSession: localSession.materialized,
941
+ refreshedLocalSession: Boolean(localSession.refreshed),
942
+ verificationSource: verification.source,
943
+ dashboardUrl: buildDashboardUrl(explicitSessionId),
944
+ };
945
+ console.log(JSON.stringify(payload, null, 2));
946
+ return;
947
+ }
948
+
689
949
  const ensured = await ensureWorkspaceSession({
690
950
  targetPath,
691
951
  ttlSeconds,
@@ -701,6 +961,7 @@ export function registerSessionCommand(program) {
701
961
  title: ensured.title || null,
702
962
  resumed: Boolean(ensured.resumedCandidate),
703
963
  dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
964
+ titleSync: ensured.titleSync || undefined,
704
965
  };
705
966
  console.log(JSON.stringify(payload, null, 2));
706
967
  });
@@ -832,8 +1093,14 @@ export function registerSessionCommand(program) {
832
1093
 
833
1094
  session
834
1095
  .command("join <sessionId>")
835
- .description("Join an active session")
836
- .option("--name <name>", "Agent display name")
1096
+ .description(
1097
+ "Attach to a remote-created session for posting and listening, materializing minimal local state on demand.",
1098
+ )
1099
+ .option("--name <name>", "Agent display name (legacy alias for --agent)")
1100
+ .option(
1101
+ "--agent <id>",
1102
+ "Granted agent id to emit an agent_join event as. Behaves like post-agent for human/placeholder ids — those are recorded in the local registry only.",
1103
+ )
837
1104
  .option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
838
1105
  .option("--model <model>", "Agent model hint", "cli")
839
1106
  .option("--path <path>", "Workspace path for the session", ".")
@@ -844,27 +1111,106 @@ export function registerSessionCommand(program) {
844
1111
  throw new Error("session id is required.");
845
1112
  }
846
1113
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1114
+
1115
+ // PR #483 contract: verify the session exists and the caller has access
1116
+ // BEFORE materializing local cache state. Without this we'd silently
1117
+ // create a phantom local NDJSON for a session that's archived or owned
1118
+ // by another tenant — which is the bug Carter reported when asking for
1119
+ // a clean "share an id from web → join in CLI" flow.
1120
+ const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
1121
+ if (!verification.ok) {
1122
+ if (verification.status === 404 || verification.reason === "not_found") {
1123
+ throw new Error(
1124
+ `Session not found, archived, or not accessible to your account. (id=${normalizedSessionId})`,
1125
+ );
1126
+ }
1127
+ if (verification.status === 403 || verification.reason === "forbidden") {
1128
+ throw new Error(
1129
+ `Session '${normalizedSessionId}' exists but your account is not a member.`,
1130
+ );
1131
+ }
1132
+ if (verification.reason === "not_authenticated") {
1133
+ throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
1134
+ }
1135
+ throw new Error(
1136
+ `Failed to verify session '${normalizedSessionId}' (${verification.reason || "unknown"}). Try again in a moment.`,
1137
+ );
1138
+ }
1139
+
1140
+ const remoteSession = verification.session || {};
1141
+ const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
1142
+ targetPath,
1143
+ title: normalizeString(remoteSession.title),
1144
+ skipRemoteProbe: true,
1145
+ remoteSession,
1146
+ });
1147
+
1148
+ const explicitAgent = normalizeString(options.agent);
1149
+ const agentSeed = explicitAgent || normalizeString(options.name);
1150
+ const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
1151
+ const role = normalizeString(options.role) || "coder";
1152
+ const model = normalizeString(options.model) || "cli";
1153
+
1154
+ // `registerAgent` already writes the canonical `agent_join` event to the
1155
+ // local NDJSON and best-effort relays it to /events via appendToStream
1156
+ // → syncSessionEventToApi. That gives us the exact `post-agent` parity
1157
+ // the spec calls for when `--agent <granted>` is provided. We don't
1158
+ // double-emit; we just record whether the explicit agent path was used
1159
+ // so the JSON output can advertise it to callers (and tests).
847
1160
  const joined = await registerAgent(normalizedSessionId, {
848
1161
  targetPath,
849
- agentId: normalizeAgentId(options.name, "cli-user"),
850
- model: normalizeString(options.model) || "cli",
851
- role: options.role || "coder",
1162
+ agentId: resolvedAgentId,
1163
+ model,
1164
+ role,
852
1165
  });
1166
+ const agentJoinRelayed =
1167
+ Boolean(explicitAgent) &&
1168
+ Boolean(resolvedAgentId) &&
1169
+ resolvedAgentId !== "cli-user" &&
1170
+ resolvedAgentId !== "unknown" &&
1171
+ !resolvedAgentId.startsWith("human-");
1172
+
1173
+ const eventCount = Number(remoteSession.eventCount ?? remoteSession.events ?? 0);
1174
+ const agents = Array.isArray(remoteSession.agents) ? remoteSession.agents : [];
1175
+ const agentCount = Number(remoteSession.agentCount ?? agents.length ?? 0);
1176
+ const lastActivityIso =
1177
+ normalizeString(remoteSession.lastInteractionAt) ||
1178
+ normalizeString(remoteSession.lastActivityAt) ||
1179
+ normalizeString(remoteSession.updatedAt) ||
1180
+ normalizeString(remoteSession.createdAt) ||
1181
+ "";
1182
+ const remoteTitle = normalizeString(remoteSession.title);
1183
+
853
1184
  const payload = {
854
1185
  command: "session join",
1186
+ joined: true,
855
1187
  targetPath,
856
1188
  sessionId: normalizedSessionId,
1189
+ title: remoteTitle || null,
857
1190
  agentId: joined.agentId,
858
1191
  role: joined.role,
859
1192
  model: joined.model,
860
1193
  status: joined.status,
861
1194
  joinedAt: joined.joinedAt,
1195
+ materializedLocalSession: localSession.materialized,
1196
+ refreshedLocalSession: Boolean(localSession.refreshed),
1197
+ verificationSource: verification.source,
1198
+ eventCount: Number.isFinite(eventCount) ? eventCount : 0,
1199
+ agentCount: Number.isFinite(agentCount) ? agentCount : 0,
1200
+ lastActivityAt: lastActivityIso || null,
1201
+ agentJoinRelayed,
862
1202
  };
863
1203
  if (shouldEmitJson(options, command)) {
864
1204
  console.log(JSON.stringify(payload, null, 2));
865
1205
  return;
866
1206
  }
867
- console.log(pc.bold(`Joined session ${normalizedSessionId}`));
1207
+ const titleLabel = remoteTitle ? `"${remoteTitle}"` : "(untitled)";
1208
+ const ageLabel = lastActivityIso ? formatRelativeAge(lastActivityIso) : "never";
1209
+ console.log(
1210
+ pc.bold(
1211
+ `Joined session ${titleLabel} (${normalizedSessionId}) — ${payload.eventCount} events, ${payload.agentCount} agents, last activity ${ageLabel}`,
1212
+ ),
1213
+ );
868
1214
  console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
869
1215
  });
870
1216
 
@@ -885,7 +1231,10 @@ export function registerSessionCommand(program) {
885
1231
  throw new Error("message is required.");
886
1232
  }
887
1233
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
888
- const agentId = normalizeAgentId(options.agent, "cli-user");
1234
+ const agentId = await defaultAgentId(options.agent, targetPath);
1235
+ const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
1236
+ targetPath,
1237
+ });
889
1238
  const to = normalizeString(options.to);
890
1239
  const eventPayload = {
891
1240
  message: normalizedMessage,
@@ -894,12 +1243,29 @@ export function registerSessionCommand(program) {
894
1243
  if (to) {
895
1244
  eventPayload.to = to;
896
1245
  }
1246
+ const clientMessageId = `cli-${randomUUID()}`;
897
1247
  const event = createAgentEvent({
898
1248
  event: "session_message",
899
1249
  agentId,
900
1250
  sessionId: normalizedSessionId,
901
1251
  payload: eventPayload,
902
1252
  });
1253
+ event.eventId = clientMessageId;
1254
+ event.idempotencyToken = clientMessageId;
1255
+ let remoteSync = null;
1256
+ if (localSession.materialized) {
1257
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1258
+ remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
1259
+ targetPath,
1260
+ });
1261
+ if (remoteSync?.synced) break;
1262
+ }
1263
+ if (!remoteSync?.synced) {
1264
+ throw new Error(
1265
+ `Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated.`,
1266
+ );
1267
+ }
1268
+ }
903
1269
  const persisted = await appendToStream(normalizedSessionId, event, {
904
1270
  targetPath,
905
1271
  });
@@ -909,6 +1275,97 @@ export function registerSessionCommand(program) {
909
1275
  sessionId: normalizedSessionId,
910
1276
  agentId,
911
1277
  event: persisted,
1278
+ materializedLocalSession: localSession.materialized,
1279
+ refreshedLocalSession: Boolean(localSession.refreshed),
1280
+ remoteSync: remoteSync || undefined,
1281
+ };
1282
+ if (shouldEmitJson(options, command)) {
1283
+ console.log(JSON.stringify(payload, null, 2));
1284
+ return;
1285
+ }
1286
+ console.log(formatEventLine(persisted));
1287
+ });
1288
+
1289
+ session
1290
+ .command("post-agent <sessionId> <message>")
1291
+ .description("Post an authenticated agent message through the canonical session event API")
1292
+ .requiredOption("--agent <id>", "Granted agent id to post as")
1293
+ .option("--model <model>", "Agent model/provider hint", "cli")
1294
+ .option("--display-name <name>", "Human-readable agent display name")
1295
+ .option("--role <role>", "Agent role metadata: coder, reviewer, tester, observer", "coder")
1296
+ .option("--to <agent>", "Direct the message to a specific agent id")
1297
+ .option("--path <path>", "Workspace path for the session", ".")
1298
+ .option("--json", "Emit machine-readable output")
1299
+ .action(async (sessionId, message, options, command) => {
1300
+ const normalizedSessionId = normalizeString(sessionId);
1301
+ if (!normalizedSessionId) {
1302
+ throw new Error("session id is required.");
1303
+ }
1304
+ const normalizedMessage = normalizeString(message);
1305
+ if (!normalizedMessage) {
1306
+ throw new Error("message is required.");
1307
+ }
1308
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1309
+ const agentId = normalizeAgentId(options.agent, "");
1310
+ if (!agentId || agentId === "cli-user" || agentId === "unknown" || agentId.startsWith("human-")) {
1311
+ throw new Error("post-agent requires a granted non-human agent id.");
1312
+ }
1313
+ const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
1314
+ targetPath,
1315
+ });
1316
+ const to = normalizeString(options.to);
1317
+ const eventPayload = {
1318
+ message: normalizedMessage,
1319
+ channel: "session",
1320
+ source: "agent",
1321
+ clientKind: "cli",
1322
+ };
1323
+ if (to) {
1324
+ eventPayload.to = to;
1325
+ }
1326
+ const agent = {
1327
+ id: agentId,
1328
+ model: normalizeString(options.model) || "cli",
1329
+ displayName: normalizeString(options.displayName) || undefined,
1330
+ role: normalizeString(options.role) || "coder",
1331
+ clientKind: "cli",
1332
+ };
1333
+ const clientMessageId = `cli-agent-${randomUUID()}`;
1334
+ const event = createAgentEvent({
1335
+ event: "session_message",
1336
+ agent,
1337
+ sessionId: normalizedSessionId,
1338
+ payload: eventPayload,
1339
+ });
1340
+ event.eventId = clientMessageId;
1341
+ event.idempotencyToken = clientMessageId;
1342
+
1343
+ let remoteSync = null;
1344
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1345
+ remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
1346
+ targetPath,
1347
+ });
1348
+ if (remoteSync?.synced) break;
1349
+ }
1350
+ if (!remoteSync?.synced) {
1351
+ throw new Error(
1352
+ `Agent post failed (${remoteSync?.reason || "unknown"}). Ensure this user has an active grant for '${agentId}'.`,
1353
+ );
1354
+ }
1355
+
1356
+ const persisted = await appendToStream(normalizedSessionId, event, {
1357
+ targetPath,
1358
+ syncRemote: false,
1359
+ });
1360
+ const payload = {
1361
+ command: "session post-agent",
1362
+ targetPath,
1363
+ sessionId: normalizedSessionId,
1364
+ agentId,
1365
+ event: persisted,
1366
+ materializedLocalSession: localSession.materialized,
1367
+ refreshedLocalSession: Boolean(localSession.refreshed),
1368
+ remoteSync,
912
1369
  };
913
1370
  if (shouldEmitJson(options, command)) {
914
1371
  console.log(JSON.stringify(payload, null, 2));
@@ -926,7 +1383,17 @@ export function registerSessionCommand(program) {
926
1383
  "Agent id to receive messages for",
927
1384
  process.env.SENTINELAYER_AGENT_ID || "cli-user",
928
1385
  )
929
- .option("--interval <seconds>", "Polling interval in seconds (default 60)", "60")
1386
+ .option("--interval <seconds>", "Idle polling interval in seconds (default 60)", "60")
1387
+ .option(
1388
+ "--active-interval <seconds>",
1389
+ "Polling interval after recent human activity (default 5)",
1390
+ "5",
1391
+ )
1392
+ .option(
1393
+ "--active-window <seconds>",
1394
+ "Seconds after a human message to keep the active interval (default 300)",
1395
+ "300",
1396
+ )
930
1397
  .option("--emit <format>", "Output format: ndjson or text", "ndjson")
931
1398
  .option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
932
1399
  .option("--path <path>", "Workspace path for the session", ".")
@@ -938,6 +1405,12 @@ export function registerSessionCommand(program) {
938
1405
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
939
1406
  const agentId = normalizeAgentId(options.agent, "cli-user");
940
1407
  const intervalSeconds = parsePositiveInteger(options.interval, "interval", 60);
1408
+ const activeIntervalSeconds = parsePositiveInteger(
1409
+ options.activeInterval,
1410
+ "active-interval",
1411
+ 5,
1412
+ );
1413
+ const activeWindowSeconds = parsePositiveInteger(options.activeWindow, "active-window", 300);
941
1414
  const limit = parsePositiveInteger(options.limit, "limit", 200);
942
1415
  const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
943
1416
  if (!["ndjson", "text"].includes(emitFormat)) {
@@ -955,7 +1428,7 @@ export function registerSessionCommand(program) {
955
1428
  if (emitFormat === "text") {
956
1429
  console.log(
957
1430
  pc.gray(
958
- `Listening to session ${normalizedSessionId} as ${agentId}; interval=${intervalSeconds}s. Press Ctrl+C to stop.`,
1431
+ `Listening to session ${normalizedSessionId} as ${agentId}; idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
959
1432
  ),
960
1433
  );
961
1434
  }
@@ -966,6 +1439,8 @@ export function registerSessionCommand(program) {
966
1439
  targetPath,
967
1440
  agentId,
968
1441
  intervalSeconds,
1442
+ activeIntervalSeconds,
1443
+ activeWindowSeconds,
969
1444
  limit,
970
1445
  since,
971
1446
  replay: Boolean(options.replay),
@@ -1029,11 +1504,17 @@ export function registerSessionCommand(program) {
1029
1504
  const emitJson = shouldEmitJson(options, command);
1030
1505
 
1031
1506
  let hydration = null;
1507
+ let remoteTail = null;
1032
1508
  if (options.remote) {
1033
1509
  hydration = await hydrateSessionFromRemote({
1034
1510
  sessionId: normalizedSessionId,
1035
1511
  targetPath,
1036
1512
  });
1513
+ remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
1514
+ targetPath,
1515
+ limit: tail,
1516
+ timeoutMs: 15_000,
1517
+ });
1037
1518
  if (!emitJson) {
1038
1519
  if (hydration.ok) {
1039
1520
  console.log(
@@ -1041,6 +1522,13 @@ export function registerSessionCommand(program) {
1041
1522
  `Hydrated from remote: relayed=${hydration.relayed} dropped=${hydration.dropped}.`,
1042
1523
  ),
1043
1524
  );
1525
+ if (hydration.eventsBackfillComplete === false) {
1526
+ console.log(
1527
+ pc.yellow(
1528
+ `Remote backfill still has more pages (${hydration.eventsBackfillReason || "incomplete"}); latest tail was fetched directly.`,
1529
+ ),
1530
+ );
1531
+ }
1044
1532
  } else {
1045
1533
  console.log(
1046
1534
  pc.yellow(
@@ -1052,10 +1540,34 @@ export function registerSessionCommand(program) {
1052
1540
  }
1053
1541
 
1054
1542
  if (!options.follow) {
1055
- const events = await readStream(normalizedSessionId, {
1543
+ const allEvents = await readStream(normalizedSessionId, {
1056
1544
  targetPath,
1057
- tail,
1545
+ tail: 0,
1058
1546
  });
1547
+ const displayEvents = [...allEvents];
1548
+ if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
1549
+ const knownKeys = new Set();
1550
+ for (const event of allEvents) {
1551
+ addSessionEventIdentityKeys(knownKeys, event);
1552
+ }
1553
+ for (const event of remoteTail.events) {
1554
+ if (sessionEventHasKnownIdentity(event, knownKeys)) {
1555
+ continue;
1556
+ }
1557
+ try {
1558
+ const appended = await appendToStream(normalizedSessionId, event, {
1559
+ targetPath,
1560
+ syncRemote: false,
1561
+ });
1562
+ displayEvents.push(appended);
1563
+ addSessionEventIdentityKeys(knownKeys, appended);
1564
+ } catch {
1565
+ displayEvents.push(event);
1566
+ addSessionEventIdentityKeys(knownKeys, event);
1567
+ }
1568
+ }
1569
+ }
1570
+ const events = dedupeSessionEvents(displayEvents).slice(-tail);
1059
1571
  const payload = {
1060
1572
  command: "session read",
1061
1573
  targetPath,
@@ -1063,7 +1575,19 @@ export function registerSessionCommand(program) {
1063
1575
  tail,
1064
1576
  count: events.length,
1065
1577
  events,
1066
- remote: hydration,
1578
+ remote: hydration
1579
+ ? {
1580
+ ...hydration,
1581
+ tailProbe: remoteTail
1582
+ ? {
1583
+ ok: Boolean(remoteTail.ok),
1584
+ reason: remoteTail.reason || "",
1585
+ count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
1586
+ cursor: remoteTail.cursor || null,
1587
+ }
1588
+ : null,
1589
+ }
1590
+ : hydration,
1067
1591
  };
1068
1592
  if (emitJson) {
1069
1593
  console.log(JSON.stringify(payload, null, 2));
@@ -1121,10 +1645,15 @@ export function registerSessionCommand(program) {
1121
1645
  if (!emitJson) {
1122
1646
  console.log(pc.gray(`Following session ${normalizedSessionId}... Press Ctrl+C to stop.`));
1123
1647
  }
1648
+ const seenFollowEvents = new Set();
1124
1649
  for await (const event of tailStream(normalizedSessionId, {
1125
1650
  targetPath,
1126
1651
  replayTail: tail,
1127
1652
  })) {
1653
+ if (sessionEventHasKnownIdentity(event, seenFollowEvents)) {
1654
+ continue;
1655
+ }
1656
+ addSessionEventIdentityKeys(seenFollowEvents, event);
1128
1657
  if (emitJson) {
1129
1658
  console.log(JSON.stringify(event));
1130
1659
  } else {
@@ -1178,6 +1707,11 @@ export function registerSessionCommand(program) {
1178
1707
  dropped: result.dropped,
1179
1708
  cursor: result.cursor,
1180
1709
  persistedCursor: result.persistedCursor,
1710
+ humanRelayed: result.humanRelayed,
1711
+ eventsRelayed: result.eventsRelayed,
1712
+ eventsCursor: result.eventsCursor,
1713
+ materializedLocalSession: result.materializedLocalSession,
1714
+ localAppendComplete: result.localAppendComplete,
1181
1715
  access: access || undefined,
1182
1716
  };
1183
1717
  if (shouldEmitJson(options, command)) {
@@ -1517,7 +2051,7 @@ export function registerSessionCommand(program) {
1517
2051
  throw new Error("session id is required.");
1518
2052
  }
1519
2053
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1520
- const agentId = normalizeAgentId(options.agent, "cli-user");
2054
+ const agentId = await defaultAgentId(options.agent, targetPath);
1521
2055
  const left = await unregisterAgent(normalizedSessionId, agentId, {
1522
2056
  reason: options.reason || "manual",
1523
2057
  targetPath,