sentinelayer-cli 0.16.0 → 0.17.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/README.md CHANGED
@@ -111,6 +111,9 @@ Sentinelayer includes a deterministic session coordination surface for multi-age
111
111
 
112
112
  Read the full guide: [docs/sessions.md](docs/sessions.md)
113
113
 
114
+ For new engineers or agents joining an active SentinelLayer loop, start with
115
+ [docs/ENGINEERING_ONBOARDING.md](docs/ENGINEERING_ONBOARDING.md).
116
+
114
117
  For strategy context, see the long-form blog draft: [docs/blog/slack-for-ai-coding-agents.md](docs/blog/slack-for-ai-coding-agents.md)
115
118
 
116
119
  ## Advanced options
@@ -914,13 +917,20 @@ Prerequisites:
914
917
  - repository secret `NPM_TOKEN` with publish access, or
915
918
  - npm trusted publishing for this repository/tag workflow
916
919
 
917
- Release options:
920
+ Release flow:
921
+
922
+ 1. Merge to `main` and let `Release Please` open/update the release PR.
923
+ 2. Merge the release PR after Omar, Quality Gates, and Attestation are green.
924
+ 3. From a clean checkout of the release commit, run the guarded release command:
925
+ ```bash
926
+ git fetch origin main --tags
927
+ git checkout main && git pull --ff-only
928
+ npm run release:publish -- --tag v0.1.1 --notes-file release-notes.md
929
+ ```
930
+ 4. The guarded command creates the tag with `git tag -s`, verifies it with `git tag -v`, pushes it, confirms the remote GitHub tag object is annotated and cryptographically verified by an allowlisted signer, then creates the GitHub release with `gh release create --verify-tag`.
931
+ 5. The tag push triggers `Release`, which re-verifies upstream checks, release artifact attestations, rollback readiness, protected `package-release` approval, and npm trusted publishing OIDC before publishing.
918
932
 
919
- 1. Merge to `main` and let `Release Please` open/update the release PR and tag.
920
- 2. Push a tag like `v0.1.1` to publish automatically (or via release-please tag creation).
921
- 3. Run `Release` manually (`workflow_dispatch`) to validate gates and rollback readiness without publishing.
922
- 4. Tag-triggered publish resolves auth mode at runtime (`NPM_TOKEN` first, otherwise trusted publishing OIDC).
923
- 5. If neither auth mode is available, publish fails closed with an explicit workflow error.
933
+ Do not run `gh release create v0.1.1` before pushing the signed tag. GitHub release creation creates a lightweight tag when the tag does not already exist, and the Release workflow intentionally fails closed for lightweight tags. If a remote tag already exists as a lightweight tag, `npm run release:publish` refuses to create the GitHub release.
924
934
 
925
935
  Release publish now enforces tarball checksum-manifest validation and attestation verification bound to `.github/workflows/release.yml` before `npm publish`.
926
936
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -11,7 +11,8 @@
11
11
  "test:unit": "node --import ./tests/setup-env.mjs --test tests/unit*.test.mjs",
12
12
  "test:e2e": "node --import ./tests/setup-env.mjs --test tests/e2e.test.mjs",
13
13
  "test:coverage": "c8 node --import ./tests/setup-env.mjs --test tests/unit*.test.mjs",
14
- "verify": "npm run check && npm run docs:build && npm run test:e2e && npm run test:coverage && npm pack --dry-run"
14
+ "verify": "npm run check && npm run docs:build && npm run test:e2e && npm run test:coverage && npm pack --dry-run",
15
+ "release:publish": "node scripts/release-publish.mjs"
15
16
  },
16
17
  "bin": {
17
18
  "sentinelayer-cli": "bin/sentinelayer-cli.js",
@@ -61,6 +61,7 @@ export function buildLegacyArgs(baseArgs, { commandOptions = {}, command } = {})
61
61
  appendPassthroughFlag(args, "--notify-email", commandOptions.notifyEmail);
62
62
  appendPassthroughFlag(args, "--email-on-complete", commandOptions.emailOnComplete);
63
63
  appendPassthroughFlag(args, "--notify-session", commandOptions.notifySession);
64
+ appendBooleanFlag(args, "--require-usage-ledger", commandOptions.requireUsageLedger);
64
65
  appendPassthroughFlag(args, "--devtestbot-base-url", commandOptions.devtestbotBaseUrl);
65
66
  appendPassthroughFlag(args, "--devtestbot-scope", commandOptions.devtestbotScope);
66
67
  appendNegatedBooleanFlag(args, "--no-devtestbot", commandOptions.devtestbot);
@@ -50,6 +50,7 @@ export function registerOmarGateCommand(program, invokeLegacy) {
50
50
  .option("--notify-email <addr>", "Send final report to this email (default: account email)")
51
51
  .option("--email-on-complete <addr>", "Trigger the API-side DD report email after the run completes")
52
52
  .option("--notify-session <session-id>", "Stream progress into this Senti session (default: auto-start)")
53
+ .option("--require-usage-ledger", "Fail if Investor-DD LLM planner calls cannot write session_usage")
53
54
  .option("--no-email", "Skip email dispatch")
54
55
  .option("--no-dashboard", "Skip dashboard card persistence")
55
56
  .option("--devtestbot-base-url <url>", "Approved absolute URL for devTestBot browser lanes")
@@ -43,6 +43,7 @@ import { listSessionTasks } from "../session/tasks.js";
43
43
  import {
44
44
  createSession,
45
45
  DEFAULT_TTL_SECONDS,
46
+ expireSession,
46
47
  getSession,
47
48
  isSessionCacheExpired,
48
49
  listActiveSessions,
@@ -86,7 +87,7 @@ import {
86
87
  generateSessionCheckpoint,
87
88
  listSessionCheckpoints,
88
89
  } from "../session/checkpoints.js";
89
- import { authLoginHint } from "../ui/command-hints.js";
90
+ import { authLoginHint, preferredCliCommand } from "../ui/command-hints.js";
90
91
  import { parseCsvTokens } from "./ai/shared.js";
91
92
 
92
93
  function shouldEmitJson(options, command) {
@@ -425,6 +426,63 @@ function sentiAutostartDisabled() {
425
426
  return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
426
427
  }
427
428
 
429
+ function buildResumeContext(candidate, { reuseWindowSeconds = 3600 } = {}) {
430
+ if (!candidate) return null;
431
+ const source = normalizeString(candidate._source) || "unknown";
432
+ const lastActivityAt =
433
+ normalizeString(candidate.lastInteractionAt) ||
434
+ normalizeString(candidate.lastActivityAt) ||
435
+ normalizeString(candidate.updatedAt) ||
436
+ normalizeString(candidate.createdAt) ||
437
+ null;
438
+ return {
439
+ source,
440
+ reuseWindowSeconds,
441
+ lastActivityAt,
442
+ reason: `recent_${source}_session`,
443
+ };
444
+ }
445
+
446
+ async function resolveSessionRemoteSyncState({ dashboardUrl } = {}) {
447
+ if (remoteSessionLookupDisabled()) {
448
+ return {
449
+ status: "disabled",
450
+ attempted: false,
451
+ reason: "remote_sync_disabled",
452
+ dashboardUrl,
453
+ };
454
+ }
455
+
456
+ let storedSession = null;
457
+ try {
458
+ storedSession = await readStoredSession();
459
+ } catch {
460
+ storedSession = null;
461
+ }
462
+ const apiUrl =
463
+ normalizeString(storedSession?.apiUrl) ||
464
+ normalizeString(process.env.SENTINELAYER_API_URL) ||
465
+ "https://api.sentinelayer.com";
466
+ const hasToken = Boolean(
467
+ normalizeString(storedSession?.token) || normalizeString(process.env.SENTINELAYER_TOKEN),
468
+ );
469
+ if (!hasToken) {
470
+ return {
471
+ status: "auth_required",
472
+ attempted: false,
473
+ reason: "not_authenticated",
474
+ apiUrl,
475
+ dashboardUrl,
476
+ };
477
+ }
478
+ return {
479
+ status: "background_sync_queued",
480
+ attempted: true,
481
+ apiUrl,
482
+ dashboardUrl,
483
+ };
484
+ }
485
+
428
486
  function mergeResumeCandidate(existing, incoming) {
429
487
  if (!existing) return incoming;
430
488
  const existingActivity = Number(existing._activityMs || 0);
@@ -614,6 +672,69 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
614
672
  return { ok: false, reason: lastReason };
615
673
  }
616
674
 
675
+ function normalizeRemoteResumeStatus(session = {}) {
676
+ return normalizeString(session?.archiveStatus || session?.status).toLowerCase();
677
+ }
678
+
679
+ function remoteStatusAllowsResume(status) {
680
+ if (!status) return true;
681
+ return status === "active" || status === "pending";
682
+ }
683
+
684
+ async function reconcileLocalResumeCandidate(candidate, { targetPath } = {}) {
685
+ if (!candidate || candidate._source !== "local" || remoteSessionLookupDisabled()) {
686
+ return { candidate, staleResume: null };
687
+ }
688
+ const verification = await verifyRemoteSession(candidate.sessionId, { targetPath }).catch((error) => ({
689
+ ok: false,
690
+ reason: normalizeString(error?.message) || "probe_failed",
691
+ }));
692
+
693
+ if (verification.ok) {
694
+ const remoteStatus = normalizeRemoteResumeStatus(verification.session);
695
+ if (remoteStatusAllowsResume(remoteStatus)) {
696
+ return { candidate, staleResume: null };
697
+ }
698
+ await expireSession(candidate.sessionId, { targetPath }).catch(() => null);
699
+ return {
700
+ candidate: null,
701
+ staleResume: {
702
+ sessionId: candidate.sessionId,
703
+ source: "remote",
704
+ reason: "remote_not_active",
705
+ remoteStatus,
706
+ action: "expired_local_and_created_new",
707
+ },
708
+ };
709
+ }
710
+
711
+ if (verification.reason === "not_found" || verification.reason === "forbidden") {
712
+ await expireSession(candidate.sessionId, { targetPath }).catch(() => null);
713
+ return {
714
+ candidate: null,
715
+ staleResume: {
716
+ sessionId: candidate.sessionId,
717
+ source: "remote",
718
+ reason: verification.reason,
719
+ remoteStatus: verification.reason,
720
+ status: verification.status || null,
721
+ action: "expired_local_and_created_new",
722
+ },
723
+ };
724
+ }
725
+
726
+ return {
727
+ candidate,
728
+ staleResume: {
729
+ sessionId: candidate.sessionId,
730
+ source: "remote",
731
+ reason: verification.reason || "probe_failed",
732
+ status: verification.status || null,
733
+ action: "kept_local_resume",
734
+ },
735
+ };
736
+ }
737
+
617
738
  // Render an absolute ISO timestamp as a coarse "Nm ago" / "Nh ago" / "Nd ago"
618
739
  // label for human-readable join output. Returns `"never"` for missing input
619
740
  // and `"just now"` for sub-minute deltas.
@@ -737,12 +858,15 @@ async function ensureWorkspaceSession({
737
858
  const titleArg = normalizeString(title);
738
859
  const fallbackTitle = deriveSessionTitle(targetPath);
739
860
  const startedAt = Date.now();
740
- const resumedCandidate = await findReusableSessionCandidate({
861
+ let resumedCandidate = await findReusableSessionCandidate({
741
862
  targetPath,
742
863
  reuseWindowSeconds,
743
864
  resume,
744
865
  forceNew,
745
866
  });
867
+ const reconciliation = await reconcileLocalResumeCandidate(resumedCandidate, { targetPath });
868
+ resumedCandidate = reconciliation.candidate;
869
+ const staleResume = reconciliation.staleResume;
746
870
  let created;
747
871
  const resumeTitle =
748
872
  titleArg || normalizeString(resumedCandidate?.title) || fallbackTitle;
@@ -825,6 +949,7 @@ async function ensureWorkspaceSession({
825
949
  title: effectiveTitle || null,
826
950
  titleAuto,
827
951
  titleSync,
952
+ staleResume,
828
953
  };
829
954
  }
830
955
 
@@ -848,6 +973,42 @@ async function defaultAgentId(value, _targetPath) {
848
973
  return resolveSessionSayAgentId(value);
849
974
  }
850
975
 
976
+ async function resolveSessionAgentEnvelope(
977
+ sessionId,
978
+ agentId,
979
+ {
980
+ targetPath = process.cwd(),
981
+ model = "",
982
+ role = "",
983
+ displayName = "",
984
+ clientKind = "cli",
985
+ } = {}
986
+ ) {
987
+ const normalizedAgentId = normalizeAgentId(agentId, "cli-user");
988
+ let registeredAgent = null;
989
+ try {
990
+ const agents = await listAgents(sessionId, { targetPath, includeInactive: true });
991
+ registeredAgent = agents.find(
992
+ (agent) => normalizeString(agent.agentId).toLowerCase() === normalizedAgentId.toLowerCase(),
993
+ );
994
+ } catch {
995
+ registeredAgent = null;
996
+ }
997
+
998
+ const resolvedModel = normalizeString(model) || normalizeString(registeredAgent?.model);
999
+ const resolvedRole = normalizeString(role) || normalizeString(registeredAgent?.role);
1000
+ const resolvedDisplayName =
1001
+ normalizeString(displayName) || normalizeString(registeredAgent?.displayName);
1002
+ const envelope = {
1003
+ id: normalizedAgentId,
1004
+ model: resolvedModel || undefined,
1005
+ role: resolvedRole || undefined,
1006
+ displayName: resolvedDisplayName || undefined,
1007
+ clientKind: normalizeString(clientKind) || undefined,
1008
+ };
1009
+ return Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined));
1010
+ }
1011
+
851
1012
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
852
1013
  const normalizedItems = Array.isArray(items) ? items : [];
853
1014
  const normalizedConcurrency = Math.max(
@@ -913,13 +1074,93 @@ function checkpointSequenceRange(checkpoint = {}) {
913
1074
  return "anchor pending";
914
1075
  }
915
1076
 
916
- function formatCheckpointLine(checkpoint = {}) {
1077
+ function checkpointGradeLabel(checkpoint = {}) {
1078
+ const grade = normalizeString(checkpoint.grade || checkpoint.gradeLetter || checkpoint.grade_letter).toUpperCase();
1079
+ if (!["A", "B", "C", "D", "F"].includes(grade)) {
1080
+ return "";
1081
+ }
1082
+ const score = Number(checkpoint.gradeScore ?? checkpoint.grade_score);
1083
+ const scoreLabel = Number.isFinite(score) ? ` ${Math.max(0, Math.min(100, Math.floor(score)))}/100` : "";
1084
+ const rawReasons = Array.isArray(checkpoint.gradeReasons)
1085
+ ? checkpoint.gradeReasons
1086
+ : Array.isArray(checkpoint.grade_reasons)
1087
+ ? checkpoint.grade_reasons
1088
+ : [];
1089
+ const reasons = rawReasons
1090
+ .map((reason) => normalizeString(reason?.message || reason?.code || reason))
1091
+ .filter(Boolean)
1092
+ .slice(0, 2);
1093
+ const reasonLabel = reasons.length ? `: ${reasons.join("; ")}` : "";
1094
+ return ` completeness ${grade}${scoreLabel}${reasonLabel}`;
1095
+ }
1096
+
1097
+ function checkpointSummarySections(checkpoint = {}) {
1098
+ const sections = checkpoint.summarySections || checkpoint.summary_sections;
1099
+ return sections && typeof sections === "object" && !Array.isArray(sections) ? sections : null;
1100
+ }
1101
+
1102
+ function normalizeCheckpointTextItems(value, limit = 3) {
1103
+ if (!Array.isArray(value)) {
1104
+ return [];
1105
+ }
1106
+ return value
1107
+ .map((item) => normalizeString(item))
1108
+ .filter(Boolean)
1109
+ .slice(0, limit);
1110
+ }
1111
+
1112
+ function formatCheckpointSectionPreview(checkpoint = {}) {
1113
+ const sections = checkpointSummarySections(checkpoint);
1114
+ if (!sections) {
1115
+ return "";
1116
+ }
1117
+ const parts = [];
1118
+ const work = normalizeCheckpointTextItems(sections.workCompleted || sections.work_completed, 1)[0];
1119
+ if (work) {
1120
+ parts.push(`work: ${work}`);
1121
+ }
1122
+ const agents = Array.isArray(sections.agentContributions)
1123
+ ? sections.agentContributions
1124
+ : Array.isArray(sections.agent_contributions)
1125
+ ? sections.agent_contributions
1126
+ : [];
1127
+ const agentLabels = agents
1128
+ .map((item) => {
1129
+ const agentId = normalizeString(item?.agentId || item?.agent_id);
1130
+ const summary = normalizeString(item?.summary);
1131
+ return agentId && summary ? `${agentId}: ${summary}` : "";
1132
+ })
1133
+ .filter(Boolean)
1134
+ .slice(0, 2);
1135
+ if (agentLabels.length) {
1136
+ parts.push(`agents: ${agentLabels.join("; ")}`);
1137
+ }
1138
+ const evidence = Array.isArray(sections.evidence) ? sections.evidence : [];
1139
+ const evidenceLabels = evidence
1140
+ .map((item) => normalizeString(item?.label || item?.value))
1141
+ .filter(Boolean)
1142
+ .slice(0, 3);
1143
+ if (evidenceLabels.length) {
1144
+ parts.push(`evidence: ${evidenceLabels.join(", ")}`);
1145
+ }
1146
+ const risks = normalizeCheckpointTextItems(sections.risks, 1);
1147
+ if (risks.length) {
1148
+ parts.push(`risks: ${risks.join("; ")}`);
1149
+ }
1150
+ const nextSteps = normalizeCheckpointTextItems(sections.nextSteps || sections.next_steps, 1);
1151
+ if (nextSteps.length) {
1152
+ parts.push(`next: ${nextSteps.join("; ")}`);
1153
+ }
1154
+ return parts.length ? ` | ${parts.join(" | ")}` : "";
1155
+ }
1156
+
1157
+ export function formatCheckpointLine(checkpoint = {}) {
917
1158
  const id = normalizeString(checkpoint.checkpointId) || "checkpoint";
918
1159
  const kind = normalizeString(checkpoint.kind) || "summary";
919
1160
  const title = normalizeString(checkpoint.title) || "Untitled checkpoint";
920
1161
  const byline = normalizeString(checkpoint.createdByAgentId || checkpoint.createdBy);
921
1162
  const by = byline ? ` by ${byline}` : "";
922
- return `${checkpointSequenceRange(checkpoint)} ${id} [${kind}] ${title}${by}`;
1163
+ return `${checkpointSequenceRange(checkpoint)} ${id} [${kind}] ${title}${by}${checkpointGradeLabel(checkpoint)}${formatCheckpointSectionPreview(checkpoint)}`;
923
1164
  }
924
1165
 
925
1166
  async function readCheckpointSummaryOption(options = {}, { targetPath } = {}) {
@@ -1224,6 +1465,9 @@ export function registerSessionCommand(program) {
1224
1465
  const launchPlan = template ? buildTemplateLaunchPlan(created.sessionId, template) : [];
1225
1466
  const dashboardUrl = buildDashboardUrl(created.sessionId);
1226
1467
  const effectiveTitle = ensured.title;
1468
+ const cliCommand = preferredCliCommand();
1469
+ const resumeContext = buildResumeContext(ensured.resumedCandidate, { reuseWindowSeconds });
1470
+ const remoteSync = await resolveSessionRemoteSyncState({ dashboardUrl });
1227
1471
 
1228
1472
  const payload = {
1229
1473
  command: "session start",
@@ -1245,23 +1489,29 @@ export function registerSessionCommand(program) {
1245
1489
  launchPlan,
1246
1490
  dashboardUrl,
1247
1491
  resumed,
1492
+ resumeSource: resumeContext?.source || null,
1493
+ resumeContext: resumeContext || undefined,
1494
+ staleResume: ensured.staleResume || undefined,
1495
+ remoteSync,
1248
1496
  title: effectiveTitle || null,
1249
1497
  titleAuto: Boolean(ensured.titleAuto),
1250
1498
  titleSync: ensured.titleSync || undefined,
1251
1499
  };
1252
1500
 
1253
1501
  // Best-effort admin visibility sync. Session creation remains local-first.
1254
- void syncSessionMetadataToApi(created.sessionId, {
1255
- targetPath,
1256
- sessionId: created.sessionId,
1257
- status: created.status,
1258
- createdAt: created.createdAt,
1259
- expiresAt: created.expiresAt,
1260
- title: effectiveTitle || null,
1261
- ttlSeconds,
1262
- template: created.template,
1263
- codebaseContext: created.codebaseContext,
1264
- }).catch(() => {});
1502
+ if (remoteSync.attempted) {
1503
+ void syncSessionMetadataToApi(created.sessionId, {
1504
+ targetPath,
1505
+ sessionId: created.sessionId,
1506
+ status: created.status,
1507
+ createdAt: created.createdAt,
1508
+ expiresAt: created.expiresAt,
1509
+ title: effectiveTitle || null,
1510
+ ttlSeconds,
1511
+ template: created.template,
1512
+ codebaseContext: created.codebaseContext,
1513
+ }).catch(() => {});
1514
+ }
1265
1515
 
1266
1516
  // Auto-start the Senti orchestrator daemon. Without this, every
1267
1517
  // session ran with `Senti actions: 1` (just the welcome alert)
@@ -1303,15 +1553,38 @@ export function registerSessionCommand(program) {
1303
1553
  console.log(pc.bold(resumed ? "Session resumed" : "Session created"));
1304
1554
  console.log(pc.gray(`Session: ${created.sessionId}`));
1305
1555
  if (titleArg) console.log(pc.gray(`Title: ${titleArg}`));
1556
+ if (resumeContext) {
1557
+ console.log(
1558
+ pc.gray(
1559
+ `Resume source: ${resumeContext.source} (last activity ${resumeContext.lastActivityAt || "unknown"}; window ${reuseWindowSeconds}s)`,
1560
+ ),
1561
+ );
1562
+ }
1563
+ if (ensured.staleResume?.action === "expired_local_and_created_new") {
1564
+ console.log(
1565
+ pc.yellow(
1566
+ `Skipped stale local session ${ensured.staleResume.sessionId}: remote status is ${ensured.staleResume.remoteStatus || ensured.staleResume.reason}.`,
1567
+ ),
1568
+ );
1569
+ }
1306
1570
  if (created.streamPath) console.log(pc.gray(`Stream: ${created.streamPath}`));
1307
1571
  console.log(pc.gray(`${resumed ? "Resumed" : "Created"} in ${durationMs}ms`));
1308
1572
  console.log(
1309
1573
  `status=${created.status} created_at=${created.createdAt} expires_at=${created.expiresAt} ttl_seconds=${ttlSeconds}`,
1310
1574
  );
1575
+ if (remoteSync.status === "auth_required") {
1576
+ console.log(
1577
+ pc.yellow(
1578
+ `Dashboard sync pending: run \`${authLoginHint()}\`, then rerun \`${cliCommand} session start\` in this workspace to publish local metadata.`,
1579
+ ),
1580
+ );
1581
+ } else if (remoteSync.status === "disabled") {
1582
+ console.log(pc.gray("Dashboard sync disabled by SENTINELAYER_SKIP_REMOTE_SYNC=1."));
1583
+ }
1311
1584
  if (!resumed) {
1312
1585
  console.log(
1313
1586
  pc.gray(
1314
- "Tip: subsequent `slc session start` in this workspace within an hour will resume this session. Pass --force-new to override.",
1587
+ `Tip: subsequent \`${cliCommand} session start\` in this workspace within an hour will resume this session. Pass --force-new to override.`,
1315
1588
  ),
1316
1589
  );
1317
1590
  }
@@ -1431,13 +1704,20 @@ export function registerSessionCommand(program) {
1431
1704
  forceNew: Boolean(options.forceNew),
1432
1705
  reuseWindowSeconds,
1433
1706
  });
1707
+ const dashboardUrl = buildDashboardUrl(ensured.created.sessionId);
1708
+ const resumeContext = buildResumeContext(ensured.resumedCandidate, { reuseWindowSeconds });
1709
+ const remoteSync = await resolveSessionRemoteSyncState({ dashboardUrl });
1434
1710
  const payload = {
1435
1711
  command: "session ensure",
1436
1712
  targetPath,
1437
1713
  sessionId: ensured.created.sessionId,
1438
1714
  title: ensured.title || null,
1439
1715
  resumed: Boolean(ensured.resumedCandidate),
1440
- dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
1716
+ resumeSource: resumeContext?.source || null,
1717
+ resumeContext: resumeContext || undefined,
1718
+ staleResume: ensured.staleResume || undefined,
1719
+ dashboardUrl,
1720
+ remoteSync,
1441
1721
  titleSync: ensured.titleSync || undefined,
1442
1722
  };
1443
1723
  console.log(JSON.stringify(payload, null, 2));
@@ -1701,6 +1981,21 @@ export function registerSessionCommand(program) {
1701
1981
  .command("say <sessionId> <message>")
1702
1982
  .description("Send a message to the session")
1703
1983
  .option("--agent <id>", "Agent id to emit from", "cli-user")
1984
+ .option(
1985
+ "--model <model>",
1986
+ "Agent model/provider hint; defaults to local joined agent metadata or SENTINELAYER_AGENT_MODEL",
1987
+ process.env.SENTINELAYER_AGENT_MODEL || "",
1988
+ )
1989
+ .option(
1990
+ "--display-name <name>",
1991
+ "Human-readable agent display name",
1992
+ process.env.SENTINELAYER_AGENT_DISPLAY_NAME || process.env.SENTINELAYER_AGENT_NAME || "",
1993
+ )
1994
+ .option(
1995
+ "--role <role>",
1996
+ "Agent role metadata; defaults to local joined agent metadata or SENTINELAYER_AGENT_ROLE",
1997
+ process.env.SENTINELAYER_AGENT_ROLE || "",
1998
+ )
1704
1999
  .option("--to <agent>", "Direct the message to a specific agent id")
1705
2000
  .option("--reply-to <sequence>", "Mark this message as a reply to a target sequence id")
1706
2001
  .option("--reply-cursor <cursor>", "Mark this message as a reply to a target event cursor")
@@ -1737,9 +2032,15 @@ export function registerSessionCommand(program) {
1737
2032
  eventPayload.replyToCursor = replyToCursor;
1738
2033
  }
1739
2034
  const clientMessageId = `cli-${randomUUID()}`;
2035
+ const agent = await resolveSessionAgentEnvelope(normalizedSessionId, agentId, {
2036
+ targetPath,
2037
+ model: options.model,
2038
+ role: options.role,
2039
+ displayName: options.displayName,
2040
+ });
1740
2041
  const event = createAgentEvent({
1741
2042
  event: "session_message",
1742
- agentId,
2043
+ agent,
1743
2044
  sessionId: normalizedSessionId,
1744
2045
  payload: eventPayload,
1745
2046
  });
@@ -1834,13 +2135,9 @@ export function registerSessionCommand(program) {
1834
2135
  event.eventId = clientMessageId;
1835
2136
  event.idempotencyToken = clientMessageId;
1836
2137
 
1837
- let remoteSync = null;
1838
- for (let attempt = 0; attempt < 2; attempt += 1) {
1839
- remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
1840
- targetPath,
1841
- });
1842
- if (remoteSync?.synced) break;
1843
- }
2138
+ const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
2139
+ targetPath,
2140
+ });
1844
2141
  if (!remoteSync?.synced) {
1845
2142
  throw new Error(
1846
2143
  `Agent post failed (${remoteSync?.reason || "unknown"}). Ensure this user has an active grant for '${agentId}'.`,
@@ -54,6 +54,14 @@ function normalizeTimestamp(value, fallbackTimestamp) {
54
54
  return new Date(epoch).toISOString();
55
55
  }
56
56
 
57
+ function normalizePositiveInteger(value) {
58
+ const normalized = Number(value);
59
+ if (!Number.isFinite(normalized) || normalized <= 0) {
60
+ return undefined;
61
+ }
62
+ return Math.floor(normalized);
63
+ }
64
+
57
65
  function stripUndefinedEntries(record) {
58
66
  const cleaned = {};
59
67
  for (const [key, value] of Object.entries(record || {})) {
@@ -112,6 +120,11 @@ export function createAgentEvent({
112
120
  agent,
113
121
  ts,
114
122
  timestamp,
123
+ eventId,
124
+ idempotencyToken,
125
+ cursor,
126
+ sequenceId,
127
+ sequence_id,
115
128
  } = {}) {
116
129
  const normalizedEvent = normalizeNonEmptyString(event);
117
130
  const normalizedAgent = normalizeAgentShape({
@@ -135,6 +148,10 @@ export function createAgentEvent({
135
148
  runId: normalizeOptionalString(runId),
136
149
  workItemId: normalizeOptionalString(workItemId),
137
150
  requestId: normalizeOptionalString(requestId),
151
+ eventId: normalizeOptionalString(eventId),
152
+ idempotencyToken: normalizeOptionalString(idempotencyToken),
153
+ cursor: normalizeOptionalString(cursor),
154
+ sequenceId: normalizePositiveInteger(sequenceId ?? sequence_id),
138
155
  ts: canonicalTs,
139
156
  // Keep legacy timestamp key for existing consumers while PR0 migrates envelope usage.
140
157
  timestamp: canonicalTs,
@@ -202,6 +219,10 @@ export function normalizeAgentEvent(evt, { allowLegacy = true } = {}) {
202
219
  runId: evt.runId,
203
220
  workItemId: evt.workItemId,
204
221
  requestId: evt.requestId,
222
+ eventId: evt.eventId,
223
+ idempotencyToken: evt.idempotencyToken,
224
+ cursor: evt.cursor,
225
+ sequenceId: evt.sequenceId ?? evt.sequence_id,
205
226
  ts: normalizedTs,
206
227
  });
207
228
  } catch {
package/src/legacy-cli.js CHANGED
@@ -1135,6 +1135,10 @@ async function runLocalOmarGateCommand(args) {
1135
1135
  const devTestBotBaseUrl = getCommandOptionValue(args, "--devtestbot-base-url") || "";
1136
1136
  const devTestBotScope = getCommandOptionValue(args, "--devtestbot-scope") || "";
1137
1137
  const emailOnComplete = getCommandOptionValue(args, "--email-on-complete") || "";
1138
+ const notifySession = getCommandOptionValue(args, "--notify-session") || "";
1139
+ const requireUsageLedger = hasCommandOption(args, "--require-usage-ledger");
1140
+ const model = getCommandOptionValue(args, "--model") || "gpt-5.3-codex";
1141
+ const provider = getCommandOptionValue(args, "--provider") || "sentinelayer";
1138
1142
 
1139
1143
  const targetPath = path.resolve(process.cwd(), pathArg);
1140
1144
  if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
@@ -1163,6 +1167,18 @@ async function runLocalOmarGateCommand(args) {
1163
1167
  baseUrl: devTestBotBaseUrl,
1164
1168
  scope: devTestBotScope,
1165
1169
  },
1170
+ sessionUsage: notifySession || requireUsageLedger
1171
+ ? {
1172
+ sessionId: notifySession,
1173
+ agentId: "investor-dd",
1174
+ model,
1175
+ provider,
1176
+ targetPath,
1177
+ billingTier: "internal",
1178
+ sourceCommand: "omargate investor-dd",
1179
+ required: requireUsageLedger,
1180
+ }
1181
+ : null,
1166
1182
  reportEmail: emailOnComplete
1167
1183
  ? {
1168
1184
  to: emailOnComplete,