sentinelayer-cli 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -50,16 +50,24 @@ import {
50
50
  updateSessionTitle,
51
51
  } from "../session/store.js";
52
52
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
53
+ import {
54
+ addSessionEventIdentityKeys,
55
+ dedupeSessionEvents,
56
+ sessionEventHasKnownIdentity,
57
+ } from "../session/event-identity.js";
53
58
  import { readSessionPreview } from "../session/preview.js";
54
59
  import {
55
60
  listSessionsFromApi,
56
61
  probeSessionAccess,
62
+ pollSessionEventsBefore,
63
+ syncSessionEventToApi,
57
64
  syncSessionMetadataToApi,
58
65
  } from "../session/sync.js";
59
66
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
60
67
  import { mergeLiveSources } from "../session/live-source.js";
61
68
  import { listenSessionEvents } from "../session/listener.js";
62
69
  import { deriveSessionTitle } from "../session/senti-naming.js";
70
+ import { pushSessionTitleToApi } from "../session/title-sync.js";
63
71
  import {
64
72
  buildDashboardUrl,
65
73
  buildTemplateLaunchPlan,
@@ -111,6 +119,10 @@ function remoteSessionLookupDisabled() {
111
119
  return String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
112
120
  }
113
121
 
122
+ function sentiAutostartDisabled() {
123
+ return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
124
+ }
125
+
114
126
  function mergeResumeCandidate(existing, incoming) {
115
127
  if (!existing) return incoming;
116
128
  const existingActivity = Number(existing._activityMs || 0);
@@ -194,29 +206,157 @@ async function findReusableSessionCandidate({
194
206
  return candidates[0] || null;
195
207
  }
196
208
 
197
- async function pushSessionTitleToApi(sessionId, title, { targetPath } = {}) {
198
- const normalizedTitle = normalizeString(title);
199
- if (!normalizedTitle || remoteSessionLookupDisabled()) return;
209
+ // Verify that a session id is reachable for the active user via the API
210
+ // singleton endpoint added in API PR #483 (`GET /api/v1/sessions/{id}`).
211
+ //
212
+ // Carter's complaint: "I can't create a session from the web and still have
213
+ // it available for you guys in CLI" — the historical CLI flow assumed the
214
+ // session was created locally first, so attaching to a web/peer-created
215
+ // session left the agent guessing about access. Singleton GET resolves
216
+ // that with one round-trip and gives us metadata for friendly output.
217
+ //
218
+ // Behaviour contract:
219
+ // - Returns `{ ok: true, source, session, status }` on success.
220
+ // - Returns `{ ok: false, reason: "not_found", status: 404 }` when the
221
+ // session genuinely isn't visible to the caller (404 + list fallback
222
+ // also empty). Callers should map this to a friendly "not found" exit.
223
+ // - Returns `{ ok: false, reason: "forbidden", status: 403 }` for explicit
224
+ // deny (caller is authenticated but not a member).
225
+ // - On 5xx: retries ONCE, then surfaces `{ ok: false, reason: "api_5xx" }`.
226
+ // - On 404 from the singleton: falls back to filtering the list endpoint
227
+ // so users on stale prod servers (pre-#483) aren't blocked. If the list
228
+ // contains the session id we treat it as success and return that row.
229
+ // - When `SENTINELAYER_SKIP_REMOTE_SYNC=1` (test bootstrap), short-circuits
230
+ // to `{ ok: true, source: "skipped", session: null }` so unit tests
231
+ // can exercise the local materialization path without a real API.
232
+ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
233
+ const normalizedSessionId = normalizeString(sessionId);
234
+ if (!normalizedSessionId) {
235
+ return { ok: false, reason: "invalid_session_id" };
236
+ }
237
+ if (remoteSessionLookupDisabled()) {
238
+ return { ok: true, source: "skipped", session: null };
239
+ }
240
+ let auth;
200
241
  try {
201
- const session = await resolveActiveAuthSession({
202
- cwd: targetPath,
242
+ auth = await resolveActiveAuthSession({
243
+ cwd: targetPath || process.cwd(),
203
244
  env: process.env,
204
245
  autoRotate: false,
205
246
  });
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
247
  } catch {
218
- /* best-effort */
248
+ return { ok: false, reason: "no_session" };
249
+ }
250
+ if (!auth || !auth.token) {
251
+ return { ok: false, reason: "not_authenticated", status: 401 };
252
+ }
253
+ const apiUrl = String(auth.apiUrl || "").replace(/\/+$/, "");
254
+ if (!apiUrl) {
255
+ return { ok: false, reason: "no_api_url" };
256
+ }
257
+ const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
258
+ const headers = { Authorization: `Bearer ${auth.token}` };
259
+ let lastReason = "unknown";
260
+ for (let attempt = 0; attempt < 2; attempt += 1) {
261
+ let response;
262
+ try {
263
+ response = await fetch(endpoint, { method: "GET", headers });
264
+ } catch (err) {
265
+ lastReason = normalizeString(err?.message) || "fetch_failed";
266
+ continue;
267
+ }
268
+ if (response && response.ok) {
269
+ const body = await response.json().catch(() => ({}));
270
+ const sessionPayload = body && body.session && typeof body.session === "object"
271
+ ? body.session
272
+ : body && typeof body === "object"
273
+ ? body
274
+ : null;
275
+ return {
276
+ ok: true,
277
+ source: "singleton",
278
+ session: sessionPayload,
279
+ status: response.status,
280
+ };
281
+ }
282
+ if (!response) {
283
+ lastReason = "no_response";
284
+ continue;
285
+ }
286
+ if (response.status === 404) {
287
+ // Pre-#483 fallback: scan the list endpoint once for the same id.
288
+ const listResult = await listSessionsFromApi({
289
+ targetPath,
290
+ includeArchived: false,
291
+ limit: 50,
292
+ }).catch(() => null);
293
+ if (listResult && listResult.ok) {
294
+ const found = (listResult.sessions || []).find(
295
+ (entry) => normalizeString(entry?.sessionId) === normalizedSessionId,
296
+ );
297
+ if (found) {
298
+ return { ok: true, source: "list_fallback", session: found, status: 200 };
299
+ }
300
+ }
301
+ return { ok: false, reason: "not_found", status: 404 };
302
+ }
303
+ if (response.status === 403) {
304
+ return { ok: false, reason: "forbidden", status: 403 };
305
+ }
306
+ if (response.status >= 500 && response.status < 600) {
307
+ lastReason = `api_${response.status}`;
308
+ continue; // retry once on 5xx
309
+ }
310
+ return { ok: false, reason: `api_${response.status}`, status: response.status };
219
311
  }
312
+ return { ok: false, reason: lastReason };
313
+ }
314
+
315
+ // Render an absolute ISO timestamp as a coarse "Nm ago" / "Nh ago" / "Nd ago"
316
+ // label for human-readable join output. Returns `"never"` for missing input
317
+ // and `"just now"` for sub-minute deltas.
318
+ function formatRelativeAge(isoTimestamp) {
319
+ const epoch = Date.parse(normalizeString(isoTimestamp));
320
+ if (!Number.isFinite(epoch)) return "never";
321
+ const deltaMs = Date.now() - epoch;
322
+ if (deltaMs < 60_000) return "just now";
323
+ const minutes = Math.floor(deltaMs / 60_000);
324
+ if (minutes < 60) return `${minutes}m ago`;
325
+ const hours = Math.floor(minutes / 60);
326
+ if (hours < 24) return `${hours}h ago`;
327
+ const days = Math.floor(hours / 24);
328
+ return `${days}d ago`;
329
+ }
330
+
331
+ async function ensureLocalSessionForRemoteCommand(
332
+ sessionId,
333
+ { targetPath, title = "", skipRemoteProbe = false } = {},
334
+ ) {
335
+ const existing = await getSession(sessionId, { targetPath });
336
+ if (existing) {
337
+ return { materialized: false, session: existing };
338
+ }
339
+ // `skipRemoteProbe` is set by callers that have already verified the session
340
+ // via `verifyRemoteSession` (the singleton GET) — re-probing the legacy
341
+ // `/events?limit=1` endpoint here would be a redundant round-trip and, for
342
+ // tests that mock only the singleton, would spuriously 404.
343
+ if (!skipRemoteProbe) {
344
+ const access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
345
+ accessible: false,
346
+ reason: normalizeString(error?.message) || "probe_failed",
347
+ }));
348
+ if (!access?.accessible) {
349
+ throw new Error(
350
+ `Session '${sessionId}' was not found locally and remote access failed (${access?.reason || "unknown"}).`,
351
+ );
352
+ }
353
+ }
354
+ const created = await createSession({
355
+ targetPath,
356
+ sessionId,
357
+ title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
358
+ });
359
+ return { materialized: true, session: created };
220
360
  }
221
361
 
222
362
  async function ensureWorkspaceSession({
@@ -296,13 +436,16 @@ async function ensureWorkspaceSession({
296
436
 
297
437
  const effectiveTitle = titleArg || normalizeString(created.title) || fallbackTitle;
298
438
  const titleAuto = !titleArg && !resumedCandidate;
439
+ const pendingTitleSync = Boolean(created.remoteTitleSync?.pending && effectiveTitle);
299
440
  const shouldPushTitle = Boolean(
300
441
  titleArg ||
301
442
  titleAuto ||
443
+ pendingTitleSync ||
302
444
  (resumedCandidate && effectiveTitle && !normalizeString(resumedCandidate.title))
303
445
  );
446
+ let titleSync = null;
304
447
  if (shouldPushTitle) {
305
- void pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
448
+ titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
306
449
  }
307
450
 
308
451
  return {
@@ -315,6 +458,7 @@ async function ensureWorkspaceSession({
315
458
  durationMs: Date.now() - startedAt,
316
459
  title: effectiveTitle || null,
317
460
  titleAuto,
461
+ titleSync,
318
462
  };
319
463
  }
320
464
 
@@ -326,6 +470,58 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
326
470
  return normalized || fallbackValue;
327
471
  }
328
472
 
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 "";
510
+ }
511
+
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");
523
+ }
524
+
329
525
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
330
526
  const normalizedItems = Array.isArray(items) ? items : [];
331
527
  const normalizedConcurrency = Math.max(
@@ -579,6 +775,7 @@ export function registerSessionCommand(program) {
579
775
  resumed,
580
776
  title: effectiveTitle || null,
581
777
  titleAuto: Boolean(ensured.titleAuto),
778
+ titleSync: ensured.titleSync || undefined,
582
779
  };
583
780
 
584
781
  // Best-effort admin visibility sync. Session creation remains local-first.
@@ -594,6 +791,20 @@ export function registerSessionCommand(program) {
594
791
  codebaseContext: created.codebaseContext,
595
792
  }).catch(() => {});
596
793
 
794
+ // Auto-start the Senti orchestrator daemon. Without this, every
795
+ // session ran with `Senti actions: 1` (just the welcome alert)
796
+ // because nothing kicked the daemon ticking — agents joining
797
+ // never got greeted, mentions never routed, recaps never fired.
798
+ // Best-effort + non-blocking: the daemon registers itself in an
799
+ // in-memory map keyed by (sessionId, targetPath) and tolerates
800
+ // being started for an already-active session (returns the
801
+ // existing handle). If the daemon fails to start (unauth env,
802
+ // missing model proxy), the session keeps working — Senti just
803
+ // stays quiet, same as before this change.
804
+ if (!sentiAutostartDisabled()) {
805
+ void startSenti(created.sessionId, { targetPath }).catch(() => {});
806
+ }
807
+
597
808
  if (shouldEmitJson(options, command)) {
598
809
  console.log(JSON.stringify(payload, null, 2));
599
810
  return;
@@ -653,6 +864,10 @@ export function registerSessionCommand(program) {
653
864
  .command("ensure")
654
865
  .description("Join or create the canonical session for this workspace and emit JSON")
655
866
  .option("--path <path>", "Workspace path for the session", ".")
867
+ .option(
868
+ "--session <id>",
869
+ "Attach to an explicit remote-created session id (verifies + materializes local state, like `session join`).",
870
+ )
656
871
  .option("--title <title>", "Title applied if a new or unnamed resumed session needs one")
657
872
  .option(
658
873
  "--ttl-seconds <seconds>",
@@ -686,6 +901,54 @@ export function registerSessionCommand(program) {
686
901
  "reuse-window-seconds",
687
902
  3600,
688
903
  );
904
+
905
+ // --session <id> short-circuit: behave like `session join`. This is the
906
+ // path Carter cared about — "create on web, share id, attach in CLI".
907
+ // We verify the session is reachable, materialize a minimal local
908
+ // NDJSON if missing, and emit the same `{sessionId, title, resumed}`
909
+ // contract callers already consume from `ensure`.
910
+ const explicitSessionId = normalizeString(options.session);
911
+ if (explicitSessionId) {
912
+ const verification = await verifyRemoteSession(explicitSessionId, { targetPath });
913
+ if (!verification.ok) {
914
+ if (verification.status === 404 || verification.reason === "not_found") {
915
+ throw new Error(
916
+ `Session not found, archived, or not accessible to your account. (id=${explicitSessionId})`,
917
+ );
918
+ }
919
+ if (verification.status === 403 || verification.reason === "forbidden") {
920
+ throw new Error(
921
+ `Session '${explicitSessionId}' exists but your account is not a member.`,
922
+ );
923
+ }
924
+ if (verification.reason === "not_authenticated") {
925
+ throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
926
+ }
927
+ throw new Error(
928
+ `Failed to verify session '${explicitSessionId}' (${verification.reason || "unknown"}). Try again in a moment.`,
929
+ );
930
+ }
931
+ const remoteSession = verification.session || {};
932
+ const localSession = await ensureLocalSessionForRemoteCommand(explicitSessionId, {
933
+ targetPath,
934
+ title: normalizeString(remoteSession.title),
935
+ skipRemoteProbe: true,
936
+ });
937
+ const payload = {
938
+ command: "session ensure",
939
+ targetPath,
940
+ sessionId: explicitSessionId,
941
+ title: normalizeString(remoteSession.title) || localSession?.session?.title || null,
942
+ resumed: true,
943
+ attached: true,
944
+ materializedLocalSession: localSession.materialized,
945
+ verificationSource: verification.source,
946
+ dashboardUrl: buildDashboardUrl(explicitSessionId),
947
+ };
948
+ console.log(JSON.stringify(payload, null, 2));
949
+ return;
950
+ }
951
+
689
952
  const ensured = await ensureWorkspaceSession({
690
953
  targetPath,
691
954
  ttlSeconds,
@@ -701,6 +964,7 @@ export function registerSessionCommand(program) {
701
964
  title: ensured.title || null,
702
965
  resumed: Boolean(ensured.resumedCandidate),
703
966
  dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
967
+ titleSync: ensured.titleSync || undefined,
704
968
  };
705
969
  console.log(JSON.stringify(payload, null, 2));
706
970
  });
@@ -832,8 +1096,14 @@ export function registerSessionCommand(program) {
832
1096
 
833
1097
  session
834
1098
  .command("join <sessionId>")
835
- .description("Join an active session")
836
- .option("--name <name>", "Agent display name")
1099
+ .description(
1100
+ "Attach to a remote-created session for posting and listening, materializing minimal local state on demand.",
1101
+ )
1102
+ .option("--name <name>", "Agent display name (legacy alias for --agent)")
1103
+ .option(
1104
+ "--agent <id>",
1105
+ "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.",
1106
+ )
837
1107
  .option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
838
1108
  .option("--model <model>", "Agent model hint", "cli")
839
1109
  .option("--path <path>", "Workspace path for the session", ".")
@@ -844,27 +1114,104 @@ export function registerSessionCommand(program) {
844
1114
  throw new Error("session id is required.");
845
1115
  }
846
1116
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1117
+
1118
+ // PR #483 contract: verify the session exists and the caller has access
1119
+ // BEFORE materializing local cache state. Without this we'd silently
1120
+ // create a phantom local NDJSON for a session that's archived or owned
1121
+ // by another tenant — which is the bug Carter reported when asking for
1122
+ // a clean "share an id from web → join in CLI" flow.
1123
+ const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
1124
+ if (!verification.ok) {
1125
+ if (verification.status === 404 || verification.reason === "not_found") {
1126
+ throw new Error(
1127
+ `Session not found, archived, or not accessible to your account. (id=${normalizedSessionId})`,
1128
+ );
1129
+ }
1130
+ if (verification.status === 403 || verification.reason === "forbidden") {
1131
+ throw new Error(
1132
+ `Session '${normalizedSessionId}' exists but your account is not a member.`,
1133
+ );
1134
+ }
1135
+ if (verification.reason === "not_authenticated") {
1136
+ throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
1137
+ }
1138
+ throw new Error(
1139
+ `Failed to verify session '${normalizedSessionId}' (${verification.reason || "unknown"}). Try again in a moment.`,
1140
+ );
1141
+ }
1142
+
1143
+ const remoteSession = verification.session || {};
1144
+ const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
1145
+ targetPath,
1146
+ title: normalizeString(remoteSession.title),
1147
+ skipRemoteProbe: true,
1148
+ });
1149
+
1150
+ const explicitAgent = normalizeString(options.agent);
1151
+ const agentSeed = explicitAgent || normalizeString(options.name);
1152
+ const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
1153
+ const role = normalizeString(options.role) || "coder";
1154
+ const model = normalizeString(options.model) || "cli";
1155
+
1156
+ // `registerAgent` already writes the canonical `agent_join` event to the
1157
+ // local NDJSON and best-effort relays it to /events via appendToStream
1158
+ // → syncSessionEventToApi. That gives us the exact `post-agent` parity
1159
+ // the spec calls for when `--agent <granted>` is provided. We don't
1160
+ // double-emit; we just record whether the explicit agent path was used
1161
+ // so the JSON output can advertise it to callers (and tests).
847
1162
  const joined = await registerAgent(normalizedSessionId, {
848
1163
  targetPath,
849
- agentId: normalizeAgentId(options.name, "cli-user"),
850
- model: normalizeString(options.model) || "cli",
851
- role: options.role || "coder",
1164
+ agentId: resolvedAgentId,
1165
+ model,
1166
+ role,
852
1167
  });
1168
+ const agentJoinRelayed =
1169
+ Boolean(explicitAgent) &&
1170
+ Boolean(resolvedAgentId) &&
1171
+ resolvedAgentId !== "cli-user" &&
1172
+ resolvedAgentId !== "unknown" &&
1173
+ !resolvedAgentId.startsWith("human-");
1174
+
1175
+ const eventCount = Number(remoteSession.eventCount ?? remoteSession.events ?? 0);
1176
+ const agents = Array.isArray(remoteSession.agents) ? remoteSession.agents : [];
1177
+ const agentCount = Number(remoteSession.agentCount ?? agents.length ?? 0);
1178
+ const lastActivityIso =
1179
+ normalizeString(remoteSession.lastInteractionAt) ||
1180
+ normalizeString(remoteSession.lastActivityAt) ||
1181
+ normalizeString(remoteSession.updatedAt) ||
1182
+ normalizeString(remoteSession.createdAt) ||
1183
+ "";
1184
+ const remoteTitle = normalizeString(remoteSession.title);
1185
+
853
1186
  const payload = {
854
1187
  command: "session join",
1188
+ joined: true,
855
1189
  targetPath,
856
1190
  sessionId: normalizedSessionId,
1191
+ title: remoteTitle || null,
857
1192
  agentId: joined.agentId,
858
1193
  role: joined.role,
859
1194
  model: joined.model,
860
1195
  status: joined.status,
861
1196
  joinedAt: joined.joinedAt,
1197
+ materializedLocalSession: localSession.materialized,
1198
+ verificationSource: verification.source,
1199
+ eventCount: Number.isFinite(eventCount) ? eventCount : 0,
1200
+ agentCount: Number.isFinite(agentCount) ? agentCount : 0,
1201
+ lastActivityAt: lastActivityIso || null,
1202
+ agentJoinRelayed,
862
1203
  };
863
1204
  if (shouldEmitJson(options, command)) {
864
1205
  console.log(JSON.stringify(payload, null, 2));
865
1206
  return;
866
1207
  }
867
- console.log(pc.bold(`Joined session ${normalizedSessionId}`));
1208
+ const titleLabel = remoteTitle ? `"${remoteTitle}"` : "(untitled)";
1209
+ const ageLabel = lastActivityIso ? formatRelativeAge(lastActivityIso) : "never";
1210
+ console.log(
1211
+ pc.bold(
1212
+ `Joined session ${titleLabel} (${normalizedSessionId}) — ${payload.eventCount} events, ${payload.agentCount} agents, last activity ${ageLabel}`,
1213
+ ),
1214
+ );
868
1215
  console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
869
1216
  });
870
1217
 
@@ -885,7 +1232,10 @@ export function registerSessionCommand(program) {
885
1232
  throw new Error("message is required.");
886
1233
  }
887
1234
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
888
- const agentId = normalizeAgentId(options.agent, "cli-user");
1235
+ const agentId = await defaultAgentId(options.agent, targetPath);
1236
+ const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
1237
+ targetPath,
1238
+ });
889
1239
  const to = normalizeString(options.to);
890
1240
  const eventPayload = {
891
1241
  message: normalizedMessage,
@@ -894,12 +1244,29 @@ export function registerSessionCommand(program) {
894
1244
  if (to) {
895
1245
  eventPayload.to = to;
896
1246
  }
1247
+ const clientMessageId = `cli-${randomUUID()}`;
897
1248
  const event = createAgentEvent({
898
1249
  event: "session_message",
899
1250
  agentId,
900
1251
  sessionId: normalizedSessionId,
901
1252
  payload: eventPayload,
902
1253
  });
1254
+ event.eventId = clientMessageId;
1255
+ event.idempotencyToken = clientMessageId;
1256
+ let remoteSync = null;
1257
+ if (localSession.materialized) {
1258
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1259
+ remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
1260
+ targetPath,
1261
+ });
1262
+ if (remoteSync?.synced) break;
1263
+ }
1264
+ if (!remoteSync?.synced) {
1265
+ throw new Error(
1266
+ `Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated.`,
1267
+ );
1268
+ }
1269
+ }
903
1270
  const persisted = await appendToStream(normalizedSessionId, event, {
904
1271
  targetPath,
905
1272
  });
@@ -909,6 +1276,95 @@ export function registerSessionCommand(program) {
909
1276
  sessionId: normalizedSessionId,
910
1277
  agentId,
911
1278
  event: persisted,
1279
+ materializedLocalSession: localSession.materialized,
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
+ remoteSync,
912
1368
  };
913
1369
  if (shouldEmitJson(options, command)) {
914
1370
  console.log(JSON.stringify(payload, null, 2));
@@ -926,7 +1382,17 @@ export function registerSessionCommand(program) {
926
1382
  "Agent id to receive messages for",
927
1383
  process.env.SENTINELAYER_AGENT_ID || "cli-user",
928
1384
  )
929
- .option("--interval <seconds>", "Polling interval in seconds (default 60)", "60")
1385
+ .option("--interval <seconds>", "Idle polling interval in seconds (default 60)", "60")
1386
+ .option(
1387
+ "--active-interval <seconds>",
1388
+ "Polling interval after recent human activity (default 5)",
1389
+ "5",
1390
+ )
1391
+ .option(
1392
+ "--active-window <seconds>",
1393
+ "Seconds after a human message to keep the active interval (default 300)",
1394
+ "300",
1395
+ )
930
1396
  .option("--emit <format>", "Output format: ndjson or text", "ndjson")
931
1397
  .option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
932
1398
  .option("--path <path>", "Workspace path for the session", ".")
@@ -938,6 +1404,12 @@ export function registerSessionCommand(program) {
938
1404
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
939
1405
  const agentId = normalizeAgentId(options.agent, "cli-user");
940
1406
  const intervalSeconds = parsePositiveInteger(options.interval, "interval", 60);
1407
+ const activeIntervalSeconds = parsePositiveInteger(
1408
+ options.activeInterval,
1409
+ "active-interval",
1410
+ 5,
1411
+ );
1412
+ const activeWindowSeconds = parsePositiveInteger(options.activeWindow, "active-window", 300);
941
1413
  const limit = parsePositiveInteger(options.limit, "limit", 200);
942
1414
  const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
943
1415
  if (!["ndjson", "text"].includes(emitFormat)) {
@@ -955,7 +1427,7 @@ export function registerSessionCommand(program) {
955
1427
  if (emitFormat === "text") {
956
1428
  console.log(
957
1429
  pc.gray(
958
- `Listening to session ${normalizedSessionId} as ${agentId}; interval=${intervalSeconds}s. Press Ctrl+C to stop.`,
1430
+ `Listening to session ${normalizedSessionId} as ${agentId}; idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
959
1431
  ),
960
1432
  );
961
1433
  }
@@ -966,6 +1438,8 @@ export function registerSessionCommand(program) {
966
1438
  targetPath,
967
1439
  agentId,
968
1440
  intervalSeconds,
1441
+ activeIntervalSeconds,
1442
+ activeWindowSeconds,
969
1443
  limit,
970
1444
  since,
971
1445
  replay: Boolean(options.replay),
@@ -1029,11 +1503,17 @@ export function registerSessionCommand(program) {
1029
1503
  const emitJson = shouldEmitJson(options, command);
1030
1504
 
1031
1505
  let hydration = null;
1506
+ let remoteTail = null;
1032
1507
  if (options.remote) {
1033
1508
  hydration = await hydrateSessionFromRemote({
1034
1509
  sessionId: normalizedSessionId,
1035
1510
  targetPath,
1036
1511
  });
1512
+ remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
1513
+ targetPath,
1514
+ limit: tail,
1515
+ timeoutMs: 15_000,
1516
+ });
1037
1517
  if (!emitJson) {
1038
1518
  if (hydration.ok) {
1039
1519
  console.log(
@@ -1041,6 +1521,13 @@ export function registerSessionCommand(program) {
1041
1521
  `Hydrated from remote: relayed=${hydration.relayed} dropped=${hydration.dropped}.`,
1042
1522
  ),
1043
1523
  );
1524
+ if (hydration.eventsBackfillComplete === false) {
1525
+ console.log(
1526
+ pc.yellow(
1527
+ `Remote backfill still has more pages (${hydration.eventsBackfillReason || "incomplete"}); latest tail was fetched directly.`,
1528
+ ),
1529
+ );
1530
+ }
1044
1531
  } else {
1045
1532
  console.log(
1046
1533
  pc.yellow(
@@ -1052,10 +1539,34 @@ export function registerSessionCommand(program) {
1052
1539
  }
1053
1540
 
1054
1541
  if (!options.follow) {
1055
- const events = await readStream(normalizedSessionId, {
1542
+ const allEvents = await readStream(normalizedSessionId, {
1056
1543
  targetPath,
1057
- tail,
1544
+ tail: 0,
1058
1545
  });
1546
+ const displayEvents = [...allEvents];
1547
+ if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
1548
+ const knownKeys = new Set();
1549
+ for (const event of allEvents) {
1550
+ addSessionEventIdentityKeys(knownKeys, event);
1551
+ }
1552
+ for (const event of remoteTail.events) {
1553
+ if (sessionEventHasKnownIdentity(event, knownKeys)) {
1554
+ continue;
1555
+ }
1556
+ try {
1557
+ const appended = await appendToStream(normalizedSessionId, event, {
1558
+ targetPath,
1559
+ syncRemote: false,
1560
+ });
1561
+ displayEvents.push(appended);
1562
+ addSessionEventIdentityKeys(knownKeys, appended);
1563
+ } catch {
1564
+ displayEvents.push(event);
1565
+ addSessionEventIdentityKeys(knownKeys, event);
1566
+ }
1567
+ }
1568
+ }
1569
+ const events = dedupeSessionEvents(displayEvents).slice(-tail);
1059
1570
  const payload = {
1060
1571
  command: "session read",
1061
1572
  targetPath,
@@ -1063,7 +1574,19 @@ export function registerSessionCommand(program) {
1063
1574
  tail,
1064
1575
  count: events.length,
1065
1576
  events,
1066
- remote: hydration,
1577
+ remote: hydration
1578
+ ? {
1579
+ ...hydration,
1580
+ tailProbe: remoteTail
1581
+ ? {
1582
+ ok: Boolean(remoteTail.ok),
1583
+ reason: remoteTail.reason || "",
1584
+ count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
1585
+ cursor: remoteTail.cursor || null,
1586
+ }
1587
+ : null,
1588
+ }
1589
+ : hydration,
1067
1590
  };
1068
1591
  if (emitJson) {
1069
1592
  console.log(JSON.stringify(payload, null, 2));
@@ -1121,10 +1644,15 @@ export function registerSessionCommand(program) {
1121
1644
  if (!emitJson) {
1122
1645
  console.log(pc.gray(`Following session ${normalizedSessionId}... Press Ctrl+C to stop.`));
1123
1646
  }
1647
+ const seenFollowEvents = new Set();
1124
1648
  for await (const event of tailStream(normalizedSessionId, {
1125
1649
  targetPath,
1126
1650
  replayTail: tail,
1127
1651
  })) {
1652
+ if (sessionEventHasKnownIdentity(event, seenFollowEvents)) {
1653
+ continue;
1654
+ }
1655
+ addSessionEventIdentityKeys(seenFollowEvents, event);
1128
1656
  if (emitJson) {
1129
1657
  console.log(JSON.stringify(event));
1130
1658
  } else {
@@ -1178,6 +1706,11 @@ export function registerSessionCommand(program) {
1178
1706
  dropped: result.dropped,
1179
1707
  cursor: result.cursor,
1180
1708
  persistedCursor: result.persistedCursor,
1709
+ humanRelayed: result.humanRelayed,
1710
+ eventsRelayed: result.eventsRelayed,
1711
+ eventsCursor: result.eventsCursor,
1712
+ materializedLocalSession: result.materializedLocalSession,
1713
+ localAppendComplete: result.localAppendComplete,
1181
1714
  access: access || undefined,
1182
1715
  };
1183
1716
  if (shouldEmitJson(options, command)) {
@@ -1517,7 +2050,7 @@ export function registerSessionCommand(program) {
1517
2050
  throw new Error("session id is required.");
1518
2051
  }
1519
2052
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1520
- const agentId = normalizeAgentId(options.agent, "cli-user");
2053
+ const agentId = await defaultAgentId(options.agent, targetPath);
1521
2054
  const left = await unregisterAgent(normalizedSessionId, agentId, {
1522
2055
  reason: options.reason || "manual",
1523
2056
  targetPath,