sentinelayer-cli 0.19.0 → 0.20.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.
@@ -2,7 +2,7 @@ import YAML from "yaml";
2
2
 
3
3
  export const DEFAULT_SCAN_WORKFLOW_PATH = ".github/workflows/omar-gate.yml";
4
4
  export const DEFAULT_SCAN_SECRET_NAME = "SENTINELAYER_TOKEN";
5
- export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@8595c4ad41e7b710ff6b1de0603da6ad8c0c3c07";
5
+ export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@03d7369cba7de2e9f15b959275c982111f0ee493";
6
6
  export const SUPPORTED_E2E_HINTS = Object.freeze(["auto", "yes", "no"]);
7
7
  export const SUPPORTED_PLAYWRIGHT_MODES = Object.freeze(["auto", "off", "baseline", "audit"]);
8
8
 
@@ -228,11 +228,12 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
228
228
  sentinelayer_token: `\${{ secrets.${normalizedSecret} }}`,
229
229
  sentinelayer_managed_llm: "false",
230
230
  openai_api_key: "${{ secrets.OPENAI_API_KEY }}",
231
+ google_api_key: "${{ secrets.GOOGLE_API_KEY }}",
231
232
  scan_mode: profile.scanMode || "deep",
232
233
  severity_gate: profile.severityGate || "P1",
233
234
  model: "gpt-5.3-codex",
234
235
  codex_model: "gpt-5.3-codex",
235
- model_fallback: "gpt-5.2-codex",
236
+ model_fallback: "gemini-2.5-flash",
236
237
  llm_failure_policy: "block",
237
238
  playwright_mode: profile.playwrightMode || "off",
238
239
  sbom_mode: profile.sbomMode || "off",
@@ -185,6 +185,43 @@ export function generateAgentId(modelName) {
185
185
  return `${prefix}-${suffix}`;
186
186
  }
187
187
 
188
+ function isActiveAgentSnapshot(snapshot) {
189
+ if (!snapshot || typeof snapshot !== "object") {
190
+ return false;
191
+ }
192
+ if (snapshot.active === false) {
193
+ return false;
194
+ }
195
+ if (normalizeString(snapshot.leftAt)) {
196
+ return false;
197
+ }
198
+ return true;
199
+ }
200
+
201
+ function chooseAgentRefreshModel(value, existingModel) {
202
+ const candidate = normalizeString(value);
203
+ const existing = normalizeString(existingModel);
204
+ if (!candidate) {
205
+ return existing || "unknown";
206
+ }
207
+ if (existing && ["cli", "unknown"].includes(candidate.toLowerCase())) {
208
+ return existing;
209
+ }
210
+ return candidate;
211
+ }
212
+
213
+ function chooseAgentRefreshRole(value, existingRole) {
214
+ const candidate = normalizeString(value);
215
+ const existing = normalizeString(existingRole);
216
+ if (!candidate) {
217
+ return existing || "observer";
218
+ }
219
+ if (existing && candidate.toLowerCase() === "observer") {
220
+ return existing;
221
+ }
222
+ return candidate;
223
+ }
224
+
188
225
  // In-process registry of agents registered by *this* CLI process. The
189
226
  // dashboard treats any participant without a terminal agent_leave /
190
227
  // agent_killed / session_killed event as "active". When a CLI exits via
@@ -275,6 +312,39 @@ export async function registerAgent(
275
312
  }
276
313
 
277
314
  const snapshotPath = buildAgentSnapshotPath(paths, resolvedAgentId);
315
+ const existing = await readAgentSnapshot(snapshotPath);
316
+
317
+ if (isActiveAgentSnapshot(existing)) {
318
+ const snapshot = normalizeAgentSnapshot(
319
+ {
320
+ ...existing,
321
+ sessionId: paths.sessionId,
322
+ agentId: resolvedAgentId,
323
+ model: chooseAgentRefreshModel(model, existing.model),
324
+ role: chooseAgentRefreshRole(role, existing.role),
325
+ status: normalizeString(existing.status) || "idle",
326
+ detail: normalizeString(existing.detail) || "",
327
+ file: normalizeString(existing.file) || null,
328
+ lastActivityAt: nowIso,
329
+ leftAt: null,
330
+ leaveReason: null,
331
+ active: true,
332
+ updatedAt: nowIso,
333
+ },
334
+ nowIso
335
+ );
336
+ await writeAgentSnapshot(snapshotPath, snapshot);
337
+ if (trackProcessExit) {
338
+ _trackLocalAgent(paths.sessionId, snapshot.agentId, targetPath);
339
+ }
340
+ return {
341
+ ...snapshot,
342
+ snapshotPath,
343
+ emittedJoinEvent: false,
344
+ emittedContextBriefing: false,
345
+ refreshedExistingAgent: true,
346
+ };
347
+ }
278
348
 
279
349
  const snapshot = normalizeAgentSnapshot(
280
350
  {
@@ -328,6 +398,54 @@ export async function registerAgent(
328
398
  }).catch(() => {});
329
399
  }
330
400
 
401
+ return {
402
+ ...snapshot,
403
+ snapshotPath,
404
+ emittedJoinEvent: true,
405
+ emittedContextBriefing: normalizeString(snapshot.agentId).toLowerCase() !== "senti",
406
+ refreshedExistingAgent: false,
407
+ };
408
+ }
409
+
410
+ export async function rememberAgentIdentity(
411
+ sessionId,
412
+ {
413
+ agentId = "",
414
+ model = "",
415
+ role = "observer",
416
+ targetPath = process.cwd(),
417
+ } = {}
418
+ ) {
419
+ const paths = resolveSessionPaths(sessionId, { targetPath });
420
+ const nowIso = new Date().toISOString();
421
+ const resolvedAgentId = normalizeString(agentId);
422
+ if (!resolvedAgentId) {
423
+ throw new Error("agentId is required.");
424
+ }
425
+
426
+ const snapshotPath = buildAgentSnapshotPath(paths, resolvedAgentId);
427
+ const existing = await readAgentSnapshot(snapshotPath);
428
+ const snapshot = normalizeAgentSnapshot(
429
+ {
430
+ ...(existing || {}),
431
+ sessionId: paths.sessionId,
432
+ agentId: resolvedAgentId,
433
+ model: normalizeString(model) || normalizeString(existing?.model) || "unknown",
434
+ role: normalizeString(role) || normalizeString(existing?.role) || "observer",
435
+ status: normalizeString(existing?.status) || "idle",
436
+ detail: normalizeString(existing?.detail) || "",
437
+ file: normalizeString(existing?.file) || null,
438
+ joinedAt: normalizeString(existing?.joinedAt) || nowIso,
439
+ lastActivityAt: nowIso,
440
+ leftAt: null,
441
+ leaveReason: null,
442
+ active: true,
443
+ updatedAt: nowIso,
444
+ },
445
+ nowIso
446
+ );
447
+
448
+ await writeAgentSnapshot(snapshotPath, snapshot);
331
449
  return {
332
450
  ...snapshot,
333
451
  snapshotPath,
@@ -9,10 +9,12 @@ const DEFAULT_CHECKPOINT_LIMIT = 100;
9
9
  const MAX_CHECKPOINT_LIMIT = 200;
10
10
  const DEFAULT_MIN_EVENTS = 20;
11
11
  const DEFAULT_MAX_EVENTS = 80;
12
+ const DEFAULT_BATCH_MAX_CHECKPOINTS = 5;
13
+ const MAX_BATCH_MAX_CHECKPOINTS = 50;
12
14
  const DEFAULT_CREATED_BY_AGENT_ID = "senti";
13
15
 
14
16
  function normalizeString(value) {
15
- return String(value || "").trim();
17
+ return String(value ?? "").trim();
16
18
  }
17
19
 
18
20
  function normalizeApiUrl(value) {
@@ -36,6 +38,15 @@ function normalizeLimit(value) {
36
38
  return Math.max(1, Math.min(MAX_CHECKPOINT_LIMIT, parsed));
37
39
  }
38
40
 
41
+ function normalizeBatchMaxCheckpoints(value) {
42
+ const parsed = parsePositiveInteger(
43
+ value,
44
+ "max-checkpoints",
45
+ DEFAULT_BATCH_MAX_CHECKPOINTS,
46
+ );
47
+ return Math.max(1, Math.min(MAX_BATCH_MAX_CHECKPOINTS, parsed));
48
+ }
49
+
39
50
  function stableHash(value) {
40
51
  return crypto
41
52
  .createHash("sha256")
@@ -97,6 +108,12 @@ function buildInvocationIdempotencyKey(operation) {
97
108
  return `sl_cli_session_checkpoint_${normalizeString(operation) || "mutation"}_${suffix}`;
98
109
  }
99
110
 
111
+ function buildBatchIdempotencyKey(baseKey, index) {
112
+ const normalizedBase = normalizeString(baseKey);
113
+ if (!normalizedBase) return "";
114
+ return `${normalizedBase}:${Number(index) + 1}`;
115
+ }
116
+
100
117
  function normalizeReason(value, fallbackValue = "checkpoint_generate_failed") {
101
118
  return (
102
119
  normalizeString(value)
@@ -345,6 +362,57 @@ export async function generateSessionCheckpoint(sessionId, options = {}) {
345
362
  };
346
363
  }
347
364
 
365
+ export async function generateSessionCheckpointBatch(sessionId, options = {}) {
366
+ const normalizedSessionId = normalizeString(sessionId);
367
+ if (!normalizedSessionId) {
368
+ throw new Error("session id is required.");
369
+ }
370
+ const maxCheckpoints = normalizeBatchMaxCheckpoints(options.maxCheckpoints);
371
+ const results = [];
372
+ const seenCheckpointIds = new Set();
373
+ let stoppedReason = "max_checkpoints";
374
+
375
+ for (let index = 0; index < maxCheckpoints; index += 1) {
376
+ const result = await generateSessionCheckpoint(normalizedSessionId, {
377
+ ...options,
378
+ idempotencyKey: buildBatchIdempotencyKey(options.idempotencyKey, index),
379
+ });
380
+ const normalized = normalizeCheckpointGenerationResult(result);
381
+ results.push(normalized);
382
+
383
+ if (!normalized.checkpoint || normalized.duplicate || !normalized.created) {
384
+ stoppedReason = normalized.reason || (normalized.duplicate ? "duplicate_checkpoint" : "not_created");
385
+ break;
386
+ }
387
+
388
+ if (normalized.checkpointId) {
389
+ if (seenCheckpointIds.has(normalized.checkpointId)) {
390
+ stoppedReason = "repeated_checkpoint";
391
+ break;
392
+ }
393
+ seenCheckpointIds.add(normalized.checkpointId);
394
+ }
395
+ }
396
+
397
+ const created = results.filter((result) => result.created && !result.duplicate && result.checkpoint);
398
+ const duplicates = results.filter((result) => result.duplicate);
399
+ const lastResult = results.at(-1) || null;
400
+ return {
401
+ ok: results.every((result) => result.ok !== false),
402
+ sessionId: normalizedSessionId,
403
+ apiUrl: results.find((result) => result.apiUrl)?.apiUrl || null,
404
+ catchUp: true,
405
+ maxCheckpoints,
406
+ attemptedCount: results.length,
407
+ createdCount: created.length,
408
+ duplicateCount: duplicates.length,
409
+ checkpointIds: created.map((result) => result.checkpointId).filter(Boolean),
410
+ stoppedReason,
411
+ lastResult,
412
+ results,
413
+ };
414
+ }
415
+
348
416
  export async function generateSessionCheckpointBestEffort(sessionId, options = {}) {
349
417
  const normalizedSessionId = normalizeString(sessionId);
350
418
  if (!normalizedSessionId) {
@@ -375,6 +443,8 @@ export async function generateSessionCheckpointBestEffort(sessionId, options = {
375
443
 
376
444
  export {
377
445
  DEFAULT_CREATED_BY_AGENT_ID,
446
+ DEFAULT_BATCH_MAX_CHECKPOINTS,
378
447
  DEFAULT_MAX_EVENTS,
379
448
  DEFAULT_MIN_EVENTS,
449
+ MAX_BATCH_MAX_CHECKPOINTS,
380
450
  };
@@ -5,8 +5,9 @@ export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
5
5
  "When you have an agent grant, post agent updates with `sl session post-agent <id> \"status: <update>\" --agent <your-agent-id>` so they render as the agent, not the human relay.",
6
6
  "Before implementation, post a short plan and file claims with `sl session say <id> \"plan: <scope>; files: <paths>\"`.",
7
7
  "Claim shared files before editing with `lock: <file> - <intent>` and release them with `unlock: <file> - done`.",
8
- "Run a background listener for replies: `sl session listen --session <id> --agent <your-name> --interval 60 --active-interval 5 --emit ndjson`; this idles at 60s and switches to 5s after human activity. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
9
- "Use message actions for low-noise coordination before posting a new top-level message: `sl session react <id> ack --target-sequence <n>` for ACKs, `sl session action <id> working_on --target-sequence <n>` for ownership, `sl session view <id> <sequence>` for read receipts, and `sl session reply <id> <sequence> \"<message>\"` / `sl session comment <id> <sequence> \"<message>\"` for threaded responses. Run `sl session actions` to list all action types.",
8
+ "Run a background/secondary listener for replies with `sl session listen --session <id> --agent <your-name> --interval 60 --active-interval 5 --emit ndjson --no-presence`; this idles at 60s, switches to 5s after human activity, and avoids durable listener heartbeat noise. `session listen` is only a delivery cursor, not a grounding command; join or recap before acting. For your primary interactive listener, omit `--no-presence` so other participants can see you online. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
9
+ "For long-lived rooms, make sure exactly one visible participant owns the Senti daemon: `sl session daemon --session <id> --recap-interval 300 --checkpoint-interval 60`. If no durable recap/checkpoint is appearing, run `sl session recap now <id> --remote --agent <your-name> --json` before posting a long plan.",
10
+ "Use message actions for low-noise coordination before posting a new top-level message: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK is useful, `sl session action <id> working_on --target-sequence <n>` for ownership, and `sl session reply <id> <sequence> \"<message>\"` / `sl session comment <id> <sequence> \"<message>\"` for threaded responses. Read receipts are automatic when you use `sl session read <id> --remote --agent <your-name>`; reserve `sl session view <id> <sequence>` for repair/backfill. Run `sl session actions` for the full list.",
10
11
  "Search before asking peers to restate context: `sl session search <id> \"<topic>\" --limit 10`.",
11
12
  "Run `sl review --diff` after each finished file or PR-ready diff and post the result summary back to the session.",
12
13
  "Post findings through `sl session say <id> \"finding: [P2] <title> in <file>:<line>\"` with enough context for a peer to act.",