sentinelayer-cli 0.34.6 → 0.35.0

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.6",
3
+ "version": "0.35.0",
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,
@@ -109,6 +115,7 @@ import {
109
115
  } from "../session/rotating-log.js";
110
116
  import { buildSessionRecap } from "../session/recap.js";
111
117
  import { computeTranscriptStats } from "../session/transcript.js";
118
+ import { buildSessionUsageLedger } from "../session/pricing-ledger.js";
112
119
  import {
113
120
  buildSessionUsageReport,
114
121
  buildSessionUsageReportFromLedgerPayload,
@@ -150,6 +157,22 @@ function normalizeString(value) {
150
157
  return String(value || "").trim();
151
158
  }
152
159
 
160
+ // A wake hook hands remote-influenced event fields to a local host command
161
+ // (as SL_WAKE_* env vars). Bound + strip each value so a crafted event cannot
162
+ // inject control chars / newlines into the child environment or blow it up.
163
+ const WAKE_VALUE_MAX_LENGTH = 512;
164
+ function sanitizeWakeValue(value) {
165
+ // Strip control chars (incl. NUL / newline) by codepoint so a crafted remote
166
+ // event cannot inject into the child env; a codepoint check avoids putting a
167
+ // control-char regex literal in source.
168
+ let out = "";
169
+ for (const ch of normalizeString(value)) {
170
+ const code = ch.codePointAt(0);
171
+ out += code < 0x20 || code === 0x7f ? " " : ch;
172
+ }
173
+ return out.slice(0, WAKE_VALUE_MAX_LENGTH);
174
+ }
175
+
153
176
  function messagePartsHaveContent(messageParts) {
154
177
  const parts = Array.isArray(messageParts) ? messageParts : [messageParts];
155
178
  return parts.some((part) => normalizeString(part));
@@ -2160,14 +2183,28 @@ export function createSessionWakeRunner({
2160
2183
  return;
2161
2184
  }
2162
2185
  busy = true;
2186
+ const rawSequence = event?.sequenceId ?? event?.sequence_id ?? "";
2187
+ const sanitizedSequence = sanitizeWakeValue(rawSequence);
2163
2188
  const env = {
2164
2189
  ...process.env,
2165
- SL_WAKE_SESSION_ID: normalizeString(sessionId),
2166
- SL_WAKE_AGENT_ID: normalizeString(agentId),
2167
- SL_WAKE_EVENT_TYPE: normalizeString(event?.event),
2168
- SL_WAKE_EVENT_CURSOR: normalizeString(event?.cursor),
2169
- SL_WAKE_EVENT_SEQUENCE: String(event?.sequenceId ?? event?.sequence_id ?? ""),
2170
- SL_WAKE_ACTOR_ID: normalizeString(event?.agent?.id || event?.agentId),
2190
+ SL_WAKE_SESSION_ID: sanitizeWakeValue(sessionId),
2191
+ SL_WAKE_AGENT_ID: sanitizeWakeValue(agentId),
2192
+ SL_WAKE_EVENT_TYPE: sanitizeWakeValue(event?.event),
2193
+ SL_WAKE_EVENT_CURSOR: sanitizeWakeValue(event?.cursor),
2194
+ SL_WAKE_EVENT_SEQUENCE: sanitizedSequence,
2195
+ SL_WAKE_ACTOR_ID: sanitizeWakeValue(event?.agent?.id || event?.agentId),
2196
+ };
2197
+ // Metadata-only wake envelope for the host command's stdin. Deliberately
2198
+ // EXCLUDES the event body/content so session message text / PII never
2199
+ // reaches an external process — the woken agent re-reads the room over its
2200
+ // own authenticated channel using this cursor/sequence.
2201
+ const wakePayload = {
2202
+ event: env.SL_WAKE_EVENT_TYPE,
2203
+ sessionId: env.SL_WAKE_SESSION_ID,
2204
+ agentId: env.SL_WAKE_AGENT_ID,
2205
+ cursor: env.SL_WAKE_EVENT_CURSOR,
2206
+ sequenceId: typeof rawSequence === "number" && Number.isFinite(rawSequence) ? rawSequence : sanitizedSequence || null,
2207
+ actorId: env.SL_WAKE_ACTOR_ID,
2171
2208
  };
2172
2209
  let child;
2173
2210
  try {
@@ -2185,7 +2222,7 @@ export function createSessionWakeRunner({
2185
2222
  });
2186
2223
  try {
2187
2224
  if (child && child.stdin) {
2188
- child.stdin.write(JSON.stringify(event ?? {}));
2225
+ child.stdin.write(JSON.stringify(wakePayload));
2189
2226
  child.stdin.end();
2190
2227
  }
2191
2228
  } catch {
@@ -4507,9 +4544,17 @@ export function registerSessionCommand(program) {
4507
4544
  .option("--from-now", "Advance the listen cursor to the latest durable event before polling")
4508
4545
  .option("--replay", "Emit matching historical events on the first poll")
4509
4546
  .option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
4547
+ .option(
4548
+ "--force",
4549
+ "Stop and replace an existing local listener process for this session/agent",
4550
+ )
4551
+ .option(
4552
+ "--allow-duplicate",
4553
+ "Allow multiple local listeners for the same session/agent (advanced; can duplicate wake hooks)",
4554
+ )
4510
4555
  .option(
4511
4556
  "--wake <command>",
4512
- "Wake hook: run this shell command on each matched event (notify->resume bridge). Event JSON is piped to stdin; SL_WAKE_* env vars are set.",
4557
+ "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.",
4513
4558
  )
4514
4559
  .option(
4515
4560
  "--wake-host <name>",
@@ -4635,18 +4680,59 @@ export function registerSessionCommand(program) {
4635
4680
  if (options.fromNow && options.since !== undefined) {
4636
4681
  throw new Error("Use either --from-now or --since, not both.");
4637
4682
  }
4638
- const restoreConsoleLog = listenLogPath
4639
- ? installRotatingConsoleLog({
4640
- logPath: listenLogPath,
4641
- maxBytes: parsePositiveInteger(options.logMaxBytes, "log-max-bytes", DEFAULT_ROTATING_LOG_MAX_BYTES),
4642
- maxFiles: parsePositiveInteger(options.logMaxFiles, "log-max-files", DEFAULT_ROTATING_LOG_MAX_FILES),
4643
- tee: true,
4644
- })
4645
- : null;
4683
+ const listenerId = `listener-${agentId}-${randomUUID()}`;
4684
+ const singletonEnabled = maxPolls === null && !options.allowDuplicate;
4685
+ let listenerPidRecordActive = false;
4686
+ if (singletonEnabled) {
4687
+ const existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
4688
+ targetPath,
4689
+ });
4690
+ if (existingListener.running && !options.force) {
4691
+ throw new Error(
4692
+ `A local 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.`,
4693
+ );
4694
+ }
4695
+ if (existingListener.running && options.force) {
4696
+ const stopResult = await requestListenerProcessStop(existingListener.pid);
4697
+ if (!stopResult.stopped) {
4698
+ throw new Error(
4699
+ `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.`,
4700
+ );
4701
+ }
4702
+ }
4703
+ await writeListenerPidRecord(normalizedSessionId, agentId, {
4704
+ targetPath,
4705
+ listenerId,
4706
+ transport: listenTransport,
4707
+ intervalSeconds,
4708
+ activeIntervalSeconds,
4709
+ presenceIntervalSeconds,
4710
+ logFile: listenLogPath,
4711
+ });
4712
+ listenerPidRecordActive = true;
4713
+ }
4714
+ let restoreConsoleLog = null;
4715
+ try {
4716
+ restoreConsoleLog = listenLogPath
4717
+ ? installRotatingConsoleLog({
4718
+ logPath: listenLogPath,
4719
+ maxBytes: parsePositiveInteger(options.logMaxBytes, "log-max-bytes", DEFAULT_ROTATING_LOG_MAX_BYTES),
4720
+ maxFiles: parsePositiveInteger(options.logMaxFiles, "log-max-files", DEFAULT_ROTATING_LOG_MAX_FILES),
4721
+ tee: true,
4722
+ })
4723
+ : null;
4724
+ } catch (error) {
4725
+ if (listenerPidRecordActive) {
4726
+ await removeListenerPidRecord(normalizedSessionId, agentId, {
4727
+ targetPath,
4728
+ onlyForPid: process.pid,
4729
+ }).catch(() => {});
4730
+ }
4731
+ throw error;
4732
+ }
4646
4733
  const ac = new AbortController();
4647
4734
  const onSigint = () => ac.abort();
4648
4735
  process.on("SIGINT", onSigint);
4649
- const listenerId = `listener-${agentId}-${randomUUID()}`;
4650
4736
  const durablePresenceEnabled = options.presence !== false;
4651
4737
  const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
4652
4738
  const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
@@ -4878,6 +4964,12 @@ export function registerSessionCommand(program) {
4878
4964
  restoreConsoleLog();
4879
4965
  }
4880
4966
  process.removeListener("SIGINT", onSigint);
4967
+ if (listenerPidRecordActive) {
4968
+ await removeListenerPidRecord(normalizedSessionId, agentId, {
4969
+ targetPath,
4970
+ onlyForPid: process.pid,
4971
+ }).catch(() => {});
4972
+ }
4881
4973
  }
4882
4974
  });
4883
4975
 
@@ -6267,9 +6359,14 @@ export function registerSessionCommand(program) {
6267
6359
  const hiddenControlEventCount =
6268
6360
  dedupedEvents.length - transcriptBaseEvents.length + rawActionEvents.length - actionEvents.length;
6269
6361
  const exportEvents = mergeSessionActionEvents(transcriptBaseEvents, actionEvents);
6362
+ const usageLedger = buildSessionUsageLedger(exportEvents, {
6363
+ sessionId: normalizedSessionId,
6364
+ includeEstimatedMessages: true,
6365
+ });
6270
6366
  const stats = computeTranscriptStats({
6271
6367
  sessionMeta: sessionPayload,
6272
6368
  events: exportEvents,
6369
+ usageLedger,
6273
6370
  });
6274
6371
  const participants = buildSessionParticipants({
6275
6372
  statsAgents: stats.agents,
@@ -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
 
@@ -227,16 +227,20 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
227
227
  with: {
228
228
  github_token: "${{ github.token }}",
229
229
  openai_api_key: "${{ secrets.OPENAI_API_KEY }}",
230
- google_api_key: "${{ secrets.GOOGLE_API_KEY }}",
231
- llm_provider: "${{ secrets.GOOGLE_API_KEY != '' && 'google' || 'openai' }}",
230
+ google_api_key:
231
+ "${{ secrets.GOOGLE_GEMINI_API_KEY != '' && secrets.GOOGLE_GEMINI_API_KEY || secrets.GOOGLE_API_KEY }}",
232
+ llm_provider:
233
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'google' || 'openai' }}",
232
234
  sentinelayer_token: `\${{ secrets.${normalizedSecret} }}`,
233
- sentinelayer_managed_llm: `\${{ secrets.GOOGLE_API_KEY == '' && secrets.OPENAI_API_KEY == '' && secrets.${normalizedSecret} != '' }}`,
235
+ sentinelayer_managed_llm: `\${{ secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '' && secrets.OPENAI_API_KEY == '' && secrets.${normalizedSecret} != '' }}`,
234
236
  scan_mode: profile.scanMode || "deep",
235
237
  severity_gate: profile.severityGate || "P1",
236
- model: "${{ secrets.GOOGLE_API_KEY != '' && 'gemini-2.5-pro' || 'gpt-5.3-codex' }}",
238
+ model:
239
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-5.3-codex' }}",
237
240
  codex_model: "gpt-5.3-codex",
238
- model_fallback: "${{ secrets.GOOGLE_API_KEY != '' && 'gemini-2.5-flash' || 'gpt-4.1-mini' }}",
239
- use_codex: "${{ secrets.GOOGLE_API_KEY == '' }}",
241
+ model_fallback:
242
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-4.1-mini' }}",
243
+ use_codex: "${{ secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '' }}",
240
244
  codex_only: "false",
241
245
  llm_failure_policy: "block",
242
246
  max_daily_scans: "${{ vars.OMAR_MAX_DAILY_SCANS || '200' }}",
@@ -295,11 +299,15 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
295
299
  OMAR_SEVERITY_GATE: "${{ github.event_name == 'workflow_dispatch' && inputs.severity_gate || 'P1' }}",
296
300
  OMAR_P2_MAX_ALLOWED: "${{ github.event_name == 'workflow_dispatch' && inputs.p2_max_allowed || '5' }}",
297
301
  OMAR_OPENAI_KEY_PRESENT: "${{ secrets.OPENAI_API_KEY != '' }}",
298
- OMAR_GOOGLE_KEY_PRESENT: "${{ secrets.GOOGLE_API_KEY != '' }}",
299
- OMAR_MANAGED_LLM: `\${{ secrets.GOOGLE_API_KEY == '' && secrets.OPENAI_API_KEY == '' && secrets.${normalizedSecret} != '' }}`,
300
- OMAR_LLM_PROVIDER: "${{ secrets.GOOGLE_API_KEY != '' && 'google' || 'openai' }}",
301
- OMAR_MODEL: "${{ secrets.GOOGLE_API_KEY != '' && 'gemini-2.5-pro' || 'gpt-5.3-codex' }}",
302
- OMAR_MODEL_FALLBACK: "${{ secrets.GOOGLE_API_KEY != '' && 'gemini-2.5-flash' || 'gpt-4.1-mini' }}",
302
+ OMAR_GOOGLE_KEY_PRESENT:
303
+ "${{ secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '' }}",
304
+ OMAR_MANAGED_LLM: `\${{ secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '' && secrets.OPENAI_API_KEY == '' && secrets.${normalizedSecret} != '' }}`,
305
+ OMAR_LLM_PROVIDER:
306
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'google' || 'openai' }}",
307
+ OMAR_MODEL:
308
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-5.3-codex' }}",
309
+ OMAR_MODEL_FALLBACK:
310
+ "${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-4.1-mini' }}",
303
311
  },
304
312
  run: [
305
313
  "set -euo pipefail",
@@ -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.`,