sentinelayer-cli 0.11.3 โ†’ 0.12.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.11.3",
3
+ "version": "0.12.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,7 +1,7 @@
1
1
  import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { randomUUID } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
 
6
6
  import pc from "picocolors";
7
7
 
@@ -59,9 +59,12 @@ import {
59
59
  } from "../session/event-identity.js";
60
60
  import { readSessionPreview } from "../session/preview.js";
61
61
  import {
62
+ createSessionMessageAction,
63
+ listSessionMessageActions,
62
64
  listSessionsFromApi,
63
65
  probeSessionAccess,
64
66
  pollSessionEventsBefore,
67
+ searchSessionEvents,
65
68
  syncSessionEventToApi,
66
69
  syncSessionMetadataToApi,
67
70
  } from "../session/sync.js";
@@ -69,6 +72,7 @@ import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
69
72
  import { mergeLiveSources } from "../session/live-source.js";
70
73
  import { listenSessionEvents } from "../session/listener.js";
71
74
  import { buildSessionRecap } from "../session/recap.js";
75
+ import { computeTranscriptStats } from "../session/transcript.js";
72
76
  import { deriveSessionTitle } from "../session/senti-naming.js";
73
77
  import { pushSessionTitleToApi } from "../session/title-sync.js";
74
78
  import {
@@ -96,6 +100,236 @@ function normalizeString(value) {
96
100
  return String(value || "").trim();
97
101
  }
98
102
 
103
+ const SESSION_MESSAGE_ACTION_TYPES = new Set([
104
+ "ack",
105
+ "working_on",
106
+ "reply",
107
+ "like",
108
+ "dislike",
109
+ "disregard",
110
+ ]);
111
+
112
+ function normalizeSessionMessageActionType(value) {
113
+ const normalized = normalizeString(value).toLowerCase();
114
+ if (!SESSION_MESSAGE_ACTION_TYPES.has(normalized)) {
115
+ throw new Error(
116
+ `action type must be one of: ${[...SESSION_MESSAGE_ACTION_TYPES].join(", ")}.`,
117
+ );
118
+ }
119
+ return normalized;
120
+ }
121
+
122
+ function parseOptionalPositiveInteger(rawValue, field) {
123
+ if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
124
+ return null;
125
+ }
126
+ return parsePositiveInteger(rawValue, field, null);
127
+ }
128
+
129
+ function shortSha256(value) {
130
+ return createHash("sha256").update(String(value || "")).digest("hex").slice(0, 32);
131
+ }
132
+
133
+ function actionEventType(actionType) {
134
+ if (actionType === "reply") return "session_reply";
135
+ if (actionType === "like" || actionType === "dislike") return "session_reaction";
136
+ return "session_action";
137
+ }
138
+
139
+ function actionTargetSequence(action = {}) {
140
+ const value = Number(action.targetSequenceId ?? action.target_sequence_id ?? 0);
141
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
142
+ }
143
+
144
+ function actionTargetCursor(action = {}) {
145
+ return normalizeString(action.targetCursor ?? action.target_cursor);
146
+ }
147
+
148
+ function actionActorId(action = {}) {
149
+ return normalizeString(action.actorId ?? action.actor_id) || "unknown";
150
+ }
151
+
152
+ function actionCreatedAt(action = {}) {
153
+ return normalizeString(action.createdAt ?? action.created_at) || new Date().toISOString();
154
+ }
155
+
156
+ function actionDisplayMessage(action = {}) {
157
+ const actionType = normalizeString(action.actionType ?? action.action_type).toLowerCase();
158
+ const targetSequence = actionTargetSequence(action);
159
+ const targetLabel = targetSequence ? `#${targetSequence}` : actionTargetCursor(action) || "target";
160
+ const note = normalizeString(action.note);
161
+ if (note) return `${actionType} ${targetLabel}: ${note}`;
162
+ return `${actionType} ${targetLabel}`;
163
+ }
164
+
165
+ function buildSessionActionEvent(sessionId, action = {}) {
166
+ const actionType = normalizeString(action.actionType ?? action.action_type).toLowerCase();
167
+ if (!SESSION_MESSAGE_ACTION_TYPES.has(actionType)) return null;
168
+ const id =
169
+ normalizeString(action.id) ||
170
+ shortSha256(
171
+ JSON.stringify({
172
+ actionType,
173
+ targetSequenceId: actionTargetSequence(action),
174
+ targetCursor: actionTargetCursor(action),
175
+ actorId: actionActorId(action),
176
+ note: normalizeString(action.note),
177
+ createdAt: actionCreatedAt(action),
178
+ }),
179
+ );
180
+ const actorId = actionActorId(action);
181
+ const event = createAgentEvent({
182
+ event: actionEventType(actionType),
183
+ agent: {
184
+ id: actorId,
185
+ role: normalizeString(action.actorRole ?? action.actor_role) || undefined,
186
+ model: normalizeString(action.actorKind ?? action.actor_kind) || "session-action",
187
+ },
188
+ sessionId,
189
+ ts: actionCreatedAt(action),
190
+ payload: {
191
+ actionId: id,
192
+ actionType,
193
+ targetSequenceId: actionTargetSequence(action),
194
+ targetCursor: actionTargetCursor(action) || null,
195
+ note: normalizeString(action.note) || null,
196
+ message: actionDisplayMessage(action),
197
+ source: "session_action",
198
+ },
199
+ });
200
+ event.eventId = `session-action-${id}`;
201
+ event.idempotencyToken = normalizeString(action.idempotencyKey ?? action.idempotency_key) || event.eventId;
202
+ event.cursor = `action:${id}`;
203
+ return event;
204
+ }
205
+
206
+ function buildSessionActionEvents(sessionId, actions = []) {
207
+ return (Array.isArray(actions) ? actions : [])
208
+ .map((action) => buildSessionActionEvent(sessionId, action))
209
+ .filter(Boolean);
210
+ }
211
+
212
+ function eventTimestampMs(event = {}) {
213
+ for (const key of ["ts", "timestamp", "createdAt", "at"]) {
214
+ const epoch = Date.parse(normalizeString(event?.[key]));
215
+ if (Number.isFinite(epoch)) return epoch;
216
+ }
217
+ return 0;
218
+ }
219
+
220
+ function eventSequenceNumber(event = {}) {
221
+ for (const key of ["sequenceId", "sequence_id", "sequence"]) {
222
+ const value = Number(event?.[key]);
223
+ if (Number.isFinite(value)) return value;
224
+ }
225
+ return 0;
226
+ }
227
+
228
+ function mergeSessionActionEvents(events = [], actionEvents = []) {
229
+ return dedupeSessionEvents([...(Array.isArray(events) ? events : []), ...actionEvents])
230
+ .map((event, index) => ({ event, index }))
231
+ .sort((left, right) => {
232
+ const timeDiff = eventTimestampMs(left.event) - eventTimestampMs(right.event);
233
+ if (timeDiff !== 0) return timeDiff;
234
+ const sequenceDiff = eventSequenceNumber(left.event) - eventSequenceNumber(right.event);
235
+ if (sequenceDiff !== 0) return sequenceDiff;
236
+ return left.index - right.index;
237
+ })
238
+ .map((entry) => entry.event);
239
+ }
240
+
241
+ async function appendActionEventIfMissing(sessionId, actionEvent, { targetPath } = {}) {
242
+ if (!actionEvent) return { appended: false, reason: "no_event", event: null };
243
+ const existingEvents = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
244
+ const knownKeys = new Set();
245
+ for (const event of existingEvents) {
246
+ addSessionEventIdentityKeys(knownKeys, event);
247
+ }
248
+ if (sessionEventHasKnownIdentity(actionEvent, knownKeys)) {
249
+ return { appended: false, reason: "already_present", event: actionEvent };
250
+ }
251
+ const appended = await appendToStream(sessionId, actionEvent, {
252
+ targetPath,
253
+ syncRemote: false,
254
+ });
255
+ return { appended: true, reason: "", event: appended };
256
+ }
257
+
258
+ function defaultActionIdempotencyKey({
259
+ actionType,
260
+ targetSequenceId,
261
+ targetCursor,
262
+ note,
263
+ agentId,
264
+ } = {}) {
265
+ const target = targetSequenceId ? `seq:${targetSequenceId}` : `cursor:${normalizeString(targetCursor)}`;
266
+ const noteHash = note ? shortSha256(note) : "none";
267
+ const actor = normalizeString(agentId) || "user";
268
+ return `cli:${normalizeString(actionType).toLowerCase()}:${target}:${actor}:${noteHash}`;
269
+ }
270
+
271
+ function compareIsoDesc(left = "", right = "") {
272
+ return normalizeString(right).localeCompare(normalizeString(left));
273
+ }
274
+
275
+ function buildSessionParticipants({ statsAgents = [], registeredAgents = [] } = {}) {
276
+ const byAgentId = new Map();
277
+ for (const agent of Array.isArray(statsAgents) ? statsAgents : []) {
278
+ const agentId = normalizeString(agent?.agentId || agent?.id);
279
+ if (!agentId) continue;
280
+ byAgentId.set(agentId, {
281
+ ...agent,
282
+ agentId,
283
+ registered: false,
284
+ source: "events",
285
+ });
286
+ }
287
+
288
+ for (const agent of Array.isArray(registeredAgents) ? registeredAgents : []) {
289
+ const agentId = normalizeString(agent?.agentId || agent?.id);
290
+ if (!agentId) continue;
291
+ const existing = byAgentId.get(agentId);
292
+ if (existing) {
293
+ byAgentId.set(agentId, {
294
+ ...existing,
295
+ model: existing.model || normalizeString(agent.model),
296
+ role: normalizeString(agent.role) || existing.role,
297
+ status: normalizeString(agent.status) || existing.status,
298
+ registered: true,
299
+ source: "events+registry",
300
+ joinedAt: normalizeString(agent.joinedAt) || existing.joinedAt,
301
+ lastActivityAt: normalizeString(agent.lastActivityAt) || existing.lastActivityAt,
302
+ active: agent.active,
303
+ });
304
+ continue;
305
+ }
306
+ byAgentId.set(agentId, {
307
+ agentId,
308
+ displayName: normalizeString(agent.displayName) || agentId,
309
+ model: normalizeString(agent.model),
310
+ role: normalizeString(agent.role),
311
+ status: normalizeString(agent.status),
312
+ firstSeen: normalizeString(agent.joinedAt) || null,
313
+ lastSeen: normalizeString(agent.lastActivityAt) || normalizeString(agent.joinedAt) || null,
314
+ joinedAt: normalizeString(agent.joinedAt) || null,
315
+ lastActivityAt: normalizeString(agent.lastActivityAt) || null,
316
+ active: agent.active,
317
+ eventCount: 0,
318
+ activeSeconds: 0,
319
+ tokens: 0,
320
+ costUsd: 0,
321
+ registered: true,
322
+ source: "registry",
323
+ });
324
+ }
325
+
326
+ return [...byAgentId.values()].sort((left, right) => {
327
+ const eventDelta = Number(right.eventCount || 0) - Number(left.eventCount || 0);
328
+ if (eventDelta !== 0) return eventDelta;
329
+ return compareIsoDesc(left.lastSeen || left.lastActivityAt, right.lastSeen || right.lastActivityAt);
330
+ });
331
+ }
332
+
99
333
  function parsePositiveInteger(rawValue, field, fallbackValue) {
100
334
  if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
101
335
  return fallbackValue;
@@ -590,6 +824,16 @@ function formatEventLine(event = {}) {
590
824
  const type = normalizeString(event.event || event.type) || "event";
591
825
  const agentId = normalizeString(event.agent?.id || event.agentId || "unknown");
592
826
  const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
827
+ if (type === "session_action" || type === "session_reply" || type === "session_reaction") {
828
+ const actionType = normalizeString(payload.actionType) || type.replace(/^session_/, "");
829
+ const targetSequence = Number(payload.targetSequenceId || 0);
830
+ const target = Number.isFinite(targetSequence) && targetSequence > 0
831
+ ? `#${Math.floor(targetSequence)}`
832
+ : normalizeString(payload.targetCursor) || "target";
833
+ const note = normalizeString(payload.note || payload.message || "");
834
+ const suffix = note && note !== `${actionType} ${target}` ? `: ${note}` : "";
835
+ return `${ts} ${agentId} ${type} ${actionType} ${target}${suffix}`;
836
+ }
593
837
  const message = normalizeString(payload.message || payload.response || payload.alert || payload.reason || "");
594
838
  if (message) {
595
839
  return `${ts} ${agentId} ${type}: ${message}`;
@@ -1291,6 +1535,8 @@ export function registerSessionCommand(program) {
1291
1535
  agentId: resolvedAgentId,
1292
1536
  model,
1293
1537
  role,
1538
+ trackProcessExit: false,
1539
+ awaitRemoteSync: Boolean(explicitAgent),
1294
1540
  });
1295
1541
  const agentJoinRelayed =
1296
1542
  Boolean(explicitAgent) &&
@@ -1348,6 +1594,8 @@ export function registerSessionCommand(program) {
1348
1594
  .description("Send a message to the session")
1349
1595
  .option("--agent <id>", "Agent id to emit from", "cli-user")
1350
1596
  .option("--to <agent>", "Direct the message to a specific agent id")
1597
+ .option("--reply-to <sequence>", "Mark this message as a reply to a target sequence id")
1598
+ .option("--reply-cursor <cursor>", "Mark this message as a reply to a target event cursor")
1351
1599
  .option("--path <path>", "Workspace path for the session", ".")
1352
1600
  .option("--json", "Emit machine-readable output")
1353
1601
  .action(async (sessionId, message, options, command) => {
@@ -1365,6 +1613,8 @@ export function registerSessionCommand(program) {
1365
1613
  targetPath,
1366
1614
  });
1367
1615
  const to = normalizeString(options.to);
1616
+ const replyToSequenceId = parseOptionalPositiveInteger(options.replyTo, "reply-to");
1617
+ const replyToCursor = normalizeString(options.replyCursor);
1368
1618
  const eventPayload = {
1369
1619
  message: normalizedMessage,
1370
1620
  channel: "session",
@@ -1372,6 +1622,12 @@ export function registerSessionCommand(program) {
1372
1622
  if (to) {
1373
1623
  eventPayload.to = to;
1374
1624
  }
1625
+ if (replyToSequenceId) {
1626
+ eventPayload.replyToSequenceId = replyToSequenceId;
1627
+ }
1628
+ if (replyToCursor) {
1629
+ eventPayload.replyToCursor = replyToCursor;
1630
+ }
1375
1631
  const clientMessageId = `cli-${randomUUID()}`;
1376
1632
  const event = createAgentEvent({
1377
1633
  event: "session_message",
@@ -1504,6 +1760,140 @@ export function registerSessionCommand(program) {
1504
1760
  console.log(formatEventLine(persisted));
1505
1761
  });
1506
1762
 
1763
+ async function runMessageActionCommand({
1764
+ sessionId,
1765
+ actionType,
1766
+ options,
1767
+ command,
1768
+ commandName = "session action",
1769
+ targetSequenceId: targetSequenceIdOverride = null,
1770
+ targetCursor: targetCursorOverride = "",
1771
+ note: noteOverride = "",
1772
+ } = {}) {
1773
+ const normalizedSessionId = normalizeString(sessionId);
1774
+ if (!normalizedSessionId) {
1775
+ throw new Error("session id is required.");
1776
+ }
1777
+ const normalizedActionType = normalizeSessionMessageActionType(actionType);
1778
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1779
+ const targetSequenceId =
1780
+ targetSequenceIdOverride ||
1781
+ parseOptionalPositiveInteger(options.targetSequence, "target-sequence");
1782
+ const targetCursor = normalizeString(targetCursorOverride) || normalizeString(options.targetCursor);
1783
+ if (!targetSequenceId && !targetCursor) {
1784
+ throw new Error("Provide --target-sequence or --target-cursor.");
1785
+ }
1786
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
1787
+ const note = normalizeString(noteOverride) || normalizeString(options.note);
1788
+ const agentId = await defaultAgentId(options.agent, targetPath);
1789
+ const idempotencyKey =
1790
+ normalizeString(options.idempotencyKey) ||
1791
+ defaultActionIdempotencyKey({
1792
+ actionType: normalizedActionType,
1793
+ targetSequenceId,
1794
+ targetCursor,
1795
+ note,
1796
+ agentId,
1797
+ });
1798
+
1799
+ const result = await createSessionMessageAction(normalizedSessionId, {
1800
+ actionType: normalizedActionType,
1801
+ targetPath,
1802
+ targetSequenceId,
1803
+ targetCursor,
1804
+ note,
1805
+ metadata: {
1806
+ source: "cli",
1807
+ agentId,
1808
+ },
1809
+ idempotencyKey,
1810
+ timeoutMs: 15_000,
1811
+ });
1812
+ if (!result.ok || !result.action) {
1813
+ throw new Error(`Session action failed (${result.reason || "unknown"}).`);
1814
+ }
1815
+ const actionEvent = buildSessionActionEvent(normalizedSessionId, result.action);
1816
+ const localAppend = await appendActionEventIfMissing(normalizedSessionId, actionEvent, {
1817
+ targetPath,
1818
+ });
1819
+ const payload = {
1820
+ command: commandName,
1821
+ targetPath,
1822
+ sessionId: normalizedSessionId,
1823
+ actionType: normalizedActionType,
1824
+ duplicate: Boolean(result.duplicate),
1825
+ action: result.action,
1826
+ event: localAppend.event,
1827
+ localAppend: {
1828
+ appended: Boolean(localAppend.appended),
1829
+ reason: localAppend.reason || "",
1830
+ },
1831
+ };
1832
+ if (shouldEmitJson(options, command)) {
1833
+ console.log(JSON.stringify(payload, null, 2));
1834
+ return payload;
1835
+ }
1836
+ console.log(formatEventLine(localAppend.event || actionEvent));
1837
+ return payload;
1838
+ }
1839
+
1840
+ session
1841
+ .command("action <sessionId> <actionType>")
1842
+ .description("Create a message action for a target session event")
1843
+ .option("--target-sequence <n>", "Target event sequence id")
1844
+ .option("--target-cursor <cursor>", "Target event cursor")
1845
+ .option("--note <text>", "Optional action note or reply body")
1846
+ .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
1847
+ .option("--idempotency-key <key>", "Explicit idempotency key")
1848
+ .option("--path <path>", "Workspace path for the session", ".")
1849
+ .option("--json", "Emit machine-readable output")
1850
+ .action(async (sessionId, actionType, options, command) => {
1851
+ await runMessageActionCommand({ sessionId, actionType, options, command });
1852
+ });
1853
+
1854
+ session
1855
+ .command("react <sessionId> <reaction>")
1856
+ .description("React to a target session event with like or dislike")
1857
+ .option("--target-sequence <n>", "Target event sequence id")
1858
+ .option("--target-cursor <cursor>", "Target event cursor")
1859
+ .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
1860
+ .option("--idempotency-key <key>", "Explicit idempotency key")
1861
+ .option("--path <path>", "Workspace path for the session", ".")
1862
+ .option("--json", "Emit machine-readable output")
1863
+ .action(async (sessionId, reaction, options, command) => {
1864
+ const normalizedReaction = normalizeSessionMessageActionType(reaction);
1865
+ if (normalizedReaction !== "like" && normalizedReaction !== "dislike") {
1866
+ throw new Error("reaction must be one of: like, dislike.");
1867
+ }
1868
+ await runMessageActionCommand({
1869
+ sessionId,
1870
+ actionType: normalizedReaction,
1871
+ options,
1872
+ command,
1873
+ commandName: "session react",
1874
+ });
1875
+ });
1876
+
1877
+ session
1878
+ .command("reply <sessionId> <targetSequenceId> <message...>")
1879
+ .description("Reply to a target session event using the message-action channel")
1880
+ .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
1881
+ .option("--idempotency-key <key>", "Explicit idempotency key")
1882
+ .option("--path <path>", "Workspace path for the session", ".")
1883
+ .option("--json", "Emit machine-readable output")
1884
+ .action(async (sessionId, targetSequenceId, messageParts, options, command) => {
1885
+ const message = Array.isArray(messageParts) ? messageParts.join(" ") : messageParts;
1886
+ await runMessageActionCommand({
1887
+ sessionId,
1888
+ actionType: "reply",
1889
+ options,
1890
+ command,
1891
+ commandName: "session reply",
1892
+ targetSequenceId: parsePositiveInteger(targetSequenceId, "targetSequenceId", 0),
1893
+ note: message,
1894
+ });
1895
+ });
1896
+
1507
1897
  session
1508
1898
  .command("listen")
1509
1899
  .description("Background-poll a session for events addressed to this agent or broadcast")
@@ -1708,6 +2098,8 @@ export function registerSessionCommand(program) {
1708
2098
  "--remote",
1709
2099
  "Hydrate from the SentinelLayer API before reading (pulls web-posted messages into the local NDJSON)",
1710
2100
  )
2101
+ .option("--before-sequence <n>", "Remote page ending before this sequence id")
2102
+ .option("--no-actions", "Do not include remote message actions/replies/reactions")
1711
2103
  .option("--path <path>", "Workspace path for the session", ".")
1712
2104
  .option("--json", "Emit machine-readable output")
1713
2105
  .action(async (sessionId, options, command) => {
@@ -1717,10 +2109,12 @@ export function registerSessionCommand(program) {
1717
2109
  }
1718
2110
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1719
2111
  const tail = parsePositiveInteger(options.tail, "tail", 20);
2112
+ const beforeSequence = parseOptionalPositiveInteger(options.beforeSequence, "before-sequence");
1720
2113
  const emitJson = shouldEmitJson(options, command);
1721
2114
 
1722
2115
  let hydration = null;
1723
2116
  let remoteTail = null;
2117
+ let remoteActions = null;
1724
2118
  if (options.remote) {
1725
2119
  const authSession = await resolveActiveAuthSession({
1726
2120
  cwd: targetPath,
@@ -1736,9 +2130,17 @@ export function registerSessionCommand(program) {
1736
2130
  });
1737
2131
  remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
1738
2132
  targetPath,
2133
+ beforeSequence,
1739
2134
  limit: tail,
1740
2135
  timeoutMs: 15_000,
1741
2136
  });
2137
+ if (options.actions !== false) {
2138
+ remoteActions = await listSessionMessageActions(normalizedSessionId, {
2139
+ targetPath,
2140
+ limit: 500,
2141
+ timeoutMs: 15_000,
2142
+ });
2143
+ }
1742
2144
  if (!emitJson) {
1743
2145
  if (hydration.ok) {
1744
2146
  console.log(
@@ -1760,6 +2162,13 @@ export function registerSessionCommand(program) {
1760
2162
  ),
1761
2163
  );
1762
2164
  }
2165
+ if (remoteActions && !remoteActions.ok) {
2166
+ console.log(
2167
+ pc.yellow(
2168
+ `Remote message actions skipped (${remoteActions.reason}); showing events only.`,
2169
+ ),
2170
+ );
2171
+ }
1763
2172
  }
1764
2173
  }
1765
2174
 
@@ -1795,7 +2204,10 @@ export function registerSessionCommand(program) {
1795
2204
  }
1796
2205
  }
1797
2206
  }
1798
- const events = dedupeSessionEvents(displayEvents).slice(-tail);
2207
+ const actionEvents = remoteActions?.ok
2208
+ ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
2209
+ : [];
2210
+ const events = mergeSessionActionEvents(displayEvents, actionEvents).slice(-tail);
1799
2211
  const remoteVerified = Boolean(
1800
2212
  options.remote &&
1801
2213
  ((hydration && hydration.ok) || (remoteTail && remoteTail.ok))
@@ -1805,6 +2217,7 @@ export function registerSessionCommand(program) {
1805
2217
  targetPath,
1806
2218
  sessionId: normalizedSessionId,
1807
2219
  tail,
2220
+ beforeSequence,
1808
2221
  count: events.length,
1809
2222
  events,
1810
2223
  displaySource: !options.remote
@@ -1825,11 +2238,24 @@ export function registerSessionCommand(program) {
1825
2238
  reason: remoteTail.reason || "",
1826
2239
  count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
1827
2240
  cursor: remoteTail.cursor || null,
2241
+ beforeSequence: remoteTail.beforeSequence || null,
1828
2242
  verified: Boolean(remoteTail.ok),
1829
2243
  appended: remoteTailAppended,
1830
2244
  displayedOnly: remoteTailDisplayedOnly,
1831
2245
  }
1832
2246
  : null,
2247
+ actions: remoteActions
2248
+ ? {
2249
+ ok: Boolean(remoteActions.ok),
2250
+ reason: remoteActions.reason || "",
2251
+ count: Array.isArray(remoteActions.actions)
2252
+ ? remoteActions.actions.length
2253
+ : 0,
2254
+ actions: remoteActions.actions || [],
2255
+ syntheticEventCount: actionEvents.length,
2256
+ projection: remoteActions.projection || null,
2257
+ }
2258
+ : null,
1833
2259
  }
1834
2260
  : hydration,
1835
2261
  };
@@ -1906,6 +2332,57 @@ export function registerSessionCommand(program) {
1906
2332
  }
1907
2333
  });
1908
2334
 
2335
+ session
2336
+ .command("search <sessionId> <query>")
2337
+ .description("Search durable API session events by text, event type, or agent")
2338
+ .option("--before-sequence <n>", "Return matches older than this sequence id")
2339
+ .option("--limit <n>", "Maximum search results (default 20, max 50)", "20")
2340
+ .option("--path <path>", "Workspace path for the session", ".")
2341
+ .option("--json", "Emit machine-readable output")
2342
+ .action(async (sessionId, query, options, command) => {
2343
+ const normalizedSessionId = normalizeString(sessionId);
2344
+ if (!normalizedSessionId) {
2345
+ throw new Error("session id is required.");
2346
+ }
2347
+ const normalizedQuery = normalizeString(query);
2348
+ if (normalizedQuery.length < 2) {
2349
+ throw new Error("query must be at least 2 characters.");
2350
+ }
2351
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2352
+ const beforeSequence = parseOptionalPositiveInteger(options.beforeSequence, "before-sequence");
2353
+ const limit = parsePositiveInteger(options.limit, "limit", 20);
2354
+ const result = await searchSessionEvents(normalizedSessionId, {
2355
+ query: normalizedQuery,
2356
+ targetPath,
2357
+ beforeSequence,
2358
+ limit,
2359
+ timeoutMs: 15_000,
2360
+ });
2361
+ const payload = {
2362
+ command: "session search",
2363
+ targetPath,
2364
+ sessionId: normalizedSessionId,
2365
+ ...result,
2366
+ nextBeforeSequence: result.nextBeforeSequence || null,
2367
+ };
2368
+ if (shouldEmitJson(options, command)) {
2369
+ console.log(JSON.stringify(payload, null, 2));
2370
+ return;
2371
+ }
2372
+ if (!result.ok) {
2373
+ throw new Error(`Session search failed (${result.reason || "unknown"}).`);
2374
+ }
2375
+ for (const item of result.results || []) {
2376
+ const event = item.event || {};
2377
+ const sequence = item.sequenceId ? `#${item.sequenceId}` : "";
2378
+ const snippet = normalizeString(item.snippet);
2379
+ console.log(`${sequence} ${formatEventLine(event)}${snippet ? ` | ${snippet}` : ""}`);
2380
+ }
2381
+ if (result.hasMore && result.nextBeforeSequence) {
2382
+ console.log(pc.gray(`More results before sequence ${result.nextBeforeSequence}.`));
2383
+ }
2384
+ });
2385
+
1909
2386
  session
1910
2387
  .command("sync <sessionId>")
1911
2388
  .description(
@@ -2238,6 +2715,11 @@ export function registerSessionCommand(program) {
2238
2715
  "json",
2239
2716
  )
2240
2717
  .option("--out <file>", "Write to file instead of stdout")
2718
+ .option(
2719
+ "--remote",
2720
+ "Hydrate from the SentinelLayer API before exporting and include remote message actions",
2721
+ )
2722
+ .option("--no-actions", "Do not include remote message actions/replies/reactions")
2241
2723
  .option("--path <path>", "Workspace path for the session", ".")
2242
2724
  .action(async (sessionId, options) => {
2243
2725
  const normalizedSessionId = normalizeString(sessionId);
@@ -2249,6 +2731,21 @@ export function registerSessionCommand(program) {
2249
2731
  if (format !== "json" && format !== "ndjson") {
2250
2732
  throw new Error(`--format must be 'json' or 'ndjson' (received '${format}').`);
2251
2733
  }
2734
+ let hydration = null;
2735
+ let remoteActions = null;
2736
+ if (options.remote) {
2737
+ hydration = await hydrateSessionFromRemote({
2738
+ sessionId: normalizedSessionId,
2739
+ targetPath,
2740
+ }).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
2741
+ if (options.actions !== false) {
2742
+ remoteActions = await listSessionMessageActions(normalizedSessionId, {
2743
+ targetPath,
2744
+ limit: 500,
2745
+ timeoutMs: 15_000,
2746
+ });
2747
+ }
2748
+ }
2252
2749
 
2253
2750
  const sessionPayload = await getSession(normalizedSessionId, { targetPath });
2254
2751
  if (!sessionPayload) {
@@ -2269,13 +2766,31 @@ export function registerSessionCommand(program) {
2269
2766
  limit: 5_000,
2270
2767
  }),
2271
2768
  ]);
2769
+ const actionEvents = remoteActions?.ok
2770
+ ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
2771
+ : [];
2772
+ const exportEvents = mergeSessionActionEvents(events, actionEvents);
2773
+ const stats = computeTranscriptStats({
2774
+ sessionMeta: sessionPayload,
2775
+ events: exportEvents,
2776
+ });
2777
+ const participants = buildSessionParticipants({
2778
+ statsAgents: stats.agents,
2779
+ registeredAgents: agents,
2780
+ });
2272
2781
 
2273
2782
  let output;
2274
2783
  if (format === "ndjson") {
2275
2784
  const lines = [];
2276
2785
  lines.push(JSON.stringify({ kind: "session", value: sessionPayload }));
2277
2786
  for (const agent of agents) lines.push(JSON.stringify({ kind: "agent", value: agent }));
2278
- for (const event of events) lines.push(JSON.stringify({ kind: "event", value: event }));
2787
+ for (const participant of participants) {
2788
+ lines.push(JSON.stringify({ kind: "participant", value: participant }));
2789
+ }
2790
+ for (const action of remoteActions?.actions || []) {
2791
+ lines.push(JSON.stringify({ kind: "action", value: action }));
2792
+ }
2793
+ for (const event of exportEvents) lines.push(JSON.stringify({ kind: "event", value: event }));
2279
2794
  for (const task of tasks.tasks || []) lines.push(JSON.stringify({ kind: "task", value: task }));
2280
2795
  output = `${lines.join("\n")}\n`;
2281
2796
  } else {
@@ -2285,13 +2800,35 @@ export function registerSessionCommand(program) {
2285
2800
  exportedAt: new Date().toISOString(),
2286
2801
  session: sessionPayload,
2287
2802
  agents,
2288
- events,
2803
+ participants,
2804
+ actions: remoteActions?.actions || [],
2805
+ actionProjection: remoteActions?.projection || null,
2806
+ actionEvents,
2807
+ events: exportEvents,
2289
2808
  tasks: tasks.tasks || [],
2809
+ remote: {
2810
+ hydration,
2811
+ actions: remoteActions
2812
+ ? {
2813
+ ok: Boolean(remoteActions.ok),
2814
+ reason: remoteActions.reason || "",
2815
+ count: Array.isArray(remoteActions.actions) ? remoteActions.actions.length : 0,
2816
+ syntheticEventCount: actionEvents.length,
2817
+ }
2818
+ : null,
2819
+ },
2290
2820
  counts: {
2291
- agents: agents.length,
2292
- events: events.length,
2821
+ agents: participants.length,
2822
+ participants: participants.length,
2823
+ derivedAgents: stats.agents.length,
2824
+ registeredAgents: agents.length,
2825
+ events: exportEvents.length,
2826
+ rawEvents: events.length,
2827
+ actions: Array.isArray(remoteActions?.actions) ? remoteActions.actions.length : 0,
2828
+ actionEvents: actionEvents.length,
2293
2829
  tasks: (tasks.tasks || []).length,
2294
2830
  },
2831
+ totals: stats.totals,
2295
2832
  },
2296
2833
  null,
2297
2834
  2,
@@ -2305,7 +2842,7 @@ export function registerSessionCommand(program) {
2305
2842
  await fsp.writeFile(outPath, output, "utf-8");
2306
2843
  console.log(
2307
2844
  pc.gray(
2308
- `Exported ${events.length} events / ${agents.length} agents / ${
2845
+ `Exported ${exportEvents.length} events / ${participants.length} participants (${agents.length} registered agents) / ${
2309
2846
  (tasks.tasks || []).length
2310
2847
  } tasks โ†’ ${outPath}`,
2311
2848
  ),
@@ -2329,6 +2866,7 @@ export function registerSessionCommand(program) {
2329
2866
  "--remote",
2330
2867
  "Hydrate from the SentinelLayer API before rendering (pulls web-posted messages into the local NDJSON)",
2331
2868
  )
2869
+ .option("--no-actions", "Do not include remote message actions/replies/reactions")
2332
2870
  .option("--path <path>", "Workspace path for the session", ".")
2333
2871
  .option("--json", "Emit machine-readable output")
2334
2872
  .action(async (sessionId, options, command) => {
@@ -2340,11 +2878,19 @@ export function registerSessionCommand(program) {
2340
2878
  const emitJson = shouldEmitJson(options, command);
2341
2879
 
2342
2880
  let hydration = null;
2881
+ let remoteActions = null;
2343
2882
  if (options.remote) {
2344
2883
  hydration = await hydrateSessionFromRemote({
2345
2884
  sessionId: normalizedSessionId,
2346
2885
  targetPath,
2347
2886
  }).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
2887
+ if (options.actions !== false) {
2888
+ remoteActions = await listSessionMessageActions(normalizedSessionId, {
2889
+ targetPath,
2890
+ limit: 500,
2891
+ timeoutMs: 15_000,
2892
+ });
2893
+ }
2348
2894
  }
2349
2895
 
2350
2896
  const sessionPayload = await getSession(normalizedSessionId, { targetPath });
@@ -2356,6 +2902,10 @@ export function registerSessionCommand(program) {
2356
2902
  listAgents(normalizedSessionId, { targetPath, includeInactive: true }),
2357
2903
  readStream(normalizedSessionId, { targetPath, tail: 0 }),
2358
2904
  ]);
2905
+ const actionEvents = remoteActions?.ok
2906
+ ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
2907
+ : [];
2908
+ const transcriptEvents = mergeSessionActionEvents(events, actionEvents);
2359
2909
 
2360
2910
  // Pull GitHub/Google avatar + display name from the active auth
2361
2911
  // session so any human-id seen in the stream renders with the
@@ -2391,7 +2941,7 @@ export function registerSessionCommand(program) {
2391
2941
  createdAt: sessionPayload.createdAt,
2392
2942
  status: sessionPayload.status,
2393
2943
  },
2394
- events,
2944
+ events: transcriptEvents,
2395
2945
  agents,
2396
2946
  speakerProfiles,
2397
2947
  options: {
@@ -2399,6 +2949,10 @@ export function registerSessionCommand(program) {
2399
2949
  includeSystemEvents: options.systemEvents !== false,
2400
2950
  },
2401
2951
  });
2952
+ const participants = buildSessionParticipants({
2953
+ statsAgents: stats.agents,
2954
+ registeredAgents: agents,
2955
+ });
2402
2956
 
2403
2957
  const outArg = normalizeString(options.out);
2404
2958
  const outPath = outArg
@@ -2412,12 +2966,30 @@ export function registerSessionCommand(program) {
2412
2966
  sessionId: normalizedSessionId,
2413
2967
  outPath,
2414
2968
  bytes: Buffer.byteLength(markdown, "utf-8"),
2415
- eventCount: events.length,
2416
- agentCount: agents.length,
2969
+ eventCount: transcriptEvents.length,
2970
+ rawEventCount: events.length,
2971
+ actionCount: Array.isArray(remoteActions?.actions) ? remoteActions.actions.length : 0,
2972
+ actionEventCount: actionEvents.length,
2973
+ agentCount: participants.length,
2974
+ participantCount: participants.length,
2975
+ derivedAgentCount: stats.agents.length,
2976
+ registeredAgentCount: agents.length,
2977
+ participants,
2417
2978
  sessionLiveSeconds: stats.sessionLiveSeconds,
2418
2979
  sentiActions: stats.sentiActions,
2419
2980
  totals: stats.totals,
2420
- remote: hydration,
2981
+ remote: {
2982
+ hydration,
2983
+ actions: remoteActions
2984
+ ? {
2985
+ ok: Boolean(remoteActions.ok),
2986
+ reason: remoteActions.reason || "",
2987
+ count: Array.isArray(remoteActions.actions) ? remoteActions.actions.length : 0,
2988
+ syntheticEventCount: actionEvents.length,
2989
+ projection: remoteActions.projection || null,
2990
+ }
2991
+ : null,
2992
+ },
2421
2993
  };
2422
2994
  if (emitJson) {
2423
2995
  console.log(JSON.stringify(payload, null, 2));
@@ -2426,7 +2998,7 @@ export function registerSessionCommand(program) {
2426
2998
  console.log(pc.bold(`Downloaded session ${normalizedSessionId} โ†’ ${outPath}`));
2427
2999
  console.log(
2428
3000
  pc.gray(
2429
- `${events.length} events ยท ${agents.length} agents ยท live ${stats.sessionLiveSeconds}s ยท senti=${stats.sentiActions} ยท tokens=${stats.totals.tokenTotal} ยท cost=$${stats.totals.costTotalUsd.toFixed(4)}`,
3001
+ `${transcriptEvents.length} events ยท ${participants.length} participants (${agents.length} registered agents) ยท actions=${payload.actionCount} ยท live ${stats.sessionLiveSeconds}s ยท senti=${stats.sentiActions} ยท tokens=${stats.totals.tokenTotal} ยท cost=$${stats.totals.costTotalUsd.toFixed(4)}`,
2430
3002
  ),
2431
3003
  );
2432
3004
  });
@@ -156,14 +156,19 @@ async function writeAgentSnapshot(snapshotPath, snapshot) {
156
156
  await fsp.rename(tmpPath, snapshotPath);
157
157
  }
158
158
 
159
- async function emitAgentEvent(sessionId, event, payload, { targetPath = process.cwd() } = {}) {
159
+ async function emitAgentEvent(
160
+ sessionId,
161
+ event,
162
+ payload,
163
+ { targetPath = process.cwd(), awaitRemoteSync = false } = {}
164
+ ) {
160
165
  const envelope = createAgentEvent({
161
166
  event,
162
167
  agentId: payload.agentId,
163
168
  sessionId,
164
169
  payload,
165
170
  });
166
- await appendToStream(sessionId, envelope, { targetPath });
171
+ await appendToStream(sessionId, envelope, { targetPath, awaitRemoteSync });
167
172
  }
168
173
 
169
174
  function buildAgentSnapshotPath(paths, agentId) {
@@ -238,7 +243,14 @@ function _ensureExitHooksInstalled() {
238
243
 
239
244
  export async function registerAgent(
240
245
  sessionId,
241
- { agentId = "", model = "", role = "observer", targetPath = process.cwd() } = {}
246
+ {
247
+ agentId = "",
248
+ model = "",
249
+ role = "observer",
250
+ targetPath = process.cwd(),
251
+ trackProcessExit = true,
252
+ awaitRemoteSync = false,
253
+ } = {}
242
254
  ) {
243
255
  const paths = resolveSessionPaths(sessionId, { targetPath });
244
256
  const nowIso = new Date().toISOString();
@@ -289,8 +301,10 @@ export async function registerAgent(
289
301
  model: snapshot.model,
290
302
  role: snapshot.role,
291
303
  status: snapshot.status,
292
- }, { targetPath });
293
- _trackLocalAgent(paths.sessionId, snapshot.agentId, targetPath);
304
+ }, { targetPath, awaitRemoteSync });
305
+ if (trackProcessExit) {
306
+ _trackLocalAgent(paths.sessionId, snapshot.agentId, targetPath);
307
+ }
294
308
 
295
309
  if (renamedFrom) {
296
310
  const welcome = buildSentiWelcome({
@@ -310,6 +324,7 @@ export async function registerAgent(
310
324
  await emitContextBriefing(paths.sessionId, {
311
325
  forAgentId: snapshot.agentId,
312
326
  targetPath,
327
+ awaitRemoteSync,
313
328
  }).catch(() => {});
314
329
  }
315
330
 
@@ -559,6 +559,7 @@ export async function emitContextBriefing(
559
559
  targetPath = process.cwd(),
560
560
  nowIso = new Date().toISOString(),
561
561
  includeJoinRules = true,
562
+ awaitRemoteSync = false,
562
563
  } = {}
563
564
  ) {
564
565
  const recap = await buildSessionRecap(sessionId, {
@@ -588,6 +589,7 @@ export async function emitContextBriefing(
588
589
  });
589
590
  const persisted = await appendToStream(sessionId, event, {
590
591
  targetPath,
592
+ awaitRemoteSync,
591
593
  });
592
594
  return {
593
595
  recap,
@@ -166,14 +166,25 @@ async function pollSessionEventPages({
166
166
  };
167
167
  }
168
168
 
169
+ const pageEvents = Array.isArray(result.events) ? result.events : [];
169
170
  const nextCursor =
170
171
  typeof result.cursor === "string" && result.cursor.trim() ? result.cursor.trim() : cursor;
171
172
  const progressed = nextCursor && cursorAdvances(nextCursor, cursor);
173
+ if (nextCursor && cursor && !progressed && pageEvents.length === 0) {
174
+ return {
175
+ ok: true,
176
+ reason: "",
177
+ events,
178
+ cursor,
179
+ pageCount,
180
+ complete: true,
181
+ truncated: false,
182
+ };
183
+ }
172
184
  if (nextCursor && cursor && !progressed) {
173
185
  reason = "cursor_not_advanced";
174
186
  break;
175
187
  }
176
- const pageEvents = Array.isArray(result.events) ? result.events : [];
177
188
  events.push(...pageEvents);
178
189
  cursor = nextCursor || cursor;
179
190
 
@@ -337,7 +337,12 @@ function filterBySince(events = [], since) {
337
337
  export async function appendToStream(
338
338
  sessionId,
339
339
  event,
340
- { targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS, syncRemote = true } = {}
340
+ {
341
+ targetPath = process.cwd(),
342
+ maxEvents = DEFAULT_MAX_STREAM_EVENTS,
343
+ syncRemote = true,
344
+ awaitRemoteSync = false,
345
+ } = {}
341
346
  ) {
342
347
  const paths = resolveSessionPaths(sessionId, { targetPath });
343
348
  let metadata = await readSessionMetadata(paths);
@@ -381,9 +386,14 @@ export async function appendToStream(
381
386
 
382
387
  if (syncRemote) {
383
388
  // Best-effort dashboard sync. Never block local stream durability on API state.
384
- void syncSessionEventToApi(paths.sessionId, canonicalEvent, {
389
+ const syncPromise = syncSessionEventToApi(paths.sessionId, canonicalEvent, {
385
390
  targetPath,
386
391
  }).catch(() => {});
392
+ if (awaitRemoteSync) {
393
+ await syncPromise;
394
+ } else {
395
+ void syncPromise;
396
+ }
387
397
  }
388
398
 
389
399
  return canonicalEvent;
@@ -15,6 +15,8 @@ const HUMAN_MESSAGE_LIMIT_PER_MINUTE = 10;
15
15
  const HUMAN_MESSAGE_MAX_LENGTH = 2_000;
16
16
  const HUMAN_MESSAGE_FETCH_LIMIT = 50;
17
17
  const SESSION_EVENT_FETCH_LIMIT = 200;
18
+ const SESSION_ACTION_FETCH_LIMIT = 500;
19
+ const SESSION_SEARCH_FETCH_LIMIT = 50;
18
20
 
19
21
  // Audit ยง2.9: crash-recovery contract for in-memory circuit state.
20
22
  // Persist outbound/inbound circuit state to disk so a process restart
@@ -1238,6 +1240,347 @@ export async function pollSessionEventsBefore(
1238
1240
  }
1239
1241
 
1240
1242
 
1243
+ export async function listSessionMessageActions(
1244
+ sessionId,
1245
+ {
1246
+ targetPath = process.cwd(),
1247
+ targetSequenceId = null,
1248
+ limit = SESSION_ACTION_FETCH_LIMIT,
1249
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1250
+ forceCircuitProbe = false,
1251
+ resolveAuthSession = resolveActiveAuthSession,
1252
+ fetchImpl = fetchWithTimeout,
1253
+ nowMs = Date.now,
1254
+ } = {}
1255
+ ) {
1256
+ const normalizedSessionId = normalizeString(sessionId);
1257
+ if (!normalizedSessionId) {
1258
+ return {
1259
+ ok: false,
1260
+ reason: "invalid_session_id",
1261
+ actions: [],
1262
+ count: 0,
1263
+ projection: null,
1264
+ };
1265
+ }
1266
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1267
+ if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
1268
+ return {
1269
+ ok: false,
1270
+ reason: "circuit_breaker_open",
1271
+ actions: [],
1272
+ count: 0,
1273
+ projection: null,
1274
+ };
1275
+ }
1276
+
1277
+ let session = null;
1278
+ try {
1279
+ session = await resolveAuthSession({
1280
+ cwd: targetPath,
1281
+ env: process.env,
1282
+ autoRotate: false,
1283
+ });
1284
+ } catch {
1285
+ return { ok: false, reason: "no_session", actions: [], count: 0, projection: null };
1286
+ }
1287
+ if (!session || !session.token) {
1288
+ return { ok: false, reason: "not_authenticated", actions: [], count: 0, projection: null };
1289
+ }
1290
+
1291
+ const apiBaseUrl = resolveApiBaseUrl(session);
1292
+ const query = new URLSearchParams();
1293
+ const normalizedTargetSequence = Number(targetSequenceId);
1294
+ if (Number.isFinite(normalizedTargetSequence) && normalizedTargetSequence > 0) {
1295
+ query.set("targetSequenceId", String(Math.floor(normalizedTargetSequence)));
1296
+ }
1297
+ query.set(
1298
+ "limit",
1299
+ String(Math.max(1, Math.min(SESSION_ACTION_FETCH_LIMIT, normalizePositiveInteger(limit, 200))))
1300
+ );
1301
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/actions?${query.toString()}`;
1302
+
1303
+ try {
1304
+ const response = await fetchImpl(
1305
+ endpoint,
1306
+ {
1307
+ method: "GET",
1308
+ headers: { Authorization: `Bearer ${session.token}` },
1309
+ },
1310
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
1311
+ );
1312
+ if (!response || !response.ok) {
1313
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1314
+ return {
1315
+ ok: false,
1316
+ reason: `api_${response ? response.status : "no_response"}`,
1317
+ actions: [],
1318
+ count: 0,
1319
+ projection: null,
1320
+ };
1321
+ }
1322
+ const payload = await response.json().catch(() => ({}));
1323
+ recordCircuitSuccess(inboundCircuit);
1324
+ const actions = Array.isArray(payload?.actions) ? payload.actions : [];
1325
+ return {
1326
+ ok: true,
1327
+ reason: "",
1328
+ sessionId: normalizeString(payload?.sessionId) || normalizedSessionId,
1329
+ actions,
1330
+ count: Number(payload?.count ?? actions.length) || actions.length,
1331
+ projection: payload?.projection && typeof payload.projection === "object"
1332
+ ? payload.projection
1333
+ : null,
1334
+ };
1335
+ } catch (error) {
1336
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1337
+ return {
1338
+ ok: false,
1339
+ reason: normalizeString(error?.message) || "actions_read_failed",
1340
+ actions: [],
1341
+ count: 0,
1342
+ projection: null,
1343
+ };
1344
+ }
1345
+ }
1346
+
1347
+ export async function createSessionMessageAction(
1348
+ sessionId,
1349
+ {
1350
+ actionType,
1351
+ targetPath = process.cwd(),
1352
+ targetSequenceId = null,
1353
+ targetCursor = "",
1354
+ note = "",
1355
+ metadata = {},
1356
+ idempotencyKey = "",
1357
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1358
+ resolveAuthSession = resolveActiveAuthSession,
1359
+ fetchImpl = fetchWithTimeout,
1360
+ nowMs = Date.now,
1361
+ } = {}
1362
+ ) {
1363
+ const normalizedSessionId = normalizeString(sessionId);
1364
+ const normalizedActionType = normalizeString(actionType).toLowerCase();
1365
+ if (!normalizedSessionId || !normalizedActionType) {
1366
+ return { ok: false, reason: "invalid_input", action: null };
1367
+ }
1368
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
1369
+ return { ok: false, reason: "remote_sync_disabled_env", action: null };
1370
+ }
1371
+
1372
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1373
+ if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
1374
+ return { ok: false, reason: "circuit_breaker_open", action: null };
1375
+ }
1376
+
1377
+ let session = null;
1378
+ try {
1379
+ session = await resolveAuthSession({
1380
+ cwd: targetPath,
1381
+ env: process.env,
1382
+ autoRotate: false,
1383
+ });
1384
+ } catch {
1385
+ return { ok: false, reason: "no_session", action: null };
1386
+ }
1387
+ if (!session || !session.token) {
1388
+ return { ok: false, reason: "not_authenticated", action: null };
1389
+ }
1390
+
1391
+ const apiBaseUrl = resolveApiBaseUrl(session);
1392
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/actions`;
1393
+ const body = {
1394
+ actionType: normalizedActionType,
1395
+ metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {},
1396
+ };
1397
+ const normalizedTargetSequence = Number(targetSequenceId);
1398
+ if (Number.isFinite(normalizedTargetSequence) && normalizedTargetSequence > 0) {
1399
+ body.targetSequenceId = Math.floor(normalizedTargetSequence);
1400
+ }
1401
+ const normalizedTargetCursor = normalizeString(targetCursor);
1402
+ if (normalizedTargetCursor) {
1403
+ body.targetCursor = normalizedTargetCursor;
1404
+ }
1405
+ const normalizedNote = normalizeString(note);
1406
+ if (normalizedNote) {
1407
+ body.note = normalizedNote;
1408
+ }
1409
+ const normalizedIdempotencyKey = normalizeString(idempotencyKey);
1410
+ if (normalizedIdempotencyKey) {
1411
+ body.idempotencyKey = normalizedIdempotencyKey;
1412
+ }
1413
+
1414
+ try {
1415
+ const response = await fetchImpl(
1416
+ endpoint,
1417
+ {
1418
+ method: "POST",
1419
+ headers: {
1420
+ "Content-Type": "application/json",
1421
+ Authorization: `Bearer ${session.token}`,
1422
+ },
1423
+ body: JSON.stringify(body),
1424
+ },
1425
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
1426
+ );
1427
+ if (!response || !response.ok) {
1428
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1429
+ return {
1430
+ ok: false,
1431
+ reason: `api_${response ? response.status : "no_response"}`,
1432
+ action: null,
1433
+ };
1434
+ }
1435
+ const payload = await response.json().catch(() => ({}));
1436
+ recordCircuitSuccess(outboundCircuit);
1437
+ return {
1438
+ ok: Boolean(payload?.ok ?? true),
1439
+ reason: "",
1440
+ duplicate: Boolean(payload?.duplicate),
1441
+ action: payload?.action && typeof payload.action === "object" ? payload.action : null,
1442
+ };
1443
+ } catch (error) {
1444
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1445
+ return {
1446
+ ok: false,
1447
+ reason: normalizeString(error?.message) || "action_write_failed",
1448
+ action: null,
1449
+ };
1450
+ }
1451
+ }
1452
+
1453
+ export async function searchSessionEvents(
1454
+ sessionId,
1455
+ {
1456
+ query,
1457
+ targetPath = process.cwd(),
1458
+ beforeSequence = null,
1459
+ limit = 20,
1460
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1461
+ forceCircuitProbe = false,
1462
+ resolveAuthSession = resolveActiveAuthSession,
1463
+ fetchImpl = fetchWithTimeout,
1464
+ nowMs = Date.now,
1465
+ } = {}
1466
+ ) {
1467
+ const normalizedSessionId = normalizeString(sessionId);
1468
+ const normalizedQuery = normalizeString(query);
1469
+ if (!normalizedSessionId || normalizedQuery.length < 2) {
1470
+ return {
1471
+ ok: false,
1472
+ reason: "invalid_input",
1473
+ query: normalizedQuery,
1474
+ results: [],
1475
+ count: 0,
1476
+ hasMore: false,
1477
+ nextBeforeSequence: null,
1478
+ };
1479
+ }
1480
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1481
+ if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
1482
+ return {
1483
+ ok: false,
1484
+ reason: "circuit_breaker_open",
1485
+ query: normalizedQuery,
1486
+ results: [],
1487
+ count: 0,
1488
+ hasMore: false,
1489
+ nextBeforeSequence: null,
1490
+ };
1491
+ }
1492
+
1493
+ let session = null;
1494
+ try {
1495
+ session = await resolveAuthSession({
1496
+ cwd: targetPath,
1497
+ env: process.env,
1498
+ autoRotate: false,
1499
+ });
1500
+ } catch {
1501
+ return {
1502
+ ok: false,
1503
+ reason: "no_session",
1504
+ query: normalizedQuery,
1505
+ results: [],
1506
+ count: 0,
1507
+ hasMore: false,
1508
+ nextBeforeSequence: null,
1509
+ };
1510
+ }
1511
+ if (!session || !session.token) {
1512
+ return {
1513
+ ok: false,
1514
+ reason: "not_authenticated",
1515
+ query: normalizedQuery,
1516
+ results: [],
1517
+ count: 0,
1518
+ hasMore: false,
1519
+ nextBeforeSequence: null,
1520
+ };
1521
+ }
1522
+
1523
+ const apiBaseUrl = resolveApiBaseUrl(session);
1524
+ const queryParams = new URLSearchParams();
1525
+ queryParams.set("q", normalizedQuery);
1526
+ const normalizedBeforeSequence = Number(beforeSequence);
1527
+ if (Number.isFinite(normalizedBeforeSequence) && normalizedBeforeSequence > 0) {
1528
+ queryParams.set("beforeSequence", String(Math.floor(normalizedBeforeSequence)));
1529
+ }
1530
+ queryParams.set(
1531
+ "limit",
1532
+ String(Math.max(1, Math.min(SESSION_SEARCH_FETCH_LIMIT, normalizePositiveInteger(limit, 20))))
1533
+ );
1534
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/events/search?${queryParams.toString()}`;
1535
+
1536
+ try {
1537
+ const response = await fetchImpl(
1538
+ endpoint,
1539
+ {
1540
+ method: "GET",
1541
+ headers: { Authorization: `Bearer ${session.token}` },
1542
+ },
1543
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
1544
+ );
1545
+ if (!response || !response.ok) {
1546
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1547
+ return {
1548
+ ok: false,
1549
+ reason: `api_${response ? response.status : "no_response"}`,
1550
+ query: normalizedQuery,
1551
+ results: [],
1552
+ count: 0,
1553
+ hasMore: false,
1554
+ nextBeforeSequence: null,
1555
+ };
1556
+ }
1557
+ const payload = await response.json().catch(() => ({}));
1558
+ recordCircuitSuccess(inboundCircuit);
1559
+ const results = Array.isArray(payload?.results) ? payload.results : [];
1560
+ return {
1561
+ ok: true,
1562
+ reason: "",
1563
+ query: normalizeString(payload?.query) || normalizedQuery,
1564
+ results,
1565
+ count: Number(payload?.count ?? results.length) || results.length,
1566
+ hasMore: Boolean(payload?.has_more ?? payload?.hasMore),
1567
+ nextBeforeSequence: Number(payload?.next_before_sequence ?? payload?.nextBeforeSequence) || null,
1568
+ };
1569
+ } catch (error) {
1570
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1571
+ return {
1572
+ ok: false,
1573
+ reason: normalizeString(error?.message) || "search_failed",
1574
+ query: normalizedQuery,
1575
+ results: [],
1576
+ count: 0,
1577
+ hasMore: false,
1578
+ nextBeforeSequence: null,
1579
+ };
1580
+ }
1581
+ }
1582
+
1583
+
1241
1584
  /**
1242
1585
  * List sessions owned by the active user via `GET /api/v1/sessions`.
1243
1586
  *
@@ -41,6 +41,9 @@ const TRANSCRIPT_EVENT_KINDS = new Set([
41
41
  "session_message",
42
42
  "session_say",
43
43
  "agent_response",
44
+ "session_action",
45
+ "session_reply",
46
+ "session_reaction",
44
47
  "session_usage",
45
48
  "human_relay",
46
49
  "agent_join",
@@ -359,26 +362,32 @@ export function buildTranscriptMarkdown({
359
362
  `| ${avatarMd(identity)} | **${agent.displayName}** \`${agent.agentId}\` | ${agent.family} | ${formatDuration(agent.activeSeconds)} | ${agent.eventCount} | ${agent.tokens.toLocaleString("en-US")} | $${agent.costUsd.toFixed(4)} |`,
360
363
  );
361
364
  }
362
- if (stats.agents.length === 0) {
363
- lines.push("| ๐Ÿ‘ค | (no agents joined) | โ€” | 0s | 0 | 0 | $0.00 |");
364
- }
365
365
  // Surface registered-but-silent agents at the bottom of the table so
366
366
  // the participants list is comprehensive even if they never emitted
367
367
  // a stream event.
368
368
  const seenIds = new Set(stats.agents.map((a) => a.agentId));
369
+ const silentRegisteredAgents = [];
369
370
  for (const registered of agents || []) {
370
371
  const id = normalize(registered?.agentId);
371
372
  if (!id || seenIds.has(id)) continue;
373
+ seenIds.add(id);
372
374
  const profile = speakerProfiles.get(id) || null;
373
375
  const identity = resolveSpeakerIdentity({
374
376
  agentId: id,
375
377
  agentModel: registered.model || "",
376
378
  profile,
377
379
  });
380
+ silentRegisteredAgents.push({ id, identity });
381
+ }
382
+ if (stats.agents.length === 0 && silentRegisteredAgents.length === 0) {
383
+ lines.push("| ๐Ÿ‘ค | (no agents joined) | โ€” | 0s | 0 | 0 | $0.00 |");
384
+ }
385
+ for (const registered of silentRegisteredAgents) {
378
386
  lines.push(
379
- `| ${avatarMd(identity)} | **${identity.displayName}** \`${id}\` | ${identity.family} | 0s ยท idle | 0 | 0 | $0.0000 |`,
387
+ `| ${avatarMd(registered.identity)} | **${registered.identity.displayName}** \`${registered.id}\` | ${registered.identity.family} | 0s ยท idle | 0 | 0 | $0.0000 |`,
380
388
  );
381
389
  }
390
+ stats.participantCount = stats.agents.length + silentRegisteredAgents.length;
382
391
  lines.push("");
383
392
 
384
393
  // Conversation