sentinelayer-cli 0.34.7 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.34.7",
3
+ "version": "0.35.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -65,6 +65,12 @@ import {
65
65
  updateSessionTitle,
66
66
  } from "../session/store.js";
67
67
  import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
68
+ import {
69
+ getListenerProcessStatus,
70
+ removeListenerPidRecord,
71
+ requestListenerProcessStop,
72
+ writeListenerPidRecord,
73
+ } from "../session/listener-process.js";
68
74
  import {
69
75
  filterSessionMaterialEvents,
70
76
  isSessionControlEvent,
@@ -4395,6 +4401,7 @@ export function registerSessionCommand(program) {
4395
4401
  )
4396
4402
  .option("--path <path>", "Workspace path for the session", ".")
4397
4403
  .option("--limit <n>", "Recent events to scan for heartbeats (default 200)", "200")
4404
+ .option("--max-pages <n>", "Maximum older event pages to scan for heartbeats (default 5)", "5")
4398
4405
  .option("--json", "Emit machine-readable output")
4399
4406
  .action(async (sessionId, options, command) => {
4400
4407
  const normalizedSessionId = normalizeString(sessionId);
@@ -4404,7 +4411,12 @@ export function registerSessionCommand(program) {
4404
4411
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4405
4412
  await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
4406
4413
  const limit = parsePositiveInteger(options.limit, "limit", 200);
4407
- const result = await fetchSessionListeners(normalizedSessionId, { targetPath, limit });
4414
+ const maxPages = parsePositiveInteger(options.maxPages, "max-pages", 5);
4415
+ const result = await fetchSessionListeners(normalizedSessionId, {
4416
+ targetPath,
4417
+ limit,
4418
+ maxPages,
4419
+ });
4408
4420
  const listeners = Array.isArray(result.listeners) ? result.listeners : [];
4409
4421
  const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
4410
4422
  const payload = {
@@ -4414,6 +4426,11 @@ export function registerSessionCommand(program) {
4414
4426
  reason: result.ok ? undefined : result.reason,
4415
4427
  count: listeners.length,
4416
4428
  liveCount: live,
4429
+ pageCount: Number(result.pageCount || 0),
4430
+ scannedEventCount: Number(result.scannedEventCount || 0),
4431
+ listenerEventCount: Number(result.listenerEventCount || 0),
4432
+ partial: Boolean(result.partial),
4433
+ beforeSequence: result.beforeSequence || null,
4417
4434
  listeners,
4418
4435
  };
4419
4436
  if (shouldEmitJson(options, command)) {
@@ -4538,6 +4555,14 @@ export function registerSessionCommand(program) {
4538
4555
  .option("--from-now", "Advance the listen cursor to the latest durable event before polling")
4539
4556
  .option("--replay", "Emit matching historical events on the first poll")
4540
4557
  .option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
4558
+ .option(
4559
+ "--force",
4560
+ "Stop and replace an existing local listener process for this session/agent",
4561
+ )
4562
+ .option(
4563
+ "--allow-duplicate",
4564
+ "Allow multiple local listeners for the same session/agent (advanced; can duplicate wake hooks)",
4565
+ )
4541
4566
  .option(
4542
4567
  "--wake <command>",
4543
4568
  "Wake hook: run this shell command on each matched event (notify->resume bridge). Metadata JSON is piped to stdin; sanitized SL_WAKE_* env vars are set.",
@@ -4666,18 +4691,59 @@ export function registerSessionCommand(program) {
4666
4691
  if (options.fromNow && options.since !== undefined) {
4667
4692
  throw new Error("Use either --from-now or --since, not both.");
4668
4693
  }
4669
- const restoreConsoleLog = listenLogPath
4670
- ? installRotatingConsoleLog({
4671
- logPath: listenLogPath,
4672
- maxBytes: parsePositiveInteger(options.logMaxBytes, "log-max-bytes", DEFAULT_ROTATING_LOG_MAX_BYTES),
4673
- maxFiles: parsePositiveInteger(options.logMaxFiles, "log-max-files", DEFAULT_ROTATING_LOG_MAX_FILES),
4674
- tee: true,
4675
- })
4676
- : null;
4694
+ const listenerId = `listener-${agentId}-${randomUUID()}`;
4695
+ const singletonEnabled = maxPolls === null && !options.allowDuplicate;
4696
+ let listenerPidRecordActive = false;
4697
+ if (singletonEnabled) {
4698
+ const existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
4699
+ targetPath,
4700
+ });
4701
+ if (existingListener.running && !options.force) {
4702
+ throw new Error(
4703
+ `A session listener is already running for session ${normalizedSessionId} as ${agentId} (pid ${existingListener.pid}). Use --force to take over or --allow-duplicate if you intentionally need more than one.`,
4704
+ );
4705
+ }
4706
+ if (existingListener.running && options.force) {
4707
+ const stopResult = await requestListenerProcessStop(existingListener.pid);
4708
+ if (!stopResult.stopped) {
4709
+ throw new Error(
4710
+ `Unable to stop existing listener pid ${existingListener.pid} for session ${normalizedSessionId} as ${agentId} (${stopResult.reason}). Stop it manually or use --allow-duplicate if parallel listeners are intentional.`,
4711
+ );
4712
+ }
4713
+ }
4714
+ await writeListenerPidRecord(normalizedSessionId, agentId, {
4715
+ targetPath,
4716
+ listenerId,
4717
+ transport: listenTransport,
4718
+ intervalSeconds,
4719
+ activeIntervalSeconds,
4720
+ presenceIntervalSeconds,
4721
+ logFile: listenLogPath,
4722
+ });
4723
+ listenerPidRecordActive = true;
4724
+ }
4725
+ let restoreConsoleLog = null;
4726
+ try {
4727
+ restoreConsoleLog = listenLogPath
4728
+ ? installRotatingConsoleLog({
4729
+ logPath: listenLogPath,
4730
+ maxBytes: parsePositiveInteger(options.logMaxBytes, "log-max-bytes", DEFAULT_ROTATING_LOG_MAX_BYTES),
4731
+ maxFiles: parsePositiveInteger(options.logMaxFiles, "log-max-files", DEFAULT_ROTATING_LOG_MAX_FILES),
4732
+ tee: true,
4733
+ })
4734
+ : null;
4735
+ } catch (error) {
4736
+ if (listenerPidRecordActive) {
4737
+ await removeListenerPidRecord(normalizedSessionId, agentId, {
4738
+ targetPath,
4739
+ onlyForPid: process.pid,
4740
+ }).catch(() => {});
4741
+ }
4742
+ throw error;
4743
+ }
4677
4744
  const ac = new AbortController();
4678
4745
  const onSigint = () => ac.abort();
4679
4746
  process.on("SIGINT", onSigint);
4680
- const listenerId = `listener-${agentId}-${randomUUID()}`;
4681
4747
  const durablePresenceEnabled = options.presence !== false;
4682
4748
  const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
4683
4749
  const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
@@ -4909,6 +4975,12 @@ export function registerSessionCommand(program) {
4909
4975
  restoreConsoleLog();
4910
4976
  }
4911
4977
  process.removeListener("SIGINT", onSigint);
4978
+ if (listenerPidRecordActive) {
4979
+ await removeListenerPidRecord(normalizedSessionId, agentId, {
4980
+ targetPath,
4981
+ onlyForPid: process.pid,
4982
+ }).catch(() => {});
4983
+ }
4912
4984
  }
4913
4985
  });
4914
4986
 
@@ -563,6 +563,47 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
563
563
  server: "sentinelayer-session-mcp",
564
564
  },
565
565
  },
566
+ {
567
+ name: "read_history",
568
+ title: "Read Senti History",
569
+ description:
570
+ "Hydrate a bounded recent, older, or after-cursor SentinelLayer session transcript window without recipient filtering.",
571
+ input_schema: {
572
+ type: "object",
573
+ additionalProperties: false,
574
+ required: ["sessionId"],
575
+ properties: {
576
+ sessionId: { type: "string" },
577
+ cursor: { type: "string" },
578
+ beforeSequence: { type: "integer", minimum: 1 },
579
+ limit: { type: "integer", minimum: 1, maximum: 200 },
580
+ actionLimit: { type: "integer", minimum: 1, maximum: 200 },
581
+ includeActions: { type: "boolean" },
582
+ includeControlEvents: { type: "boolean" },
583
+ },
584
+ },
585
+ transport: {
586
+ type: "internal",
587
+ method: "POST",
588
+ url: "sentinelayer://session/read_history",
589
+ timeout_ms: 15000,
590
+ auth: { mode: "none" },
591
+ },
592
+ budgets: {
593
+ max_calls_per_run: 60,
594
+ max_runtime_ms: 15000,
595
+ },
596
+ security: {
597
+ requires_human_approval: false,
598
+ kill_switch: "enabled",
599
+ scopes: ["session:read"],
600
+ },
601
+ metadata: {
602
+ provider: "sentinelayer",
603
+ adapter: "sentinelayer-cli",
604
+ server: "sentinelayer-session-mcp",
605
+ },
606
+ },
566
607
  {
567
608
  name: "send_message",
568
609
  title: "Send Senti Message",
@@ -985,6 +1026,13 @@ export function buildHostedSentiSessionConnectorContract({
985
1026
  long_lived_cli_token_passthrough_allowed: false,
986
1027
  },
987
1028
  tools: [
1029
+ hostedSessionTool({
1030
+ toolName: "join_and_hydrate",
1031
+ operation: "join_and_hydrate",
1032
+ allowedUserArgs: ["limit", "actionLimit", "includeActions"],
1033
+ requiredScopes: ["session:read"],
1034
+ wakePayload: { policy: "bounded_redacted_window", may_include_message_content: true, max_events: 50 },
1035
+ }),
988
1036
  hostedSessionTool({
989
1037
  toolName: "poll_inbox",
990
1038
  operation: "read_history",
@@ -992,6 +1040,13 @@ export function buildHostedSentiSessionConnectorContract({
992
1040
  requiredScopes: ["session:read"],
993
1041
  wakePayload: { policy: "bounded_redacted_window", may_include_message_content: true, max_events: 50 },
994
1042
  }),
1043
+ hostedSessionTool({
1044
+ toolName: "read_history",
1045
+ operation: "read_history",
1046
+ allowedUserArgs: ["cursor", "beforeSequence", "limit", "actionLimit", "includeActions"],
1047
+ requiredScopes: ["session:read"],
1048
+ wakePayload: { policy: "bounded_redacted_window", may_include_message_content: true, max_events: 50 },
1049
+ }),
995
1050
  hostedSessionTool({
996
1051
  toolName: "send_message",
997
1052
  operation: "send_message",
@@ -1279,7 +1334,7 @@ export function validateHostedSentiSessionConnectorContract(payload, { registryP
1279
1334
  const registry = validateMcpToolRegistry(registryPayload);
1280
1335
  const localToolNames = new Set(registry.tools.map((tool) => tool.name));
1281
1336
  const missingLocalTools = parsed.tools
1282
- .filter((tool) => tool.operation !== "subscribe_wake" && tool.operation !== "presence_renew" && tool.operation !== "runner_teardown")
1337
+ .filter((tool) => tool.operation !== "join_and_hydrate" && tool.operation !== "subscribe_wake" && tool.operation !== "presence_renew" && tool.operation !== "runner_teardown")
1283
1338
  .map((tool) => tool.local_tool_name)
1284
1339
  .filter((toolName) => !localToolNames.has(toolName));
1285
1340
  if (missingLocalTools.length > 0) {
@@ -31,6 +31,8 @@ const SESSION_MCP_CONFIRM_PAGE_LIMIT = 200;
31
31
  const SESSION_MCP_CONFIRM_MAX_PAGES = 10;
32
32
  const SESSION_MCP_CONFIRM_TOTAL_TIMEOUT_MS = 10_000;
33
33
  const SESSION_MCP_CONFIRM_REQUEST_TIMEOUT_MS = 2_000;
34
+ const SESSION_MCP_HISTORY_MAX_PAGES = 10;
35
+ const SESSION_MCP_HISTORY_MIN_RAW_PAGE_LIMIT = DEFAULT_TOOL_LIMIT;
34
36
  const JSON_RPC_VERSION = "2.0";
35
37
  const CONTENT_LENGTH_PREFIX = "content-length:";
36
38
  const SESSION_MESSAGE_ACTION_TYPES = new Set([
@@ -444,6 +446,110 @@ function recentHumanActivityFromActions(remoteActions = null, { limit = DEFAULT_
444
446
  : [];
445
447
  }
446
448
 
449
+ function normalizeHistoryEvents(events = [], { includeControlEvents = false } = {}) {
450
+ return (Array.isArray(events) ? events : []).filter((event) => {
451
+ if (!includeControlEvents && isSessionControlEvent(event)) return false;
452
+ return true;
453
+ });
454
+ }
455
+
456
+ async function readSessionHistoryWindow({
457
+ sessionId,
458
+ targetPath,
459
+ cursor = null,
460
+ beforeSequence = null,
461
+ limit = DEFAULT_TOOL_LIMIT,
462
+ includeControlEvents = false,
463
+ forceCircuitProbe = false,
464
+ pollSessionEventsFn = pollSessionEvents,
465
+ pollSessionEventsBeforeFn = pollSessionEventsBefore,
466
+ } = {}) {
467
+ const source = cursor ? "after_cursor" : beforeSequence ? "before_sequence" : "latest_tail";
468
+ let pageCursor = cursor;
469
+ let pageBeforeSequence = beforeSequence;
470
+ let responseCursor = cursor;
471
+ let responseBeforeSequence = beforeSequence || null;
472
+ let rawEventCount = 0;
473
+ let pages = 0;
474
+ let events = [];
475
+
476
+ while (events.length < limit && pages < SESSION_MCP_HISTORY_MAX_PAGES) {
477
+ const remaining = Math.max(1, limit - events.length);
478
+ const pageLimit = Math.max(
479
+ remaining,
480
+ Math.min(MAX_TOOL_LIMIT, SESSION_MCP_HISTORY_MIN_RAW_PAGE_LIMIT),
481
+ );
482
+ const result = cursor
483
+ ? await pollSessionEventsFn(sessionId, {
484
+ targetPath,
485
+ since: pageCursor,
486
+ limit: pageLimit,
487
+ forceCircuitProbe,
488
+ })
489
+ : await pollSessionEventsBeforeFn(sessionId, {
490
+ targetPath,
491
+ beforeSequence: pageBeforeSequence,
492
+ limit: pageLimit,
493
+ forceCircuitProbe,
494
+ });
495
+ if (!result?.ok) {
496
+ if (pages > 0) break;
497
+ return {
498
+ ok: false,
499
+ reason: result?.reason || "read_history_failed",
500
+ source,
501
+ cursor: responseCursor,
502
+ beforeSequence: responseBeforeSequence,
503
+ events: [],
504
+ eventCount: 0,
505
+ pages,
506
+ };
507
+ }
508
+
509
+ pages += 1;
510
+ const rawEvents = Array.isArray(result.events) ? result.events : [];
511
+ rawEventCount += rawEvents.length;
512
+ const materialEvents = normalizeHistoryEvents(rawEvents, { includeControlEvents });
513
+ if (cursor) {
514
+ events.push(...materialEvents);
515
+ const nextCursor = normalizeString(result.cursor) || pageCursor;
516
+ responseCursor = nextCursor || responseCursor;
517
+ if (!rawEvents.length || !nextCursor || nextCursor === pageCursor) break;
518
+ pageCursor = nextCursor;
519
+ continue;
520
+ }
521
+
522
+ events = [...materialEvents, ...events];
523
+ if (pages === 1) {
524
+ responseCursor = normalizeString(result.cursor) || responseCursor;
525
+ }
526
+ const nextBeforeSequence = normalizePositiveInteger(result.beforeSequence || result.before_sequence, null);
527
+ responseBeforeSequence = nextBeforeSequence;
528
+ if (!rawEvents.length || !nextBeforeSequence || nextBeforeSequence === pageBeforeSequence) break;
529
+ pageBeforeSequence = nextBeforeSequence;
530
+ }
531
+
532
+ const windowEvents = cursor ? events.slice(0, limit) : events.slice(-limit);
533
+ const firstWindowEvent = windowEvents[0] || null;
534
+ const lastWindowEvent = windowEvents[windowEvents.length - 1] || null;
535
+ const windowCursor = normalizeString(lastWindowEvent?.cursor);
536
+ const windowBeforeSequence = normalizePositiveInteger(
537
+ firstWindowEvent?.sequenceId ?? firstWindowEvent?.sequence_id,
538
+ null,
539
+ );
540
+
541
+ return {
542
+ ok: true,
543
+ reason: "",
544
+ source,
545
+ cursor: windowCursor || responseCursor,
546
+ beforeSequence: cursor ? null : windowBeforeSequence || responseBeforeSequence,
547
+ events: windowEvents,
548
+ eventCount: rawEventCount,
549
+ pages,
550
+ };
551
+ }
552
+
447
553
  function requireSessionId(input = {}) {
448
554
  const sessionId = normalizeString(input.sessionId || input.session_id || input.session);
449
555
  if (!sessionId) {
@@ -807,6 +913,83 @@ export function createSessionMcpToolHandlers({
807
913
  };
808
914
  },
809
915
 
916
+ async read_history(input = {}) {
917
+ const sessionId = requireSessionId(input);
918
+ const cursor = normalizeString(input.cursor || input.after || input.since) || null;
919
+ const beforeSequence = normalizeOptionalPositiveInteger(
920
+ input.beforeSequence || input.before_sequence,
921
+ "beforeSequence",
922
+ );
923
+ if (cursor && beforeSequence) {
924
+ throw new Error("Use either cursor/after/since or beforeSequence, not both.");
925
+ }
926
+ const limit = normalizeLimit(input.limit || input.tail || input.maxEvents || input.max_events);
927
+ const actionLimit = normalizeLimit(input.actionLimit || input.action_limit || limit);
928
+ const includeActions = input.includeActions !== false && input.include_actions !== false;
929
+ const includeControlEvents = Boolean(input.includeControlEvents || input.include_control_events);
930
+ const includeRaw = Boolean(input.includeRaw || input.include_raw);
931
+ const forceCircuitProbe = input.forceCircuitProbe !== false && input.force_circuit_probe !== false;
932
+
933
+ const result = await readSessionHistoryWindow({
934
+ sessionId,
935
+ targetPath,
936
+ cursor,
937
+ beforeSequence,
938
+ limit,
939
+ includeControlEvents,
940
+ forceCircuitProbe,
941
+ pollSessionEventsFn,
942
+ pollSessionEventsBeforeFn,
943
+ });
944
+ if (!result?.ok) {
945
+ return {
946
+ ok: false,
947
+ reason: result?.reason || "read_history_failed",
948
+ sessionId,
949
+ source: result?.source || (cursor ? "after_cursor" : beforeSequence ? "before_sequence" : "latest_tail"),
950
+ cursor,
951
+ beforeSequence: beforeSequence || null,
952
+ events: [],
953
+ eventCount: 0,
954
+ materialEventCount: 0,
955
+ };
956
+ }
957
+
958
+ const events = Array.isArray(result.events) ? result.events : [];
959
+ const actionResult = includeActions
960
+ ? await listSessionMessageActionsFn(sessionId, {
961
+ targetPath,
962
+ limit: actionLimit,
963
+ forceCircuitProbe,
964
+ })
965
+ : null;
966
+ const recentHumanActivity = actionResult?.ok
967
+ ? recentHumanActivityFromActions(actionResult, { limit: actionLimit })
968
+ : [];
969
+
970
+ return {
971
+ ok: true,
972
+ reason: "",
973
+ sessionId,
974
+ source: result.source,
975
+ cursor: normalizeString(result.cursor) || cursor,
976
+ beforeSequence: normalizePositiveInteger(result.beforeSequence || result.before_sequence, null),
977
+ eventCount: normalizePositiveInteger(result.eventCount, 0),
978
+ materialEventCount: events.length,
979
+ pageCount: normalizePositiveInteger(result.pages, 0),
980
+ recentHumanActivityCount: recentHumanActivity.length,
981
+ recentHumanActivity,
982
+ actionProjection: actionResult
983
+ ? {
984
+ ok: Boolean(actionResult.ok),
985
+ reason: actionResult.reason || "",
986
+ count: Array.isArray(actionResult.actions) ? actionResult.actions.length : 0,
987
+ }
988
+ : null,
989
+ events: includeRaw ? events : events.map((event) => summarizeSessionEvent(event)),
990
+ };
991
+ },
992
+
810
993
  async send_message(input = {}) {
811
994
  const sessionId = requireSessionId(input);
812
995
  const agentId = requireAgentId(input);
@@ -1013,6 +1196,27 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1013
1196
  },
1014
1197
  },
1015
1198
  },
1199
+ {
1200
+ name: "read_history",
1201
+ title: "Read Senti History",
1202
+ description:
1203
+ "Hydrate a bounded recent, older, or after-cursor session transcript window without recipient filtering.",
1204
+ inputSchema: {
1205
+ type: "object",
1206
+ additionalProperties: false,
1207
+ required: ["sessionId"],
1208
+ properties: {
1209
+ sessionId: { type: "string", minLength: 1 },
1210
+ cursor: { type: "string" },
1211
+ beforeSequence: { type: "integer", minimum: 1 },
1212
+ limit: { type: "integer", minimum: 1, maximum: MAX_TOOL_LIMIT, default: DEFAULT_TOOL_LIMIT },
1213
+ actionLimit: { type: "integer", minimum: 1, maximum: MAX_TOOL_LIMIT, default: DEFAULT_TOOL_LIMIT },
1214
+ includeActions: { type: "boolean", default: true },
1215
+ includeControlEvents: { type: "boolean", default: false },
1216
+ includeRaw: { type: "boolean", default: false },
1217
+ },
1218
+ },
1219
+ },
1016
1220
  {
1017
1221
  name: "send_message",
1018
1222
  title: "Send Senti Message",
@@ -1285,7 +1489,7 @@ export async function handleMcpJsonRpcMessage(
1285
1489
  version: "0.20.0",
1286
1490
  },
1287
1491
  instructions:
1288
- "Use native Senti tools for coordination actions and generated sl.* bridge tools for the rest of SentinelLayer CLI. Poll first with poll_inbox, use session_action/session_react/session_reply/session_lock/session_unlock/session_locks for low-noise session work, use send_message for durable posts, and use generated command tools such as sl.auth.status or sl.review.scan for ordinary CLI commands.",
1492
+ "Use native Senti tools for coordination actions and generated sl.* bridge tools for the rest of SentinelLayer CLI. Use poll_inbox for addressed wake-style delivery, read_history for recent/older transcript grounding, session_action/session_react/session_reply/session_lock/session_unlock/session_locks for low-noise session work, send_message for durable posts, and generated command tools such as sl.auth.status or sl.review.scan for ordinary CLI commands.",
1289
1493
  });
1290
1494
  }
1291
1495
 
@@ -230,18 +230,17 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
230
230
  google_api_key:
231
231
  "${{ secrets.GOOGLE_GEMINI_API_KEY != '' && secrets.GOOGLE_GEMINI_API_KEY || secrets.GOOGLE_API_KEY }}",
232
232
  llm_provider:
233
- "${{ secrets.OPENAI_API_KEY != '' && 'openai' || ((secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'google' || 'openai') }}",
233
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'google' || 'openai' }}",
234
234
  sentinelayer_token: `\${{ secrets.${normalizedSecret} }}`,
235
235
  sentinelayer_managed_llm: `\${{ secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '' && secrets.OPENAI_API_KEY == '' && secrets.${normalizedSecret} != '' }}`,
236
236
  scan_mode: profile.scanMode || "deep",
237
237
  severity_gate: profile.severityGate || "P1",
238
238
  model:
239
- "${{ secrets.OPENAI_API_KEY != '' && 'gpt-5.3-codex' || ((secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-5.3-codex') }}",
239
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-5.3-codex' }}",
240
240
  codex_model: "gpt-5.3-codex",
241
241
  model_fallback:
242
242
  "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-4.1-mini' }}",
243
- use_codex:
244
- "${{ secrets.OPENAI_API_KEY != '' || (secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '') }}",
243
+ use_codex: "${{ secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '' }}",
245
244
  codex_only: "false",
246
245
  llm_failure_policy: "block",
247
246
  max_daily_scans: "${{ vars.OMAR_MAX_DAILY_SCANS || '200' }}",
@@ -304,9 +303,9 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
304
303
  "${{ secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '' }}",
305
304
  OMAR_MANAGED_LLM: `\${{ secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '' && secrets.OPENAI_API_KEY == '' && secrets.${normalizedSecret} != '' }}`,
306
305
  OMAR_LLM_PROVIDER:
307
- "${{ secrets.OPENAI_API_KEY != '' && 'openai' || ((secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'google' || 'openai') }}",
306
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'google' || 'openai' }}",
308
307
  OMAR_MODEL:
309
- "${{ secrets.OPENAI_API_KEY != '' && 'gpt-5.3-codex' || ((secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-5.3-codex') }}",
308
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-5.3-codex' }}",
310
309
  OMAR_MODEL_FALLBACK:
311
310
  "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-4.1-mini' }}",
312
311
  },
@@ -353,7 +352,7 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
353
352
  " 'llm_provider': env('OMAR_LLM_PROVIDER', 'openai'),",
354
353
  " 'model': env('OMAR_MODEL', 'gpt-5.3-codex'),",
355
354
  " 'model_fallback': env('OMAR_MODEL_FALLBACK', 'gpt-4.1-mini'),",
356
- " 'llm_route': 'openai_api_key' if bool_env('OMAR_OPENAI_KEY_PRESENT') else ('google_api_key' if bool_env('OMAR_GOOGLE_KEY_PRESENT') else 'sentinelayer_managed'),",
355
+ " 'llm_route': 'google_api_key' if bool_env('OMAR_GOOGLE_KEY_PRESENT') else ('openai_api_key' if bool_env('OMAR_OPENAI_KEY_PRESENT') else 'sentinelayer_managed'),",
357
356
  " 'google_key_present': bool_env('OMAR_GOOGLE_KEY_PRESENT'),",
358
357
  " 'openai_key_present': bool_env('OMAR_OPENAI_KEY_PRESENT'),",
359
358
  " 'managed_llm': bool_env('OMAR_MANAGED_LLM'),",
@@ -5,7 +5,7 @@ export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
5
5
  "When you have an agent grant, post agent updates with `sl session post-agent <id> \"status: <update>\" --agent <your-agent-id>` so they render as the agent, not the human relay.",
6
6
  "Before implementation, post a short plan and file claims with `sl session say <id> \"plan: <scope>; files: <paths>\"`.",
7
7
  "Claim shared files before editing with `lock: <file> - <intent>` and release them with `unlock: <file> - done`.",
8
- "Run a background/secondary listener for replies with `sl session listen --session <id> --agent <your-name> --interval 60 --active-interval 5 --emit ndjson --no-presence`; this idles at 60s, switches to 5s after human activity, and avoids durable listener heartbeat noise. `session listen` is only a delivery cursor, not a grounding command; join or recap before acting. For your primary interactive listener, omit `--no-presence` so other participants can see you online. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
8
+ "Run one background/secondary listener for replies with `sl session listen --session <id> --agent <your-name> --interval 60 --active-interval 5 --emit ndjson --no-presence`; this idles at 60s, switches to 5s after human activity, and avoids durable listener heartbeat noise. `session listen` now refuses duplicate local listeners for the same session/agent by default; use `--force` only to stop and replace an existing local owner and `--allow-duplicate` only when you intentionally need parallel wake hooks. `session listen` is only a delivery cursor, not a grounding command; join or recap before acting. For your primary interactive listener, omit `--no-presence` so other participants can see you online. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
9
9
  "For long-lived rooms, make sure exactly one visible participant owns the Senti daemon: `sl session daemon --session <id> --recap-interval 300 --checkpoint-interval 60`. If no durable recap/checkpoint is appearing, run `sl session recap now <id> --remote --agent <your-name> --json` before posting a long plan.",
10
10
  "Use message actions for low-noise coordination before posting a new top-level message: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK is useful, `sl session action <id> working_on --target-sequence <n>` for ownership, and `sl session reply <id> <sequence> \"<message>\"` / `sl session comment <id> <sequence> \"<message>\"` for threaded responses. Read receipts are automatic when you use `sl session read <id> --remote --agent <your-name>`; reserve `sl session view <id> <sequence>` for repair/backfill. Run `sl session actions` for the full list.",
11
11
  "Search before asking peers to restate context: `sl session search <id> \"<topic>\" --limit 10`.",
@@ -38,7 +38,7 @@ export function buildFirstSentiMessage({ sessionId } = {}) {
38
38
  `7) sl session reply ${sid} <THIS_SEQ> "ACK <AGENT_ID> role=<role> model=<model> locks=none first_action=<one concrete action>" --agent <AGENT_ID>`,
39
39
  "",
40
40
  "STAY REACHABLE — two honest paths, use YOURS (always pass --agent <AGENT_ID> so the room knows who's listening):",
41
- `- Persistent process (Codex/GPT): keep ONE listener alive + a 60s watchdog that restarts it if the pid dies (cursor catch-up replays anything missed):`,
41
+ `- Persistent process (Codex/GPT): keep ONE listener alive. Re-running listen refuses a duplicate local pid by default; use --force only to stop and replace an existing local owner and --allow-duplicate only for intentional parallel wake hooks. Pair it with a 60s watchdog that restarts it if the pid dies (cursor catch-up replays anything missed):`,
42
42
  ` sl session listen --session ${sid} --agent <AGENT_ID> --active-interval 30 --active-window 300 --interval 60 --presence-interval 60 --model <MODEL> --display-name <AGENT_ID>`,
43
43
  `- No socket (Claude Code): you are NOT continuously connected — you re-arm a wake each turn. 30-270s while actively waiting, 20-30min idle when quiet. Treat time-critical things as POSTED messages you catch next tick, never assumed instant.`,
44
44
  `CADENCE both converge to: ~30s active; after 5min quiet → 60s; after another 5 → 90s; +30s every 5min to a floor. Wake IMMEDIATELY on: new human/ORCH msg, a direct reply/@mention, a lock conflict, a deploy/gate notice.`,
@@ -0,0 +1,354 @@
1
+ import fsp from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { execFile } from "node:child_process";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import { promisify } from "node:util";
8
+
9
+ import { isProcessAlive } from "./daemon-spawn.js";
10
+ import { resolveSessionPaths } from "./paths.js";
11
+
12
+ const LISTENER_DIR_NAME = "listeners";
13
+ const GLOBAL_LISTENER_DIR_NAME = "session-listeners";
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ function normalizeString(value) {
17
+ return String(value || "").trim();
18
+ }
19
+
20
+ export function normalizeListenerProcessKey(agentId = "") {
21
+ return (
22
+ normalizeString(agentId)
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9._-]+/g, "-")
25
+ .replace(/^-+|-+$/g, "") || "agent"
26
+ );
27
+ }
28
+
29
+ function normalizeCommandLine(value) {
30
+ return normalizeString(value).toLowerCase();
31
+ }
32
+
33
+ function listenerCommandLineMatches(commandLine, { sessionId = "", agentId = "" } = {}) {
34
+ const normalized = normalizeCommandLine(commandLine);
35
+ if (!normalized) return false;
36
+ const normalizedSessionId = normalizeString(sessionId).toLowerCase();
37
+ const normalizedAgentId = normalizeString(agentId).toLowerCase();
38
+ const hasCliEntry =
39
+ normalized.includes("sentinelayer-cli") ||
40
+ /[\\/](?:sl|sentinelayer-cli)\.js\b/.test(normalized);
41
+ return (
42
+ hasCliEntry &&
43
+ normalized.includes("session") &&
44
+ normalized.includes("listen") &&
45
+ (!normalizedSessionId || normalized.includes(normalizedSessionId)) &&
46
+ (!normalizedAgentId || normalized.includes(normalizedAgentId))
47
+ );
48
+ }
49
+
50
+ async function readWindowsCommandLine(pid) {
51
+ const command = [
52
+ "$ErrorActionPreference = 'Stop';",
53
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${Number(pid)}";`,
54
+ "if ($p) { $p.CommandLine }",
55
+ ].join(" ");
56
+ const { stdout } = await execFileAsync(
57
+ "powershell.exe",
58
+ ["-NoProfile", "-NonInteractive", "-Command", command],
59
+ { windowsHide: true, timeout: 2000 },
60
+ );
61
+ return normalizeString(stdout);
62
+ }
63
+
64
+ async function readPosixCommandLine(pid) {
65
+ if (process.platform === "linux") {
66
+ try {
67
+ const raw = await fsp.readFile(`/proc/${Number(pid)}/cmdline`);
68
+ return normalizeString(raw.toString("utf8").replace(/\0/g, " "));
69
+ } catch {
70
+ // Fall through to ps(1), which also covers restricted /proc mounts.
71
+ }
72
+ }
73
+ const { stdout } = await execFileAsync("ps", ["-p", String(Number(pid)), "-o", "command="], {
74
+ timeout: 2000,
75
+ });
76
+ return normalizeString(stdout);
77
+ }
78
+
79
+ export async function readProcessCommandLine(pid) {
80
+ const numericPid = Number(pid);
81
+ if (!Number.isInteger(numericPid) || numericPid <= 0) return "";
82
+ try {
83
+ return process.platform === "win32"
84
+ ? await readWindowsCommandLine(numericPid)
85
+ : await readPosixCommandLine(numericPid);
86
+ } catch {
87
+ return "";
88
+ }
89
+ }
90
+
91
+ export function resolveListenerPidPath(
92
+ sessionId,
93
+ agentId,
94
+ { targetPath = process.cwd() } = {},
95
+ ) {
96
+ const paths = resolveSessionPaths(sessionId, { targetPath });
97
+ return path.join(
98
+ paths.sessionDir,
99
+ LISTENER_DIR_NAME,
100
+ `${normalizeListenerProcessKey(agentId)}.json`,
101
+ );
102
+ }
103
+
104
+ export function resolveGlobalListenerPidPath(
105
+ sessionId,
106
+ agentId,
107
+ { homeDir = os.homedir() } = {},
108
+ ) {
109
+ return path.join(
110
+ path.resolve(String(homeDir || os.homedir())),
111
+ ".sentinelayer",
112
+ GLOBAL_LISTENER_DIR_NAME,
113
+ normalizeListenerProcessKey(sessionId),
114
+ `${normalizeListenerProcessKey(agentId)}.json`,
115
+ );
116
+ }
117
+
118
+ async function readPidRecordAtPath(pidPath) {
119
+ try {
120
+ const raw = await fsp.readFile(pidPath, "utf-8");
121
+ const parsed = JSON.parse(raw);
122
+ if (!parsed || typeof parsed !== "object") {
123
+ return null;
124
+ }
125
+ return parsed;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ async function writePidRecordAtPath(pidPath, record) {
132
+ await fsp.mkdir(path.dirname(pidPath), { recursive: true });
133
+ await fsp.writeFile(pidPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
134
+ }
135
+
136
+ async function removePidRecordAtPath(pidPath, { onlyForPid = null } = {}) {
137
+ if (onlyForPid != null) {
138
+ const existing = await readPidRecordAtPath(pidPath);
139
+ if (existing && Number(existing.pid) !== Number(onlyForPid)) {
140
+ return false;
141
+ }
142
+ }
143
+ try {
144
+ await fsp.unlink(pidPath);
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ export async function readListenerPidRecord(
152
+ sessionId,
153
+ agentId,
154
+ { targetPath = process.cwd() } = {},
155
+ ) {
156
+ const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
157
+ return readPidRecordAtPath(pidPath);
158
+ }
159
+
160
+ export async function readGlobalListenerPidRecord(
161
+ sessionId,
162
+ agentId,
163
+ { homeDir = os.homedir() } = {},
164
+ ) {
165
+ const pidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
166
+ return readPidRecordAtPath(pidPath);
167
+ }
168
+
169
+ export async function writeListenerPidRecord(
170
+ sessionId,
171
+ agentId,
172
+ {
173
+ targetPath = process.cwd(),
174
+ pid = process.pid,
175
+ listenerId = "",
176
+ transport = "",
177
+ intervalSeconds = null,
178
+ activeIntervalSeconds = null,
179
+ presenceIntervalSeconds = null,
180
+ logFile = "",
181
+ homeDir = os.homedir(),
182
+ } = {},
183
+ ) {
184
+ const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
185
+ const globalPidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
186
+ const record = {
187
+ pid: Number(pid),
188
+ sessionId: normalizeString(sessionId),
189
+ agentId: normalizeString(agentId),
190
+ listenerKey: normalizeListenerProcessKey(agentId),
191
+ listenerId: normalizeString(listenerId),
192
+ targetPath: path.resolve(String(targetPath || ".")),
193
+ transport: normalizeString(transport) || undefined,
194
+ intervalSeconds: Number(intervalSeconds) || undefined,
195
+ activeIntervalSeconds: Number(activeIntervalSeconds) || undefined,
196
+ presenceIntervalSeconds: Number(presenceIntervalSeconds) || undefined,
197
+ logFile: normalizeString(logFile) || undefined,
198
+ startedAt: new Date().toISOString(),
199
+ };
200
+ const globalRecord = {
201
+ ...record,
202
+ recordScope: "global",
203
+ localPidPath: pidPath,
204
+ };
205
+ await writePidRecordAtPath(globalPidPath, globalRecord);
206
+ try {
207
+ await writePidRecordAtPath(pidPath, record);
208
+ } catch (error) {
209
+ await removePidRecordAtPath(globalPidPath, { onlyForPid: pid }).catch(() => {});
210
+ throw error;
211
+ }
212
+ return record;
213
+ }
214
+
215
+ export async function removeListenerPidRecord(
216
+ sessionId,
217
+ agentId,
218
+ { targetPath = process.cwd(), onlyForPid = null, homeDir = os.homedir() } = {},
219
+ ) {
220
+ const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
221
+ const globalPidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
222
+ const localRemoved = await removePidRecordAtPath(pidPath, { onlyForPid });
223
+ const globalRemoved = await removePidRecordAtPath(globalPidPath, { onlyForPid });
224
+ return Boolean(localRemoved || globalRemoved);
225
+ }
226
+
227
+ async function listenerStatusFromRecord(
228
+ record,
229
+ {
230
+ sessionId,
231
+ agentId,
232
+ recordScope,
233
+ _readProcessCommandLine = readProcessCommandLine,
234
+ } = {},
235
+ ) {
236
+ const alive = isProcessAlive(record.pid);
237
+ if (!alive) {
238
+ return {
239
+ running: false,
240
+ pid: null,
241
+ stale: true,
242
+ record,
243
+ recordScope,
244
+ listenerKey: normalizeListenerProcessKey(agentId),
245
+ };
246
+ }
247
+ const commandLine = await _readProcessCommandLine(record.pid);
248
+ if (!listenerCommandLineMatches(commandLine, { sessionId, agentId })) {
249
+ return {
250
+ running: false,
251
+ pid: null,
252
+ stale: true,
253
+ reused: Boolean(commandLine),
254
+ unverified: !commandLine,
255
+ record,
256
+ recordScope,
257
+ listenerKey: normalizeListenerProcessKey(agentId),
258
+ commandLine,
259
+ };
260
+ }
261
+ return {
262
+ running: true,
263
+ pid: Number(record.pid),
264
+ stale: false,
265
+ record,
266
+ recordScope,
267
+ listenerKey: normalizeListenerProcessKey(agentId),
268
+ commandLine,
269
+ };
270
+ }
271
+
272
+ export async function getListenerProcessStatus(
273
+ sessionId,
274
+ agentId,
275
+ {
276
+ targetPath = process.cwd(),
277
+ homeDir = os.homedir(),
278
+ _readProcessCommandLine = readProcessCommandLine,
279
+ } = {},
280
+ ) {
281
+ const records = [
282
+ {
283
+ record: await readGlobalListenerPidRecord(sessionId, agentId, { homeDir }),
284
+ recordScope: "global",
285
+ },
286
+ {
287
+ record: await readListenerPidRecord(sessionId, agentId, { targetPath }),
288
+ recordScope: "local",
289
+ },
290
+ ];
291
+ const staleStatuses = [];
292
+ for (const entry of records) {
293
+ if (!entry.record) continue;
294
+ const status = await listenerStatusFromRecord(entry.record, {
295
+ sessionId,
296
+ agentId,
297
+ recordScope: entry.recordScope,
298
+ _readProcessCommandLine,
299
+ });
300
+ if (status.running) {
301
+ return status;
302
+ }
303
+ staleStatuses.push(status);
304
+ }
305
+ if (staleStatuses.length === 0) {
306
+ return {
307
+ running: false,
308
+ pid: null,
309
+ stale: false,
310
+ record: null,
311
+ listenerKey: normalizeListenerProcessKey(agentId),
312
+ };
313
+ }
314
+ return staleStatuses[0];
315
+ }
316
+
317
+ export async function requestListenerProcessStop(
318
+ pid,
319
+ { signal = "SIGTERM", timeoutMs = 3000, pollIntervalMs = 100 } = {},
320
+ ) {
321
+ const numericPid = Number(pid);
322
+ if (!Number.isInteger(numericPid) || numericPid <= 0) {
323
+ return { requested: false, stopped: true, reason: "invalid_pid" };
324
+ }
325
+ if (numericPid === process.pid) {
326
+ return { requested: false, stopped: false, reason: "self" };
327
+ }
328
+ if (!isProcessAlive(numericPid)) {
329
+ return { requested: false, stopped: true, reason: "not_running" };
330
+ }
331
+
332
+ try {
333
+ process.kill(numericPid, signal);
334
+ } catch (error) {
335
+ if (error?.code === "ESRCH") {
336
+ return { requested: false, stopped: true, reason: "not_running" };
337
+ }
338
+ return {
339
+ requested: false,
340
+ stopped: false,
341
+ reason: normalizeString(error?.code) || "signal_failed",
342
+ };
343
+ }
344
+
345
+ const deadlineMs = Date.now() + Math.max(1, Number(timeoutMs) || 3000);
346
+ const sleepMs = Math.max(10, Number(pollIntervalMs) || 100);
347
+ while (Date.now() < deadlineMs) {
348
+ if (!isProcessAlive(numericPid)) {
349
+ return { requested: true, stopped: true, reason: "stopped" };
350
+ }
351
+ await sleep(sleepMs);
352
+ }
353
+ return { requested: true, stopped: false, reason: "timeout" };
354
+ }
@@ -305,6 +305,8 @@ export async function listenSessionEvents({
305
305
  const activeWindowMs =
306
306
  Math.max(1, normalizePositiveInteger(activeWindowSeconds, DEFAULT_ACTIVE_WINDOW_SECONDS)) *
307
307
  1000;
308
+ const autoStreamIdleTimeoutMs =
309
+ normalizedTransport === "auto" ? Math.max(5_000, Math.min(idleSleepMs, 30_000)) : 0;
308
310
 
309
311
  if (fromNow) {
310
312
  const latest = await _pollLatest(normalizedSessionId, {
@@ -507,9 +509,6 @@ export async function listenSessionEvents({
507
509
  streamHeartbeatInFlight = false;
508
510
  }
509
511
  }, streamHeartbeatMs);
510
- if (streamHeartbeatTimer && typeof streamHeartbeatTimer.unref === "function") {
511
- streamHeartbeatTimer.unref();
512
- }
513
512
  }
514
513
 
515
514
  await notifyLifecycle(
@@ -527,6 +526,7 @@ export async function listenSessionEvents({
527
526
  const streamResult = await _stream(normalizedSessionId, {
528
527
  targetPath,
529
528
  since: cursor,
529
+ idleTimeoutMs: autoStreamIdleTimeoutMs,
530
530
  signal,
531
531
  onEvent: async (event) => {
532
532
  await processEventBatch([event], normalizeString(event?.cursor) || cursor);
@@ -10,6 +10,9 @@ const LISTENER_EVENT_TYPES = new Set([
10
10
  // listener likely died without a clean stop — show it as stale, not live.
11
11
  const DEFAULT_STALE_AFTER_MS = 180_000;
12
12
  const MAX_STALE_GRACE_MS = 60_000;
13
+ const DEFAULT_LISTENER_FETCH_LIMIT = 200;
14
+ const DEFAULT_LISTENER_FETCH_MAX_PAGES = 5;
15
+ const MAX_LISTENER_FETCH_MAX_PAGES = 25;
13
16
 
14
17
  function normalizeString(value) {
15
18
  return String(value || "").trim();
@@ -143,18 +146,93 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
143
146
 
144
147
  /**
145
148
  * Fetch recent session events from the API and summarize the listeners.
146
- * `limit` controls how far back we look for heartbeats.
149
+ * `limit` controls the raw API page size. Busy rooms can push durable
150
+ * keepalive heartbeats out of a single tail page, so we walk a small bounded
151
+ * number of older pages while the tail contains no listener lifecycle events
152
+ * and the sequence cursor advances.
147
153
  */
148
154
  export async function fetchSessionListeners(
149
155
  sessionId,
150
- { targetPath = process.cwd(), limit = 200, nowMs = Date.now, poll = pollSessionEventsBefore } = {}
156
+ {
157
+ targetPath = process.cwd(),
158
+ limit = DEFAULT_LISTENER_FETCH_LIMIT,
159
+ maxPages = DEFAULT_LISTENER_FETCH_MAX_PAGES,
160
+ nowMs = Date.now,
161
+ forceCircuitProbe = true,
162
+ poll = pollSessionEventsBefore,
163
+ } = {}
151
164
  ) {
152
- const result = await poll(sessionId, { targetPath, limit });
153
- if (!result?.ok) {
154
- return { ok: false, reason: normalizeString(result?.reason) || "fetch_failed", listeners: [] };
165
+ const pageLimit = Math.max(
166
+ 1,
167
+ Math.min(DEFAULT_LISTENER_FETCH_LIMIT, positiveInt(limit) || DEFAULT_LISTENER_FETCH_LIMIT),
168
+ );
169
+ const maxPageCount = Math.max(
170
+ 1,
171
+ Math.min(MAX_LISTENER_FETCH_MAX_PAGES, positiveInt(maxPages) || DEFAULT_LISTENER_FETCH_MAX_PAGES),
172
+ );
173
+ let beforeSequence = null;
174
+ let latestResult = null;
175
+ let allEvents = [];
176
+ let pageCount = 0;
177
+ let listenerEventCount = 0;
178
+ let partial = false;
179
+ let partialReason = "";
180
+
181
+ for (let page = 0; page < maxPageCount; page += 1) {
182
+ const result = await poll(sessionId, {
183
+ targetPath,
184
+ beforeSequence,
185
+ limit: pageLimit,
186
+ forceCircuitProbe,
187
+ });
188
+ pageCount += 1;
189
+
190
+ if (!result?.ok) {
191
+ if (!latestResult) {
192
+ return {
193
+ ok: false,
194
+ reason: normalizeString(result?.reason) || "fetch_failed",
195
+ listeners: [],
196
+ pageCount,
197
+ scannedEventCount: allEvents.length,
198
+ listenerEventCount: 0,
199
+ partial: false,
200
+ };
201
+ }
202
+ partial = true;
203
+ partialReason = normalizeString(result?.reason) || "partial_fetch";
204
+ break;
205
+ }
206
+
207
+ latestResult = result;
208
+ const pageEvents = Array.isArray(result.events) ? result.events : [];
209
+ allEvents = [...pageEvents, ...allEvents];
210
+ listenerEventCount += pageEvents.filter((event) =>
211
+ LISTENER_EVENT_TYPES.has(normalizeString(event?.event))
212
+ ).length;
213
+
214
+ if (pageEvents.length < pageLimit) break;
215
+ if (listenerEventCount > 0) break;
216
+
217
+ const candidateBeforeSequence = Number(result.beforeSequence || 0);
218
+ if (!Number.isFinite(candidateBeforeSequence) || candidateBeforeSequence <= 0) break;
219
+ const currentBeforeSequence = Number(beforeSequence || 0);
220
+ if (currentBeforeSequence > 0 && candidateBeforeSequence >= currentBeforeSequence) break;
221
+ beforeSequence = Math.floor(candidateBeforeSequence);
155
222
  }
156
- const listeners = summarizeListeners(result.events || [], { nowMs: nowMs() });
157
- return { ok: true, sessionId: normalizeString(sessionId), listeners };
223
+
224
+ const listeners = summarizeListeners(allEvents, { nowMs: nowMs() });
225
+ return {
226
+ ok: true,
227
+ sessionId: normalizeString(sessionId),
228
+ listeners,
229
+ pageCount,
230
+ scannedEventCount: allEvents.length,
231
+ listenerEventCount,
232
+ beforeSequence: latestResult?.beforeSequence || beforeSequence || null,
233
+ partial,
234
+ reason: partial ? partialReason : "",
235
+ };
158
236
  }
159
237
 
160
238
  export function formatListenerLine(row) {
@@ -1479,6 +1479,7 @@ export async function pollSessionEvents(
1479
1479
  * @param {object} [options]
1480
1480
  * @param {string|null} [options.since] - durable cursor to resume after
1481
1481
  * @param {AbortSignal} [options.signal]
1482
+ * @param {number} [options.idleTimeoutMs] - abort a connected stream after this many silent milliseconds
1482
1483
  * @param {(event: object) => Promise<void>|void} [options.onEvent]
1483
1484
  * @param {(payload: object) => Promise<void>|void} [options.onError]
1484
1485
  * @param {() => Promise<void>|void} [options.onHeartbeat]
@@ -1490,6 +1491,7 @@ export async function streamSessionEvents(
1490
1491
  targetPath = process.cwd(),
1491
1492
  since = null,
1492
1493
  timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1494
+ idleTimeoutMs = 0,
1493
1495
  signal = undefined,
1494
1496
  resolveAuthSession = resolveActiveAuthSession,
1495
1497
  fetchImpl = fetch,
@@ -1545,10 +1547,26 @@ export async function streamSessionEvents(
1545
1547
  const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/stream${suffix}`;
1546
1548
  const controller = new AbortController();
1547
1549
  const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
1550
+ const normalizedIdleTimeoutMs = normalizePositiveInteger(idleTimeoutMs, 0);
1548
1551
  const timeoutHandle = setTimeout(() => controller.abort(), normalizedTimeoutMs);
1549
1552
  if (typeof timeoutHandle.unref === "function") {
1550
1553
  timeoutHandle.unref();
1551
1554
  }
1555
+ let idleTimeoutHandle = null;
1556
+ let idleTimedOut = false;
1557
+ const clearIdleTimeout = () => {
1558
+ if (!idleTimeoutHandle) return;
1559
+ clearTimeout(idleTimeoutHandle);
1560
+ idleTimeoutHandle = null;
1561
+ };
1562
+ const armIdleTimeout = () => {
1563
+ if (!normalizedIdleTimeoutMs) return;
1564
+ clearIdleTimeout();
1565
+ idleTimeoutHandle = setTimeout(() => {
1566
+ idleTimedOut = true;
1567
+ controller.abort();
1568
+ }, normalizedIdleTimeoutMs);
1569
+ };
1552
1570
  const forwardAbort = () => controller.abort(signal?.reason);
1553
1571
  if (signal) {
1554
1572
  if (signal.aborted) {
@@ -1574,10 +1592,13 @@ export async function streamSessionEvents(
1574
1592
  );
1575
1593
  } catch (error) {
1576
1594
  clearTimeout(timeoutHandle);
1595
+ clearIdleTimeout();
1577
1596
  if (signal) signal.removeEventListener("abort", forwardAbort);
1578
1597
  return {
1579
1598
  ok: false,
1580
- reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
1599
+ reason: idleTimedOut
1600
+ ? "stream_idle_timeout"
1601
+ : isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
1581
1602
  cursor: normalizedSince,
1582
1603
  eventCount: 0,
1583
1604
  errorCount: 0,
@@ -1621,7 +1642,9 @@ export async function streamSessionEvents(
1621
1642
  };
1622
1643
  }
1623
1644
 
1645
+ armIdleTimeout();
1624
1646
  for await (const chunk of readResponseTextChunks(response)) {
1647
+ armIdleTimeout();
1625
1648
  buffer += decoder.decode(chunk, { stream: true });
1626
1649
  buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1627
1650
  const blocks = buffer.split("\n\n");
@@ -1637,13 +1660,16 @@ export async function streamSessionEvents(
1637
1660
  } catch (error) {
1638
1661
  return {
1639
1662
  ok: false,
1640
- reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
1663
+ reason: idleTimedOut
1664
+ ? "stream_idle_timeout"
1665
+ : isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
1641
1666
  cursor,
1642
1667
  eventCount,
1643
1668
  errorCount,
1644
1669
  aborted: Boolean(signal?.aborted || isAbortLike(error)),
1645
1670
  };
1646
1671
  } finally {
1672
+ clearIdleTimeout();
1647
1673
  if (signal) signal.removeEventListener("abort", forwardAbort);
1648
1674
  }
1649
1675