sentinelayer-cli 0.28.4 → 0.29.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.28.4",
3
+ "version": "0.29.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -20,6 +20,8 @@ export function registerOmarGateCommand(program, invokeLegacy) {
20
20
  .option("--model <id>", "LLM model override (default: gpt-5.3-codex)")
21
21
  .option("--provider <name>", "LLM provider: sentinelayer, openai, anthropic")
22
22
  .option("--max-cost <usd>", "Maximum AI layer cost in USD (default: 5.0)")
23
+ .option("--notify-session <session-id>", "Write Omar Deep billing-grade usage entries into this Senti session")
24
+ .option("--require-usage-ledger", "Fail if Omar Deep AI calls cannot write billing-grade session_usage")
23
25
  .option("--persona <csv>", "Only run these personas (comma-separated IDs); unknown IDs are dropped + warned")
24
26
  .option("--skip-persona <csv>", "Skip these personas (comma-separated IDs)")
25
27
  .option("--stream", "Emit NDJSON events to stdout as personas run")
@@ -140,6 +140,8 @@ const SESSION_SAY_CONFIRM_ATTEMPTS = 3;
140
140
  const SESSION_SAY_CONFIRM_DELAY_MS = 250;
141
141
  const SESSION_SAY_CONFIRM_PAGE_LIMIT = 200;
142
142
  const SESSION_SAY_CONFIRM_MAX_PAGES = 10;
143
+ const SESSION_SAY_CONFIRM_TOTAL_TIMEOUT_MS = 10_000;
144
+ const SESSION_SAY_CONFIRM_REQUEST_TIMEOUT_MS = 2_000;
143
145
  const SESSION_SAY_LOCAL_ONLY_REASONS = new Set([
144
146
  "no_session",
145
147
  "not_authenticated",
@@ -201,21 +203,131 @@ async function readSessionConfirmationAnchor(sessionId, { targetPath } = {}) {
201
203
  };
202
204
  }
203
205
 
204
- async function confirmSessionEventVisible(sessionId, clientMessageId, { targetPath, anchorCursor = null } = {}) {
206
+ function normalizePositiveIntegerOrDefault(value, fallbackValue) {
207
+ const normalized = Number(value);
208
+ if (!Number.isFinite(normalized) || normalized <= 0) {
209
+ return fallbackValue;
210
+ }
211
+ return Math.floor(normalized);
212
+ }
213
+
214
+ function confirmationRemainingMs(deadlineMs, nowMs) {
215
+ const now = Number(nowMs()) || Date.now();
216
+ return Math.max(0, Math.floor(deadlineMs - now));
217
+ }
218
+
219
+ async function confirmSessionEventVisible(
220
+ sessionId,
221
+ clientMessageId,
222
+ {
223
+ targetPath,
224
+ anchorCursor = null,
225
+ pollSessionEventsFn = pollSessionEvents,
226
+ pollSessionEventsBeforeFn = pollSessionEventsBefore,
227
+ sleepFn = sleep,
228
+ nowMs = Date.now,
229
+ attempts = SESSION_SAY_CONFIRM_ATTEMPTS,
230
+ delayMs = SESSION_SAY_CONFIRM_DELAY_MS,
231
+ pageLimit = SESSION_SAY_CONFIRM_PAGE_LIMIT,
232
+ maxPages = SESSION_SAY_CONFIRM_MAX_PAGES,
233
+ totalTimeoutMs = SESSION_SAY_CONFIRM_TOTAL_TIMEOUT_MS,
234
+ requestTimeoutMs = SESSION_SAY_CONFIRM_REQUEST_TIMEOUT_MS,
235
+ } = {},
236
+ ) {
205
237
  let lastReason = "not_visible";
206
238
  let checked = 0;
207
239
  let pages = 0;
240
+ let tailChecks = 0;
208
241
  const normalizedAnchorCursor = normalizeString(anchorCursor) || null;
209
- for (let attempt = 1; attempt <= SESSION_SAY_CONFIRM_ATTEMPTS; attempt += 1) {
210
- let pageCursor = normalizedAnchorCursor;
211
- for (let page = 1; page <= SESSION_SAY_CONFIRM_MAX_PAGES; page += 1) {
212
- pages += 1;
213
- const result = await pollSessionEvents(sessionId, {
242
+ const normalizedAttempts = normalizePositiveIntegerOrDefault(attempts, SESSION_SAY_CONFIRM_ATTEMPTS);
243
+ const normalizedDelayMs = normalizePositiveIntegerOrDefault(delayMs, SESSION_SAY_CONFIRM_DELAY_MS);
244
+ const normalizedPageLimit = normalizePositiveIntegerOrDefault(pageLimit, SESSION_SAY_CONFIRM_PAGE_LIMIT);
245
+ const normalizedMaxPages = normalizePositiveIntegerOrDefault(maxPages, SESSION_SAY_CONFIRM_MAX_PAGES);
246
+ const normalizedTotalTimeoutMs = normalizePositiveIntegerOrDefault(
247
+ totalTimeoutMs,
248
+ SESSION_SAY_CONFIRM_TOTAL_TIMEOUT_MS,
249
+ );
250
+ const normalizedRequestTimeoutMs = normalizePositiveIntegerOrDefault(
251
+ requestTimeoutMs,
252
+ SESSION_SAY_CONFIRM_REQUEST_TIMEOUT_MS,
253
+ );
254
+ const deadlineMs = (Number(nowMs()) || Date.now()) + normalizedTotalTimeoutMs;
255
+ const nextRequestTimeoutMs = () => {
256
+ const remaining = confirmationRemainingMs(deadlineMs, nowMs);
257
+ if (remaining <= 0) return 0;
258
+ return Math.max(1, Math.min(normalizedRequestTimeoutMs, remaining));
259
+ };
260
+
261
+ const maybeConfirmFromLatestTail = async () => {
262
+ const timeoutMs = nextRequestTimeoutMs();
263
+ if (timeoutMs <= 0) {
264
+ lastReason = "confirmation_timeout";
265
+ return null;
266
+ }
267
+ tailChecks += 1;
268
+ let result = null;
269
+ try {
270
+ result = await pollSessionEventsBeforeFn(sessionId, {
214
271
  targetPath,
215
- since: pageCursor,
216
- limit: SESSION_SAY_CONFIRM_PAGE_LIMIT,
272
+ limit: normalizedPageLimit,
273
+ timeoutMs,
217
274
  forceCircuitProbe: true,
218
275
  });
276
+ } catch (error) {
277
+ lastReason = normalizeString(error?.code || error?.message) || "confirmation_tail_poll_failed";
278
+ return null;
279
+ }
280
+ if (!result?.ok) {
281
+ lastReason = normalizeString(result?.reason) || "confirmation_tail_poll_failed";
282
+ return null;
283
+ }
284
+ const events = Array.isArray(result.events) ? result.events : [];
285
+ checked += events.length;
286
+ const confirmedEvent = events.find((candidate) => sessionEventMatchesClientMessageId(candidate, clientMessageId));
287
+ if (!confirmedEvent) {
288
+ lastReason = "not_visible";
289
+ return null;
290
+ }
291
+ return {
292
+ confirmed: true,
293
+ reason: "",
294
+ checked,
295
+ pages,
296
+ tailChecks,
297
+ source: "latest_tail",
298
+ anchorCursor: normalizedAnchorCursor,
299
+ event: confirmedEvent,
300
+ };
301
+ };
302
+
303
+ for (let attempt = 1; attempt <= normalizedAttempts; attempt += 1) {
304
+ let pageCursor = normalizedAnchorCursor;
305
+ for (let page = 1; page <= normalizedMaxPages; page += 1) {
306
+ const timeoutMs = nextRequestTimeoutMs();
307
+ if (timeoutMs <= 0) {
308
+ return {
309
+ confirmed: false,
310
+ reason: "confirmation_timeout",
311
+ checked,
312
+ pages,
313
+ tailChecks,
314
+ anchorCursor: normalizedAnchorCursor,
315
+ };
316
+ }
317
+ pages += 1;
318
+ let result = null;
319
+ try {
320
+ result = await pollSessionEventsFn(sessionId, {
321
+ targetPath,
322
+ since: pageCursor,
323
+ limit: normalizedPageLimit,
324
+ timeoutMs,
325
+ forceCircuitProbe: true,
326
+ });
327
+ } catch (error) {
328
+ lastReason = normalizeString(error?.code || error?.message) || "confirmation_poll_failed";
329
+ break;
330
+ }
219
331
  if (!result?.ok) {
220
332
  lastReason = normalizeString(result?.reason) || "confirmation_poll_failed";
221
333
  break;
@@ -229,6 +341,8 @@ async function confirmSessionEventVisible(sessionId, clientMessageId, { targetPa
229
341
  reason: "",
230
342
  checked,
231
343
  pages,
344
+ tailChecks,
345
+ source: "forward_cursor",
232
346
  anchorCursor: normalizedAnchorCursor,
233
347
  event: confirmedEvent,
234
348
  };
@@ -240,8 +354,17 @@ async function confirmSessionEventVisible(sessionId, clientMessageId, { targetPa
240
354
  }
241
355
  pageCursor = nextCursor;
242
356
  }
243
- if (attempt < SESSION_SAY_CONFIRM_ATTEMPTS) {
244
- await sleep(SESSION_SAY_CONFIRM_DELAY_MS);
357
+ const tailConfirmation = await maybeConfirmFromLatestTail();
358
+ if (tailConfirmation) {
359
+ return tailConfirmation;
360
+ }
361
+ if (attempt < normalizedAttempts) {
362
+ const remaining = confirmationRemainingMs(deadlineMs, nowMs);
363
+ if (remaining <= 0) {
364
+ lastReason = "confirmation_timeout";
365
+ break;
366
+ }
367
+ await sleepFn(Math.min(normalizedDelayMs, remaining));
245
368
  }
246
369
  }
247
370
  return {
@@ -249,6 +372,7 @@ async function confirmSessionEventVisible(sessionId, clientMessageId, { targetPa
249
372
  reason: lastReason,
250
373
  checked,
251
374
  pages,
375
+ tailChecks,
252
376
  anchorCursor: normalizedAnchorCursor,
253
377
  };
254
378
  }
@@ -1058,6 +1182,44 @@ async function findReusableSessionCandidate({
1058
1182
  // - When `SENTINELAYER_SKIP_REMOTE_SYNC=1` (test bootstrap), short-circuits
1059
1183
  // to `{ ok: true, source: "skipped", session: null }` so unit tests
1060
1184
  // can exercise the local materialization path without a real API.
1185
+ async function fetchRemoteSessionDetail(endpoint, headers) {
1186
+ let response;
1187
+ try {
1188
+ response = await fetch(endpoint, { method: "GET", headers });
1189
+ } catch (err) {
1190
+ return {
1191
+ ok: false,
1192
+ response: null,
1193
+ status: 0,
1194
+ reason: normalizeString(err?.message) || "fetch_failed",
1195
+ retryable: true,
1196
+ };
1197
+ }
1198
+ if (response && response.ok) {
1199
+ return { ok: true, response, status: response.status, reason: "", retryable: false };
1200
+ }
1201
+ if (!response) {
1202
+ return { ok: false, response: null, status: 0, reason: "no_response", retryable: true };
1203
+ }
1204
+ const status = Number(response.status) || 0;
1205
+ return {
1206
+ ok: false,
1207
+ response,
1208
+ status,
1209
+ reason: `api_${status || "unknown"}`,
1210
+ retryable: status >= 500 && status < 600,
1211
+ };
1212
+ }
1213
+
1214
+ async function parseRemoteSessionDetailResponse(response) {
1215
+ const body = await response.json().catch(() => ({}));
1216
+ return body && body.session && typeof body.session === "object"
1217
+ ? body.session
1218
+ : body && typeof body === "object"
1219
+ ? body
1220
+ : null;
1221
+ }
1222
+
1061
1223
  async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1062
1224
  const normalizedSessionId = normalizeString(sessionId);
1063
1225
  if (!normalizedSessionId) {
@@ -1085,60 +1247,43 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1085
1247
  }
1086
1248
  const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
1087
1249
  const headers = { Authorization: `Bearer ${auth.token}` };
1088
- let lastReason = "unknown";
1089
- for (let attempt = 0; attempt < 2; attempt += 1) {
1090
- let response;
1091
- try {
1092
- response = await fetch(endpoint, { method: "GET", headers });
1093
- } catch (err) {
1094
- lastReason = normalizeString(err?.message) || "fetch_failed";
1095
- continue;
1096
- }
1097
- if (response && response.ok) {
1098
- const body = await response.json().catch(() => ({}));
1099
- const sessionPayload = body && body.session && typeof body.session === "object"
1100
- ? body.session
1101
- : body && typeof body === "object"
1102
- ? body
1103
- : null;
1104
- return {
1105
- ok: true,
1106
- source: "singleton",
1107
- session: sessionPayload,
1108
- status: response.status,
1109
- };
1110
- }
1111
- if (!response) {
1112
- lastReason = "no_response";
1113
- continue;
1114
- }
1115
- if (response.status === 404) {
1116
- // Pre-#483 fallback: scan the list endpoint once for the same id.
1117
- const listResult = await listSessionsFromApi({
1118
- targetPath,
1119
- includeArchived: false,
1120
- limit: 50,
1121
- }).catch(() => null);
1122
- if (listResult && listResult.ok) {
1123
- const found = (listResult.sessions || []).find(
1124
- (entry) => normalizeString(entry?.sessionId) === normalizedSessionId,
1125
- );
1126
- if (found) {
1127
- return { ok: true, source: "list_fallback", session: found, status: 200 };
1128
- }
1250
+ const firstAttempt = await fetchRemoteSessionDetail(endpoint, headers);
1251
+ const detail = firstAttempt.retryable
1252
+ ? await fetchRemoteSessionDetail(endpoint, headers)
1253
+ : firstAttempt;
1254
+ if (detail.ok) {
1255
+ return {
1256
+ ok: true,
1257
+ source: "singleton",
1258
+ session: await parseRemoteSessionDetailResponse(detail.response),
1259
+ status: detail.status,
1260
+ };
1261
+ }
1262
+ if (detail.status === 404) {
1263
+ // Pre-#483 fallback: scan the list endpoint once for the same id.
1264
+ const listResult = await listSessionsFromApi({
1265
+ targetPath,
1266
+ includeArchived: false,
1267
+ limit: 50,
1268
+ }).catch(() => null);
1269
+ if (listResult && listResult.ok) {
1270
+ const found = (listResult.sessions || []).find(
1271
+ (entry) => normalizeString(entry?.sessionId) === normalizedSessionId,
1272
+ );
1273
+ if (found) {
1274
+ return { ok: true, source: "list_fallback", session: found, status: 200 };
1129
1275
  }
1130
- return { ok: false, reason: "not_found", status: 404 };
1131
- }
1132
- if (response.status === 403) {
1133
- return { ok: false, reason: "forbidden", status: 403 };
1134
- }
1135
- if (response.status >= 500 && response.status < 600) {
1136
- lastReason = `api_${response.status}`;
1137
- continue; // retry once on 5xx
1138
1276
  }
1139
- return { ok: false, reason: `api_${response.status}`, status: response.status };
1277
+ return { ok: false, reason: "not_found", status: 404 };
1140
1278
  }
1141
- return { ok: false, reason: lastReason };
1279
+ if (detail.status === 403) {
1280
+ return { ok: false, reason: "forbidden", status: 403 };
1281
+ }
1282
+ return {
1283
+ ok: false,
1284
+ reason: detail.reason || "unknown",
1285
+ status: detail.status || undefined,
1286
+ };
1142
1287
  }
1143
1288
 
1144
1289
  function normalizeRemoteResumeStatus(session = {}) {
@@ -1953,6 +2098,18 @@ function isSessionControlEvent(event = {}) {
1953
2098
  return normalizeString(payload.source).toLowerCase() === "session_listen";
1954
2099
  }
1955
2100
 
2101
+ function summarizeSessionTailEvent(event = null) {
2102
+ if (!event || typeof event !== "object") return null;
2103
+ return {
2104
+ type: normalizeString(event.event || event.type) || null,
2105
+ sequenceId: Number(event.sequenceId ?? event.sequence_id) || null,
2106
+ cursor: normalizeString(event.cursor) || null,
2107
+ ts: normalizeString(event.ts || event.timestamp) || null,
2108
+ agentId: normalizeString(event.agent?.id || event.agentId || event.agent_id) || null,
2109
+ control: isSessionControlEvent(event),
2110
+ };
2111
+ }
2112
+
1956
2113
  function checkpointSequenceRange(checkpoint = {}) {
1957
2114
  const start = Number(checkpoint.startSequence || 0);
1958
2115
  const end = Number(checkpoint.endSequence || 0);
@@ -4606,6 +4763,7 @@ export function registerSessionCommand(program) {
4606
4763
  let hydration = null;
4607
4764
  let remoteTail = null;
4608
4765
  let remoteActions = null;
4766
+ let remoteTailStats = null;
4609
4767
  if (options.remote) {
4610
4768
  const authSession = await resolveActiveAuthSession({
4611
4769
  cwd: targetPath,
@@ -4632,6 +4790,23 @@ export function registerSessionCommand(program) {
4632
4790
  timeoutMs: 15_000,
4633
4791
  });
4634
4792
  }
4793
+ const remoteTailEvents = remoteTail?.ok && Array.isArray(remoteTail.events) ? remoteTail.events : [];
4794
+ const latestEvent = remoteTailEvents[remoteTailEvents.length - 1] || null;
4795
+ const latestVisibleEvent = [...remoteTailEvents]
4796
+ .reverse()
4797
+ .find((event) => includeControlEvents || !isSessionControlEvent(event)) || null;
4798
+ const controlEventCount = remoteTailEvents.filter((event) => isSessionControlEvent(event)).length;
4799
+ remoteTailStats = {
4800
+ controlEventCount,
4801
+ materialEventCount: Math.max(0, remoteTailEvents.length - controlEventCount),
4802
+ latestEvent: summarizeSessionTailEvent(latestEvent),
4803
+ latestVisibleEvent: summarizeSessionTailEvent(latestVisibleEvent),
4804
+ latestActivityHidden: Boolean(
4805
+ latestEvent &&
4806
+ isSessionControlEvent(latestEvent) &&
4807
+ !includeControlEvents
4808
+ ),
4809
+ };
4635
4810
  if (!emitJson) {
4636
4811
  if (hydration.ok) {
4637
4812
  console.log(
@@ -4660,6 +4835,16 @@ export function registerSessionCommand(program) {
4660
4835
  ),
4661
4836
  );
4662
4837
  }
4838
+ if (remoteTailStats.latestActivityHidden) {
4839
+ const latest = remoteTailStats.latestEvent;
4840
+ const type = latest?.type || "control event";
4841
+ const seq = latest?.sequenceId ? ` seq=${latest.sequenceId}` : "";
4842
+ console.log(
4843
+ pc.gray(
4844
+ `Latest remote activity is hidden control-plane traffic (${type}${seq}); showing recent material messages. Use --include-control-events to inspect it.`,
4845
+ ),
4846
+ );
4847
+ }
4663
4848
  }
4664
4849
  }
4665
4850
 
@@ -4757,6 +4942,11 @@ export function registerSessionCommand(program) {
4757
4942
  verified: Boolean(remoteTail.ok),
4758
4943
  appended: remoteTailAppended,
4759
4944
  displayedOnly: remoteTailDisplayedOnly,
4945
+ controlEventCount: remoteTailStats?.controlEventCount || 0,
4946
+ materialEventCount: remoteTailStats?.materialEventCount || 0,
4947
+ latestEvent: remoteTailStats?.latestEvent || null,
4948
+ latestVisibleEvent: remoteTailStats?.latestVisibleEvent || null,
4949
+ latestActivityHidden: Boolean(remoteTailStats?.latestActivityHidden),
4760
4950
  }
4761
4951
  : null,
4762
4952
  actions: remoteActions
package/src/legacy-cli.js CHANGED
@@ -1298,6 +1298,8 @@ async function runLocalOmarGateCommand(args) {
1298
1298
  const maxCostUsd = parseFloat(getCommandOptionValue(args, "--max-cost") || "5.0") || 5.0;
1299
1299
  const modelOverride = getCommandOptionValue(args, "--model") || "";
1300
1300
  const providerOverride = getCommandOptionValue(args, "--provider") || "";
1301
+ const notifySession = getCommandOptionValue(args, "--notify-session") || "";
1302
+ const requireUsageLedger = hasCommandOption(args, "--require-usage-ledger");
1301
1303
  const scanMode = getCommandOptionValue(args, "--scan-mode") || "deep";
1302
1304
  const maxParallel = parseInt(getCommandOptionValue(args, "--max-parallel") || "4", 10) || 4;
1303
1305
  const streamEnabled = hasCommandOption(args, "--stream");
@@ -1388,6 +1390,8 @@ async function runLocalOmarGateCommand(args) {
1388
1390
  onEvent: streamHandler,
1389
1391
  includeOnly: includeOnly.length > 0 ? includeOnly : null,
1390
1392
  skipPersonas: skipPersonas.length > 0 ? skipPersonas : null,
1393
+ usageSessionId: notifySession,
1394
+ requireUsageLedger,
1391
1395
  });
1392
1396
 
1393
1397
  // Use orchestrator results as the AI layer. aiResult represents ONLY
@@ -1415,6 +1419,7 @@ async function runLocalOmarGateCommand(args) {
1415
1419
  status: p.status,
1416
1420
  findings: p.findings || 0,
1417
1421
  costUsd: p.costUsd || 0,
1422
+ billing: p.billing || null,
1418
1423
  durationMs: p.durationMs || 0,
1419
1424
  error: p.error || null,
1420
1425
  })),
@@ -1445,8 +1450,17 @@ async function runLocalOmarGateCommand(args) {
1445
1450
  outputDir: outputDirArg,
1446
1451
  provider: providerOverride || undefined,
1447
1452
  model: modelOverride || undefined,
1453
+ sessionId: notifySession,
1448
1454
  maxCostUsd,
1449
1455
  dryRun: aiDryRun,
1456
+ requireUsageLedger,
1457
+ sourceCommand: "omargate deep",
1458
+ billingAgentId: "omargate-orchestrator",
1459
+ billingAction: "omargate_deep",
1460
+ billingMetadata: {
1461
+ scanMode,
1462
+ },
1463
+ usageFailureLabel: "OmarGate AI",
1450
1464
  env: process.env,
1451
1465
  });
1452
1466
  } catch (aiError) {
@@ -22,6 +22,8 @@ const SESSION_MCP_CONFIRM_ATTEMPTS = 3;
22
22
  const SESSION_MCP_CONFIRM_DELAY_MS = 250;
23
23
  const SESSION_MCP_CONFIRM_PAGE_LIMIT = 200;
24
24
  const SESSION_MCP_CONFIRM_MAX_PAGES = 10;
25
+ const SESSION_MCP_CONFIRM_TOTAL_TIMEOUT_MS = 10_000;
26
+ const SESSION_MCP_CONFIRM_REQUEST_TIMEOUT_MS = 2_000;
25
27
  const JSON_RPC_VERSION = "2.0";
26
28
  const CONTENT_LENGTH_PREFIX = "content-length:";
27
29
 
@@ -48,6 +50,11 @@ function normalizePositiveInteger(value, fallbackValue) {
48
50
  return Math.floor(normalized);
49
51
  }
50
52
 
53
+ function confirmationRemainingMs(deadlineMs, nowMs) {
54
+ const now = Number(nowMs()) || Date.now();
55
+ return Math.max(0, Math.floor(deadlineMs - now));
56
+ }
57
+
51
58
  function normalizeLimit(value) {
52
59
  return Math.max(1, Math.min(MAX_TOOL_LIMIT, normalizePositiveInteger(value, DEFAULT_TOOL_LIMIT)));
53
60
  }
@@ -199,32 +206,111 @@ async function confirmSessionEventVisible(
199
206
  targetPath,
200
207
  anchorCursor = null,
201
208
  pollSessionEventsFn = pollSessionEvents,
209
+ pollSessionEventsBeforeFn = pollSessionEventsBefore,
202
210
  sleepFn = sleep,
211
+ nowMs = Date.now,
203
212
  attempts = SESSION_MCP_CONFIRM_ATTEMPTS,
204
213
  delayMs = SESSION_MCP_CONFIRM_DELAY_MS,
205
214
  pageLimit = SESSION_MCP_CONFIRM_PAGE_LIMIT,
206
215
  maxPages = SESSION_MCP_CONFIRM_MAX_PAGES,
216
+ totalTimeoutMs = SESSION_MCP_CONFIRM_TOTAL_TIMEOUT_MS,
217
+ requestTimeoutMs = SESSION_MCP_CONFIRM_REQUEST_TIMEOUT_MS,
207
218
  } = {},
208
219
  ) {
209
220
  let lastReason = "not_visible";
210
221
  let checked = 0;
211
222
  let pages = 0;
223
+ let tailChecks = 0;
212
224
  const normalizedAnchorCursor = normalizeString(anchorCursor) || null;
213
225
  const normalizedAttempts = normalizePositiveInteger(attempts, SESSION_MCP_CONFIRM_ATTEMPTS);
214
226
  const normalizedDelayMs = normalizePositiveInteger(delayMs, SESSION_MCP_CONFIRM_DELAY_MS);
215
227
  const normalizedPageLimit = normalizeLimit(pageLimit);
216
228
  const normalizedMaxPages = normalizePositiveInteger(maxPages, SESSION_MCP_CONFIRM_MAX_PAGES);
229
+ const normalizedTotalTimeoutMs = normalizePositiveInteger(
230
+ totalTimeoutMs,
231
+ SESSION_MCP_CONFIRM_TOTAL_TIMEOUT_MS,
232
+ );
233
+ const normalizedRequestTimeoutMs = normalizePositiveInteger(
234
+ requestTimeoutMs,
235
+ SESSION_MCP_CONFIRM_REQUEST_TIMEOUT_MS,
236
+ );
237
+ const deadlineMs = (Number(nowMs()) || Date.now()) + normalizedTotalTimeoutMs;
238
+ const nextRequestTimeoutMs = () => {
239
+ const remaining = confirmationRemainingMs(deadlineMs, nowMs);
240
+ if (remaining <= 0) return 0;
241
+ return Math.max(1, Math.min(normalizedRequestTimeoutMs, remaining));
242
+ };
217
243
 
218
- for (let attempt = 1; attempt <= normalizedAttempts; attempt += 1) {
219
- let pageCursor = normalizedAnchorCursor;
220
- for (let page = 1; page <= normalizedMaxPages; page += 1) {
221
- pages += 1;
222
- const result = await pollSessionEventsFn(sessionId, {
244
+ const maybeConfirmFromLatestTail = async () => {
245
+ const timeoutMs = nextRequestTimeoutMs();
246
+ if (timeoutMs <= 0) {
247
+ lastReason = "confirmation_timeout";
248
+ return null;
249
+ }
250
+ tailChecks += 1;
251
+ let result = null;
252
+ try {
253
+ result = await pollSessionEventsBeforeFn(sessionId, {
223
254
  targetPath,
224
- since: pageCursor,
225
255
  limit: normalizedPageLimit,
256
+ timeoutMs,
226
257
  forceCircuitProbe: true,
227
258
  });
259
+ } catch (error) {
260
+ lastReason = normalizeString(error?.code || error?.message) || "confirmation_tail_poll_failed";
261
+ return null;
262
+ }
263
+ if (!result?.ok) {
264
+ lastReason = normalizeString(result?.reason) || "confirmation_tail_poll_failed";
265
+ return null;
266
+ }
267
+ const events = Array.isArray(result.events) ? result.events : [];
268
+ checked += events.length;
269
+ const confirmedEvent = events.find((candidate) => sessionEventMatchesClientMessageId(candidate, clientMessageId));
270
+ if (!confirmedEvent) {
271
+ lastReason = "not_visible";
272
+ return null;
273
+ }
274
+ return {
275
+ confirmed: true,
276
+ reason: "",
277
+ checked,
278
+ pages,
279
+ tailChecks,
280
+ source: "latest_tail",
281
+ anchorCursor: normalizedAnchorCursor,
282
+ event: confirmedEvent,
283
+ };
284
+ };
285
+
286
+ for (let attempt = 1; attempt <= normalizedAttempts; attempt += 1) {
287
+ let pageCursor = normalizedAnchorCursor;
288
+ for (let page = 1; page <= normalizedMaxPages; page += 1) {
289
+ const timeoutMs = nextRequestTimeoutMs();
290
+ if (timeoutMs <= 0) {
291
+ return {
292
+ confirmed: false,
293
+ reason: "confirmation_timeout",
294
+ checked,
295
+ pages,
296
+ tailChecks,
297
+ anchorCursor: normalizedAnchorCursor,
298
+ };
299
+ }
300
+ pages += 1;
301
+ let result = null;
302
+ try {
303
+ result = await pollSessionEventsFn(sessionId, {
304
+ targetPath,
305
+ since: pageCursor,
306
+ limit: normalizedPageLimit,
307
+ timeoutMs,
308
+ forceCircuitProbe: true,
309
+ });
310
+ } catch (error) {
311
+ lastReason = normalizeString(error?.code || error?.message) || "confirmation_poll_failed";
312
+ break;
313
+ }
228
314
  if (!result?.ok) {
229
315
  lastReason = normalizeString(result?.reason) || "confirmation_poll_failed";
230
316
  break;
@@ -238,6 +324,8 @@ async function confirmSessionEventVisible(
238
324
  reason: "",
239
325
  checked,
240
326
  pages,
327
+ tailChecks,
328
+ source: "forward_cursor",
241
329
  anchorCursor: normalizedAnchorCursor,
242
330
  event: confirmedEvent,
243
331
  };
@@ -249,8 +337,17 @@ async function confirmSessionEventVisible(
249
337
  }
250
338
  pageCursor = nextCursor;
251
339
  }
340
+ const tailConfirmation = await maybeConfirmFromLatestTail();
341
+ if (tailConfirmation) {
342
+ return tailConfirmation;
343
+ }
252
344
  if (attempt < normalizedAttempts) {
253
- await sleepFn(normalizedDelayMs);
345
+ const remaining = confirmationRemainingMs(deadlineMs, nowMs);
346
+ if (remaining <= 0) {
347
+ lastReason = "confirmation_timeout";
348
+ break;
349
+ }
350
+ await sleepFn(Math.min(normalizedDelayMs, remaining));
254
351
  }
255
352
  }
256
353
 
@@ -259,6 +356,7 @@ async function confirmSessionEventVisible(
259
356
  reason: lastReason,
260
357
  checked,
261
358
  pages,
359
+ tailChecks,
262
360
  anchorCursor: normalizedAnchorCursor,
263
361
  };
264
362
  }
@@ -441,6 +539,7 @@ async function persistSessionEvent({
441
539
  targetPath,
442
540
  anchorCursor: remoteConfirmationAnchor.cursor,
443
541
  pollSessionEventsFn,
542
+ pollSessionEventsBeforeFn,
444
543
  sleepFn,
445
544
  attempts: confirmationAttempts,
446
545
  delayMs: confirmationDelayMs,
@@ -8,7 +8,7 @@ import {
8
8
  resolveProvider,
9
9
  } from "../ai/client.js";
10
10
  import { computeProviderCost } from "../billing/price-book.js";
11
- import { buildBillingRunId, buildCallIdempotencyKey } from "../billing/ledger-entry.js";
11
+ import { buildBillingRunId, buildCallIdempotencyKey, sanitizeBillingMetadata } from "../billing/ledger-entry.js";
12
12
  import { recordSessionUsage } from "../billing/session-usage.js";
13
13
  import { loadConfig } from "../config/service.js";
14
14
  import { evaluateBudget } from "../cost/budget.js";
@@ -488,6 +488,12 @@ export async function runAiReviewLayer({
488
488
  dryRun = false,
489
489
  requireUsageLedger = false,
490
490
  usageRecorder = recordSessionUsage,
491
+ sourceCommand = "review",
492
+ billingAgentId = "audit-orchestrator",
493
+ billingAction = "audit_run",
494
+ billingTier = "internal",
495
+ billingMetadata = {},
496
+ usageFailureLabel = "Review AI",
491
497
  env = process.env,
492
498
  } = {}) {
493
499
  const normalizedTargetPath = path.resolve(String(targetPath || "."));
@@ -499,6 +505,12 @@ export async function runAiReviewLayer({
499
505
  );
500
506
  const normalizedRunId = normalizeString(runId) || "review-ai";
501
507
  const normalizedRequireUsageLedger = Boolean(requireUsageLedger);
508
+ const normalizedSourceCommand = normalizeString(sourceCommand) || "review";
509
+ const normalizedBillingAgentId = normalizeString(billingAgentId) || "audit-orchestrator";
510
+ const normalizedBillingAction = normalizeString(billingAction) || "audit_run";
511
+ const normalizedBillingTier = normalizeString(billingTier) || "internal";
512
+ const normalizedBillingMetadata = sanitizeBillingMetadata(billingMetadata);
513
+ const normalizedUsageFailureLabel = normalizeString(usageFailureLabel) || "Review AI";
502
514
 
503
515
  const config = await loadConfig({ cwd: normalizedTargetPath, env });
504
516
  let resolvedProvider = resolveProvider({
@@ -641,7 +653,7 @@ export async function runAiReviewLayer({
641
653
  toolCalls: 1,
642
654
  },
643
655
  metadata: {
644
- sourceCommand: "review",
656
+ sourceCommand: normalizedSourceCommand,
645
657
  layer: "ai_reasoning",
646
658
  provider: resolvedProvider,
647
659
  model: resolvedModel,
@@ -657,28 +669,31 @@ export async function runAiReviewLayer({
657
669
  sessionId: normalizedSessionId,
658
670
  invocationTimestamp: startedAtIso,
659
671
  configHash: stableConfigHash({
660
- sourceCommand: "review",
672
+ sourceCommand: normalizedSourceCommand,
661
673
  layer: "ai_reasoning",
662
674
  provider: resolvedProvider,
663
675
  model: resolvedModel,
664
676
  mode: normalizedMode,
665
677
  runId: normalizedRunId,
666
678
  dryRun: Boolean(dryRun),
679
+ billingAgentId: normalizedBillingAgentId,
680
+ billingAction: normalizedBillingAction,
667
681
  }),
668
682
  });
669
683
  sessionUsageLedger = await usageRecorder(
670
684
  normalizedSessionId,
671
685
  {
672
- agentId: "audit-orchestrator",
673
- action: "audit_run",
686
+ agentId: normalizedBillingAgentId,
687
+ action: normalizedBillingAction,
674
688
  model: resolvedModel,
675
689
  inputTokens,
676
690
  outputTokens,
677
691
  idempotencyKey: buildCallIdempotencyKey({ runId: billingRunId, callIndex: 0 }),
678
- billingTier: "internal",
692
+ billingTier: normalizedBillingTier,
679
693
  createdAt: startedAtIso,
680
694
  metadata: {
681
- sourceCommand: "review",
695
+ ...normalizedBillingMetadata,
696
+ sourceCommand: normalizedSourceCommand,
682
697
  layer: "ai_reasoning",
683
698
  provider: resolvedProvider,
684
699
  mode: normalizedMode,
@@ -698,7 +713,7 @@ export async function runAiReviewLayer({
698
713
  };
699
714
  if (normalizedRequireUsageLedger) {
700
715
  throw new Error(
701
- `Review AI usage ledger recording failed: ${sessionUsageLedger.reason || "unknown"}.`
716
+ `${normalizedUsageFailureLabel} usage ledger recording failed: ${sessionUsageLedger.reason || "unknown"}.`
702
717
  );
703
718
  }
704
719
  }
@@ -729,7 +744,7 @@ export async function runAiReviewLayer({
729
744
  reasonCodes: budget.reasons.map((reason) => reason.code),
730
745
  },
731
746
  metadata: {
732
- sourceCommand: "review",
747
+ sourceCommand: normalizedSourceCommand,
733
748
  layer: "ai_reasoning",
734
749
  provider: resolvedProvider,
735
750
  model: resolvedModel,
@@ -423,6 +423,10 @@ function omargateConfidenceFloorForPersona(personaId) {
423
423
  return OMARGATE_CONFIDENCE_FLOORS[personaId] || OMARGATE_DEFAULT_CONFIDENCE_FLOOR;
424
424
  }
425
425
 
426
+ function isUsageLedgerFailure(message = "") {
427
+ return /usage ledger/i.test(String(message || ""));
428
+ }
429
+
426
430
  async function runOmarPersonaSwarm({
427
431
  personaId,
428
432
  identity,
@@ -436,6 +440,9 @@ async function runOmarPersonaSwarm({
436
440
  perPersonaCost,
437
441
  dryRun,
438
442
  onEvent,
443
+ usageSessionId = "",
444
+ requireUsageLedger = false,
445
+ usageRecorder = undefined,
439
446
  } = {}) {
440
447
  const scope = buildPersonaFileScope({ deterministic });
441
448
  const decision = decideSwarm({ scope });
@@ -537,10 +544,26 @@ async function runOmarPersonaSwarm({
537
544
  outputDir,
538
545
  provider: provider || undefined,
539
546
  model: model || undefined,
540
- sessionId: `${subagentRunId}-ai`,
547
+ sessionId: usageSessionId || `${subagentRunId}-ai`,
541
548
  maxCostUsd: budget.maxCostUsd,
542
549
  systemPrompt,
543
550
  dryRun,
551
+ requireUsageLedger,
552
+ usageRecorder,
553
+ sourceCommand: "omargate deep",
554
+ billingAgentId: `omargate-${personaId}-subagent-${subagentIndex}`,
555
+ billingAction: "omargate_deep",
556
+ billingMetadata: {
557
+ personaId,
558
+ subagentIndex,
559
+ partitionCount: partitions.length,
560
+ parentRunId: runId,
561
+ swarmRunId,
562
+ subagentRunId,
563
+ scanMode: mode,
564
+ fileCount: scopedFiles.length,
565
+ },
566
+ usageFailureLabel: "OmarGate AI",
544
567
  env: process.env,
545
568
  });
546
569
 
@@ -610,6 +633,7 @@ async function runOmarPersonaSwarm({
610
633
  summary: result?.summary || summarizeFindings(findings),
611
634
  costUsd: result?.usage?.costUsd || 0,
612
635
  model: result?.model || model || null,
636
+ billing: result?.billing || null,
613
637
  artifacts: result?.artifacts || null,
614
638
  durationMs: Date.now() - subagentStart,
615
639
  };
@@ -671,6 +695,41 @@ async function runOmarPersonaSwarm({
671
695
  const summary = summarizeFindings(findings);
672
696
  const okCount = settledSubagents.filter((result) => result.status === "ok").length;
673
697
  const errorCount = settledSubagents.filter((result) => result.status === "error").length;
698
+ const usageLedgerErrors = settledSubagents.filter((result) =>
699
+ result.status === "error" && isUsageLedgerFailure(result.error)
700
+ );
701
+ if (requireUsageLedger && usageLedgerErrors.length > 0) {
702
+ return {
703
+ personaId,
704
+ status: "error",
705
+ findings: [],
706
+ summary: { P0: 0, P1: 0, P2: 0, P3: 0, blocking: false },
707
+ costUsd: totalCostUsd,
708
+ model: model || null,
709
+ durationMs: Date.now() - startedAt,
710
+ error: `OmarGate required usage ledger failed for ${usageLedgerErrors.length}/${settledSubagents.length} subagent(s).`,
711
+ swarm: {
712
+ runId: swarmRunId,
713
+ decision,
714
+ subagentCount: settledSubagents.length,
715
+ ok: okCount,
716
+ error: errorCount,
717
+ partitionSizes: partitions.map((files) => files.length),
718
+ blackboardEntries: blackboard.length,
719
+ subagents: settledSubagents.map((result) => ({
720
+ id: result.agentId,
721
+ index: result.subagentIndex,
722
+ status: result.status,
723
+ files: result.files,
724
+ findings: (result.findings || []).length,
725
+ costUsd: result.costUsd || 0,
726
+ durationMs: result.durationMs || 0,
727
+ error: result.error || null,
728
+ artifacts: result.artifacts || null,
729
+ })),
730
+ },
731
+ };
732
+ }
674
733
 
675
734
  if (onEvent) {
676
735
  onEvent(createAgentEvent({
@@ -723,6 +782,7 @@ async function runOmarPersonaSwarm({
723
782
  files: result.files,
724
783
  findings: (result.findings || []).length,
725
784
  costUsd: result.costUsd || 0,
785
+ billing: result.billing || null,
726
786
  durationMs: result.durationMs || 0,
727
787
  error: result.error || null,
728
788
  artifacts: result.artifacts || null,
@@ -747,6 +807,8 @@ async function runOmarPersonaSwarm({
747
807
  * @param {Function} [options.onEvent] - Event callback for streaming
748
808
  * @param {string[] | null} [options.includeOnly] - Only run these persona IDs (filters scan-mode roster).
749
809
  * @param {string[] | null} [options.skipPersonas] - Skip these persona IDs (filters scan-mode roster).
810
+ * @param {boolean} [options.requireUsageLedger] - Fail if Omar Deep AI calls cannot write billing-grade session_usage.
811
+ * @param {string} [options.usageSessionId] - Optional shared Senti session id for billing-grade usage entries.
750
812
  * @returns {Promise<object>} Orchestrated results
751
813
  */
752
814
  export async function runOmarGateOrchestrator({
@@ -762,6 +824,9 @@ export async function runOmarGateOrchestrator({
762
824
  onEvent = null,
763
825
  includeOnly = null,
764
826
  skipPersonas = null,
827
+ requireUsageLedger = false,
828
+ usageSessionId = "",
829
+ usageRecorder = undefined,
765
830
  } = {}) {
766
831
  const runId = `omargate-${Date.now()}-${randomUUID().slice(0, 8)}`;
767
832
  const startTime = Date.now();
@@ -931,6 +996,9 @@ export async function runOmarGateOrchestrator({
931
996
  perPersonaCost,
932
997
  dryRun,
933
998
  onEvent,
999
+ requireUsageLedger,
1000
+ usageSessionId,
1001
+ usageRecorder,
934
1002
  });
935
1003
 
936
1004
  if (swarmResult) {
@@ -999,9 +1067,21 @@ export async function runOmarGateOrchestrator({
999
1067
  outputDir,
1000
1068
  provider: provider || undefined,
1001
1069
  model: model || undefined,
1070
+ sessionId: usageSessionId || `${runId}-${personaId}-ai`,
1002
1071
  maxCostUsd: perPersonaCost,
1003
1072
  systemPrompt,
1004
1073
  dryRun,
1074
+ requireUsageLedger,
1075
+ usageRecorder,
1076
+ sourceCommand: "omargate deep",
1077
+ billingAgentId: `omargate-${personaId}`,
1078
+ billingAction: "omargate_deep",
1079
+ billingMetadata: {
1080
+ personaId,
1081
+ parentRunId: runId,
1082
+ scanMode: mode,
1083
+ },
1084
+ usageFailureLabel: "OmarGate AI",
1005
1085
  env: process.env,
1006
1086
  });
1007
1087
 
@@ -1062,6 +1142,7 @@ export async function runOmarGateOrchestrator({
1062
1142
  summary: result?.summary || { P0: 0, P1: 0, P2: 0, P3: 0 },
1063
1143
  costUsd: personaCost,
1064
1144
  model: result?.model || model || null,
1145
+ billing: result?.billing || null,
1065
1146
  artifacts: result?.artifacts || null,
1066
1147
  durationMs: Date.now() - personaStart,
1067
1148
  };
@@ -1106,6 +1187,18 @@ export async function runOmarGateOrchestrator({
1106
1187
  durationMs: 0,
1107
1188
  })
1108
1189
  );
1190
+ const usageLedgerFailures = settled.filter((r) =>
1191
+ r.status === "error" && isUsageLedgerFailure(r.error)
1192
+ );
1193
+ if (requireUsageLedger && usageLedgerFailures.length > 0) {
1194
+ const examples = usageLedgerFailures
1195
+ .slice(0, 3)
1196
+ .map((r) => `${r.personaId}: ${r.error}`)
1197
+ .join("; ");
1198
+ throw new Error(
1199
+ `OmarGate required usage ledger recording failed for ${usageLedgerFailures.length}/${settled.length} persona(s).${examples ? ` ${examples}` : ""}`
1200
+ );
1201
+ }
1109
1202
 
1110
1203
  // Reconcile AI findings with deterministic findings — canonical single list.
1111
1204
  // Confidence boost when multiple layers agree; deterministic findings get
@@ -1183,6 +1276,7 @@ export async function runOmarGateOrchestrator({
1183
1276
  costUsd: r.costUsd,
1184
1277
  durationMs: r.durationMs,
1185
1278
  model: r.model || null,
1279
+ billing: r.billing || null,
1186
1280
  error: r.error || null,
1187
1281
  swarm: r.swarm || null,
1188
1282
  artifacts: r.artifacts || null,