sentinelayer-cli 0.8.12 → 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.
Files changed (37) hide show
  1. package/package.json +7 -2
  2. package/src/agents/backend/tools/timeout-audit.js +33 -17
  3. package/src/agents/devtestbot/config/definition.js +100 -0
  4. package/src/agents/devtestbot/config/system-prompt.js +92 -0
  5. package/src/agents/devtestbot/index.js +9 -0
  6. package/src/agents/devtestbot/runner.js +775 -0
  7. package/src/agents/devtestbot/tool.js +707 -0
  8. package/src/commands/legacy-args.js +4 -0
  9. package/src/commands/omargate.js +4 -0
  10. package/src/commands/session.js +960 -159
  11. package/src/commands/swarm.js +11 -2
  12. package/src/guide/generator.js +14 -0
  13. package/src/legacy-cli.js +35 -18
  14. package/src/prompt/generator.js +4 -16
  15. package/src/review/ai-review.js +95 -6
  16. package/src/review/dd-report-email-client.js +148 -0
  17. package/src/review/investor-dd-devtestbot.js +599 -0
  18. package/src/review/investor-dd-orchestrator.js +135 -3
  19. package/src/review/omargate-orchestrator.js +20 -2
  20. package/src/review/persona-prompts.js +34 -1
  21. package/src/review/report.js +61 -2
  22. package/src/scan/generator.js +1 -1
  23. package/src/session/coordination-guidance.js +49 -0
  24. package/src/session/daemon.js +3 -2
  25. package/src/session/event-identity.js +139 -0
  26. package/src/session/listener.js +330 -0
  27. package/src/session/live-source.js +11 -2
  28. package/src/session/mentions.js +130 -0
  29. package/src/session/remote-hydrate.js +223 -8
  30. package/src/session/setup-guides.js +3 -15
  31. package/src/session/store.js +117 -5
  32. package/src/session/stream.js +17 -7
  33. package/src/session/sync.js +375 -26
  34. package/src/session/title-sync.js +107 -0
  35. package/src/spec/generator.js +8 -10
  36. package/src/swarm/registry.js +20 -0
  37. package/src/swarm/runtime.js +139 -1
@@ -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,
@@ -47,17 +47,27 @@ import {
47
47
  listActiveSessions,
48
48
  listAllSessions,
49
49
  recordSessionProvisionedIdentities,
50
+ updateSessionTitle,
50
51
  } from "../session/store.js";
51
52
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
53
+ import {
54
+ addSessionEventIdentityKeys,
55
+ dedupeSessionEvents,
56
+ sessionEventHasKnownIdentity,
57
+ } from "../session/event-identity.js";
52
58
  import { readSessionPreview } from "../session/preview.js";
53
59
  import {
54
60
  listSessionsFromApi,
55
61
  probeSessionAccess,
62
+ pollSessionEventsBefore,
63
+ syncSessionEventToApi,
56
64
  syncSessionMetadataToApi,
57
65
  } from "../session/sync.js";
58
66
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
59
67
  import { mergeLiveSources } from "../session/live-source.js";
68
+ import { listenSessionEvents } from "../session/listener.js";
60
69
  import { deriveSessionTitle } from "../session/senti-naming.js";
70
+ import { pushSessionTitleToApi } from "../session/title-sync.js";
61
71
  import {
62
72
  buildDashboardUrl,
63
73
  buildTemplateLaunchPlan,
@@ -89,6 +99,369 @@ function parsePositiveInteger(rawValue, field, fallbackValue) {
89
99
  return Math.floor(normalized);
90
100
  }
91
101
 
102
+ function normalizeComparablePath(value) {
103
+ return String(value || "")
104
+ .trim()
105
+ .replace(/\\/g, "/")
106
+ .replace(/\/+$/g, "")
107
+ .toLowerCase();
108
+ }
109
+
110
+ function latestSessionActivityMs(entry = {}) {
111
+ for (const key of ["lastInteractionAt", "lastActivityAt", "createdAt"]) {
112
+ const epoch = Date.parse(normalizeString(entry[key]));
113
+ if (Number.isFinite(epoch)) return epoch;
114
+ }
115
+ return 0;
116
+ }
117
+
118
+ function remoteSessionLookupDisabled() {
119
+ return String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
120
+ }
121
+
122
+ function sentiAutostartDisabled() {
123
+ return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
124
+ }
125
+
126
+ function mergeResumeCandidate(existing, incoming) {
127
+ if (!existing) return incoming;
128
+ const existingActivity = Number(existing._activityMs || 0);
129
+ const incomingActivity = Number(incoming._activityMs || 0);
130
+ const preferIncomingPaths = existing._source !== "local" && incoming._source === "local";
131
+ const base = preferIncomingPaths ? incoming : existing;
132
+ const other = preferIncomingPaths ? existing : incoming;
133
+ return {
134
+ ...base,
135
+ title: normalizeString(base.title) || normalizeString(other.title) || null,
136
+ lastActivityAt:
137
+ normalizeString(incoming.lastActivityAt) || normalizeString(existing.lastActivityAt) || null,
138
+ lastInteractionAt:
139
+ normalizeString(incoming.lastInteractionAt) || normalizeString(existing.lastInteractionAt) || null,
140
+ _activityMs: Math.max(existingActivity, incomingActivity),
141
+ };
142
+ }
143
+
144
+ async function findReusableSessionCandidate({
145
+ targetPath,
146
+ reuseWindowSeconds = 3600,
147
+ resume = true,
148
+ forceNew = false,
149
+ } = {}) {
150
+ if (forceNew || resume === false) return null;
151
+ const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
152
+ const byId = new Map();
153
+
154
+ try {
155
+ const active = await listActiveSessions({ targetPath });
156
+ for (const entry of active) {
157
+ const activityMs = latestSessionActivityMs(entry);
158
+ if (!activityMs || activityMs < cutoffMs) continue;
159
+ const candidate = {
160
+ ...entry,
161
+ _source: "local",
162
+ _activityMs: activityMs,
163
+ };
164
+ byId.set(entry.sessionId, mergeResumeCandidate(byId.get(entry.sessionId), candidate));
165
+ }
166
+ } catch {
167
+ /* local lookup failure is non-fatal */
168
+ }
169
+
170
+ if (!remoteSessionLookupDisabled()) {
171
+ try {
172
+ const remote = await listSessionsFromApi({
173
+ targetPath,
174
+ includeArchived: false,
175
+ limit: 50,
176
+ });
177
+ if (remote && remote.ok) {
178
+ const normalizedTarget = normalizeComparablePath(targetPath);
179
+ for (const entry of remote.sessions || []) {
180
+ const codebase = normalizeComparablePath(entry.codebasePath || entry.targetPath);
181
+ if (!codebase || codebase !== normalizedTarget) continue;
182
+ if (entry.archiveStatus && entry.archiveStatus !== "active") continue;
183
+ const activityMs = latestSessionActivityMs(entry);
184
+ if (!activityMs || activityMs < cutoffMs) continue;
185
+ const candidate = {
186
+ sessionId: entry.sessionId,
187
+ createdAt: entry.createdAt,
188
+ lastActivityAt: entry.lastActivityAt,
189
+ expiresAt: entry.expiresAt,
190
+ status: entry.status || "active",
191
+ template: entry.templateName || null,
192
+ title: entry.title || null,
193
+ _source: "remote",
194
+ _activityMs: activityMs,
195
+ };
196
+ byId.set(entry.sessionId, mergeResumeCandidate(byId.get(entry.sessionId), candidate));
197
+ }
198
+ }
199
+ } catch {
200
+ /* remote lookup failure is non-fatal */
201
+ }
202
+ }
203
+
204
+ const candidates = [...byId.values()];
205
+ candidates.sort((left, right) => Number(right._activityMs || 0) - Number(left._activityMs || 0));
206
+ return candidates[0] || null;
207
+ }
208
+
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;
241
+ try {
242
+ auth = await resolveActiveAuthSession({
243
+ cwd: targetPath || process.cwd(),
244
+ env: process.env,
245
+ autoRotate: false,
246
+ });
247
+ } catch {
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 };
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 };
360
+ }
361
+
362
+ async function ensureWorkspaceSession({
363
+ targetPath,
364
+ ttlSeconds = DEFAULT_TTL_SECONDS,
365
+ template = null,
366
+ title = "",
367
+ resume = true,
368
+ forceNew = false,
369
+ reuseWindowSeconds = 3600,
370
+ } = {}) {
371
+ const titleArg = normalizeString(title);
372
+ const fallbackTitle = deriveSessionTitle(targetPath);
373
+ const startedAt = Date.now();
374
+ const resumedCandidate = await findReusableSessionCandidate({
375
+ targetPath,
376
+ reuseWindowSeconds,
377
+ resume,
378
+ forceNew,
379
+ });
380
+ let created;
381
+ const resumeTitle =
382
+ titleArg || normalizeString(resumedCandidate?.title) || fallbackTitle;
383
+
384
+ if (resumedCandidate) {
385
+ if (resumedCandidate._source === "remote" && !resumedCandidate.sessionDir) {
386
+ created = await createSession({
387
+ targetPath,
388
+ ttlSeconds,
389
+ sessionId: resumedCandidate.sessionId,
390
+ title: resumeTitle,
391
+ createdAt: resumedCandidate.createdAt,
392
+ expiresAt: resumedCandidate.expiresAt,
393
+ lastInteractionAt:
394
+ resumedCandidate.lastInteractionAt ||
395
+ resumedCandidate.lastActivityAt ||
396
+ resumedCandidate.createdAt,
397
+ });
398
+ } else {
399
+ created = {
400
+ sessionId: resumedCandidate.sessionId,
401
+ sessionDir: resumedCandidate.sessionDir || null,
402
+ metadataPath: resumedCandidate.metadataPath || null,
403
+ streamPath: resumedCandidate.streamPath || null,
404
+ createdAt: resumedCandidate.createdAt,
405
+ updatedAt: resumedCandidate.updatedAt || null,
406
+ lastInteractionAt: resumedCandidate.lastInteractionAt || null,
407
+ expiresAt: resumedCandidate.expiresAt,
408
+ elapsedTimer: resumedCandidate.elapsedTimer || 0,
409
+ renewalCount: resumedCandidate.renewalCount || 0,
410
+ status: resumedCandidate.status || "active",
411
+ template: resumedCandidate.template || null,
412
+ title: normalizeString(resumedCandidate.title) || null,
413
+ codebaseContext: resumedCandidate.codebaseContext || null,
414
+ };
415
+ if (resumeTitle && resumeTitle !== created.title) {
416
+ const updated = await updateSessionTitle(created.sessionId, {
417
+ targetPath,
418
+ title: resumeTitle,
419
+ }).catch(() => null);
420
+ if (updated) {
421
+ created = {
422
+ ...created,
423
+ ...updated,
424
+ };
425
+ }
426
+ }
427
+ }
428
+ } else {
429
+ created = await createSession({
430
+ targetPath,
431
+ ttlSeconds,
432
+ template,
433
+ title: titleArg || fallbackTitle,
434
+ });
435
+ }
436
+
437
+ const effectiveTitle = titleArg || normalizeString(created.title) || fallbackTitle;
438
+ const titleAuto = !titleArg && !resumedCandidate;
439
+ const pendingTitleSync = Boolean(created.remoteTitleSync?.pending && effectiveTitle);
440
+ const shouldPushTitle = Boolean(
441
+ titleArg ||
442
+ titleAuto ||
443
+ pendingTitleSync ||
444
+ (resumedCandidate && effectiveTitle && !normalizeString(resumedCandidate.title))
445
+ );
446
+ let titleSync = null;
447
+ if (shouldPushTitle) {
448
+ titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
449
+ }
450
+
451
+ return {
452
+ created: {
453
+ ...created,
454
+ title: effectiveTitle || null,
455
+ resumed: Boolean(resumedCandidate),
456
+ },
457
+ resumedCandidate,
458
+ durationMs: Date.now() - startedAt,
459
+ title: effectiveTitle || null,
460
+ titleAuto,
461
+ titleSync,
462
+ };
463
+ }
464
+
92
465
  function normalizeAgentId(value, fallbackValue = "cli-user") {
93
466
  const normalized = normalizeString(value)
94
467
  .toLowerCase()
@@ -97,6 +470,58 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
97
470
  return normalized || fallbackValue;
98
471
  }
99
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
+
100
525
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
101
526
  const normalizedItems = Array.isArray(items) ? items : [];
102
527
  const normalizedConcurrency = Math.max(
@@ -279,6 +704,15 @@ export function registerSessionCommand(program) {
279
704
  "--force-new",
280
705
  "Always create a new session even if a recent active one exists for this workspace",
281
706
  )
707
+ .option(
708
+ "--resume",
709
+ "Reuse the most recent active session for this workspace when one is inside the reuse window",
710
+ true,
711
+ )
712
+ .option(
713
+ "--no-resume",
714
+ "Disable automatic resume and mint a new session unless --force-new is also present",
715
+ )
282
716
  .option(
283
717
  "--reuse-window-seconds <seconds>",
284
718
  "Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
@@ -302,149 +736,22 @@ export function registerSessionCommand(program) {
302
736
  "reuse-window-seconds",
303
737
  3600,
304
738
  );
305
-
306
- // Auto-resume: prefer an existing active session for this codebase
307
- // over minting a new one. We check both local filesystem state and the
308
- // remote registry — local-only resume meant a fresh checkout / second
309
- // machine would orphan the room each time, exactly the mess Carter
310
- // surfaced ("all of them look like one chat re-created").
311
- //
312
- // Order:
313
- // 1. Local session for the same targetPath inside the reuse window.
314
- // 2. Remote active session whose codebasePath matches the absolute
315
- // targetPath, sorted by last activity. We fold these into the
316
- // candidate pool so a session minted on another machine can be
317
- // rejoined rather than duplicated.
318
- // `--force-new` opts back into the old "always mint" behavior.
319
- let resumed = null;
320
- if (!options.forceNew) {
321
- const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
322
- const candidates = [];
323
- try {
324
- const active = await listActiveSessions({ targetPath });
325
- for (const entry of active) {
326
- const createdMs = Date.parse(entry.createdAt || "");
327
- if (Number.isFinite(createdMs) && createdMs >= cutoffMs) {
328
- candidates.push({ ...entry, _source: "local" });
329
- }
330
- }
331
- } catch {
332
- /* local lookup failure is non-fatal */
333
- }
334
- try {
335
- const remote = await listSessionsFromApi({
336
- targetPath,
337
- includeArchived: false,
338
- limit: 50,
339
- });
340
- if (remote && remote.ok) {
341
- const normalizedTarget = String(targetPath).toLowerCase();
342
- for (const entry of remote.sessions || []) {
343
- const codebase = String(entry.codebasePath || "").toLowerCase();
344
- if (!codebase || codebase !== normalizedTarget) continue;
345
- if (entry.archiveStatus && entry.archiveStatus !== "active") continue;
346
- const lastMs = Date.parse(entry.lastActivityAt || entry.createdAt || "");
347
- if (Number.isFinite(lastMs) && lastMs >= cutoffMs) {
348
- candidates.push({
349
- sessionId: entry.sessionId,
350
- createdAt: entry.createdAt,
351
- lastActivityAt: entry.lastActivityAt,
352
- expiresAt: entry.expiresAt,
353
- status: entry.status || "active",
354
- template: entry.templateName || null,
355
- title: entry.title || null,
356
- _source: "remote",
357
- });
358
- }
359
- }
360
- }
361
- } catch {
362
- /* remote lookup failure is non-fatal */
363
- }
364
- if (candidates.length > 0) {
365
- // Prefer the most recent activity. Local + remote may name the
366
- // same session; dedupe on sessionId before picking.
367
- const seen = new Set();
368
- const deduped = [];
369
- for (const entry of candidates) {
370
- if (seen.has(entry.sessionId)) continue;
371
- seen.add(entry.sessionId);
372
- deduped.push(entry);
373
- }
374
- deduped.sort((a, b) =>
375
- String(b.lastActivityAt || b.createdAt || "").localeCompare(
376
- String(a.lastActivityAt || a.createdAt || ""),
377
- ),
378
- );
379
- resumed = deduped[0];
380
- }
381
- }
382
-
383
- const startedAt = Date.now();
384
- let created;
385
- if (resumed) {
386
- // Surface the resumed session's metadata in the same shape
387
- // createSession returns so downstream code stays unchanged.
388
- created = {
389
- sessionId: resumed.sessionId,
390
- sessionDir: resumed.sessionDir || null,
391
- metadataPath: resumed.metadataPath || null,
392
- streamPath: resumed.streamPath || null,
393
- createdAt: resumed.createdAt,
394
- expiresAt: resumed.expiresAt,
395
- elapsedTimer: 0,
396
- renewalCount: resumed.renewalCount || 0,
397
- status: resumed.status || "active",
398
- template: resumed.template || null,
399
- codebaseContext: resumed.codebaseContext || null,
400
- resumed: true,
401
- };
402
- } else {
403
- created = await createSession({
404
- targetPath,
405
- ttlSeconds,
406
- template,
407
- });
408
- }
409
- const durationMs = Date.now() - startedAt;
739
+ const titleArg = normalizeString(options.title);
740
+ const ensured = await ensureWorkspaceSession({
741
+ targetPath,
742
+ ttlSeconds,
743
+ template,
744
+ title: titleArg,
745
+ resume: options.resume !== false,
746
+ forceNew: Boolean(options.forceNew),
747
+ reuseWindowSeconds,
748
+ });
749
+ const created = ensured.created;
750
+ const resumed = Boolean(ensured.resumedCandidate);
751
+ const durationMs = ensured.durationMs;
410
752
  const launchPlan = template ? buildTemplateLaunchPlan(created.sessionId, template) : [];
411
753
  const dashboardUrl = buildDashboardUrl(created.sessionId);
412
- // Default the title to a stable codebase+date slug so the web sidebar
413
- // never fills with anonymous "<null>" rows. The caller can still
414
- // override with --title. We skip the auto-title for resumed sessions
415
- // because the room already has a name we don't want to clobber.
416
- const titleArg = normalizeString(options.title);
417
- const autoTitle = !resumed && !titleArg ? deriveSessionTitle(targetPath) : "";
418
- const effectiveTitle = titleArg || autoTitle;
419
-
420
- // If a title needs to land on the dashboard, push it. We always push
421
- // when the caller passed --title, AND we push the auto-derived title
422
- // for fresh (non-resumed) sessions so the room is never anonymous on
423
- // the web. Best-effort, non-blocking.
424
- if (effectiveTitle) {
425
- void (async () => {
426
- try {
427
- const session = await resolveActiveAuthSession({
428
- cwd: targetPath,
429
- env: process.env,
430
- autoRotate: false,
431
- });
432
- if (!session?.token || !session?.apiUrl) return;
433
- const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
434
- await requestJsonMutation(
435
- `${apiUrl}/api/v1/sessions/${encodeURIComponent(created.sessionId)}/title`,
436
- {
437
- method: "POST",
438
- operationName: "session.set_title",
439
- headers: { Authorization: `Bearer ${session.token}` },
440
- body: { title: effectiveTitle },
441
- },
442
- );
443
- } catch (_error) {
444
- /* best-effort */
445
- }
446
- })();
447
- }
754
+ const effectiveTitle = ensured.title;
448
755
 
449
756
  const payload = {
450
757
  command: "session start",
@@ -455,7 +762,9 @@ export function registerSessionCommand(program) {
455
762
  metadataPath: created.metadataPath,
456
763
  streamPath: created.streamPath,
457
764
  createdAt: created.createdAt,
765
+ updatedAt: created.updatedAt,
458
766
  expiresAt: created.expiresAt,
767
+ lastInteractionAt: created.lastInteractionAt,
459
768
  ttlSeconds,
460
769
  elapsedTimer: created.elapsedTimer,
461
770
  renewalCount: created.renewalCount,
@@ -463,9 +772,10 @@ export function registerSessionCommand(program) {
463
772
  template: created.template,
464
773
  launchPlan,
465
774
  dashboardUrl,
466
- resumed: Boolean(resumed),
775
+ resumed,
467
776
  title: effectiveTitle || null,
468
- titleAuto: Boolean(autoTitle && !titleArg),
777
+ titleAuto: Boolean(ensured.titleAuto),
778
+ titleSync: ensured.titleSync || undefined,
469
779
  };
470
780
 
471
781
  // Best-effort admin visibility sync. Session creation remains local-first.
@@ -475,11 +785,26 @@ export function registerSessionCommand(program) {
475
785
  status: created.status,
476
786
  createdAt: created.createdAt,
477
787
  expiresAt: created.expiresAt,
788
+ title: effectiveTitle || null,
478
789
  ttlSeconds,
479
790
  template: created.template,
480
791
  codebaseContext: created.codebaseContext,
481
792
  }).catch(() => {});
482
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
+
483
808
  if (shouldEmitJson(options, command)) {
484
809
  console.log(JSON.stringify(payload, null, 2));
485
810
  return;
@@ -535,6 +860,115 @@ export function registerSessionCommand(program) {
535
860
  await program.parseAsync(args, { from: "user" });
536
861
  });
537
862
 
863
+ session
864
+ .command("ensure")
865
+ .description("Join or create the canonical session for this workspace and emit JSON")
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
+ )
871
+ .option("--title <title>", "Title applied if a new or unnamed resumed session needs one")
872
+ .option(
873
+ "--ttl-seconds <seconds>",
874
+ `Session time-to-live in seconds when a new session is minted (default ${DEFAULT_TTL_SECONDS})`
875
+ )
876
+ .option(
877
+ "--force-new",
878
+ "Always create a new session even if a recent active one exists for this workspace",
879
+ )
880
+ .option(
881
+ "--resume",
882
+ "Reuse the most recent active session for this workspace when one is inside the reuse window",
883
+ true,
884
+ )
885
+ .option("--no-resume", "Disable automatic resume and mint a new session")
886
+ .option(
887
+ "--reuse-window-seconds <seconds>",
888
+ "Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
889
+ "3600",
890
+ )
891
+ .option("--json", "Emit machine-readable output (default for this command)")
892
+ .action(async (options) => {
893
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
894
+ const ttlSeconds = parsePositiveInteger(
895
+ options.ttlSeconds,
896
+ "ttl-seconds",
897
+ DEFAULT_TTL_SECONDS,
898
+ );
899
+ const reuseWindowSeconds = parsePositiveInteger(
900
+ options.reuseWindowSeconds,
901
+ "reuse-window-seconds",
902
+ 3600,
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
+
952
+ const ensured = await ensureWorkspaceSession({
953
+ targetPath,
954
+ ttlSeconds,
955
+ title: normalizeString(options.title),
956
+ resume: options.resume !== false,
957
+ forceNew: Boolean(options.forceNew),
958
+ reuseWindowSeconds,
959
+ });
960
+ const payload = {
961
+ command: "session ensure",
962
+ targetPath,
963
+ sessionId: ensured.created.sessionId,
964
+ title: ensured.title || null,
965
+ resumed: Boolean(ensured.resumedCandidate),
966
+ dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
967
+ titleSync: ensured.titleSync || undefined,
968
+ };
969
+ console.log(JSON.stringify(payload, null, 2));
970
+ });
971
+
538
972
  session
539
973
  .command("set-title <sessionId> <title>")
540
974
  .description("Set the human-readable title on a session (visible in web sidebar + transcript).")
@@ -564,10 +998,15 @@ export function registerSessionCommand(program) {
564
998
  body: { title: normalizedTitle },
565
999
  },
566
1000
  );
1001
+ const localUpdated = await updateSessionTitle(normalizedSessionId, {
1002
+ targetPath,
1003
+ title: normalizedTitle,
1004
+ }).catch(() => null);
567
1005
  const payload = {
568
1006
  command: "session set-title",
569
1007
  sessionId: normalizedSessionId,
570
1008
  title: normalizedTitle,
1009
+ localUpdated: Boolean(localUpdated),
571
1010
  result,
572
1011
  };
573
1012
  if (shouldEmitJson(options, command)) {
@@ -657,8 +1096,14 @@ export function registerSessionCommand(program) {
657
1096
 
658
1097
  session
659
1098
  .command("join <sessionId>")
660
- .description("Join an active session")
661
- .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
+ )
662
1107
  .option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
663
1108
  .option("--model <model>", "Agent model hint", "cli")
664
1109
  .option("--path <path>", "Workspace path for the session", ".")
@@ -669,27 +1114,104 @@ export function registerSessionCommand(program) {
669
1114
  throw new Error("session id is required.");
670
1115
  }
671
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).
672
1162
  const joined = await registerAgent(normalizedSessionId, {
673
1163
  targetPath,
674
- agentId: normalizeAgentId(options.name, "cli-user"),
675
- model: normalizeString(options.model) || "cli",
676
- role: options.role || "coder",
1164
+ agentId: resolvedAgentId,
1165
+ model,
1166
+ role,
677
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
+
678
1186
  const payload = {
679
1187
  command: "session join",
1188
+ joined: true,
680
1189
  targetPath,
681
1190
  sessionId: normalizedSessionId,
1191
+ title: remoteTitle || null,
682
1192
  agentId: joined.agentId,
683
1193
  role: joined.role,
684
1194
  model: joined.model,
685
1195
  status: joined.status,
686
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,
687
1203
  };
688
1204
  if (shouldEmitJson(options, command)) {
689
1205
  console.log(JSON.stringify(payload, null, 2));
690
1206
  return;
691
1207
  }
692
- 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
+ );
693
1215
  console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
694
1216
  });
695
1217
 
@@ -697,6 +1219,7 @@ export function registerSessionCommand(program) {
697
1219
  .command("say <sessionId> <message>")
698
1220
  .description("Send a message to the session")
699
1221
  .option("--agent <id>", "Agent id to emit from", "cli-user")
1222
+ .option("--to <agent>", "Direct the message to a specific agent id")
700
1223
  .option("--path <path>", "Workspace path for the session", ".")
701
1224
  .option("--json", "Emit machine-readable output")
702
1225
  .action(async (sessionId, message, options, command) => {
@@ -709,16 +1232,41 @@ export function registerSessionCommand(program) {
709
1232
  throw new Error("message is required.");
710
1233
  }
711
1234
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
712
- const agentId = normalizeAgentId(options.agent, "cli-user");
1235
+ const agentId = await defaultAgentId(options.agent, targetPath);
1236
+ const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
1237
+ targetPath,
1238
+ });
1239
+ const to = normalizeString(options.to);
1240
+ const eventPayload = {
1241
+ message: normalizedMessage,
1242
+ channel: "session",
1243
+ };
1244
+ if (to) {
1245
+ eventPayload.to = to;
1246
+ }
1247
+ const clientMessageId = `cli-${randomUUID()}`;
713
1248
  const event = createAgentEvent({
714
1249
  event: "session_message",
715
1250
  agentId,
716
1251
  sessionId: normalizedSessionId,
717
- payload: {
718
- message: normalizedMessage,
719
- channel: "session",
720
- },
1252
+ payload: eventPayload,
721
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
+ }
722
1270
  const persisted = await appendToStream(normalizedSessionId, event, {
723
1271
  targetPath,
724
1272
  });
@@ -728,6 +1276,8 @@ export function registerSessionCommand(program) {
728
1276
  sessionId: normalizedSessionId,
729
1277
  agentId,
730
1278
  event: persisted,
1279
+ materializedLocalSession: localSession.materialized,
1280
+ remoteSync: remoteSync || undefined,
731
1281
  };
732
1282
  if (shouldEmitJson(options, command)) {
733
1283
  console.log(JSON.stringify(payload, null, 2));
@@ -736,6 +1286,198 @@ export function registerSessionCommand(program) {
736
1286
  console.log(formatEventLine(persisted));
737
1287
  });
738
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,
1368
+ };
1369
+ if (shouldEmitJson(options, command)) {
1370
+ console.log(JSON.stringify(payload, null, 2));
1371
+ return;
1372
+ }
1373
+ console.log(formatEventLine(persisted));
1374
+ });
1375
+
1376
+ session
1377
+ .command("listen")
1378
+ .description("Background-poll a session for events addressed to this agent or broadcast")
1379
+ .requiredOption("--session <id>", "Session id to listen to")
1380
+ .option(
1381
+ "--agent <id>",
1382
+ "Agent id to receive messages for",
1383
+ process.env.SENTINELAYER_AGENT_ID || "cli-user",
1384
+ )
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
+ )
1396
+ .option("--emit <format>", "Output format: ndjson or text", "ndjson")
1397
+ .option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
1398
+ .option("--path <path>", "Workspace path for the session", ".")
1399
+ .option("--since <cursor>", "Override the persisted listen cursor")
1400
+ .option("--replay", "Emit matching historical events on the first poll")
1401
+ .option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
1402
+ .action(async (options) => {
1403
+ const normalizedSessionId = resolveSessionIdOption(options);
1404
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1405
+ const agentId = normalizeAgentId(options.agent, "cli-user");
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);
1413
+ const limit = parsePositiveInteger(options.limit, "limit", 200);
1414
+ const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
1415
+ if (!["ndjson", "text"].includes(emitFormat)) {
1416
+ throw new Error("--emit must be one of: ndjson, text.");
1417
+ }
1418
+ const maxPolls =
1419
+ options.maxPolls === undefined
1420
+ ? null
1421
+ : parsePositiveInteger(options.maxPolls, "max-polls", 1);
1422
+ const since = options.since === undefined ? undefined : String(options.since);
1423
+ const ac = new AbortController();
1424
+ const onSigint = () => ac.abort();
1425
+ process.on("SIGINT", onSigint);
1426
+
1427
+ if (emitFormat === "text") {
1428
+ console.log(
1429
+ pc.gray(
1430
+ `Listening to session ${normalizedSessionId} as ${agentId}; idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
1431
+ ),
1432
+ );
1433
+ }
1434
+
1435
+ try {
1436
+ await listenSessionEvents({
1437
+ sessionId: normalizedSessionId,
1438
+ targetPath,
1439
+ agentId,
1440
+ intervalSeconds,
1441
+ activeIntervalSeconds,
1442
+ activeWindowSeconds,
1443
+ limit,
1444
+ since,
1445
+ replay: Boolean(options.replay),
1446
+ maxPolls,
1447
+ signal: ac.signal,
1448
+ onEvent: async (event) => {
1449
+ if (emitFormat === "ndjson") {
1450
+ console.log(JSON.stringify(event));
1451
+ } else {
1452
+ console.log(formatEventLine(event));
1453
+ }
1454
+ },
1455
+ onError: async (result) => {
1456
+ const reason = normalizeString(result?.reason) || "poll_failed";
1457
+ if (emitFormat === "ndjson") {
1458
+ console.log(
1459
+ JSON.stringify(
1460
+ createAgentEvent({
1461
+ event: "session_listen_error",
1462
+ agentId,
1463
+ sessionId: normalizedSessionId,
1464
+ payload: {
1465
+ reason,
1466
+ cursor: result?.cursor || null,
1467
+ },
1468
+ }),
1469
+ ),
1470
+ );
1471
+ } else {
1472
+ console.log(pc.yellow(`Listen poll skipped (${reason}).`));
1473
+ }
1474
+ },
1475
+ });
1476
+ } finally {
1477
+ process.removeListener("SIGINT", onSigint);
1478
+ }
1479
+ });
1480
+
739
1481
  session
740
1482
  .command("read <sessionId>")
741
1483
  .description("Read recent session messages")
@@ -761,11 +1503,17 @@ export function registerSessionCommand(program) {
761
1503
  const emitJson = shouldEmitJson(options, command);
762
1504
 
763
1505
  let hydration = null;
1506
+ let remoteTail = null;
764
1507
  if (options.remote) {
765
1508
  hydration = await hydrateSessionFromRemote({
766
1509
  sessionId: normalizedSessionId,
767
1510
  targetPath,
768
1511
  });
1512
+ remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
1513
+ targetPath,
1514
+ limit: tail,
1515
+ timeoutMs: 15_000,
1516
+ });
769
1517
  if (!emitJson) {
770
1518
  if (hydration.ok) {
771
1519
  console.log(
@@ -773,6 +1521,13 @@ export function registerSessionCommand(program) {
773
1521
  `Hydrated from remote: relayed=${hydration.relayed} dropped=${hydration.dropped}.`,
774
1522
  ),
775
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
+ }
776
1531
  } else {
777
1532
  console.log(
778
1533
  pc.yellow(
@@ -784,10 +1539,34 @@ export function registerSessionCommand(program) {
784
1539
  }
785
1540
 
786
1541
  if (!options.follow) {
787
- const events = await readStream(normalizedSessionId, {
1542
+ const allEvents = await readStream(normalizedSessionId, {
788
1543
  targetPath,
789
- tail,
1544
+ tail: 0,
790
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);
791
1570
  const payload = {
792
1571
  command: "session read",
793
1572
  targetPath,
@@ -795,7 +1574,19 @@ export function registerSessionCommand(program) {
795
1574
  tail,
796
1575
  count: events.length,
797
1576
  events,
798
- 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,
799
1590
  };
800
1591
  if (emitJson) {
801
1592
  console.log(JSON.stringify(payload, null, 2));
@@ -853,10 +1644,15 @@ export function registerSessionCommand(program) {
853
1644
  if (!emitJson) {
854
1645
  console.log(pc.gray(`Following session ${normalizedSessionId}... Press Ctrl+C to stop.`));
855
1646
  }
1647
+ const seenFollowEvents = new Set();
856
1648
  for await (const event of tailStream(normalizedSessionId, {
857
1649
  targetPath,
858
1650
  replayTail: tail,
859
1651
  })) {
1652
+ if (sessionEventHasKnownIdentity(event, seenFollowEvents)) {
1653
+ continue;
1654
+ }
1655
+ addSessionEventIdentityKeys(seenFollowEvents, event);
860
1656
  if (emitJson) {
861
1657
  console.log(JSON.stringify(event));
862
1658
  } else {
@@ -910,6 +1706,11 @@ export function registerSessionCommand(program) {
910
1706
  dropped: result.dropped,
911
1707
  cursor: result.cursor,
912
1708
  persistedCursor: result.persistedCursor,
1709
+ humanRelayed: result.humanRelayed,
1710
+ eventsRelayed: result.eventsRelayed,
1711
+ eventsCursor: result.eventsCursor,
1712
+ materializedLocalSession: result.materializedLocalSession,
1713
+ localAppendComplete: result.localAppendComplete,
913
1714
  access: access || undefined,
914
1715
  };
915
1716
  if (shouldEmitJson(options, command)) {
@@ -1249,7 +2050,7 @@ export function registerSessionCommand(program) {
1249
2050
  throw new Error("session id is required.");
1250
2051
  }
1251
2052
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1252
- const agentId = normalizeAgentId(options.agent, "cli-user");
2053
+ const agentId = await defaultAgentId(options.agent, targetPath);
1253
2054
  const left = await unregisterAgent(normalizedSessionId, agentId, {
1254
2055
  reason: options.reason || "manual",
1255
2056
  targetPath,