sentinelayer-cli 0.8.11 → 0.9.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.
Files changed (38) hide show
  1. package/package.json +10 -5
  2. package/src/agents/devtestbot/config/definition.js +100 -0
  3. package/src/agents/devtestbot/config/system-prompt.js +92 -0
  4. package/src/agents/devtestbot/index.js +9 -0
  5. package/src/agents/devtestbot/runner.js +769 -0
  6. package/src/agents/devtestbot/tool.js +707 -0
  7. package/src/agents/jules/stream.js +2 -12
  8. package/src/audit/orchestrator.js +471 -114
  9. package/src/audit/persona-loop.js +1342 -0
  10. package/src/audit/registry.js +58 -2
  11. package/src/commands/audit.js +42 -1
  12. package/src/commands/legacy-args.js +32 -1
  13. package/src/commands/omargate.js +4 -0
  14. package/src/commands/session.js +417 -89
  15. package/src/commands/swarm.js +11 -2
  16. package/src/cost/history.js +41 -21
  17. package/src/events/schema.js +27 -1
  18. package/src/guide/generator.js +14 -0
  19. package/src/legacy-cli.js +110 -18
  20. package/src/prompt/generator.js +4 -16
  21. package/src/review/ai-review.js +95 -6
  22. package/src/review/dd-report-email-client.js +148 -0
  23. package/src/review/investor-dd-devtestbot.js +599 -0
  24. package/src/review/investor-dd-orchestrator.js +135 -3
  25. package/src/review/omargate-cache.js +285 -0
  26. package/src/review/omargate-orchestrator.js +605 -4
  27. package/src/review/persona-prompts.js +34 -1
  28. package/src/review/report.js +189 -4
  29. package/src/session/coordination-guidance.js +48 -0
  30. package/src/session/daemon.js +3 -2
  31. package/src/session/listener.js +236 -0
  32. package/src/session/senti-naming.js +36 -0
  33. package/src/session/setup-guides.js +3 -15
  34. package/src/session/store.js +54 -5
  35. package/src/session/sync.js +23 -0
  36. package/src/spec/generator.js +8 -10
  37. package/src/swarm/registry.js +20 -0
  38. package/src/swarm/runtime.js +139 -1
@@ -27,6 +27,29 @@ Non-negotiables for your review:
27
27
 
28
28
  Your output must help an acquirer decide whether to buy this codebase. Be FOUND-violations accurate, not speculation-padded.`;
29
29
 
30
+ export const ELEVEN_LENS_EVIDENCE_APPENDIX = `## 11-lens evidence contract
31
+ Evaluate every confirmed finding through these lenses before returning it:
32
+
33
+ A. Route/runtime boundary integrity
34
+ B. State, lifecycle, and hook correctness
35
+ C. Render cost, re-render, and scalability mechanics
36
+ D. Hydration, SSR, streaming, and environment divergence
37
+ E. Data fetching, caching, timeout, and freshness behavior
38
+ F. Bundle/dependency footprint and code-splitting risk
39
+ G. Assets, scripts, layout stability, and resource loading
40
+ H. Accessibility, keyboard, focus, and trust-critical UX
41
+ I. Mobile/responsive reliability across 360px, 768px, and desktop
42
+ J. Verification, rollback, and QA readiness
43
+ K. AI governance, provenance, HITL, and agent/tool permission surfaces
44
+
45
+ For each finding include:
46
+ - lensEvidence: object keyed by lens letter with "passed", "failed", or "not_applicable" plus one short evidence sentence
47
+ - reproduction: object with type (manual_step | shell | runtime_probe | static_trace) and steps array; required for P0/P1
48
+ - user_impact: one sentence describing what a user/operator/system experiences
49
+ - trafficLight: green | yellow | red for automation safety
50
+ - rootCause: why the issue exists
51
+ - recommendedFix: concrete fix path`;
52
+
30
53
  const PERSONA_PROMPTS = {
31
54
  security: {
32
55
  role: "Nina Patel — Security Specialist",
@@ -291,6 +314,8 @@ ${FAANG_GRADE_PREAMBLE}
291
314
 
292
315
  ${persona.focus}
293
316
 
317
+ ${ELEVEN_LENS_EVIDENCE_APPENDIX}
318
+
294
319
  ${checklistBlock}
295
320
  ## Context
296
321
  Target: ${targetPath || "(not provided)"}
@@ -313,6 +338,10 @@ Return a JSON OBJECT (not array) with this shape — return ONLY the JSON, no ot
313
338
  "line": 42,
314
339
  "title": "Brief description",
315
340
  "evidence": "Concrete code excerpt at file:line (min 1 line)",
341
+ "lensEvidence": { "A": "not_applicable: no route/runtime boundary impact", "K": "passed: no AI governance surface involved" },
342
+ "reproduction": { "type": "static_trace", "steps": ["Inspect path/to/file.ext:42", "Trace the value/control flow to the failing behavior"] },
343
+ "user_impact": "One sentence describing the user/operator/system failure mode",
344
+ "trafficLight": "green|yellow|red",
316
345
  "rootCause": "Why this is a problem",
317
346
  "recommendedFix": "Specific code change to apply",
318
347
  "confidence": 0.85,
@@ -326,6 +355,8 @@ Rules:
326
355
  - Maximum ${maxFindings} findings.
327
356
  - Only report findings you have HIGH confidence in (>= 0.7).
328
357
  - Every finding MUST have concrete file:line evidence AND a non-empty \`evidence\` code excerpt.
358
+ - Every finding MUST include \`lensEvidence\`, \`user_impact\`, \`trafficLight\`, \`rootCause\`, and \`recommendedFix\`.
359
+ - P0/P1 findings MUST include \`reproduction\` steps.
329
360
  - Do NOT repeat findings already in the deterministic scan.
330
361
  - Do NOT report hypothetical/speculative issues.
331
362
  - Focus on REAL, EXPLOITABLE, IMPACTFUL problems in your domain.
@@ -337,10 +368,12 @@ Rules:
337
368
  function buildGenericPrompt({ targetPath, deterministicSummary, maxFindings }) {
338
369
  return `You are a senior code reviewer. Analyze the code for security, quality, and reliability issues.
339
370
 
371
+ ${ELEVEN_LENS_EVIDENCE_APPENDIX}
372
+
340
373
  Target: ${targetPath || "(not provided)"}
341
374
  Deterministic scan: P0=${deterministicSummary.P0 || 0} P1=${deterministicSummary.P1 || 0} P2=${deterministicSummary.P2 || 0}
342
375
 
343
- Return a JSON array of up to ${maxFindings} findings with: severity, file, line, title, evidence, rootCause, recommendedFix, confidence.
376
+ Return a JSON object with inspectedFiles, coverage, and up to ${maxFindings} findings. Each finding needs: severity, file, line, title, evidence, lensEvidence, reproduction for P0/P1, user_impact, trafficLight, rootCause, recommendedFix, confidence.
344
377
  Only report findings with concrete evidence. Do NOT repeat deterministic findings.`;
345
378
  }
346
379
 
@@ -62,6 +62,75 @@ function formatConfidence(value) {
62
62
  return Math.max(0, Math.min(1, normalized));
63
63
  }
64
64
 
65
+ function normalizeConfidenceFloor(value) {
66
+ const normalized = Number(value);
67
+ if (!Number.isFinite(normalized)) {
68
+ return 0.7;
69
+ }
70
+ return Math.max(0, Math.min(1, normalized));
71
+ }
72
+
73
+ function normalizeTrafficLight(value) {
74
+ const normalized = normalizeString(value).toLowerCase();
75
+ if (["green", "yellow", "red"].includes(normalized)) {
76
+ return normalized;
77
+ }
78
+ return "";
79
+ }
80
+
81
+ function cloneJsonCompatible(value) {
82
+ if (value === undefined || value === null || value === "") {
83
+ return null;
84
+ }
85
+ if (typeof value === "string") {
86
+ return normalizeString(value) || null;
87
+ }
88
+ try {
89
+ return JSON.parse(JSON.stringify(value));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function confidenceFloorForFinding(finding = {}, {
96
+ source = "ai",
97
+ confidenceFloors = {},
98
+ defaultConfidenceFloor = 0.7,
99
+ } = {}) {
100
+ const persona = normalizeString(finding.persona || finding.personaId || finding.agentId);
101
+ const layer = normalizeString(finding.layer);
102
+ const identity = sourceIdentityForFinding(finding, source);
103
+ const floor =
104
+ finding.confidenceFloor ??
105
+ finding.personaConfidenceFloor ??
106
+ confidenceFloors[identity] ??
107
+ confidenceFloors[persona] ??
108
+ confidenceFloors[layer] ??
109
+ confidenceFloors[source] ??
110
+ defaultConfidenceFloor;
111
+ return normalizeConfidenceFloor(floor);
112
+ }
113
+
114
+ function sourceIdentityForFinding(finding = {}, source = "ai") {
115
+ if (source === "deterministic") {
116
+ return "deterministic";
117
+ }
118
+ const persona = normalizeString(
119
+ finding.persona || finding.personaId || finding.agentId || finding.layer
120
+ );
121
+ return `ai:${persona || "generic"}`;
122
+ }
123
+
124
+ function hasMultiSourceConfirmation(finding = {}) {
125
+ const confirmationSources = Array.isArray(finding.confirmationSources)
126
+ ? finding.confirmationSources
127
+ : [];
128
+ const sourceIdentities = confirmationSources.length > 0
129
+ ? confirmationSources
130
+ : (Array.isArray(finding.sources) ? finding.sources : []);
131
+ return new Set(sourceIdentities.filter(Boolean)).size >= 2;
132
+ }
133
+
65
134
  function dedupeKeyForFinding(finding = {}) {
66
135
  const file = toPosixPath(normalizeString(finding.file) || "unknown");
67
136
  const line = Number(finding.line || 1);
@@ -104,25 +173,85 @@ function summarizeFindings(findings = []) {
104
173
  };
105
174
  }
106
175
 
176
+ export function dropBelowConfidence(findings = [], { threshold = 0.7 } = {}) {
177
+ const defaultThreshold = normalizeConfidenceFloor(threshold);
178
+ const kept = [];
179
+ const dropped = [];
180
+
181
+ for (const finding of findings || []) {
182
+ const confidence = formatConfidence(finding.confidence);
183
+ const confidenceFloor = normalizeConfidenceFloor(
184
+ finding.confidenceFloor ?? finding.personaConfidenceFloor ?? defaultThreshold
185
+ );
186
+ if (!hasMultiSourceConfirmation(finding) && confidence < confidenceFloor) {
187
+ dropped.push({
188
+ ...finding,
189
+ confidence,
190
+ confidenceFloor,
191
+ droppedReason: "below_confidence_floor_single_source",
192
+ });
193
+ continue;
194
+ }
195
+ kept.push({
196
+ ...finding,
197
+ confidence,
198
+ confidenceFloor,
199
+ });
200
+ }
201
+
202
+ return {
203
+ findings: kept,
204
+ dropped,
205
+ droppedCount: dropped.length,
206
+ threshold: defaultThreshold,
207
+ };
208
+ }
209
+
107
210
  export function reconcileReviewFindings({
108
211
  deterministicFindings = [],
109
212
  aiFindings = [],
213
+ confidenceFloor = 0.7,
214
+ defaultConfidenceFloor = confidenceFloor,
215
+ confidenceFloors = {},
110
216
  } = {}) {
111
217
  const merged = new Map();
218
+ const normalizedDefaultConfidenceFloor = normalizeConfidenceFloor(defaultConfidenceFloor);
112
219
 
113
220
  const addFinding = (finding, source) => {
221
+ const persona = normalizeString(finding.persona || finding.personaId || finding.agentId);
222
+ const confidenceFloorForSource = confidenceFloorForFinding(finding, {
223
+ source,
224
+ confidenceFloors,
225
+ defaultConfidenceFloor: normalizedDefaultConfidenceFloor,
226
+ });
227
+ const evidence = normalizeString(finding.evidence || finding.excerpt);
228
+ const rootCause = normalizeString(finding.rootCause || finding.root_cause);
229
+ const recommendedFix = normalizeString(
230
+ finding.recommendedFix || finding.recommended_fix || finding.suggestedFix
231
+ );
232
+ const suggestedFix = normalizeString(finding.suggestedFix || recommendedFix);
114
233
  const normalized = {
115
234
  findingId: "",
116
235
  severity: normalizeSeverity(finding.severity),
117
236
  file: toPosixPath(normalizeString(finding.file) || "unknown"),
118
237
  line: Math.max(1, Math.floor(Number(finding.line || 1))),
119
238
  message: normalizeString(finding.message) || "Unnamed finding",
120
- excerpt: normalizeString(finding.excerpt),
239
+ excerpt: normalizeString(finding.excerpt || evidence || rootCause),
121
240
  ruleId: normalizeString(finding.ruleId),
122
- suggestedFix: normalizeString(finding.suggestedFix),
241
+ suggestedFix,
242
+ evidence,
243
+ lensEvidence: cloneJsonCompatible(finding.lensEvidence || finding.lens_evidence),
244
+ reproduction: cloneJsonCompatible(finding.reproduction),
245
+ userImpact: normalizeString(finding.userImpact || finding.user_impact),
246
+ trafficLight: normalizeTrafficLight(finding.trafficLight || finding.traffic_light),
247
+ rootCause,
248
+ recommendedFix: recommendedFix || suggestedFix,
249
+ persona,
123
250
  layer: normalizeString(finding.layer),
124
251
  confidence: source === "deterministic" ? 1 : formatConfidence(finding.confidence),
252
+ confidenceFloor: confidenceFloorForSource,
125
253
  sources: [source],
254
+ confirmationSources: [sourceIdentityForFinding(finding, source)],
126
255
  adjudication: {
127
256
  verdict: "pending",
128
257
  note: "",
@@ -138,8 +267,25 @@ export function reconcileReviewFindings({
138
267
  }
139
268
 
140
269
  const nextSources = new Set([...(existing.sources || []), source]);
270
+ const nextConfirmationSources = new Set([
271
+ ...(existing.confirmationSources || []),
272
+ ...(normalized.confirmationSources || []),
273
+ ]);
141
274
  const preferred = compareFindingPriority(existing, normalized) <= 0 ? existing : normalized;
142
275
  preferred.sources = [...nextSources].sort((left, right) => left.localeCompare(right));
276
+ preferred.confirmationSources = [...nextConfirmationSources].sort((left, right) =>
277
+ left.localeCompare(right)
278
+ );
279
+ preferred.confidenceFloor = Math.max(
280
+ normalizeConfidenceFloor(existing.confidenceFloor),
281
+ normalizeConfidenceFloor(normalized.confidenceFloor)
282
+ );
283
+ if (!preferred.persona) {
284
+ preferred.persona = existing.persona || normalized.persona;
285
+ }
286
+ if (!preferred.layer) {
287
+ preferred.layer = existing.layer || normalized.layer;
288
+ }
143
289
  if (!preferred.excerpt) {
144
290
  preferred.excerpt = existing.excerpt || normalized.excerpt;
145
291
  }
@@ -149,6 +295,27 @@ export function reconcileReviewFindings({
149
295
  if (!preferred.suggestedFix) {
150
296
  preferred.suggestedFix = existing.suggestedFix || normalized.suggestedFix;
151
297
  }
298
+ if (!preferred.evidence) {
299
+ preferred.evidence = existing.evidence || normalized.evidence;
300
+ }
301
+ if (!preferred.lensEvidence) {
302
+ preferred.lensEvidence = existing.lensEvidence || normalized.lensEvidence;
303
+ }
304
+ if (!preferred.reproduction) {
305
+ preferred.reproduction = existing.reproduction || normalized.reproduction;
306
+ }
307
+ if (!preferred.userImpact) {
308
+ preferred.userImpact = existing.userImpact || normalized.userImpact;
309
+ }
310
+ if (!preferred.trafficLight) {
311
+ preferred.trafficLight = existing.trafficLight || normalized.trafficLight;
312
+ }
313
+ if (!preferred.rootCause) {
314
+ preferred.rootCause = existing.rootCause || normalized.rootCause;
315
+ }
316
+ if (!preferred.recommendedFix) {
317
+ preferred.recommendedFix = existing.recommendedFix || normalized.recommendedFix;
318
+ }
152
319
  merged.set(key, preferred);
153
320
  };
154
321
 
@@ -159,7 +326,10 @@ export function reconcileReviewFindings({
159
326
  addFinding(finding, "ai");
160
327
  }
161
328
 
162
- const findings = [...merged.values()].sort((left, right) => {
329
+ const confidenceFilter = dropBelowConfidence([...merged.values()], {
330
+ threshold: normalizedDefaultConfidenceFloor,
331
+ });
332
+ const findings = confidenceFilter.findings.sort((left, right) => {
163
333
  const severityDelta = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity];
164
334
  if (severityDelta !== 0) {
165
335
  return severityDelta;
@@ -177,7 +347,13 @@ export function reconcileReviewFindings({
177
347
 
178
348
  return {
179
349
  findings,
180
- summary: summarizeFindings(findings),
350
+ droppedFindings: confidenceFilter.dropped,
351
+ summary: {
352
+ ...summarizeFindings(findings),
353
+ confidenceFloor: confidenceFilter.threshold,
354
+ droppedBelowConfidence: confidenceFilter.droppedCount,
355
+ droppedBelowConfidenceSingleSource: confidenceFilter.droppedCount,
356
+ },
181
357
  };
182
358
  }
183
359
 
@@ -234,6 +410,9 @@ function composeReportMarkdown(report = {}) {
234
410
  ` confidence: ${(formatConfidence(finding.confidence) * 100).toFixed(0)}%\n` +
235
411
  ` sources: ${(finding.sources || []).join(", ") || "none"}\n` +
236
412
  ` verdict: ${finding.adjudication?.verdict || "pending"}\n` +
413
+ (finding.trafficLight ? ` traffic_light: ${finding.trafficLight}\n` : "") +
414
+ (finding.userImpact ? ` user_impact: ${finding.userImpact}\n` : "") +
415
+ (finding.rootCause ? ` root_cause: ${finding.rootCause}\n` : "") +
237
416
  ` suggested_fix: ${finding.suggestedFix || "Review and remediate as needed."}`
238
417
  )
239
418
  .join("\n")
@@ -250,6 +429,7 @@ function composeReportMarkdown(report = {}) {
250
429
  `- Findings: P0=${report.summary.P0} P1=${report.summary.P1} P2=${report.summary.P2} P3=${report.summary.P3}`,
251
430
  `- Blocking: ${report.summary.blocking ? "yes" : "no"}`,
252
431
  `- Total findings: ${report.findings.length}`,
432
+ `- Dropped below confidence floor (single-source): ${report.summary.droppedBelowConfidence || 0}`,
253
433
  "",
254
434
  "Metadata:",
255
435
  `- commit_sha: ${report.metadata.git.commitSha || "unknown"}`,
@@ -280,6 +460,8 @@ export async function buildUnifiedReviewReport({
280
460
  deterministic,
281
461
  aiLayer = null,
282
462
  specFile = "",
463
+ defaultConfidenceFloor = 0.7,
464
+ confidenceFloors = {},
283
465
  } = {}) {
284
466
  const normalizedTargetPath = path.resolve(String(targetPath || "."));
285
467
  const normalizedMode = normalizeString(mode) || "full";
@@ -289,6 +471,8 @@ export async function buildUnifiedReviewReport({
289
471
  const reconciliation = reconcileReviewFindings({
290
472
  deterministicFindings: deterministic?.findings || [],
291
473
  aiFindings: aiLayer?.findings || [],
474
+ defaultConfidenceFloor,
475
+ confidenceFloors,
292
476
  });
293
477
  const spec = await resolveSpecMetadata(normalizedTargetPath, specFile);
294
478
  const commitSha = runGit(normalizedTargetPath, ["rev-parse", "HEAD"]);
@@ -303,6 +487,7 @@ export async function buildUnifiedReviewReport({
303
487
  mode: normalizedMode,
304
488
  summary: reconciliation.summary,
305
489
  findings: reconciliation.findings,
490
+ droppedFindings: reconciliation.droppedFindings,
306
491
  severityMatrix: buildSeverityMatrix(),
307
492
  metadata: {
308
493
  git: {
@@ -0,0 +1,48 @@
1
+ export const COORDINATION_GUIDANCE_TITLE = "Multi-Agent Coordination Protocol";
2
+
3
+ export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
4
+ "Find the recent Senti session for this codebase: run `sl session list --path .` and `sl session list --remote --path .`; join the right room with `sl session join <id> --name <your-name> --role coder`.",
5
+ "Before implementation, post a short plan and file claims with `sl session say <id> \"plan: <scope>; files: <paths>\"`.",
6
+ "Claim shared files before editing with `lock: <file> - <intent>` and release them with `unlock: <file> - done`.",
7
+ "Run a background listener for replies: `sl session listen --session <id> --agent <your-name> --interval 60 --emit ndjson`; if background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
8
+ "Run `sl review --diff` after each finished file or PR-ready diff and post the result summary back to the session.",
9
+ "Post findings through `sl session say <id> \"finding: [P2] <title> in <file>:<line>\"` with enough context for a peer to act.",
10
+ "Ask for help in-session instead of stopping on unexpected file changes, blocked context, or ambiguous ownership.",
11
+ "Offer non-conflicting follow-up work to peers when you finish your claimed scope or discover separable tasks.",
12
+ "Run `sl --help` when you hit an unfamiliar workflow before guessing at command syntax.",
13
+ "Leave the session when done with `sl session leave <id>` after posting the final status and verification evidence.",
14
+ ]);
15
+
16
+ export function getCoordinationEtiquetteItems() {
17
+ return [...COORDINATION_ETIQUETTE_ITEMS];
18
+ }
19
+
20
+ export function renderCoordinationNumberedList({
21
+ items = COORDINATION_ETIQUETTE_ITEMS,
22
+ indent = "",
23
+ } = {}) {
24
+ return items.map((item, index) => `${indent}${index + 1}. ${item}`).join("\n");
25
+ }
26
+
27
+ export function renderCoordinationBulletList({
28
+ items = COORDINATION_ETIQUETTE_ITEMS,
29
+ indent = "",
30
+ } = {}) {
31
+ return items.map((item) => `${indent}- ${item}`).join("\n");
32
+ }
33
+
34
+ export function renderCoordinationMarkdownSection({
35
+ headingLevel = 2,
36
+ title = COORDINATION_GUIDANCE_TITLE,
37
+ } = {}) {
38
+ const level = Math.max(1, Math.min(6, Number.parseInt(String(headingLevel || 2), 10) || 2));
39
+ return `${"#".repeat(level)} ${title}
40
+ ${renderCoordinationNumberedList()}`;
41
+ }
42
+
43
+ export function renderCoordinationTicketBlock() {
44
+ return [
45
+ "Coordination rules:",
46
+ renderCoordinationNumberedList(),
47
+ ].join("\n");
48
+ }
@@ -45,6 +45,7 @@ const HELP_MODEL_TIMEOUT_MS = 3_000;
45
45
  const HELP_CONTEXT_EVENT_TAIL = 50;
46
46
  const HELP_CONTEXT_RESULT_LIMIT = 6;
47
47
  const HELP_BLACKBOARD_ENTRY_LIMIT = 40;
48
+ const WATCHER_STARTUP_REPLAY_TAIL = 100;
48
49
  const FILE_CONFLICT_WINDOW_MS = 60_000;
49
50
  const RENEWAL_WINDOW_MS = 60 * 60 * 1000;
50
51
  const RENEWAL_THRESHOLD_EVENTS = 10;
@@ -608,7 +609,7 @@ async function runHelpWatcher(daemonState) {
608
609
  targetPath: daemonState.targetPath,
609
610
  signal,
610
611
  since: daemonState.startedAt,
611
- replayTail: 0,
612
+ replayTail: WATCHER_STARTUP_REPLAY_TAIL,
612
613
  pollMs: Math.max(25, Math.min(250, Math.floor(daemonState.helpRequestTimeoutMs / 4))),
613
614
  })) {
614
615
  if (!daemonState.running) {
@@ -781,7 +782,7 @@ async function runSessionDirectiveWatcher(daemonState) {
781
782
  targetPath: daemonState.targetPath,
782
783
  signal,
783
784
  since: daemonState.startedAt,
784
- replayTail: 0,
785
+ replayTail: WATCHER_STARTUP_REPLAY_TAIL,
785
786
  pollMs: 100,
786
787
  })) {
787
788
  if (!daemonState.running) {
@@ -0,0 +1,236 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ import { pollSessionEvents } from "./sync.js";
4
+ import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
5
+
6
+ const BROADCAST_RECIPIENTS = new Set([
7
+ "*",
8
+ "all",
9
+ "broadcast",
10
+ "everyone",
11
+ "anyone",
12
+ "agents",
13
+ "all-agents",
14
+ ]);
15
+
16
+ function normalizeString(value) {
17
+ return String(value || "").trim();
18
+ }
19
+
20
+ function normalizeComparableId(value) {
21
+ return normalizeString(value)
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9._-]+/g, "-")
24
+ .replace(/^-+|-+$/g, "");
25
+ }
26
+
27
+ function normalizePositiveInteger(value, fallbackValue) {
28
+ if (value === undefined || value === null || String(value).trim() === "") {
29
+ return fallbackValue;
30
+ }
31
+ const normalized = Number(value);
32
+ if (!Number.isFinite(normalized) || normalized <= 0) return fallbackValue;
33
+ return Math.floor(normalized);
34
+ }
35
+
36
+ function isPlainObject(value) {
37
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
38
+ }
39
+
40
+ function addRecipientValue(values, value) {
41
+ if (value === undefined || value === null) return;
42
+ if (Array.isArray(value)) {
43
+ for (const item of value) addRecipientValue(values, item);
44
+ return;
45
+ }
46
+ if (isPlainObject(value)) {
47
+ addRecipientValue(values, value.id || value.agentId || value.name);
48
+ return;
49
+ }
50
+ const raw = normalizeString(value);
51
+ if (!raw) return;
52
+ for (const token of raw.split(/[\s,;]+/g)) {
53
+ const normalized = normalizeString(token);
54
+ if (normalized) values.push(normalized);
55
+ }
56
+ }
57
+
58
+ export function collectSessionEventRecipients(event = {}) {
59
+ const values = [];
60
+ if (!isPlainObject(event)) return values;
61
+ const payload = isPlainObject(event.payload) ? event.payload : {};
62
+ for (const source of [
63
+ event.to,
64
+ event.recipient,
65
+ event.recipients,
66
+ event.targetAgent,
67
+ event.targetAgentId,
68
+ payload.to,
69
+ payload.recipient,
70
+ payload.recipients,
71
+ payload.targetAgent,
72
+ payload.targetAgentId,
73
+ ]) {
74
+ addRecipientValue(values, source);
75
+ }
76
+ return values;
77
+ }
78
+
79
+ export function eventMatchesAgent(event = {}, agentId = "") {
80
+ if (!isPlainObject(event)) return false;
81
+ const normalizedAgentId = normalizeComparableId(agentId);
82
+ if (!normalizedAgentId) return false;
83
+
84
+ const payload = isPlainObject(event.payload) ? event.payload : {};
85
+ if (event.broadcast === true || payload.broadcast === true) return true;
86
+
87
+ const recipients = collectSessionEventRecipients(event);
88
+ if (recipients.length === 0) return true;
89
+
90
+ for (const recipient of recipients) {
91
+ const rawRecipient = normalizeString(recipient).toLowerCase();
92
+ if (BROADCAST_RECIPIENTS.has(rawRecipient)) return true;
93
+ const normalizedRecipient = normalizeComparableId(recipient);
94
+ if (!normalizedRecipient) continue;
95
+ if (BROADCAST_RECIPIENTS.has(normalizedRecipient)) return true;
96
+ if (normalizedRecipient === normalizedAgentId) return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ export function listenCursorSuffix(agentId = "") {
102
+ return `listen-${normalizeComparableId(agentId) || "agent"}`;
103
+ }
104
+
105
+ async function defaultSleep(ms, { signal } = {}) {
106
+ await delay(ms, undefined, { signal });
107
+ }
108
+
109
+ function shouldAbort(error, signal) {
110
+ return Boolean(signal?.aborted || error?.name === "AbortError" || error?.code === "ABORT_ERR");
111
+ }
112
+
113
+ function cursorFromEvents(events = [], fallbackCursor = null) {
114
+ let cursor = normalizeString(fallbackCursor) || null;
115
+ for (const event of events) {
116
+ const candidate = normalizeString(event?.cursor);
117
+ if (candidate) cursor = candidate;
118
+ }
119
+ return cursor;
120
+ }
121
+
122
+ function eventTimestampMs(event = {}) {
123
+ for (const key of ["ts", "timestamp", "createdAt", "at"]) {
124
+ const epoch = Date.parse(normalizeString(event?.[key]));
125
+ if (Number.isFinite(epoch)) return epoch;
126
+ }
127
+ return 0;
128
+ }
129
+
130
+ /**
131
+ * Poll session events in the background and emit only events addressed to
132
+ * the current agent or broadcast to everyone. The loop advances its cursor
133
+ * across non-matching events so direct listeners do not replay unrelated
134
+ * traffic forever.
135
+ */
136
+ export async function listenSessionEvents({
137
+ sessionId,
138
+ targetPath = process.cwd(),
139
+ agentId = "cli-user",
140
+ intervalSeconds = 60,
141
+ limit = 200,
142
+ since = undefined,
143
+ replay = false,
144
+ maxPolls = null,
145
+ signal,
146
+ onEvent = async () => {},
147
+ onError = async () => {},
148
+ _poll = pollSessionEvents,
149
+ _readCursor = readSyncCursor,
150
+ _writeCursor = writeSyncCursor,
151
+ _sleep = defaultSleep,
152
+ _nowMs = Date.now,
153
+ } = {}) {
154
+ const normalizedSessionId = normalizeString(sessionId);
155
+ const normalizedAgentId = normalizeComparableId(agentId) || "cli-user";
156
+ if (!normalizedSessionId) {
157
+ throw new Error("session id is required.");
158
+ }
159
+
160
+ const cursorSuffix = listenCursorSuffix(normalizedAgentId);
161
+ let cursor =
162
+ typeof since === "string" || since === null
163
+ ? normalizeString(since) || null
164
+ : await _readCursor(normalizedSessionId, { targetPath, suffix: cursorSuffix });
165
+ let primed = Boolean(cursor) || Boolean(replay);
166
+ let pollCount = 0;
167
+ let emitted = 0;
168
+ let matched = 0;
169
+ let persistedCursor = false;
170
+ let lastReason = "";
171
+ const maxPollCount = normalizePositiveInteger(maxPolls, 0);
172
+ const pollLimit = normalizePositiveInteger(limit, 200);
173
+ const sleepMs = Math.max(1, normalizePositiveInteger(intervalSeconds, 60)) * 1000;
174
+ const startedAtMs = Number(_nowMs()) || Date.now();
175
+
176
+ while (!signal?.aborted) {
177
+ pollCount += 1;
178
+ const result = await _poll(normalizedSessionId, {
179
+ targetPath,
180
+ since: cursor,
181
+ limit: pollLimit,
182
+ });
183
+
184
+ if (result?.ok) {
185
+ lastReason = "";
186
+ const events = Array.isArray(result.events) ? result.events : [];
187
+ const shouldEmitBatch = primed || Boolean(replay);
188
+ for (const event of events) {
189
+ if (!eventMatchesAgent(event, normalizedAgentId)) continue;
190
+ matched += 1;
191
+ if (!shouldEmitBatch && eventTimestampMs(event) < startedAtMs) continue;
192
+ await onEvent(event);
193
+ emitted += 1;
194
+ }
195
+
196
+ const nextCursor = normalizeString(result.cursor) || cursorFromEvents(events, cursor);
197
+ if (nextCursor && nextCursor !== cursor) {
198
+ const writeResult = await _writeCursor(normalizedSessionId, nextCursor, {
199
+ targetPath,
200
+ suffix: cursorSuffix,
201
+ }).catch(() => null);
202
+ persistedCursor = Boolean(writeResult?.written) || persistedCursor;
203
+ cursor = nextCursor;
204
+ }
205
+ primed = true;
206
+ } else {
207
+ lastReason = normalizeString(result?.reason) || "poll_failed";
208
+ await onError({
209
+ ok: false,
210
+ reason: lastReason,
211
+ cursor: result?.cursor || cursor || null,
212
+ });
213
+ }
214
+
215
+ if (maxPollCount > 0 && pollCount >= maxPollCount) break;
216
+ try {
217
+ await _sleep(sleepMs, { signal });
218
+ } catch (error) {
219
+ if (shouldAbort(error, signal)) break;
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ return {
225
+ ok: true,
226
+ sessionId: normalizedSessionId,
227
+ agentId: normalizedAgentId,
228
+ cursor,
229
+ cursorSuffix,
230
+ pollCount,
231
+ matched,
232
+ emitted,
233
+ persistedCursor,
234
+ reason: lastReason,
235
+ };
236
+ }