sentinelayer-cli 0.6.2 → 0.8.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.
Files changed (280) hide show
  1. package/README.md +1009 -996
  2. package/bin/create-sentinelayer.js +5 -5
  3. package/bin/sentinelayer-cli.js +4 -4
  4. package/bin/sl.js +5 -5
  5. package/package.json +64 -63
  6. package/src/agents/ai-governance/index.js +12 -0
  7. package/src/agents/ai-governance/tools/base.js +171 -0
  8. package/src/agents/ai-governance/tools/eval-regression.js +47 -0
  9. package/src/agents/ai-governance/tools/hitl-audit.js +81 -0
  10. package/src/agents/ai-governance/tools/index.js +52 -0
  11. package/src/agents/ai-governance/tools/prompt-drift.js +42 -0
  12. package/src/agents/ai-governance/tools/provenance-check.js +69 -0
  13. package/src/agents/backend/index.js +12 -0
  14. package/src/agents/backend/tools/base.js +189 -0
  15. package/src/agents/backend/tools/circuit-breaker-check.js +123 -0
  16. package/src/agents/backend/tools/idempotency-audit.js +105 -0
  17. package/src/agents/backend/tools/index.js +87 -0
  18. package/src/agents/backend/tools/retry-audit.js +132 -0
  19. package/src/agents/backend/tools/timeout-audit.js +144 -0
  20. package/src/agents/code-quality/index.js +12 -0
  21. package/src/agents/code-quality/tools/base.js +159 -0
  22. package/src/agents/code-quality/tools/complexity-measure.js +197 -0
  23. package/src/agents/code-quality/tools/coupling-analysis.js +81 -0
  24. package/src/agents/code-quality/tools/cycle-detect.js +49 -0
  25. package/src/agents/code-quality/tools/dep-graph.js +196 -0
  26. package/src/agents/code-quality/tools/index.js +89 -0
  27. package/src/agents/data-layer/index.js +12 -0
  28. package/src/agents/data-layer/tools/base.js +181 -0
  29. package/src/agents/data-layer/tools/index-audit.js +165 -0
  30. package/src/agents/data-layer/tools/index.js +83 -0
  31. package/src/agents/data-layer/tools/migration-scan.js +135 -0
  32. package/src/agents/data-layer/tools/query-explain.js +120 -0
  33. package/src/agents/data-layer/tools/tenancy-scan.js +166 -0
  34. package/src/agents/documentation/index.js +12 -0
  35. package/src/agents/documentation/tools/api-diff.js +91 -0
  36. package/src/agents/documentation/tools/base.js +151 -0
  37. package/src/agents/documentation/tools/dead-link-check.js +58 -0
  38. package/src/agents/documentation/tools/docstring-coverage.js +78 -0
  39. package/src/agents/documentation/tools/index.js +52 -0
  40. package/src/agents/documentation/tools/readme-freshness.js +61 -0
  41. package/src/agents/envelope/fix-cycle.js +45 -0
  42. package/src/agents/envelope/index.js +31 -0
  43. package/src/agents/envelope/loop.js +150 -0
  44. package/src/agents/envelope/pulse.js +18 -0
  45. package/src/agents/envelope/stream.js +40 -0
  46. package/src/agents/infrastructure/index.js +12 -0
  47. package/src/agents/infrastructure/tools/base.js +171 -0
  48. package/src/agents/infrastructure/tools/checkov-run.js +32 -0
  49. package/src/agents/infrastructure/tools/drift-detect.js +59 -0
  50. package/src/agents/infrastructure/tools/iam-least-priv-check.js +78 -0
  51. package/src/agents/infrastructure/tools/index.js +52 -0
  52. package/src/agents/infrastructure/tools/tflint-run.js +31 -0
  53. package/src/agents/jules/config/definition.js +160 -160
  54. package/src/agents/jules/config/system-prompt.js +182 -182
  55. package/src/agents/jules/error-intake.js +51 -51
  56. package/src/agents/jules/fix-cycle.js +17 -17
  57. package/src/agents/jules/loop.js +460 -450
  58. package/src/agents/jules/pulse.js +10 -10
  59. package/src/agents/jules/stream.js +187 -186
  60. package/src/agents/jules/swarm/file-scanner.js +74 -74
  61. package/src/agents/jules/swarm/index.js +11 -11
  62. package/src/agents/jules/swarm/orchestrator.js +362 -362
  63. package/src/agents/jules/swarm/pattern-hunter.js +123 -123
  64. package/src/agents/jules/swarm/sub-agent.js +315 -309
  65. package/src/agents/jules/tools/aidenid-email.js +189 -189
  66. package/src/agents/jules/tools/auth-audit.js +1708 -1691
  67. package/src/agents/jules/tools/dispatch.js +340 -335
  68. package/src/agents/jules/tools/file-edit.js +2 -2
  69. package/src/agents/jules/tools/file-read.js +2 -2
  70. package/src/agents/jules/tools/frontend-analyze.js +570 -570
  71. package/src/agents/jules/tools/glob.js +2 -2
  72. package/src/agents/jules/tools/grep.js +2 -2
  73. package/src/agents/jules/tools/index.js +29 -29
  74. package/src/agents/jules/tools/path-guards.js +2 -2
  75. package/src/agents/jules/tools/runtime-audit.js +507 -507
  76. package/src/agents/jules/tools/shell.js +2 -2
  77. package/src/agents/jules/tools/url-policy.js +100 -100
  78. package/src/agents/mode.js +113 -0
  79. package/src/agents/observability/index.js +12 -0
  80. package/src/agents/observability/tools/alert-audit.js +39 -0
  81. package/src/agents/observability/tools/base.js +181 -0
  82. package/src/agents/observability/tools/dashboard-gap.js +42 -0
  83. package/src/agents/observability/tools/index.js +54 -0
  84. package/src/agents/observability/tools/log-schema-check.js +74 -0
  85. package/src/agents/observability/tools/span-coverage.js +74 -0
  86. package/src/agents/persona-visuals.js +102 -61
  87. package/src/agents/release/index.js +12 -0
  88. package/src/agents/release/tools/base.js +181 -0
  89. package/src/agents/release/tools/changelog-diff.js +86 -0
  90. package/src/agents/release/tools/feature-flag-audit.js +126 -0
  91. package/src/agents/release/tools/index.js +61 -0
  92. package/src/agents/release/tools/rollback-verify.js +129 -0
  93. package/src/agents/release/tools/semver-check.js +109 -0
  94. package/src/agents/reliability/index.js +12 -0
  95. package/src/agents/reliability/tools/backpressure-check.js +129 -0
  96. package/src/agents/reliability/tools/base.js +181 -0
  97. package/src/agents/reliability/tools/chaos-probe.js +109 -0
  98. package/src/agents/reliability/tools/graceful-degradation-check.js +114 -0
  99. package/src/agents/reliability/tools/health-check-audit.js +111 -0
  100. package/src/agents/reliability/tools/index.js +87 -0
  101. package/src/agents/run-persona.js +109 -0
  102. package/src/agents/security/index.js +12 -0
  103. package/src/agents/security/tools/authz-audit.js +134 -0
  104. package/src/agents/security/tools/base.js +190 -0
  105. package/src/agents/security/tools/crypto-review.js +175 -0
  106. package/src/agents/security/tools/index.js +97 -0
  107. package/src/agents/security/tools/sast-scan.js +175 -0
  108. package/src/agents/security/tools/secrets-scan.js +216 -0
  109. package/src/agents/shared-tools/dispatch-core.js +320 -315
  110. package/src/agents/shared-tools/file-edit.js +180 -180
  111. package/src/agents/shared-tools/file-read.js +100 -100
  112. package/src/agents/shared-tools/glob.js +168 -168
  113. package/src/agents/shared-tools/grep.js +228 -228
  114. package/src/agents/shared-tools/index.js +46 -46
  115. package/src/agents/shared-tools/path-guards.js +161 -161
  116. package/src/agents/shared-tools/shell.js +383 -383
  117. package/src/agents/supply-chain/index.js +12 -0
  118. package/src/agents/supply-chain/tools/attestation-check.js +42 -0
  119. package/src/agents/supply-chain/tools/base.js +151 -0
  120. package/src/agents/supply-chain/tools/index.js +52 -0
  121. package/src/agents/supply-chain/tools/lockfile-integrity.js +73 -0
  122. package/src/agents/supply-chain/tools/package-verify.js +56 -0
  123. package/src/agents/supply-chain/tools/sbom-diff.js +34 -0
  124. package/src/agents/testing/index.js +12 -0
  125. package/src/agents/testing/tools/base.js +202 -0
  126. package/src/agents/testing/tools/coverage-gap.js +144 -0
  127. package/src/agents/testing/tools/flake-detect.js +125 -0
  128. package/src/agents/testing/tools/index.js +85 -0
  129. package/src/agents/testing/tools/mutation-test.js +143 -0
  130. package/src/agents/testing/tools/snapshot-diff.js +103 -0
  131. package/src/ai/aidenid.js +1021 -1009
  132. package/src/ai/client.js +553 -553
  133. package/src/ai/domain-target-store.js +268 -268
  134. package/src/ai/identity-store.js +270 -270
  135. package/src/ai/proxy.js +137 -137
  136. package/src/ai/site-store.js +145 -145
  137. package/src/audit/agents/architecture.js +180 -180
  138. package/src/audit/agents/compliance.js +179 -179
  139. package/src/audit/agents/documentation.js +165 -165
  140. package/src/audit/agents/performance.js +145 -145
  141. package/src/audit/agents/security.js +215 -215
  142. package/src/audit/agents/testing.js +172 -172
  143. package/src/audit/orchestrator.js +557 -557
  144. package/src/audit/package.js +204 -204
  145. package/src/audit/registry.js +284 -284
  146. package/src/audit/replay.js +103 -103
  147. package/src/auth/gate.js +428 -371
  148. package/src/auth/http.js +681 -611
  149. package/src/auth/service.js +1106 -1106
  150. package/src/auth/session-store.js +813 -813
  151. package/src/cli.js +257 -252
  152. package/src/commands/ai/identity-lifecycle.js +1338 -1338
  153. package/src/commands/ai/provision-governance.js +1272 -1272
  154. package/src/commands/ai/shared.js +147 -147
  155. package/src/commands/ai.js +11 -11
  156. package/src/commands/apply.js +12 -12
  157. package/src/commands/audit.js +1171 -1166
  158. package/src/commands/auth.js +419 -419
  159. package/src/commands/chat.js +184 -191
  160. package/src/commands/config.js +184 -184
  161. package/src/commands/cost.js +311 -311
  162. package/src/commands/daemon/core.js +850 -850
  163. package/src/commands/daemon/extended.js +1048 -1048
  164. package/src/commands/daemon/shared.js +213 -213
  165. package/src/commands/daemon.js +11 -11
  166. package/src/commands/guide.js +174 -174
  167. package/src/commands/ingest.js +58 -58
  168. package/src/commands/init.js +55 -55
  169. package/src/commands/legacy-args.js +20 -10
  170. package/src/commands/mcp.js +461 -461
  171. package/src/commands/omargate.js +63 -29
  172. package/src/commands/persona.js +65 -20
  173. package/src/commands/plugin.js +260 -260
  174. package/src/commands/policy.js +132 -132
  175. package/src/commands/prompt.js +238 -238
  176. package/src/commands/review.js +704 -704
  177. package/src/commands/scan.js +865 -872
  178. package/src/commands/session.js +1238 -0
  179. package/src/commands/spec.js +771 -716
  180. package/src/commands/swarm.js +651 -651
  181. package/src/commands/telemetry.js +202 -202
  182. package/src/commands/watch.js +511 -511
  183. package/src/config/agent-dictionary.js +182 -182
  184. package/src/config/io.js +56 -56
  185. package/src/config/paths.js +18 -18
  186. package/src/config/schema.js +55 -55
  187. package/src/config/service.js +184 -184
  188. package/src/coord/events-log.js +141 -0
  189. package/src/coord/handshake.js +719 -0
  190. package/src/coord/index.js +35 -0
  191. package/src/coord/paths.js +84 -0
  192. package/src/coord/priority.js +62 -0
  193. package/src/coord/tarjan.js +157 -0
  194. package/src/cost/budget.js +235 -235
  195. package/src/cost/history.js +188 -188
  196. package/src/cost/tokenizer.js +160 -0
  197. package/src/cost/tracker.js +232 -171
  198. package/src/daemon/artifact-lineage.js +896 -534
  199. package/src/daemon/assignment-ledger.js +1083 -770
  200. package/src/daemon/ast-drift.js +496 -0
  201. package/src/daemon/ast-parser-layer.js +258 -258
  202. package/src/daemon/budget-governor.js +633 -633
  203. package/src/daemon/callgraph-overlay.js +646 -646
  204. package/src/daemon/error-worker.js +1209 -626
  205. package/src/daemon/fix-cycle.js +384 -377
  206. package/src/daemon/hybrid-mapper.js +929 -929
  207. package/src/daemon/ingest-refresh.js +79 -11
  208. package/src/daemon/jira-lifecycle.js +767 -632
  209. package/src/daemon/operator-control.js +657 -657
  210. package/src/daemon/pulse.js +327 -327
  211. package/src/daemon/reliability-lane.js +471 -471
  212. package/src/daemon/scope-engine.js +1068 -0
  213. package/src/daemon/watchdog.js +971 -971
  214. package/src/events/schema.js +190 -0
  215. package/src/guide/generator.js +316 -316
  216. package/src/ingest/engine.js +933 -918
  217. package/src/ingest/ownership.js +380 -0
  218. package/src/interactive/index.js +97 -97
  219. package/src/legacy-cli.js +3228 -2994
  220. package/src/mcp/registry.js +695 -695
  221. package/src/memory/blackboard.js +301 -301
  222. package/src/memory/retrieval.js +581 -581
  223. package/src/orchestrator/kai-chen.js +126 -0
  224. package/src/plugin/manifest.js +553 -553
  225. package/src/policy/packs.js +144 -144
  226. package/src/prompt/generator.js +136 -118
  227. package/src/review/ai-review.js +672 -679
  228. package/src/review/compliance-pack.js +389 -0
  229. package/src/review/investor-dd-config.js +54 -0
  230. package/src/review/investor-dd-file-loop.js +303 -0
  231. package/src/review/investor-dd-file-router.js +406 -0
  232. package/src/review/investor-dd-html-report.js +233 -0
  233. package/src/review/investor-dd-notification.js +120 -0
  234. package/src/review/investor-dd-orchestrator.js +405 -0
  235. package/src/review/investor-dd-persona-runner.js +275 -0
  236. package/src/review/live-validator.js +253 -0
  237. package/src/review/local-review.js +1351 -1305
  238. package/src/review/omargate-interactive.js +68 -68
  239. package/src/review/omargate-orchestrator.js +492 -300
  240. package/src/review/persona-prompts.js +484 -296
  241. package/src/review/reconciliation-rules.js +329 -0
  242. package/src/review/replay.js +235 -235
  243. package/src/review/report.js +664 -664
  244. package/src/review/reproducibility-chain.js +136 -0
  245. package/src/review/scan-modes.js +147 -42
  246. package/src/review/spec-binding.js +487 -487
  247. package/src/scaffold/generator.js +67 -67
  248. package/src/scaffold/templates.js +150 -150
  249. package/src/scan/generator.js +418 -418
  250. package/src/scan/gh-secrets.js +107 -107
  251. package/src/session/agent-registry.js +359 -0
  252. package/src/session/analytics.js +479 -0
  253. package/src/session/daemon.js +1396 -0
  254. package/src/session/file-locks.js +666 -0
  255. package/src/session/paths.js +37 -0
  256. package/src/session/recap.js +567 -0
  257. package/src/session/redact.js +82 -0
  258. package/src/session/runtime-bridge.js +762 -0
  259. package/src/session/scoring.js +406 -0
  260. package/src/session/setup-guides.js +304 -0
  261. package/src/session/store.js +704 -0
  262. package/src/session/stream.js +333 -0
  263. package/src/session/sync.js +753 -0
  264. package/src/session/tasks.js +1054 -0
  265. package/src/session/templates.js +188 -0
  266. package/src/spec/generator.js +619 -519
  267. package/src/spec/regenerate.js +237 -237
  268. package/src/spec/templates.js +91 -91
  269. package/src/swarm/dashboard.js +247 -247
  270. package/src/swarm/factory.js +363 -363
  271. package/src/swarm/pentest.js +934 -934
  272. package/src/swarm/registry.js +419 -419
  273. package/src/swarm/report.js +158 -158
  274. package/src/swarm/runtime.js +569 -576
  275. package/src/swarm/scenario-dsl.js +272 -272
  276. package/src/telemetry/ledger.js +302 -302
  277. package/src/telemetry/session-tracker.js +234 -234
  278. package/src/telemetry/sync.js +203 -203
  279. package/src/ui/command-hints.js +13 -13
  280. package/src/ui/markdown.js +220 -220
@@ -0,0 +1,1396 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import fsp from "node:fs/promises";
4
+
5
+ import { invokeViaProxy } from "../ai/proxy.js";
6
+ import { createAgentEvent } from "../events/schema.js";
7
+ import {
8
+ buildDocumentsFromBlackboardEntries,
9
+ buildLocalHybridIndex,
10
+ buildSharedMemoryCorpus,
11
+ queryLocalHybridIndex,
12
+ } from "../memory/retrieval.js";
13
+ import {
14
+ endSession as endTelemetrySession,
15
+ recordLlmUsage,
16
+ startSession as startTelemetrySession,
17
+ } from "../telemetry/session-tracker.js";
18
+ import {
19
+ detectStaleAgents,
20
+ heartbeatAgent,
21
+ listAgents,
22
+ registerAgent,
23
+ unregisterAgent,
24
+ } from "./agent-registry.js";
25
+ import {
26
+ DEFAULT_FILE_LOCK_TTL_SECONDS,
27
+ lockFile,
28
+ unlockFile,
29
+ } from "./file-locks.js";
30
+ import { resolveSessionPaths } from "./paths.js";
31
+ import {
32
+ DEFAULT_RECAP_INACTIVITY_MS,
33
+ DEFAULT_RECAP_INTERVAL_MS,
34
+ emitPeriodicRecap,
35
+ } from "./recap.js";
36
+ import { stopRuntimeRunsForSession } from "./runtime-bridge.js";
37
+ import { pollHumanMessages } from "./sync.js";
38
+ import { getSession, renewSession } from "./store.js";
39
+ import { appendToStream, readStream, tailStream } from "./stream.js";
40
+ import { handleTaskDirective } from "./tasks.js";
41
+
42
+ const DAEMON_TICK_INTERVAL_MS = 30_000;
43
+ const HELP_REQUEST_TIMEOUT_MS = 1_200;
44
+ const HELP_MODEL_TIMEOUT_MS = 3_000;
45
+ const HELP_CONTEXT_EVENT_TAIL = 50;
46
+ const HELP_CONTEXT_RESULT_LIMIT = 6;
47
+ const HELP_BLACKBOARD_ENTRY_LIMIT = 40;
48
+ const FILE_CONFLICT_WINDOW_MS = 60_000;
49
+ const RENEWAL_WINDOW_MS = 60 * 60 * 1000;
50
+ const RENEWAL_THRESHOLD_EVENTS = 10;
51
+ const RENEWAL_LEAD_MS = 60 * 60 * 1000;
52
+ const DEFAULT_STALE_AGENT_SECONDS = 90;
53
+ const DEFAULT_RECAP_INTERVAL_MS_OVERRIDE = DEFAULT_RECAP_INTERVAL_MS;
54
+ const DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE = DEFAULT_RECAP_INACTIVITY_MS;
55
+
56
+ const SENTI_MODEL = "gpt-5.4-mini";
57
+ const SENTI_IDENTITY = Object.freeze({
58
+ id: "senti",
59
+ model: SENTI_MODEL,
60
+ persona: "Senti",
61
+ fullName: "Senti - SentinelLayer Session Daemon",
62
+ role: "daemon",
63
+ color: "magenta",
64
+ description:
65
+ "Session moderator, health monitor, and context provider. Short for SentinelLayer - your AI team lead.",
66
+ });
67
+
68
+ const ACTIVE_SENTI_DAEMONS = new Map();
69
+
70
+ function normalizeString(value) {
71
+ return String(value || "").trim();
72
+ }
73
+
74
+ function normalizeIsoTimestamp(value, fallbackIso = new Date().toISOString()) {
75
+ const normalized = normalizeString(value);
76
+ if (!normalized) {
77
+ return fallbackIso;
78
+ }
79
+ const epoch = Date.parse(normalized);
80
+ if (!Number.isFinite(epoch)) {
81
+ return fallbackIso;
82
+ }
83
+ return new Date(epoch).toISOString();
84
+ }
85
+
86
+ function normalizePositiveInteger(value, fallbackValue) {
87
+ const normalized = Number(value);
88
+ if (!Number.isFinite(normalized) || normalized <= 0) {
89
+ return fallbackValue;
90
+ }
91
+ return Math.max(1, Math.floor(normalized));
92
+ }
93
+
94
+ function buildDaemonKey(sessionId, targetPath) {
95
+ return `${path.resolve(String(targetPath || "."))}::${normalizeString(sessionId)}`;
96
+ }
97
+
98
+ async function emitSentiEvent(
99
+ sessionId,
100
+ event,
101
+ payload = {},
102
+ { targetPath = process.cwd(), nowIso = new Date().toISOString() } = {}
103
+ ) {
104
+ const envelope = createAgentEvent({
105
+ event,
106
+ agentId: SENTI_IDENTITY.id,
107
+ agentModel: SENTI_IDENTITY.model,
108
+ sessionId,
109
+ ts: normalizeIsoTimestamp(nowIso, new Date().toISOString()),
110
+ payload,
111
+ });
112
+ await appendToStream(sessionId, envelope, {
113
+ targetPath,
114
+ });
115
+ return envelope;
116
+ }
117
+
118
+ function formatCodebaseSynopsis(session = {}) {
119
+ const summary = session.codebaseContext?.summary || {};
120
+ const loc = Number(summary.totalLoc || 0);
121
+ const files = Number(summary.filesScanned || 0);
122
+ const frameworks = Array.isArray(session.codebaseContext?.frameworks)
123
+ ? session.codebaseContext.frameworks
124
+ : [];
125
+ const frameworkText = frameworks.length > 0 ? frameworks.slice(0, 3).join(", ") : "unknown stack";
126
+ return `${frameworkText}, ${files} files, ${loc.toLocaleString("en-US")} LOC`;
127
+ }
128
+
129
+ function buildWelcomeMessage(session = {}, activeAgents = []) {
130
+ const roster = activeAgents
131
+ .filter((agent) => normalizeString(agent.agentId) !== SENTI_IDENTITY.id)
132
+ .map((agent) => `${agent.agentId} (${agent.status || "idle"})`)
133
+ .slice(0, 6);
134
+ const rosterText = roster.length > 0 ? roster.join(", ") : "no active agents yet";
135
+ return `Senti here. Session ${session.sessionId} is live. Codebase: ${formatCodebaseSynopsis(
136
+ session
137
+ )}. Active agents: ${rosterText}. Talk to me with @senti or /senti.`;
138
+ }
139
+
140
+ async function upsertSentiAgent(sessionId, { targetPath = process.cwd(), model = SENTI_MODEL } = {}) {
141
+ const activeAgents = await listAgents(sessionId, {
142
+ targetPath,
143
+ includeInactive: true,
144
+ });
145
+ const existing = activeAgents.find((agent) => normalizeString(agent.agentId) === SENTI_IDENTITY.id);
146
+ if (!existing) {
147
+ return registerAgent(sessionId, {
148
+ agentId: SENTI_IDENTITY.id,
149
+ model,
150
+ role: "daemon",
151
+ targetPath,
152
+ });
153
+ }
154
+ return heartbeatAgent(sessionId, SENTI_IDENTITY.id, {
155
+ status: "watching",
156
+ detail: "Monitoring session health and help requests.",
157
+ targetPath,
158
+ });
159
+ }
160
+
161
+ function createSentiState({
162
+ daemonKey,
163
+ sessionId,
164
+ targetPath,
165
+ startedAt,
166
+ model,
167
+ staleAgentSeconds,
168
+ helpRequestTimeoutMs,
169
+ tickIntervalMs,
170
+ recapIntervalMs,
171
+ recapInactivityMs,
172
+ helpResponder,
173
+ llmInvoker,
174
+ telemetrySessionId,
175
+ }) {
176
+ return {
177
+ daemonKey,
178
+ sessionId,
179
+ targetPath,
180
+ startedAt,
181
+ model,
182
+ staleAgentSeconds,
183
+ helpRequestTimeoutMs,
184
+ tickIntervalMs,
185
+ recapIntervalMs,
186
+ recapInactivityMs,
187
+ helpResponder,
188
+ llmInvoker,
189
+ telemetrySessionId,
190
+ running: true,
191
+ tickTimer: null,
192
+ helpAbortController: new AbortController(),
193
+ pendingHelpTimers: new Map(),
194
+ staleAlertedAgents: new Set(),
195
+ fileActivity: new Map(),
196
+ conflictAlertAt: new Map(),
197
+ lastTickAt: null,
198
+ lastTickSummary: null,
199
+ recapEmitter: null,
200
+ humanMessageCursor: null,
201
+ humanMessagePollInFlight: false,
202
+ };
203
+ }
204
+
205
+ async function hasHelpResponseFromPeer(
206
+ sessionId,
207
+ requestEvent,
208
+ {
209
+ targetPath = process.cwd(),
210
+ } = {}
211
+ ) {
212
+ const requester = normalizeString(requestEvent?.agent?.id);
213
+ const requestTs = normalizeIsoTimestamp(requestEvent?.ts, new Date().toISOString());
214
+ const events = await readStream(sessionId, {
215
+ targetPath,
216
+ tail: 0,
217
+ since: requestTs,
218
+ });
219
+ const requestEpoch = Date.parse(requestTs) || 0;
220
+ return events.some((event) => {
221
+ const eventAgentId = normalizeString(event.agent?.id);
222
+ const eventEpoch = Date.parse(normalizeIsoTimestamp(event.ts, requestTs)) || 0;
223
+ if (eventEpoch <= requestEpoch) {
224
+ return false;
225
+ }
226
+ if (!eventAgentId || eventAgentId === SENTI_IDENTITY.id || eventAgentId === requester) {
227
+ return false;
228
+ }
229
+ return true;
230
+ });
231
+ }
232
+
233
+ function normalizeUsageNumber(value) {
234
+ const normalized = Number(value);
235
+ if (!Number.isFinite(normalized) || normalized < 0) {
236
+ return 0;
237
+ }
238
+ return normalized;
239
+ }
240
+
241
+ function buildStreamContextDocuments(events = []) {
242
+ return (events || [])
243
+ .map((event, index) => {
244
+ const payload = event && typeof event.payload === "object" ? event.payload : {};
245
+ const text = [
246
+ normalizeString(event.event),
247
+ normalizeString(event.agent?.id || event.agentId),
248
+ normalizeString(payload.message),
249
+ normalizeString(payload.response),
250
+ normalizeString(payload.alert),
251
+ normalizeString(payload.reason),
252
+ normalizeString(payload.file),
253
+ ]
254
+ .filter(Boolean)
255
+ .join(" ")
256
+ .trim();
257
+ if (!text) {
258
+ return null;
259
+ }
260
+ return {
261
+ documentId: `stream:${index + 1}:${normalizeIsoTimestamp(event.ts, new Date().toISOString())}`,
262
+ sourceType: "session-stream",
263
+ sourcePath: "",
264
+ severity: "P3",
265
+ updatedAt: normalizeIsoTimestamp(event.ts, new Date().toISOString()),
266
+ text,
267
+ metadata: {
268
+ category: "session-stream",
269
+ event: normalizeString(event.event),
270
+ agentId: normalizeString(event.agent?.id || event.agentId),
271
+ },
272
+ };
273
+ })
274
+ .filter(Boolean);
275
+ }
276
+
277
+ async function loadLatestBlackboardEntries(targetPath, { limit = HELP_BLACKBOARD_ENTRY_LIMIT } = {}) {
278
+ const memoryDirectory = path.join(targetPath, ".sentinelayer", "memory");
279
+ let entries = [];
280
+ try {
281
+ entries = await fsp.readdir(memoryDirectory, { withFileTypes: true });
282
+ } catch {
283
+ return [];
284
+ }
285
+
286
+ const files = entries
287
+ .filter((entry) => entry.isFile() && entry.name.startsWith("blackboard-") && entry.name.endsWith(".json"))
288
+ .map((entry) => entry.name)
289
+ .sort((left, right) => right.localeCompare(left));
290
+ for (const fileName of files) {
291
+ const filePath = path.join(memoryDirectory, fileName);
292
+ try {
293
+ const payload = JSON.parse(await fsp.readFile(filePath, "utf-8"));
294
+ if (!Array.isArray(payload.entries)) {
295
+ continue;
296
+ }
297
+ return payload.entries.slice(-Math.max(1, Math.floor(Number(limit) || HELP_BLACKBOARD_ENTRY_LIMIT)));
298
+ } catch {
299
+ // Ignore malformed artifacts and continue searching older files.
300
+ }
301
+ }
302
+ return [];
303
+ }
304
+
305
+ function buildFallbackHelpResponse({ requestMessage = "", synopsis = "context unavailable", contextHints = [] } = {}) {
306
+ const topHints = contextHints.slice(0, 2).join(" | ");
307
+ if (topHints) {
308
+ return `I saw your help_request ("${requestMessage}"). Quick context: ${synopsis}. Top hints: ${topHints}. Share the failing file or stack frame and I can route next steps.`;
309
+ }
310
+ return `I saw your help_request ("${requestMessage}"). Quick context: ${synopsis}. Share the failing file or stack frame and I can route next steps.`;
311
+ }
312
+
313
+ async function runWithTimeout(promise, timeoutMs, timeoutMessage) {
314
+ let timeoutHandle = null;
315
+ try {
316
+ const timeoutPromise = new Promise((_, reject) => {
317
+ timeoutHandle = setTimeout(() => {
318
+ reject(new Error(timeoutMessage));
319
+ }, timeoutMs);
320
+ if (typeof timeoutHandle.unref === "function") {
321
+ timeoutHandle.unref();
322
+ }
323
+ });
324
+ return await Promise.race([promise, timeoutPromise]);
325
+ } finally {
326
+ if (timeoutHandle) {
327
+ clearTimeout(timeoutHandle);
328
+ }
329
+ }
330
+ }
331
+
332
+ async function buildHelpResponseMessage(
333
+ daemonState,
334
+ requestEvent,
335
+ {
336
+ targetPath = process.cwd(),
337
+ } = {}
338
+ ) {
339
+ const requestMessage =
340
+ normalizeString(requestEvent?.payload?.message) ||
341
+ normalizeString(requestEvent?.payload?.request) ||
342
+ "help request received";
343
+
344
+ if (typeof daemonState.helpResponder === "function") {
345
+ const custom = await daemonState.helpResponder({
346
+ daemonState,
347
+ requestEvent,
348
+ targetPath,
349
+ });
350
+ const normalizedCustom = normalizeString(custom);
351
+ if (normalizedCustom) {
352
+ return {
353
+ message: normalizedCustom,
354
+ usage: {
355
+ inputTokens: 0,
356
+ outputTokens: 0,
357
+ costUsd: 0,
358
+ model: daemonState.model,
359
+ provider: "custom-responder",
360
+ latencyMs: 0,
361
+ },
362
+ fallbackPath: false,
363
+ fallbackReason: "",
364
+ contextSignals: {
365
+ documentCount: 0,
366
+ memoryHits: 0,
367
+ blackboardEntries: 0,
368
+ recentEvents: 0,
369
+ },
370
+ };
371
+ }
372
+ }
373
+
374
+ const session = await getSession(daemonState.sessionId, { targetPath });
375
+ const synopsis = session ? formatCodebaseSynopsis(session) : "codebase context unavailable";
376
+ const outputRoot = path.join(targetPath, ".sentinelayer");
377
+
378
+ const [recentEvents, blackboardEntries, sharedMemory] = await Promise.all([
379
+ readStream(daemonState.sessionId, {
380
+ targetPath,
381
+ tail: HELP_CONTEXT_EVENT_TAIL,
382
+ }).catch(() => []),
383
+ loadLatestBlackboardEntries(targetPath, {
384
+ limit: HELP_BLACKBOARD_ENTRY_LIMIT,
385
+ }),
386
+ buildSharedMemoryCorpus({
387
+ outputRoot,
388
+ targetPath,
389
+ ingest: session?.codebaseContext || {},
390
+ maxAuditRuns: 2,
391
+ }).catch(() => ({
392
+ documents: [],
393
+ sourceCounts: {},
394
+ })),
395
+ ]);
396
+
397
+ const documents = [
398
+ ...(sharedMemory.documents || []),
399
+ ...buildStreamContextDocuments(recentEvents),
400
+ ...buildDocumentsFromBlackboardEntries(blackboardEntries),
401
+ ];
402
+ const localIndex = buildLocalHybridIndex(documents);
403
+ const memoryQuery = queryLocalHybridIndex(localIndex, {
404
+ query: requestMessage,
405
+ limit: HELP_CONTEXT_RESULT_LIMIT,
406
+ minScore: 0.05,
407
+ });
408
+ const memoryHits = memoryQuery.results || [];
409
+ const contextHints = memoryHits
410
+ .slice(0, HELP_CONTEXT_RESULT_LIMIT)
411
+ .map((result) => {
412
+ const source = normalizeString(result.sourceType) || "memory";
413
+ const snippet = normalizeString(result.snippet || "").replace(/\s+/g, " ").trim();
414
+ if (!snippet) {
415
+ return "";
416
+ }
417
+ return `${source}: ${snippet}`;
418
+ })
419
+ .filter(Boolean);
420
+
421
+ const systemPrompt = [
422
+ "You are Senti, SentinelLayer's session daemon.",
423
+ "Answer the requesting agent with concise, actionable engineering guidance.",
424
+ "Prioritize concrete next steps and reference available context snippets.",
425
+ "Never invent repository files or runtime behavior.",
426
+ ].join(" ");
427
+ const userPrompt = [
428
+ `Agent request: ${requestMessage}`,
429
+ `Codebase synopsis: ${synopsis}`,
430
+ "Context snippets:",
431
+ contextHints.length > 0 ? contextHints.map((line, index) => `${index + 1}. ${line}`).join("\n") : "none",
432
+ "Respond in 2-4 short sentences.",
433
+ ].join("\n");
434
+
435
+ const startedAt = Date.now();
436
+ let llmText = "";
437
+ let fallbackPath = false;
438
+ let fallbackReason = "";
439
+ let usage = {
440
+ inputTokens: 0,
441
+ outputTokens: 0,
442
+ costUsd: 0,
443
+ model: daemonState.model,
444
+ provider: "local-fallback",
445
+ latencyMs: 0,
446
+ };
447
+
448
+ const llmTimeoutMs = Math.max(
449
+ 80,
450
+ Math.min(
451
+ HELP_MODEL_TIMEOUT_MS,
452
+ normalizePositiveInteger(daemonState.helpRequestTimeoutMs, HELP_REQUEST_TIMEOUT_MS) * 2
453
+ )
454
+ );
455
+
456
+ try {
457
+ const llmResult = await runWithTimeout(
458
+ Promise.resolve(
459
+ daemonState.llmInvoker({
460
+ model: daemonState.model,
461
+ systemPrompt,
462
+ prompt: userPrompt,
463
+ maxTokens: 320,
464
+ temperature: 0.1,
465
+ })
466
+ ),
467
+ llmTimeoutMs,
468
+ "Senti model response timeout."
469
+ );
470
+ llmText = normalizeString(llmResult?.text);
471
+ usage = {
472
+ inputTokens: normalizeUsageNumber(llmResult?.usage?.inputTokens),
473
+ outputTokens: normalizeUsageNumber(llmResult?.usage?.outputTokens),
474
+ costUsd: normalizeUsageNumber(llmResult?.usage?.costUsd),
475
+ model: normalizeString(llmResult?.usage?.model) || daemonState.model,
476
+ provider: normalizeString(llmResult?.usage?.provider) || "sentinelayer",
477
+ latencyMs: normalizeUsageNumber(llmResult?.usage?.latencyMs),
478
+ };
479
+ if (!llmText) {
480
+ fallbackPath = true;
481
+ fallbackReason = "Senti model returned an empty response.";
482
+ }
483
+ } catch (error) {
484
+ fallbackPath = true;
485
+ fallbackReason = normalizeString(error?.message || error) || "Senti model invocation failed.";
486
+ }
487
+
488
+ if (!usage.latencyMs) {
489
+ usage.latencyMs = Math.max(1, Date.now() - startedAt);
490
+ }
491
+ recordLlmUsage({
492
+ sessionId: daemonState.telemetrySessionId,
493
+ inputTokens: usage.inputTokens,
494
+ outputTokens: usage.outputTokens,
495
+ costUsd: usage.costUsd,
496
+ });
497
+
498
+ const message = llmText ||
499
+ buildFallbackHelpResponse({
500
+ requestMessage,
501
+ synopsis,
502
+ contextHints,
503
+ });
504
+ return {
505
+ message,
506
+ usage,
507
+ fallbackPath,
508
+ fallbackReason,
509
+ contextSignals: {
510
+ documentCount: documents.length,
511
+ memoryHits: memoryHits.length,
512
+ blackboardEntries: blackboardEntries.length,
513
+ recentEvents: recentEvents.length,
514
+ },
515
+ };
516
+ }
517
+
518
+ async function maybeRespondToHelpRequest(
519
+ daemonState,
520
+ requestEvent,
521
+ {
522
+ targetPath = process.cwd(),
523
+ } = {}
524
+ ) {
525
+ const requestId =
526
+ normalizeString(requestEvent.requestId) ||
527
+ normalizeString(requestEvent.payload?.requestId) ||
528
+ `${normalizeIsoTimestamp(requestEvent.ts)}:${normalizeString(requestEvent.agent?.id)}`;
529
+ if (!requestId) {
530
+ return null;
531
+ }
532
+ const hasPeerResponse = await hasHelpResponseFromPeer(daemonState.sessionId, requestEvent, {
533
+ targetPath,
534
+ });
535
+ if (hasPeerResponse) {
536
+ return null;
537
+ }
538
+ const response = await buildHelpResponseMessage(daemonState, requestEvent, {
539
+ targetPath,
540
+ });
541
+ const nowIso = new Date().toISOString();
542
+ const responseEvent = await emitSentiEvent(
543
+ daemonState.sessionId,
544
+ "help_response",
545
+ {
546
+ requestId,
547
+ targetAgentId: normalizeString(requestEvent.agent?.id) || null,
548
+ response: response.message,
549
+ sourceEvent: "help_request",
550
+ contextSignals: response.contextSignals,
551
+ },
552
+ {
553
+ targetPath,
554
+ nowIso,
555
+ }
556
+ );
557
+ await emitSentiEvent(
558
+ daemonState.sessionId,
559
+ "model_span",
560
+ {
561
+ sourceEvent: "help_request",
562
+ requestId,
563
+ model: response.usage.model || daemonState.model,
564
+ provider: response.usage.provider || "sentinelayer",
565
+ inputTokens: response.usage.inputTokens,
566
+ outputTokens: response.usage.outputTokens,
567
+ costUsd: response.usage.costUsd,
568
+ latencyMs: response.usage.latencyMs,
569
+ fallbackPath: Boolean(response.fallbackPath),
570
+ fallbackReason: response.fallbackReason || null,
571
+ contextSignals: response.contextSignals,
572
+ },
573
+ {
574
+ targetPath,
575
+ nowIso,
576
+ }
577
+ );
578
+ return responseEvent;
579
+ }
580
+
581
+ function queueHelpResponse(daemonState, requestEvent) {
582
+ if (!daemonState.running) {
583
+ return;
584
+ }
585
+ const requestId =
586
+ normalizeString(requestEvent.requestId) ||
587
+ normalizeString(requestEvent.payload?.requestId) ||
588
+ `${normalizeIsoTimestamp(requestEvent.ts)}:${normalizeString(requestEvent.agent?.id)}`;
589
+ if (!requestId || daemonState.pendingHelpTimers.has(requestId)) {
590
+ return;
591
+ }
592
+ const timer = setTimeout(() => {
593
+ daemonState.pendingHelpTimers.delete(requestId);
594
+ void maybeRespondToHelpRequest(daemonState, requestEvent, {
595
+ targetPath: daemonState.targetPath,
596
+ }).catch(() => {});
597
+ }, daemonState.helpRequestTimeoutMs);
598
+ if (typeof timer.unref === "function") {
599
+ timer.unref();
600
+ }
601
+ daemonState.pendingHelpTimers.set(requestId, timer);
602
+ }
603
+
604
+ async function runHelpWatcher(daemonState) {
605
+ const signal = daemonState.helpAbortController.signal;
606
+ try {
607
+ for await (const event of tailStream(daemonState.sessionId, {
608
+ targetPath: daemonState.targetPath,
609
+ signal,
610
+ since: daemonState.startedAt,
611
+ replayTail: 0,
612
+ pollMs: Math.max(25, Math.min(250, Math.floor(daemonState.helpRequestTimeoutMs / 4))),
613
+ })) {
614
+ if (!daemonState.running) {
615
+ return;
616
+ }
617
+ if (normalizeString(event.event) !== "help_request") {
618
+ continue;
619
+ }
620
+ if (normalizeString(event.agent?.id) === SENTI_IDENTITY.id) {
621
+ continue;
622
+ }
623
+ queueHelpResponse(daemonState, event);
624
+ }
625
+ } catch (error) {
626
+ if (error && typeof error === "object" && error.name === "AbortError") {
627
+ return;
628
+ }
629
+ throw error;
630
+ }
631
+ }
632
+
633
+ function splitFileAndIntent(raw = "") {
634
+ const normalized = normalizeString(raw);
635
+ if (!normalized) {
636
+ return {
637
+ filePath: "",
638
+ intent: "",
639
+ };
640
+ }
641
+ const separatorMatch = /\s(?:—|–|-)\s/.exec(normalized);
642
+ if (!separatorMatch) {
643
+ return {
644
+ filePath: normalizeString(normalized),
645
+ intent: "",
646
+ };
647
+ }
648
+ const separatorIndex = Number(separatorMatch.index || 0);
649
+ return {
650
+ filePath: normalizeString(normalized.slice(0, separatorIndex)),
651
+ intent: normalizeString(normalized.slice(separatorIndex + separatorMatch[0].length)),
652
+ };
653
+ }
654
+
655
+ function parseSessionDirective(event = {}) {
656
+ if (normalizeString(event.event) !== "session_message") {
657
+ return null;
658
+ }
659
+ const message = normalizeString(event.payload?.message);
660
+ if (!message) {
661
+ return null;
662
+ }
663
+ const directive = /^(lock|unlock)\s*:\s*(.+)$/i.exec(message);
664
+ if (!directive) {
665
+ return null;
666
+ }
667
+ const action = normalizeString(directive[1]).toLowerCase();
668
+ const body = normalizeString(directive[2]);
669
+ const parsed = splitFileAndIntent(body);
670
+ if (!parsed.filePath) {
671
+ return null;
672
+ }
673
+ return {
674
+ action,
675
+ filePath: parsed.filePath,
676
+ intent: parsed.intent,
677
+ };
678
+ }
679
+
680
+ async function maybeHandleSessionDirective(daemonState, event) {
681
+ const agentId = normalizeString(event.agent?.id);
682
+ if (!agentId || agentId === SENTI_IDENTITY.id) {
683
+ return null;
684
+ }
685
+ const nowIso = normalizeIsoTimestamp(event.ts, new Date().toISOString());
686
+ const fileDirective = parseSessionDirective(event);
687
+ if (fileDirective) {
688
+ if (fileDirective.action === "lock") {
689
+ const result = await lockFile(
690
+ daemonState.sessionId,
691
+ agentId,
692
+ fileDirective.filePath,
693
+ {
694
+ intent: fileDirective.intent,
695
+ ttlSeconds: DEFAULT_FILE_LOCK_TTL_SECONDS,
696
+ targetPath: daemonState.targetPath,
697
+ nowIso,
698
+ }
699
+ );
700
+ if (!result.locked) {
701
+ await emitSentiEvent(
702
+ daemonState.sessionId,
703
+ "daemon_alert",
704
+ {
705
+ alert: "file_lock_denied",
706
+ file: result.file || fileDirective.filePath,
707
+ requestedBy: agentId,
708
+ heldBy: result.heldBy || null,
709
+ since: result.since || null,
710
+ suggestion: `${fileDirective.filePath} is locked by ${result.heldBy || "another agent"} (${result.since || "recently"}). Coordinate before editing.`,
711
+ },
712
+ {
713
+ targetPath: daemonState.targetPath,
714
+ nowIso,
715
+ }
716
+ );
717
+ }
718
+ return result;
719
+ }
720
+ if (fileDirective.action === "unlock") {
721
+ const result = await unlockFile(
722
+ daemonState.sessionId,
723
+ agentId,
724
+ fileDirective.filePath,
725
+ {
726
+ reason: "session_message_unlock",
727
+ targetPath: daemonState.targetPath,
728
+ nowIso,
729
+ }
730
+ );
731
+ if (!result.unlocked && result.reason === "held_by_other_agent") {
732
+ await emitSentiEvent(
733
+ daemonState.sessionId,
734
+ "daemon_alert",
735
+ {
736
+ alert: "file_unlock_denied",
737
+ file: result.file || fileDirective.filePath,
738
+ requestedBy: agentId,
739
+ heldBy: result.heldBy || null,
740
+ since: result.since || null,
741
+ suggestion: `${fileDirective.filePath} is locked by ${result.heldBy || "another agent"}. Only the lock holder can release it.`,
742
+ },
743
+ {
744
+ targetPath: daemonState.targetPath,
745
+ nowIso,
746
+ }
747
+ );
748
+ }
749
+ return result;
750
+ }
751
+ }
752
+
753
+ try {
754
+ return await handleTaskDirective(daemonState.sessionId, event, {
755
+ targetPath: daemonState.targetPath,
756
+ nowIso,
757
+ });
758
+ } catch (error) {
759
+ await emitSentiEvent(
760
+ daemonState.sessionId,
761
+ "daemon_alert",
762
+ {
763
+ alert: "task_directive_error",
764
+ requestedBy: agentId,
765
+ reason: normalizeString(error?.message) || "Task directive failed.",
766
+ message: normalizeString(event.payload?.message) || null,
767
+ },
768
+ {
769
+ targetPath: daemonState.targetPath,
770
+ nowIso,
771
+ }
772
+ );
773
+ return null;
774
+ }
775
+ }
776
+
777
+ async function runSessionDirectiveWatcher(daemonState) {
778
+ const signal = daemonState.helpAbortController.signal;
779
+ try {
780
+ for await (const event of tailStream(daemonState.sessionId, {
781
+ targetPath: daemonState.targetPath,
782
+ signal,
783
+ since: daemonState.startedAt,
784
+ replayTail: 0,
785
+ pollMs: 100,
786
+ })) {
787
+ if (!daemonState.running) {
788
+ return;
789
+ }
790
+ if (normalizeString(event.event) !== "session_message") {
791
+ continue;
792
+ }
793
+ await maybeHandleSessionDirective(daemonState, event);
794
+ }
795
+ } catch (error) {
796
+ if (error && typeof error === "object" && error.name === "AbortError") {
797
+ return;
798
+ }
799
+ throw error;
800
+ }
801
+ }
802
+
803
+ function buildConflictSignature(agentA, agentB, filePath) {
804
+ const pair = [normalizeString(agentA), normalizeString(agentB)].filter(Boolean).sort().join("|");
805
+ return `${pair}::${normalizeString(filePath).replace(/\\/g, "/")}`;
806
+ }
807
+
808
+ function parseEpoch(value, fallbackIso = new Date().toISOString()) {
809
+ return Date.parse(normalizeIsoTimestamp(value, fallbackIso)) || 0;
810
+ }
811
+
812
+ function createHealthSummaryBase(nowIso, session, agents) {
813
+ return {
814
+ sessionId: session.sessionId,
815
+ generatedAt: normalizeIsoTimestamp(nowIso, new Date().toISOString()),
816
+ expiresAt: session.expiresAt,
817
+ activeAgentCount: agents.filter((agent) => normalizeString(agent.agentId) !== SENTI_IDENTITY.id).length,
818
+ staleAgents: [],
819
+ conflictAlerts: [],
820
+ renewed: null,
821
+ humanMessages: {
822
+ relayed: 0,
823
+ dropped: 0,
824
+ cursor: null,
825
+ reason: "",
826
+ },
827
+ };
828
+ }
829
+
830
+ async function emitStaleAndRecoveryAlerts(
831
+ daemonState,
832
+ summary,
833
+ staleAgents = [],
834
+ nowIso = new Date().toISOString()
835
+ ) {
836
+ const staleIds = new Set(staleAgents.map((agent) => normalizeString(agent.agentId)));
837
+
838
+ for (const staleAgent of staleAgents) {
839
+ const staleId = normalizeString(staleAgent.agentId);
840
+ if (!staleId || daemonState.staleAlertedAgents.has(staleId)) {
841
+ continue;
842
+ }
843
+ daemonState.staleAlertedAgents.add(staleId);
844
+ const alert = await emitSentiEvent(
845
+ daemonState.sessionId,
846
+ "daemon_alert",
847
+ {
848
+ alert: "stuck_detected",
849
+ targetAgentId: staleId,
850
+ idleSeconds: staleAgent.idleSeconds,
851
+ thresholdSeconds: daemonState.staleAgentSeconds,
852
+ },
853
+ {
854
+ targetPath: daemonState.targetPath,
855
+ nowIso,
856
+ }
857
+ );
858
+ summary.staleAgents.push({
859
+ agentId: staleId,
860
+ idleSeconds: staleAgent.idleSeconds,
861
+ event: alert,
862
+ });
863
+ }
864
+
865
+ for (const previousStaleId of [...daemonState.staleAlertedAgents]) {
866
+ if (staleIds.has(previousStaleId)) {
867
+ continue;
868
+ }
869
+ daemonState.staleAlertedAgents.delete(previousStaleId);
870
+ await emitSentiEvent(
871
+ daemonState.sessionId,
872
+ "daemon_alert",
873
+ {
874
+ alert: "stuck_recovered",
875
+ targetAgentId: previousStaleId,
876
+ },
877
+ {
878
+ targetPath: daemonState.targetPath,
879
+ nowIso,
880
+ }
881
+ );
882
+ }
883
+ }
884
+
885
+ async function emitConflictAlerts(
886
+ daemonState,
887
+ summary,
888
+ agents = [],
889
+ nowIso = new Date().toISOString()
890
+ ) {
891
+ const nowEpoch = parseEpoch(nowIso, nowIso);
892
+ const staleCutoff = nowEpoch - FILE_CONFLICT_WINDOW_MS * 2;
893
+
894
+ for (const [filePath, record] of daemonState.fileActivity.entries()) {
895
+ if (!record || Number(record.timestamp || 0) < staleCutoff) {
896
+ daemonState.fileActivity.delete(filePath);
897
+ }
898
+ }
899
+ for (const [signature, epoch] of daemonState.conflictAlertAt.entries()) {
900
+ if (Number(epoch || 0) < staleCutoff) {
901
+ daemonState.conflictAlertAt.delete(signature);
902
+ }
903
+ }
904
+
905
+ for (const agent of agents) {
906
+ const agentId = normalizeString(agent.agentId);
907
+ if (!agentId || agentId === SENTI_IDENTITY.id) {
908
+ continue;
909
+ }
910
+ const filePath = normalizeString(agent.file).replace(/\\/g, "/");
911
+ if (!filePath) {
912
+ continue;
913
+ }
914
+ const activityEpoch = parseEpoch(agent.lastActivityAt, nowIso);
915
+ const previous = daemonState.fileActivity.get(filePath) || null;
916
+ if (previous && previous.agentId !== agentId) {
917
+ const deltaMs = Math.abs(activityEpoch - Number(previous.timestamp || 0));
918
+ if (deltaMs <= FILE_CONFLICT_WINDOW_MS) {
919
+ const signature = buildConflictSignature(previous.agentId, agentId, filePath);
920
+ const lastAlertEpoch = Number(daemonState.conflictAlertAt.get(signature) || 0);
921
+ if (nowEpoch - lastAlertEpoch >= FILE_CONFLICT_WINDOW_MS) {
922
+ const event = await emitSentiEvent(
923
+ daemonState.sessionId,
924
+ "daemon_alert",
925
+ {
926
+ alert: "file_conflict",
927
+ file: filePath,
928
+ agentA: previous.agentId,
929
+ agentB: agentId,
930
+ previousSeenAt: normalizeIsoTimestamp(previous.activityAt, nowIso),
931
+ currentSeenAt: normalizeIsoTimestamp(agent.lastActivityAt, nowIso),
932
+ suggestion: `${previous.agentId} and ${agentId} are touching ${filePath}. Coordinate before editing.`,
933
+ },
934
+ {
935
+ targetPath: daemonState.targetPath,
936
+ nowIso,
937
+ }
938
+ );
939
+ daemonState.conflictAlertAt.set(signature, nowEpoch);
940
+ summary.conflictAlerts.push({
941
+ file: filePath,
942
+ agentA: previous.agentId,
943
+ agentB: agentId,
944
+ event,
945
+ });
946
+ }
947
+ }
948
+ }
949
+
950
+ daemonState.fileActivity.set(filePath, {
951
+ agentId,
952
+ activityAt: normalizeIsoTimestamp(agent.lastActivityAt, nowIso),
953
+ timestamp: activityEpoch,
954
+ });
955
+ }
956
+ }
957
+
958
+ async function maybeRenewActiveSession(
959
+ daemonState,
960
+ summary,
961
+ session,
962
+ nowIso = new Date().toISOString()
963
+ ) {
964
+ const nowEpoch = parseEpoch(nowIso, nowIso);
965
+ const expiryEpoch = parseEpoch(session.expiresAt, nowIso);
966
+ if (!Number.isFinite(expiryEpoch) || expiryEpoch <= nowEpoch) {
967
+ return;
968
+ }
969
+ if (expiryEpoch - nowEpoch > RENEWAL_LEAD_MS) {
970
+ return;
971
+ }
972
+ const recentSinceIso = new Date(nowEpoch - RENEWAL_WINDOW_MS).toISOString();
973
+ const recentEvents = await readStream(daemonState.sessionId, {
974
+ targetPath: daemonState.targetPath,
975
+ tail: 0,
976
+ since: recentSinceIso,
977
+ });
978
+ if (recentEvents.length <= RENEWAL_THRESHOLD_EVENTS) {
979
+ return;
980
+ }
981
+ const renewed = await renewSession(daemonState.sessionId, {
982
+ targetPath: daemonState.targetPath,
983
+ });
984
+ summary.renewed = {
985
+ renewalCount: renewed.renewalCount,
986
+ expiresAt: renewed.expiresAt,
987
+ };
988
+ }
989
+
990
+ async function pollAndRelayHumanMessages(
991
+ daemonState,
992
+ summary,
993
+ nowIso = new Date().toISOString()
994
+ ) {
995
+ if (daemonState.humanMessagePollInFlight) {
996
+ summary.humanMessages.reason = "poll_in_flight";
997
+ return;
998
+ }
999
+
1000
+ daemonState.humanMessagePollInFlight = true;
1001
+ try {
1002
+ const polled = await pollHumanMessages(daemonState.sessionId, {
1003
+ targetPath: daemonState.targetPath,
1004
+ since: daemonState.humanMessageCursor,
1005
+ });
1006
+ if (!polled.ok) {
1007
+ summary.humanMessages.reason = normalizeString(polled.reason) || "poll_failed";
1008
+ summary.humanMessages.cursor = daemonState.humanMessageCursor;
1009
+ return;
1010
+ }
1011
+
1012
+ const relayedEvents = [];
1013
+ for (const event of polled.events || []) {
1014
+ const persisted = await appendToStream(daemonState.sessionId, event, {
1015
+ targetPath: daemonState.targetPath,
1016
+ });
1017
+ relayedEvents.push(persisted);
1018
+ }
1019
+ daemonState.humanMessageCursor = normalizeString(polled.cursor) || daemonState.humanMessageCursor;
1020
+
1021
+ summary.humanMessages.relayed = relayedEvents.length;
1022
+ summary.humanMessages.dropped = Array.isArray(polled.dropped) ? polled.dropped.length : 0;
1023
+ summary.humanMessages.cursor = daemonState.humanMessageCursor;
1024
+ summary.humanMessages.reason = "";
1025
+
1026
+ if (relayedEvents.length > 0) {
1027
+ await emitSentiEvent(
1028
+ daemonState.sessionId,
1029
+ "daemon_alert",
1030
+ {
1031
+ alert: "human_directive_received",
1032
+ relayedCount: relayedEvents.length,
1033
+ droppedCount: summary.humanMessages.dropped,
1034
+ },
1035
+ {
1036
+ targetPath: daemonState.targetPath,
1037
+ nowIso,
1038
+ }
1039
+ );
1040
+ }
1041
+ } catch (error) {
1042
+ summary.humanMessages.reason =
1043
+ normalizeString(error?.message) || "poll_relay_failed";
1044
+ summary.humanMessages.cursor = daemonState.humanMessageCursor;
1045
+ } finally {
1046
+ daemonState.humanMessagePollInFlight = false;
1047
+ }
1048
+ }
1049
+
1050
+ export async function runSentiHealthTick(
1051
+ sessionId,
1052
+ {
1053
+ targetPath = process.cwd(),
1054
+ nowIso = new Date().toISOString(),
1055
+ staleAgentSeconds = DEFAULT_STALE_AGENT_SECONDS,
1056
+ daemonState = null,
1057
+ } = {}
1058
+ ) {
1059
+ const normalizedSessionId = normalizeString(sessionId);
1060
+ if (!normalizedSessionId) {
1061
+ throw new Error("sessionId is required.");
1062
+ }
1063
+ const normalizedTargetPath = path.resolve(String(targetPath || "."));
1064
+ const session = await getSession(normalizedSessionId, {
1065
+ targetPath: normalizedTargetPath,
1066
+ });
1067
+ if (!session) {
1068
+ throw new Error(`Session '${normalizedSessionId}' was not found.`);
1069
+ }
1070
+
1071
+ const resolvedDaemonState =
1072
+ daemonState ||
1073
+ createSentiState({
1074
+ daemonKey: buildDaemonKey(normalizedSessionId, normalizedTargetPath),
1075
+ sessionId: normalizedSessionId,
1076
+ targetPath: normalizedTargetPath,
1077
+ startedAt: normalizeIsoTimestamp(nowIso, nowIso),
1078
+ model: SENTI_MODEL,
1079
+ staleAgentSeconds,
1080
+ helpRequestTimeoutMs: HELP_REQUEST_TIMEOUT_MS,
1081
+ tickIntervalMs: DAEMON_TICK_INTERVAL_MS,
1082
+ recapIntervalMs: DEFAULT_RECAP_INTERVAL_MS_OVERRIDE,
1083
+ recapInactivityMs: DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE,
1084
+ helpResponder: null,
1085
+ llmInvoker: invokeViaProxy,
1086
+ telemetrySessionId: null,
1087
+ });
1088
+ const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
1089
+ const activeAgents = await listAgents(normalizedSessionId, {
1090
+ targetPath: normalizedTargetPath,
1091
+ includeInactive: false,
1092
+ });
1093
+ const filteredAgents = activeAgents.filter((agent) => normalizeString(agent.agentId) !== SENTI_IDENTITY.id);
1094
+ const staleAgents = detectStaleAgents(filteredAgents, {
1095
+ idleThresholdSeconds: normalizePositiveInteger(
1096
+ staleAgentSeconds,
1097
+ normalizePositiveInteger(resolvedDaemonState.staleAgentSeconds, DEFAULT_STALE_AGENT_SECONDS)
1098
+ ),
1099
+ nowIso: normalizedNow,
1100
+ });
1101
+
1102
+ const summary = createHealthSummaryBase(normalizedNow, session, activeAgents);
1103
+ await emitStaleAndRecoveryAlerts(resolvedDaemonState, summary, staleAgents, normalizedNow);
1104
+ await emitConflictAlerts(resolvedDaemonState, summary, filteredAgents, normalizedNow);
1105
+ await maybeRenewActiveSession(resolvedDaemonState, summary, session, normalizedNow);
1106
+ await pollAndRelayHumanMessages(resolvedDaemonState, summary, normalizedNow);
1107
+ return summary;
1108
+ }
1109
+
1110
+ export async function startSenti(
1111
+ sessionId,
1112
+ {
1113
+ model = SENTI_MODEL,
1114
+ targetPath = process.cwd(),
1115
+ autoStart = true,
1116
+ tickIntervalMs = DAEMON_TICK_INTERVAL_MS,
1117
+ staleAgentSeconds = DEFAULT_STALE_AGENT_SECONDS,
1118
+ helpRequestTimeoutMs = HELP_REQUEST_TIMEOUT_MS,
1119
+ recapIntervalMs = DEFAULT_RECAP_INTERVAL_MS_OVERRIDE,
1120
+ recapInactivityMs = DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE,
1121
+ helpResponder = null,
1122
+ llmInvoker = invokeViaProxy,
1123
+ } = {}
1124
+ ) {
1125
+ const normalizedSessionId = normalizeString(sessionId);
1126
+ if (!normalizedSessionId) {
1127
+ throw new Error("sessionId is required.");
1128
+ }
1129
+ const normalizedTargetPath = path.resolve(String(targetPath || "."));
1130
+ const daemonKey = buildDaemonKey(normalizedSessionId, normalizedTargetPath);
1131
+ const existing = ACTIVE_SENTI_DAEMONS.get(daemonKey);
1132
+ if (existing && existing.running) {
1133
+ return existing.handle;
1134
+ }
1135
+
1136
+ const session = await getSession(normalizedSessionId, {
1137
+ targetPath: normalizedTargetPath,
1138
+ });
1139
+ if (!session) {
1140
+ throw new Error(`Session '${normalizedSessionId}' was not found.`);
1141
+ }
1142
+
1143
+ const normalizedTickIntervalMs = normalizePositiveInteger(tickIntervalMs, DAEMON_TICK_INTERVAL_MS);
1144
+ const normalizedHelpTimeoutMs = normalizePositiveInteger(
1145
+ helpRequestTimeoutMs,
1146
+ HELP_REQUEST_TIMEOUT_MS
1147
+ );
1148
+ const normalizedStaleSeconds = normalizePositiveInteger(
1149
+ staleAgentSeconds,
1150
+ DEFAULT_STALE_AGENT_SECONDS
1151
+ );
1152
+ const normalizedRecapIntervalMs = normalizePositiveInteger(
1153
+ recapIntervalMs,
1154
+ DEFAULT_RECAP_INTERVAL_MS_OVERRIDE
1155
+ );
1156
+ const normalizedRecapInactivityMs = normalizePositiveInteger(
1157
+ recapInactivityMs,
1158
+ DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE
1159
+ );
1160
+ const nowIso = new Date().toISOString();
1161
+ const telemetrySession = startTelemetrySession(`session daemon ${normalizedSessionId}`);
1162
+ const daemonState = createSentiState({
1163
+ daemonKey,
1164
+ sessionId: normalizedSessionId,
1165
+ targetPath: normalizedTargetPath,
1166
+ startedAt: nowIso,
1167
+ model: normalizeString(model) || SENTI_MODEL,
1168
+ staleAgentSeconds: normalizedStaleSeconds,
1169
+ helpRequestTimeoutMs: normalizedHelpTimeoutMs,
1170
+ tickIntervalMs: normalizedTickIntervalMs,
1171
+ recapIntervalMs: normalizedRecapIntervalMs,
1172
+ recapInactivityMs: normalizedRecapInactivityMs,
1173
+ helpResponder,
1174
+ llmInvoker: typeof llmInvoker === "function" ? llmInvoker : invokeViaProxy,
1175
+ telemetrySessionId: telemetrySession?.id || null,
1176
+ });
1177
+
1178
+ await upsertSentiAgent(normalizedSessionId, {
1179
+ targetPath: normalizedTargetPath,
1180
+ model: daemonState.model,
1181
+ });
1182
+ const activeAgents = await listAgents(normalizedSessionId, {
1183
+ targetPath: normalizedTargetPath,
1184
+ includeInactive: false,
1185
+ });
1186
+ await emitSentiEvent(
1187
+ normalizedSessionId,
1188
+ "daemon_alert",
1189
+ {
1190
+ alert: "senti_online",
1191
+ model: daemonState.model,
1192
+ message: buildWelcomeMessage(session, activeAgents),
1193
+ codebaseSynopsis: formatCodebaseSynopsis(session),
1194
+ activeAgents: activeAgents
1195
+ .filter((agent) => normalizeString(agent.agentId) !== SENTI_IDENTITY.id)
1196
+ .map((agent) => ({
1197
+ agentId: agent.agentId,
1198
+ status: agent.status,
1199
+ role: agent.role,
1200
+ })),
1201
+ },
1202
+ {
1203
+ targetPath: normalizedTargetPath,
1204
+ nowIso,
1205
+ }
1206
+ );
1207
+
1208
+ const runTick = async (tickNowIso = new Date().toISOString()) => {
1209
+ if (!daemonState.running) {
1210
+ return daemonState.lastTickSummary;
1211
+ }
1212
+ const summary = await runSentiHealthTick(normalizedSessionId, {
1213
+ targetPath: normalizedTargetPath,
1214
+ nowIso: tickNowIso,
1215
+ staleAgentSeconds: daemonState.staleAgentSeconds,
1216
+ daemonState,
1217
+ });
1218
+ daemonState.lastTickAt = normalizeIsoTimestamp(tickNowIso, new Date().toISOString());
1219
+ daemonState.lastTickSummary = summary;
1220
+ return summary;
1221
+ };
1222
+
1223
+ const stop = async (reason = "manual_stop") => {
1224
+ if (!daemonState.running) {
1225
+ return {
1226
+ stopped: false,
1227
+ daemonKey,
1228
+ reason: normalizeString(reason) || "manual_stop",
1229
+ };
1230
+ }
1231
+
1232
+ daemonState.running = false;
1233
+ if (daemonState.tickTimer) {
1234
+ clearInterval(daemonState.tickTimer);
1235
+ daemonState.tickTimer = null;
1236
+ }
1237
+ daemonState.helpAbortController.abort();
1238
+ for (const timer of daemonState.pendingHelpTimers.values()) {
1239
+ clearTimeout(timer);
1240
+ }
1241
+ daemonState.pendingHelpTimers.clear();
1242
+ if (daemonState.recapEmitter && daemonState.recapEmitter.isRunning()) {
1243
+ daemonState.recapEmitter.stop("daemon_stop");
1244
+ daemonState.recapEmitter = null;
1245
+ }
1246
+
1247
+ let runtimeStopSummary = null;
1248
+ try {
1249
+ runtimeStopSummary = await stopRuntimeRunsForSession(normalizedSessionId, {
1250
+ targetPath: normalizedTargetPath,
1251
+ reason: "manual_stop",
1252
+ });
1253
+ } catch {
1254
+ runtimeStopSummary = {
1255
+ sessionId: normalizedSessionId,
1256
+ targetPath: normalizedTargetPath,
1257
+ stoppedCount: 0,
1258
+ runs: [],
1259
+ };
1260
+ }
1261
+
1262
+ try {
1263
+ await unregisterAgent(normalizedSessionId, SENTI_IDENTITY.id, {
1264
+ reason: "killed",
1265
+ targetPath: normalizedTargetPath,
1266
+ });
1267
+ } catch {
1268
+ // Non-blocking: if snapshot is already gone, continue to emit explicit kill event.
1269
+ }
1270
+
1271
+ const killedEvent = await emitSentiEvent(
1272
+ normalizedSessionId,
1273
+ "agent_killed",
1274
+ {
1275
+ target: SENTI_IDENTITY.id,
1276
+ reason: normalizeString(reason) || "manual_stop",
1277
+ runtimeStops: runtimeStopSummary?.stoppedCount || 0,
1278
+ },
1279
+ {
1280
+ targetPath: normalizedTargetPath,
1281
+ nowIso: new Date().toISOString(),
1282
+ }
1283
+ );
1284
+ ACTIVE_SENTI_DAEMONS.delete(daemonKey);
1285
+ if (daemonState.telemetrySessionId) {
1286
+ endTelemetrySession({ sessionId: daemonState.telemetrySessionId });
1287
+ }
1288
+ return {
1289
+ stopped: true,
1290
+ daemonKey,
1291
+ sessionId: normalizedSessionId,
1292
+ targetPath: normalizedTargetPath,
1293
+ reason: normalizeString(reason) || "manual_stop",
1294
+ runtimeStopSummary,
1295
+ event: killedEvent,
1296
+ };
1297
+ };
1298
+
1299
+ const handle = {
1300
+ daemonKey,
1301
+ sessionId: normalizedSessionId,
1302
+ targetPath: normalizedTargetPath,
1303
+ startedAt: nowIso,
1304
+ model: daemonState.model,
1305
+ runTick,
1306
+ stop,
1307
+ isRunning: () => daemonState.running,
1308
+ getState: () => ({
1309
+ daemonKey,
1310
+ sessionId: normalizedSessionId,
1311
+ targetPath: normalizedTargetPath,
1312
+ startedAt: nowIso,
1313
+ running: daemonState.running,
1314
+ lastTickAt: daemonState.lastTickAt,
1315
+ staleAlertedAgents: [...daemonState.staleAlertedAgents],
1316
+ pendingHelpRequests: daemonState.pendingHelpTimers.size,
1317
+ recapRunning: Boolean(daemonState.recapEmitter?.isRunning?.()),
1318
+ humanMessageCursor: daemonState.humanMessageCursor,
1319
+ }),
1320
+ };
1321
+
1322
+ daemonState.handle = handle;
1323
+ ACTIVE_SENTI_DAEMONS.set(daemonKey, daemonState);
1324
+
1325
+ void runHelpWatcher(daemonState).catch(() => {});
1326
+ void runSessionDirectiveWatcher(daemonState).catch(() => {});
1327
+ daemonState.recapEmitter = emitPeriodicRecap(normalizedSessionId, {
1328
+ targetPath: normalizedTargetPath,
1329
+ intervalMs: daemonState.recapIntervalMs,
1330
+ inactivityMs: daemonState.recapInactivityMs,
1331
+ });
1332
+
1333
+ if (autoStart) {
1334
+ await runTick(nowIso);
1335
+ daemonState.tickTimer = setInterval(() => {
1336
+ void runTick(new Date().toISOString()).catch(() => {});
1337
+ }, normalizedTickIntervalMs);
1338
+ if (typeof daemonState.tickTimer.unref === "function") {
1339
+ daemonState.tickTimer.unref();
1340
+ }
1341
+ }
1342
+
1343
+ return handle;
1344
+ }
1345
+
1346
+ export async function stopSenti(
1347
+ sessionId,
1348
+ {
1349
+ targetPath = process.cwd(),
1350
+ reason = "manual_stop",
1351
+ } = {}
1352
+ ) {
1353
+ const normalizedSessionId = normalizeString(sessionId);
1354
+ if (!normalizedSessionId) {
1355
+ throw new Error("sessionId is required.");
1356
+ }
1357
+ const normalizedTargetPath = path.resolve(String(targetPath || "."));
1358
+ const daemonKey = buildDaemonKey(normalizedSessionId, normalizedTargetPath);
1359
+ const daemonState = ACTIVE_SENTI_DAEMONS.get(daemonKey);
1360
+ if (!daemonState || !daemonState.running) {
1361
+ return {
1362
+ stopped: false,
1363
+ daemonKey,
1364
+ sessionId: normalizedSessionId,
1365
+ targetPath: normalizedTargetPath,
1366
+ reason: normalizeString(reason) || "manual_stop",
1367
+ };
1368
+ }
1369
+ return daemonState.handle.stop(reason);
1370
+ }
1371
+
1372
+ export function getSentiDaemon(
1373
+ sessionId,
1374
+ {
1375
+ targetPath = process.cwd(),
1376
+ } = {}
1377
+ ) {
1378
+ const daemonKey = buildDaemonKey(sessionId, targetPath);
1379
+ const daemonState = ACTIVE_SENTI_DAEMONS.get(daemonKey);
1380
+ return daemonState ? daemonState.handle : null;
1381
+ }
1382
+
1383
+ export {
1384
+ ACTIVE_SENTI_DAEMONS,
1385
+ DAEMON_TICK_INTERVAL_MS,
1386
+ DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE,
1387
+ DEFAULT_RECAP_INTERVAL_MS_OVERRIDE,
1388
+ DEFAULT_STALE_AGENT_SECONDS,
1389
+ FILE_CONFLICT_WINDOW_MS,
1390
+ HELP_REQUEST_TIMEOUT_MS,
1391
+ RENEWAL_LEAD_MS,
1392
+ RENEWAL_THRESHOLD_EVENTS,
1393
+ RENEWAL_WINDOW_MS,
1394
+ SENTI_IDENTITY,
1395
+ SENTI_MODEL,
1396
+ };