stageflow 0.35.0 → 0.39.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 (320) hide show
  1. package/README.md +18 -8
  2. package/dist/agent/claudeAdapter.js +12 -0
  3. package/dist/agent/piAdapter.d.ts +9 -1
  4. package/dist/agent/piAdapter.js +30 -2
  5. package/dist/agent/piIsolatedMcp.js +14 -2
  6. package/dist/agent/piIsolatedMcpProbe.d.ts +5 -1
  7. package/dist/agent/piIsolatedMcpProbe.js +16 -30
  8. package/dist/agent/port.d.ts +7 -0
  9. package/dist/agents/agentId.d.ts +8 -0
  10. package/dist/agents/agentId.js +27 -0
  11. package/dist/agents/approvalBroker.d.ts +16 -0
  12. package/dist/agents/approvalBroker.js +45 -0
  13. package/dist/agents/authorChat.d.ts +19 -0
  14. package/dist/agents/authorChat.js +63 -0
  15. package/dist/agents/chatTurn.d.ts +23 -0
  16. package/dist/agents/chatTurn.js +77 -0
  17. package/dist/agents/communications/agentmail.d.ts +19 -0
  18. package/dist/agents/communications/agentmail.js +46 -0
  19. package/dist/agents/communications/agentmailListener.d.ts +59 -0
  20. package/dist/agents/communications/agentmailListener.js +183 -0
  21. package/dist/agents/communications/allowlist.d.ts +10 -0
  22. package/dist/agents/communications/allowlist.js +36 -0
  23. package/dist/agents/communications/channelConnections.d.ts +3 -0
  24. package/dist/agents/communications/channelConnections.js +51 -0
  25. package/dist/agents/communications/delivery.d.ts +53 -0
  26. package/dist/agents/communications/delivery.js +142 -0
  27. package/dist/agents/communications/email.d.ts +26 -0
  28. package/dist/agents/communications/email.js +20 -0
  29. package/dist/agents/communications/identity.d.ts +13 -0
  30. package/dist/agents/communications/identity.js +18 -0
  31. package/dist/agents/communications/imessageListener.d.ts +41 -0
  32. package/dist/agents/communications/imessageListener.js +92 -0
  33. package/dist/agents/communications/inbound.d.ts +63 -0
  34. package/dist/agents/communications/inbound.js +143 -0
  35. package/dist/agents/communications/listener.d.ts +36 -0
  36. package/dist/agents/communications/listener.js +38 -0
  37. package/dist/agents/communications/photon.d.ts +19 -0
  38. package/dist/agents/communications/photon.js +38 -0
  39. package/dist/agents/communications/port.d.ts +27 -0
  40. package/dist/agents/communications/port.js +6 -0
  41. package/dist/agents/communications/slack.d.ts +28 -0
  42. package/dist/agents/communications/slack.js +65 -0
  43. package/dist/agents/communications/supervisor.d.ts +36 -0
  44. package/dist/agents/communications/supervisor.js +96 -0
  45. package/dist/agents/communications/telegram.d.ts +19 -0
  46. package/dist/agents/communications/telegram.js +52 -0
  47. package/dist/agents/definitionSummary.d.ts +13 -0
  48. package/dist/agents/definitionSummary.js +91 -0
  49. package/dist/agents/delegate.d.ts +3 -0
  50. package/dist/agents/delegate.js +8 -0
  51. package/dist/agents/errors.d.ts +10 -0
  52. package/dist/agents/errors.js +32 -0
  53. package/dist/agents/guardrails.d.ts +24 -0
  54. package/dist/agents/guardrails.js +39 -0
  55. package/dist/agents/historyFit.d.ts +10 -0
  56. package/dist/agents/historyFit.js +26 -0
  57. package/dist/agents/hostContext.d.ts +54 -0
  58. package/dist/agents/hostContext.js +156 -0
  59. package/dist/agents/ledgers.d.ts +29 -0
  60. package/dist/agents/ledgers.js +31 -0
  61. package/dist/agents/lineDiff.d.ts +12 -0
  62. package/dist/agents/lineDiff.js +46 -0
  63. package/dist/agents/ndjson.d.ts +9 -0
  64. package/dist/agents/ndjson.js +47 -0
  65. package/dist/agents/otlpExport.d.ts +9 -0
  66. package/dist/agents/otlpExport.js +58 -0
  67. package/dist/agents/otlpSink.d.ts +7 -0
  68. package/dist/agents/otlpSink.js +23 -0
  69. package/dist/agents/policy.d.ts +31 -0
  70. package/dist/agents/policy.js +84 -0
  71. package/dist/agents/projectRoot.d.ts +5 -0
  72. package/dist/agents/projectRoot.js +15 -0
  73. package/dist/agents/runtime/piRuntime.d.ts +52 -0
  74. package/dist/agents/runtime/piRuntime.js +474 -0
  75. package/dist/agents/runtime/types.d.ts +77 -0
  76. package/dist/agents/runtime/types.js +26 -0
  77. package/dist/agents/sandbox.d.ts +17 -0
  78. package/dist/agents/sandbox.js +10 -0
  79. package/dist/agents/scheduleSource.d.ts +32 -0
  80. package/dist/agents/scheduleSource.js +100 -0
  81. package/dist/agents/service.d.ts +298 -0
  82. package/dist/agents/service.js +1105 -0
  83. package/dist/agents/sessions.d.ts +38 -0
  84. package/dist/agents/sessions.js +321 -0
  85. package/dist/agents/store.d.ts +277 -0
  86. package/dist/agents/store.js +643 -0
  87. package/dist/agents/trace.d.ts +27 -0
  88. package/dist/agents/trace.js +29 -0
  89. package/dist/agents/turnLifecycle.d.ts +72 -0
  90. package/dist/agents/turnLifecycle.js +157 -0
  91. package/dist/agents/webChat/publicChat.d.ts +36 -0
  92. package/dist/agents/webChat/publicChat.js +290 -0
  93. package/dist/agents/webChat/rateLimit.d.ts +15 -0
  94. package/dist/agents/webChat/rateLimit.js +33 -0
  95. package/dist/agents/webChat/slug.d.ts +2 -0
  96. package/dist/agents/webChat/slug.js +9 -0
  97. package/dist/channels/config.d.ts +11 -0
  98. package/dist/channels/config.js +95 -0
  99. package/dist/channels/configWriter.d.ts +40 -0
  100. package/dist/channels/configWriter.js +148 -0
  101. package/dist/channels/connectionEdits.d.ts +34 -0
  102. package/dist/channels/connectionEdits.js +53 -0
  103. package/dist/channels/errorInfo.d.ts +18 -0
  104. package/dist/channels/errorInfo.js +71 -0
  105. package/dist/channels/errors.d.ts +12 -0
  106. package/dist/channels/errors.js +13 -0
  107. package/dist/channels/health.d.ts +41 -0
  108. package/dist/channels/health.js +99 -0
  109. package/dist/channels/host.d.ts +55 -0
  110. package/dist/channels/host.js +185 -0
  111. package/dist/channels/http.d.ts +31 -0
  112. package/dist/channels/http.js +79 -0
  113. package/dist/channels/index.d.ts +10 -0
  114. package/dist/channels/index.js +9 -0
  115. package/dist/channels/ledger.d.ts +37 -0
  116. package/dist/channels/ledger.js +95 -0
  117. package/dist/channels/providers/agentmail.d.ts +26 -0
  118. package/dist/channels/providers/agentmail.js +94 -0
  119. package/dist/channels/providers/kit.d.ts +40 -0
  120. package/dist/channels/providers/kit.js +78 -0
  121. package/dist/channels/providers/photonImessage.d.ts +45 -0
  122. package/dist/channels/providers/photonImessage.js +188 -0
  123. package/dist/channels/providers/photonWhatsapp.d.ts +49 -0
  124. package/dist/channels/providers/photonWhatsapp.js +146 -0
  125. package/dist/channels/providers/slack.d.ts +25 -0
  126. package/dist/channels/providers/slack.js +102 -0
  127. package/dist/channels/providers/telegram.d.ts +21 -0
  128. package/dist/channels/providers/telegram.js +101 -0
  129. package/dist/channels/providers.d.ts +5 -0
  130. package/dist/channels/providers.js +22 -0
  131. package/dist/channels/registry.d.ts +54 -0
  132. package/dist/channels/registry.js +186 -0
  133. package/dist/channels/secretStore.d.ts +21 -0
  134. package/dist/channels/secretStore.js +75 -0
  135. package/dist/channels/secrets.d.ts +15 -0
  136. package/dist/channels/secrets.js +50 -0
  137. package/dist/channels/service.d.ts +54 -0
  138. package/dist/channels/service.js +307 -0
  139. package/dist/channels/stageChannels.d.ts +45 -0
  140. package/dist/channels/stageChannels.js +110 -0
  141. package/dist/channels/types.d.ts +169 -0
  142. package/dist/channels/types.js +1 -0
  143. package/dist/cli/agentsCommand.d.ts +14 -0
  144. package/dist/cli/agentsCommand.js +291 -0
  145. package/dist/cli/doctorCommand.js +3 -1
  146. package/dist/cli.js +9 -0
  147. package/dist/config/browseCatalog.js +4 -11
  148. package/dist/config/draftPackage.d.ts +2 -0
  149. package/dist/config/draftPackage.js +8 -0
  150. package/dist/config/hostConfig.js +8 -0
  151. package/dist/config/loadAgent.d.ts +266 -0
  152. package/dist/config/loadAgent.js +374 -0
  153. package/dist/config/loadPipeline.js +31 -4
  154. package/dist/config/loadStage.js +15 -0
  155. package/dist/config/mcpHostCatalog.d.ts +50 -0
  156. package/dist/config/mcpHostCatalog.js +405 -0
  157. package/dist/config/mcpOAuth.d.ts +37 -0
  158. package/dist/config/mcpOAuth.js +263 -0
  159. package/dist/config/mcpRedaction.d.ts +42 -0
  160. package/dist/config/mcpRedaction.js +314 -0
  161. package/dist/config/mcpToolCache.d.ts +20 -0
  162. package/dist/config/mcpToolCache.js +43 -0
  163. package/dist/config/mergePipelineIncludes.d.ts +5 -0
  164. package/dist/config/mergePipelineIncludes.js +52 -0
  165. package/dist/config/pipelineStageKeys.js +1 -0
  166. package/dist/config/resolveStageMcpServers.d.ts +35 -3
  167. package/dist/config/resolveStageMcpServers.js +124 -9
  168. package/dist/config/validateCatalog.d.ts +4 -1
  169. package/dist/config/validateCatalog.js +45 -1
  170. package/dist/errors/codes.d.ts +1 -1
  171. package/dist/errors/codes.js +1 -0
  172. package/dist/mcp/agentTools.d.ts +8 -0
  173. package/dist/mcp/agentTools.js +132 -0
  174. package/dist/mcp/catalogTools.js +7 -9
  175. package/dist/mcp/deps.d.ts +6 -0
  176. package/dist/mcp/pipelineAuthorSupport.d.ts +19 -0
  177. package/dist/mcp/pipelineAuthorSupport.js +10 -0
  178. package/dist/mcp/pipelineAuthorTools.d.ts +8 -0
  179. package/dist/mcp/pipelineAuthorTools.js +109 -0
  180. package/dist/mcp/server.js +5 -0
  181. package/dist/mcp/tools.js +4 -0
  182. package/dist/mcp/triggerTools.js +32 -0
  183. package/dist/memory/access.d.ts +36 -0
  184. package/dist/memory/access.js +116 -0
  185. package/dist/memory/agentIssues.d.ts +3 -0
  186. package/dist/memory/agentIssues.js +23 -0
  187. package/dist/memory/bashDeny.d.ts +6 -0
  188. package/dist/memory/bashDeny.js +35 -0
  189. package/dist/memory/host.d.ts +51 -0
  190. package/dist/memory/host.js +394 -0
  191. package/dist/memory/ids.d.ts +15 -0
  192. package/dist/memory/ids.js +31 -0
  193. package/dist/memory/worker.d.ts +1 -0
  194. package/dist/memory/worker.js +84 -0
  195. package/dist/models/catalog.d.ts +113 -0
  196. package/dist/models/catalog.js +238 -0
  197. package/dist/models/response.d.ts +29 -0
  198. package/dist/models/response.js +25 -0
  199. package/dist/operatorAgent/agentAuthorContext.d.ts +17 -0
  200. package/dist/operatorAgent/agentAuthorContext.js +32 -0
  201. package/dist/operatorAgent/fakeHost.js +2 -0
  202. package/dist/operatorAgent/host.js +6 -3
  203. package/dist/operatorAgent/index.d.ts +4 -0
  204. package/dist/operatorAgent/index.js +4 -0
  205. package/dist/operatorAgent/piModel.js +2 -0
  206. package/dist/operatorAgent/profiles/agentAuthor.d.ts +11 -0
  207. package/dist/operatorAgent/profiles/agentAuthor.js +192 -0
  208. package/dist/operatorAgent/profiles/agentAuthorPlaybook.d.ts +1 -0
  209. package/dist/operatorAgent/profiles/agentAuthorPlaybook.js +29 -0
  210. package/dist/operatorAgent/profiles/workshopAuthor.js +2 -2
  211. package/dist/operatorAgent/profiles/workshopAuthorTools.js +3 -3
  212. package/dist/operatorAgent/proposals.d.ts +1 -0
  213. package/dist/operatorAgent/proposals.js +23 -2
  214. package/dist/package-meta.d.ts +1 -1
  215. package/dist/package-meta.js +1 -1
  216. package/dist/preflight/channelChecks.d.ts +17 -0
  217. package/dist/preflight/channelChecks.js +106 -0
  218. package/dist/preflight/pipelinePreflight.d.ts +9 -2
  219. package/dist/preflight/pipelinePreflight.js +10 -1
  220. package/dist/runstore/createStore.d.ts +2 -0
  221. package/dist/runstore/createStore.js +8 -0
  222. package/dist/runstore/overviewSummary.d.ts +56 -0
  223. package/dist/runstore/overviewSummary.js +134 -0
  224. package/dist/runstore/port.d.ts +3 -0
  225. package/dist/runstore/sqlite/SqliteRunStore.d.ts +2 -0
  226. package/dist/runstore/sqlite/SqliteRunStore.js +18 -0
  227. package/dist/runstore/sqlite/migrations/013-agents.d.ts +12 -0
  228. package/dist/runstore/sqlite/migrations/013-agents.js +156 -0
  229. package/dist/runstore/sqlite/migrations/014-agent-deliveries.d.ts +11 -0
  230. package/dist/runstore/sqlite/migrations/014-agent-deliveries.js +32 -0
  231. package/dist/runstore/sqlite/migrations/index.js +4 -0
  232. package/dist/runtime/pipelineRunner.js +3 -1
  233. package/dist/runtime/repositoryMaterialize.d.ts +1 -1
  234. package/dist/runtime/runManager.js +4 -1
  235. package/dist/runtime/stageAttemptBootstrap.js +31 -1
  236. package/dist/runtime/stageEnvironment.d.ts +2 -0
  237. package/dist/runtime/stageEnvironment.js +6 -1
  238. package/dist/runtime/stageProcessLauncher.js +54 -1
  239. package/dist/runtime/stageSecrets.d.ts +6 -1
  240. package/dist/runtime/stageSecrets.js +34 -1
  241. package/dist/server/agentAuthorRoutes.d.ts +5 -0
  242. package/dist/server/agentAuthorRoutes.js +64 -0
  243. package/dist/server/agentRoutes.d.ts +5 -0
  244. package/dist/server/agentRoutes.js +323 -0
  245. package/dist/server/bootstrap.d.ts +11 -0
  246. package/dist/server/bootstrap.js +40 -10
  247. package/dist/server/channelRoutes.d.ts +9 -0
  248. package/dist/server/channelRoutes.js +88 -0
  249. package/dist/server/controlToken.js +8 -0
  250. package/dist/server/http.d.ts +8 -1
  251. package/dist/server/http.js +183 -20
  252. package/dist/server/mcpHost.js +1 -0
  253. package/dist/server/memoryRoutes.d.ts +5 -0
  254. package/dist/server/memoryRoutes.js +146 -0
  255. package/dist/server/projectMcpRoutes.d.ts +1 -0
  256. package/dist/server/projectMcpRoutes.js +241 -3
  257. package/dist/toolkit/guard.d.ts +27 -0
  258. package/dist/toolkit/guard.js +98 -0
  259. package/dist/toolkit/index.d.ts +48 -0
  260. package/dist/toolkit/index.js +82 -0
  261. package/dist/toolkit/meta.d.ts +36 -0
  262. package/dist/toolkit/meta.js +117 -0
  263. package/dist/toolkit/profiles.d.ts +13 -0
  264. package/dist/toolkit/profiles.js +25 -0
  265. package/dist/toolkit/record.d.ts +15 -0
  266. package/dist/toolkit/record.js +17 -0
  267. package/dist/tools/memoryTools.d.ts +95 -0
  268. package/dist/tools/memoryTools.js +179 -0
  269. package/dist/tools/sendMessage.d.ts +21 -0
  270. package/dist/tools/sendMessage.js +25 -0
  271. package/dist/types/pipeline.d.ts +7 -1
  272. package/dist/types/stage.d.ts +15 -0
  273. package/dist/ui/assets/AssistantRuntimeProvider-CsiPFhCT.js +9 -0
  274. package/dist/ui/assets/MarkdownText-D7tYaIFO.js +29 -0
  275. package/dist/ui/assets/ReadonlyStore-BfXqHykm.js +6 -0
  276. package/dist/ui/assets/Tooltip-DrpoRRNi.js +1 -0
  277. package/dist/ui/assets/chat-BbLukq9W.js +5 -0
  278. package/dist/ui/assets/chat-zIZU_bP1.css +2 -0
  279. package/dist/ui/assets/jsx-runtime-iVoFAvQ_.js +1 -0
  280. package/dist/ui/assets/{index-CIPbbBUi.css → main-BFo79krC.css} +1 -1
  281. package/dist/ui/assets/main-CijYMeM0.js +259 -0
  282. package/dist/ui/chat.html +25 -0
  283. package/dist/ui/index.html +6 -2
  284. package/dist/workshop/modelSettings.d.ts +2 -2
  285. package/dist/workshop/modelSettings.js +5 -11
  286. package/examples/README.md +1 -0
  287. package/examples/channels-smoke/README.md +23 -0
  288. package/examples/channels-smoke/channels-smoke.pipeline.yaml +11 -0
  289. package/examples/channels-smoke/channels-smoke.task.yaml +3 -0
  290. package/examples/channels-smoke/send-email.yaml +32 -0
  291. package/examples/channels-smoke/send-text.yaml +31 -0
  292. package/examples/github-release/README.md +1 -1
  293. package/examples/memory/README.md +99 -0
  294. package/examples/memory/desk-outage.task.yaml +15 -0
  295. package/examples/memory/desk-refund.task.yaml +15 -0
  296. package/examples/memory/desk-revisit-outage.task.yaml +11 -0
  297. package/examples/memory/desk-revisit.pipeline.yaml +123 -0
  298. package/examples/memory/desk.pipeline.yaml +192 -0
  299. package/examples/memory/file-card.pipeline.yaml +39 -0
  300. package/examples/memory/file-ledger.task.yaml +22 -0
  301. package/examples/memory/file-release.task.yaml +24 -0
  302. package/examples/memory/file-rich.pipeline.yaml +40 -0
  303. package/examples/memory/file-ticket.task.yaml +7 -0
  304. package/examples/memory/file-timeline.task.yaml +28 -0
  305. package/examples/memory/find-card.pipeline.yaml +46 -0
  306. package/examples/memory/find-ticket.task.yaml +7 -0
  307. package/examples/memory/missing.pipeline.yaml +16 -0
  308. package/examples/memory/recall.pipeline.yaml +24 -0
  309. package/examples/memory/recall.task.yaml +5 -0
  310. package/examples/memory/remember-ada.task.yaml +6 -0
  311. package/examples/memory/remember-grace.task.yaml +6 -0
  312. package/examples/memory/remember.pipeline.yaml +58 -0
  313. package/examples/memory/stageflow.yaml +12 -0
  314. package/examples/memory/update-card.pipeline.yaml +43 -0
  315. package/examples/memory/update-ledger.task.yaml +23 -0
  316. package/examples/memory/update-rich.pipeline.yaml +44 -0
  317. package/examples/memory/update-ticket.task.yaml +7 -0
  318. package/examples/stage-mcp/README.md +1 -1
  319. package/package.json +9 -3
  320. package/dist/ui/assets/index-BoFPkKFg.js +0 -256
@@ -0,0 +1,1105 @@
1
+ import { grantedStageflowTools } from "../toolkit/meta.js";
2
+ import { senderAllowed } from "./communications/allowlist.js";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { access, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { grantedBuiltinTools, parseAgentDefinition, parseAgentYaml, serializeAgentDefinition, } from "../config/loadAgent.js";
7
+ import { agentThreadKey, inboundDedupeKey } from "./communications/port.js";
8
+ import { generateAgentId } from "./agentId.js";
9
+ import { newWebChatSlug } from "./webChat/slug.js";
10
+ import { ApprovalBroker } from "./approvalBroker.js";
11
+ import { deliveryLedger, scheduleLedger } from "./ledgers.js";
12
+ import { TurnLifecycle } from "./turnLifecycle.js";
13
+ import { AgentError } from "./errors.js";
14
+ import { agentMemoryIssues } from "../memory/agentIssues.js";
15
+ import { diffLines } from "./lineDiff.js";
16
+ import { exportTurnsToOtlp } from "./otlpExport.js";
17
+ import { canApprove, effectiveAskFirst, isToolGranted, requiresAskFirst } from "./policy.js";
18
+ import { agentProjectKey, } from "./store.js";
19
+ const NO_ENVIRONMENT = {
20
+ mcpServerNames: async () => undefined,
21
+ skillNames: async () => undefined,
22
+ connectionIds: async () => undefined,
23
+ };
24
+ /** Tools that return session transcripts, which hold visitors' messages. */
25
+ const CONVERSATION_READING_TOOLS = new Set(["get_agent_session", "list_agent_sessions"]);
26
+ const RECENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
27
+ function sha256(text) {
28
+ return createHash("sha256").update(text).digest("hex");
29
+ }
30
+ function id(prefix) {
31
+ return `${prefix}_${randomBytes(8).toString("hex")}`;
32
+ }
33
+ function sameDefinition(left, right) {
34
+ return serializeAgentDefinition(left) === serializeAgentDefinition(right);
35
+ }
36
+ export class AgentService {
37
+ store;
38
+ environment;
39
+ now;
40
+ tails = new Map();
41
+ /** Wakes a turn paused at an ask-first tool when its approval is decided. */
42
+ approvalBroker = new ApprovalBroker();
43
+ /** Owns every legal change of a turn, its session and its approval. */
44
+ lifecycle;
45
+ /** Reply delivery bookkeeping, for ReplyDelivery. */
46
+ deliveries;
47
+ /** Next-run bookkeeping, for the scheduler. */
48
+ schedules;
49
+ constructor(options) {
50
+ this.store = options.store;
51
+ this.treeTrustMs = options.treeTrustMs ?? 2_000;
52
+ this.environment = options.environment ?? NO_ENVIRONMENT;
53
+ this.now = options.now ?? (() => new Date());
54
+ this.lifecycle = new TurnLifecycle(this.store, this.approvalBroker, () => this.iso());
55
+ this.deliveries = deliveryLedger(this.store, () => this.iso());
56
+ this.schedules = scheduleLedger(this.store);
57
+ }
58
+ /** The service clock, so waits and expiries agree with stored timestamps. */
59
+ clockMs() {
60
+ return this.now().getTime();
61
+ }
62
+ iso() {
63
+ return this.now().toISOString();
64
+ }
65
+ /** Serialize file-and-database work per project so a save and a sync never interleave. */
66
+ exclusive(projectRoot, work) {
67
+ const key = path.resolve(projectRoot);
68
+ const previous = this.tails.get(key) ?? Promise.resolve();
69
+ const run = previous.then(work, work);
70
+ this.tails.set(key, run.then(() => undefined, () => undefined));
71
+ return run;
72
+ }
73
+ dir(projectRoot) {
74
+ return path.join(projectRoot, "agents");
75
+ }
76
+ file(projectRoot, agentId) {
77
+ return path.join(this.dir(projectRoot), `${agentId}.agent.yaml`);
78
+ }
79
+ /* ---------- file sync ---------- */
80
+ /**
81
+ * What the agents directory looked like at the last full sync, per project: names, sizes and modification times.
82
+ * While it is unchanged (and old enough to trust), a sync is one readdir and one stat per file, with no reads or
83
+ * hashing. Cleared by our own writes and archives.
84
+ */
85
+ syncedTree = new Map();
86
+ /** A file touched this recently may be edited again within the clock's resolution, so its stamp is not trusted yet. */
87
+ treeTrustMs;
88
+ async treeStamp(projectRoot) {
89
+ let names;
90
+ try {
91
+ names = await readdir(this.dir(projectRoot));
92
+ }
93
+ catch (error) {
94
+ if (error.code !== "ENOENT")
95
+ throw error;
96
+ return { stamp: "", trusted: true };
97
+ }
98
+ const parts = [];
99
+ let newest = 0;
100
+ for (const name of names.sort()) {
101
+ if (!name.endsWith(".agent.yaml"))
102
+ continue;
103
+ try {
104
+ const info = await stat(path.join(this.dir(projectRoot), name));
105
+ newest = Math.max(newest, info.mtimeMs, info.ctimeMs);
106
+ parts.push(`${name}:${info.size}:${info.mtimeMs}:${info.ino}`);
107
+ }
108
+ catch {
109
+ parts.push(`${name}:gone`);
110
+ }
111
+ }
112
+ return { stamp: parts.join("|"), trusted: Date.now() - Math.floor(newest) >= this.treeTrustMs };
113
+ }
114
+ /**
115
+ * Bring the database in line with `agents/*.agent.yaml`. Only files whose
116
+ * content hash changed are parsed. An unchanged tree costs a readdir and a
117
+ * stat per file, and writes nothing; a changed or freshly touched one is
118
+ * read and hashed in full.
119
+ */
120
+ async sync(projectRoot) {
121
+ const key = path.resolve(projectRoot);
122
+ const before = await this.treeStamp(projectRoot);
123
+ if (before.trusted && this.syncedTree.get(key) === before.stamp)
124
+ return;
125
+ this.syncedTree.delete(key);
126
+ await this.fullSync(projectRoot);
127
+ // The stamp from before the sync: an edit that landed meanwhile differs from it and is picked up next time.
128
+ if (before.trusted)
129
+ this.syncedTree.set(key, before.stamp);
130
+ }
131
+ async fullSync(projectRoot) {
132
+ const projectKey = agentProjectKey(projectRoot);
133
+ let names;
134
+ try {
135
+ names = await readdir(this.dir(projectRoot));
136
+ }
137
+ catch (error) {
138
+ if (error.code !== "ENOENT")
139
+ throw error;
140
+ names = [];
141
+ }
142
+ const seen = new Set();
143
+ for (const name of names.sort()) {
144
+ if (!name.endsWith(".agent.yaml"))
145
+ continue;
146
+ const agentId = name.slice(0, -".agent.yaml".length);
147
+ seen.add(agentId);
148
+ const text = await readFile(path.join(this.dir(projectRoot), name), "utf8");
149
+ const fileHash = sha256(text);
150
+ const row = this.store.getDefinition(projectKey, agentId);
151
+ if (row && row.fileHash === fileHash && !row.archived)
152
+ continue;
153
+ this.applyFile(projectKey, agentId, text, fileHash, row);
154
+ }
155
+ for (const row of this.store.listDefinitions(projectKey)) {
156
+ if (!seen.has(row.agentId) && !row.archived) {
157
+ this.store.updateDefinitionState(projectKey, row.agentId, { archived: true, fileHash: null }, this.iso());
158
+ }
159
+ }
160
+ }
161
+ applyFile(projectKey, agentId, text, fileHash, row) {
162
+ const now = this.iso();
163
+ const parsed = parseAgentYaml(text);
164
+ const issues = !parsed.ok
165
+ ? parsed.issues
166
+ : parsed.value.id !== agentId
167
+ ? [{ path: "id", code: "agent.id_mismatch", message: `id must match the file name (${agentId})` }]
168
+ : agentMemoryIssues(parsed.value.memories);
169
+ if (!parsed.ok || issues.length > 0) {
170
+ if (!row || row.currentRevision === 0) {
171
+ this.store.recordInvalidNewDefinition(projectKey, agentId, fileHash, issues, now);
172
+ }
173
+ else {
174
+ this.store.updateDefinitionState(projectKey, agentId, { fileHash, issues, archived: false }, now);
175
+ }
176
+ return;
177
+ }
178
+ const current = row && row.currentRevision > 0 ? this.store.getRevision(projectKey, agentId, row.currentRevision) : undefined;
179
+ if (current && sameDefinition(current.definition, parsed.value)) {
180
+ this.store.updateDefinitionState(projectKey, agentId, { fileHash, issues: [], archived: false }, now);
181
+ return;
182
+ }
183
+ this.store.appendRevision(projectKey, {
184
+ definition: parsed.value,
185
+ yaml: text,
186
+ source: "file",
187
+ summary: current ? "Edited outside the console" : "Added outside the console",
188
+ fileHash,
189
+ now,
190
+ });
191
+ }
192
+ /** Write the canonical YAML and record the revision in one step. */
193
+ async writeRevision(projectRoot, definition, actor, summary, restoredFrom = null) {
194
+ const yaml = serializeAgentDefinition(definition);
195
+ await mkdir(this.dir(projectRoot), { recursive: true });
196
+ const target = this.file(projectRoot, definition.id);
197
+ const temporary = `${target}.${randomBytes(4).toString("hex")}.tmp`;
198
+ await writeFile(temporary, yaml);
199
+ await rename(temporary, target);
200
+ this.syncedTree.delete(path.resolve(projectRoot));
201
+ const revision = this.store.appendRevision(agentProjectKey(projectRoot), {
202
+ definition,
203
+ yaml,
204
+ source: actor.source,
205
+ author: actor.author ?? null,
206
+ summary,
207
+ restoredFrom,
208
+ fileHash: sha256(yaml),
209
+ now: this.iso(),
210
+ });
211
+ this.notifyChanged();
212
+ return revision;
213
+ }
214
+ changeListeners = [];
215
+ /** Called after an agent is saved, enabled, disabled, restored or archived, so listeners on channels can follow. */
216
+ onChange(listener) {
217
+ this.changeListeners.push(listener);
218
+ }
219
+ notifyChanged() {
220
+ for (const listener of this.changeListeners) {
221
+ try {
222
+ listener();
223
+ }
224
+ catch {
225
+ // a listener must never break a save
226
+ }
227
+ }
228
+ }
229
+ currentRevision(projectRoot, agentId) {
230
+ const projectKey = agentProjectKey(projectRoot);
231
+ const row = this.store.getDefinition(projectKey, agentId);
232
+ if (!row || row.currentRevision === 0)
233
+ throw new AgentError("agent.not_found");
234
+ if (row.archived)
235
+ throw new AgentError("agent.archived");
236
+ const revision = this.store.getRevision(projectKey, agentId, row.currentRevision);
237
+ if (!revision)
238
+ throw new AgentError("agent.revision_not_found");
239
+ return { row, revision };
240
+ }
241
+ /* ---------- reads ---------- */
242
+ summarize(projectKey, row, pending) {
243
+ const revision = row.currentRevision > 0 ? this.store.getRevision(projectKey, row.agentId, row.currentRevision) : undefined;
244
+ const definition = revision?.definition;
245
+ const since = new Date(this.now().getTime() - RECENT_WINDOW_MS).toISOString();
246
+ return {
247
+ id: row.agentId,
248
+ name: definition?.name ?? row.agentId,
249
+ description: definition?.description ?? null,
250
+ enabled: definition?.enabled ?? false,
251
+ revision: row.currentRevision,
252
+ issues: row.issues,
253
+ model: definition?.model ?? null,
254
+ tools: {
255
+ builtin: definition?.tools.builtin ?? [],
256
+ builtinTools: definition?.tools.builtin_tools ?? [],
257
+ mcpServers: Object.keys(definition?.tools.mcp ?? {}),
258
+ skills: definition?.tools.skills ?? [],
259
+ browser: definition?.tools.browser ?? false,
260
+ },
261
+ askFirst: definition ? effectiveAskFirst(definition) : [],
262
+ channelConnection: definition?.channel?.connection_id ?? null,
263
+ scheduleCron: definition?.schedule?.cron ?? null,
264
+ subAgents: definition?.sub_agents ?? [],
265
+ capture: definition?.observability.capture ?? null,
266
+ webChatUrl: definition?.web_chat.enabled && definition.web_chat.slug ? `/chat/${definition.web_chat.slug}` : null,
267
+ stats: this.store.sessionStats(projectKey, row.agentId, since),
268
+ pendingApprovals: pending.get(row.agentId) ?? 0,
269
+ updatedAt: row.updatedAt,
270
+ };
271
+ }
272
+ pendingByAgent(projectKey) {
273
+ const counts = new Map();
274
+ for (const approval of this.store.listPendingApprovals(projectKey)) {
275
+ counts.set(approval.agentId, (counts.get(approval.agentId) ?? 0) + 1);
276
+ }
277
+ return counts;
278
+ }
279
+ async list(projectRoot) {
280
+ return this.exclusive(projectRoot, async () => {
281
+ await this.sync(projectRoot);
282
+ const projectKey = agentProjectKey(projectRoot);
283
+ this.expireApprovals();
284
+ const pending = this.pendingByAgent(projectKey);
285
+ return this.store
286
+ .listDefinitions(projectKey)
287
+ .filter((row) => !row.archived)
288
+ .map((row) => this.summarize(projectKey, row, pending));
289
+ });
290
+ }
291
+ async get(projectRoot, agentId) {
292
+ return this.exclusive(projectRoot, async () => {
293
+ await this.sync(projectRoot);
294
+ const projectKey = agentProjectKey(projectRoot);
295
+ const row = this.store.getDefinition(projectKey, agentId);
296
+ if (!row)
297
+ throw new AgentError("agent.not_found");
298
+ const revision = row.currentRevision > 0 ? this.store.getRevision(projectKey, agentId, row.currentRevision) : undefined;
299
+ return {
300
+ agent: this.summarize(projectKey, row, this.pendingByAgent(projectKey)),
301
+ definition: revision?.definition ?? null,
302
+ yaml: revision?.yaml ?? null,
303
+ warnings: revision ? await this.warningsFor(projectRoot, revision.definition) : [],
304
+ };
305
+ });
306
+ }
307
+ /** The definition a session is pinned to. */
308
+ async definitionAt(projectRoot, agentId, revision) {
309
+ const found = this.store.getRevision(agentProjectKey(projectRoot), agentId, revision);
310
+ if (!found)
311
+ throw new AgentError("agent.revision_not_found");
312
+ return found.definition;
313
+ }
314
+ /* ---------- validation ---------- */
315
+ async warningsFor(projectRoot, definition) {
316
+ const warnings = [];
317
+ if (definition.web_chat.enabled && !definition.web_chat.slug) {
318
+ warnings.push({ path: "web_chat.slug", code: "agent.web_chat_slug_missing", message: "Web chat has no link yet; save the agent in the console to generate one" });
319
+ }
320
+ if (definition.web_chat.enabled) {
321
+ const readsChats = grantedStageflowTools(definition.tools.stageflow).filter((tool) => CONVERSATION_READING_TOOLS.has(tool));
322
+ if (readsChats.length > 0) {
323
+ warnings.push({
324
+ path: "tools.stageflow",
325
+ code: "agent.web_chat_reads_conversations",
326
+ message: `Web chat is on and this agent can read every agent's sessions (${readsChats.join(", ")}), including what customers wrote`,
327
+ });
328
+ }
329
+ }
330
+ if (grantedBuiltinTools(definition).includes("bash")) {
331
+ warnings.push({
332
+ path: "tools.builtin",
333
+ code: "agent.shell_not_path_confined",
334
+ message: "Shell access is not path-confined: this agent can change agent definitions and Stageflow state through bash even though file tools are blocked from them. Every shell command waits for approval",
335
+ });
336
+ }
337
+ if (definition.model.backend === "claude") {
338
+ warnings.push({ path: "model.backend", code: "agent.backend_unavailable", message: "Agents run on Pi today; sessions on the claude backend fail" });
339
+ }
340
+ if (definition.execution === "sandbox") {
341
+ warnings.push({ path: "execution", code: "agent.sandbox_unavailable", message: "No sandbox runner is installed; sessions on this agent fail" });
342
+ }
343
+ if (definition.model.id) {
344
+ const info = await this.environment.modelInfo?.(definition.model.id);
345
+ if (info && !info.known) {
346
+ warnings.push({ path: "model.id", code: "agent.model_unknown", message: `${definition.model.id} is not listed by a connected provider; it will still be tried` });
347
+ }
348
+ else if (info?.nativeTools) {
349
+ warnings.push({ path: "model.id", code: "agent.model_native_tools", message: "This provider runs its own file and shell tools, so grants and approvals cannot hold; sessions fail unless the Host opts in" });
350
+ }
351
+ }
352
+ const servers = Object.keys(definition.tools.mcp);
353
+ if (servers.length > 0) {
354
+ const known = await this.environment.mcpServerNames(projectRoot);
355
+ for (const server of servers) {
356
+ if (known && !known.includes(server)) {
357
+ warnings.push({
358
+ path: `tools.mcp.${server}`,
359
+ code: "agent.mcp_server_unknown",
360
+ message: `${server} is not in the project .mcp.json or the servers saved from the console`,
361
+ });
362
+ }
363
+ }
364
+ }
365
+ if (definition.tools.skills.length > 0) {
366
+ const known = await this.environment.skillNames(projectRoot);
367
+ definition.tools.skills.forEach((skill, index) => {
368
+ if (known && !known.includes(skill)) {
369
+ warnings.push({ path: `tools.skills.${index}`, code: "agent.skill_unknown", message: `Skill ${skill} not found` });
370
+ }
371
+ });
372
+ }
373
+ if (definition.sub_agents.length > 0) {
374
+ const projectKey = agentProjectKey(projectRoot);
375
+ definition.sub_agents.forEach((agentId, index) => {
376
+ const row = this.store.getDefinition(projectKey, agentId);
377
+ if (!row || row.archived || row.currentRevision === 0) {
378
+ warnings.push({ path: `sub_agents.${index}`, code: "agent.sub_agent_unknown", message: `No agent named ${agentId}` });
379
+ }
380
+ });
381
+ }
382
+ if (definition.channel) {
383
+ const known = await this.environment.connectionIds();
384
+ if (known && !known.includes(definition.channel.connection_id)) {
385
+ warnings.push({ path: "channel.connection_id", code: "agent.connection_unknown", message: `Connection ${definition.channel.connection_id} not found` });
386
+ }
387
+ if (definition.channel.allow_from.length === 0) {
388
+ warnings.push({ path: "channel.allow_from", code: "agent.channel_nobody_allowed", message: "Nobody is allowed to message this agent yet. Add the people or domains it should answer." });
389
+ }
390
+ else if (definition.channel.allow_from.includes("*")) {
391
+ warnings.push({ path: "channel.allow_from", code: "agent.channel_open_to_anyone", message: "Anyone who can reach this connection can talk to this agent" });
392
+ }
393
+ }
394
+ if (definition.approvals.approvers.channel_ids.length > 0) {
395
+ warnings.push({
396
+ path: "approvals.approvers.channel_ids",
397
+ code: "agent.channel_approvers_unused",
398
+ message: "Approving from a chat channel is not supported yet; these ids are ignored and approvals are decided in the console",
399
+ });
400
+ }
401
+ return warnings;
402
+ }
403
+ /**
404
+ * Web chat defaults and links: a new agent starts with web chat on, an enabled block without a link
405
+ * gets one, and `slug: ""` asks for a new link (rotate). Previews never mint links.
406
+ */
407
+ prepareWebChat(raw, mode) {
408
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
409
+ return raw;
410
+ const input = { ...raw };
411
+ let block = input.web_chat;
412
+ if (block === undefined && mode === "create")
413
+ block = { enabled: true };
414
+ if (typeof block !== "object" || block === null || Array.isArray(block))
415
+ return mode === "create" ? { ...input, web_chat: block } : raw;
416
+ const webChat = { ...block };
417
+ const wantsLink = webChat.enabled === true && (webChat.slug === undefined || webChat.slug === "");
418
+ if (webChat.slug === "")
419
+ delete webChat.slug;
420
+ if (wantsLink && mode !== "preview")
421
+ webChat.slug = newWebChatSlug();
422
+ return { ...input, web_chat: webChat };
423
+ }
424
+ /** The enabled agent a public link points at, or null. */
425
+ async findWebChatAgent(projectRoot, slug) {
426
+ return this.exclusive(projectRoot, async () => {
427
+ await this.sync(projectRoot);
428
+ const projectKey = agentProjectKey(projectRoot);
429
+ for (const row of this.store.listDefinitions(projectKey)) {
430
+ if (row.archived || row.currentRevision === 0)
431
+ continue;
432
+ const definition = this.store.getRevision(projectKey, row.agentId, row.currentRevision)?.definition;
433
+ if (definition?.enabled && definition.web_chat.enabled && definition.web_chat.slug === slug) {
434
+ return { agentId: row.agentId, definition };
435
+ }
436
+ }
437
+ return null;
438
+ });
439
+ }
440
+ /** Fills a missing id from the agent's name so callers never have to choose one. */
441
+ async withGeneratedId(projectRoot, raw) {
442
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
443
+ return raw;
444
+ const input = raw;
445
+ if (typeof input.id === "string" && input.id.length > 0)
446
+ return raw;
447
+ if (typeof input.name !== "string" || input.name.trim().length === 0)
448
+ return raw;
449
+ const projectKey = agentProjectKey(projectRoot);
450
+ const id = await generateAgentId(input.name, async (candidate) => {
451
+ if (this.store.getDefinition(projectKey, candidate))
452
+ return true;
453
+ return access(this.file(projectRoot, candidate)).then(() => true, () => false);
454
+ });
455
+ return { ...input, id };
456
+ }
457
+ /** One connection answers for one agent: another agent already on this connection is an error. */
458
+ async channelConflicts(projectRoot, definition) {
459
+ const connectionId = definition.channel?.connection_id;
460
+ if (!connectionId)
461
+ return [];
462
+ const projectKey = agentProjectKey(projectRoot);
463
+ const identityOf = async (id) => (await this.environment.connectionIdentity?.(id).catch(() => undefined)) ?? `connection:${id}`;
464
+ const mine = await identityOf(connectionId);
465
+ const others = this.store
466
+ .listDefinitions(projectKey)
467
+ .filter((row) => !row.archived && row.currentRevision > 0 && row.agentId !== definition.id)
468
+ .map((row) => ({ agentId: row.agentId, connection: this.store.getRevision(projectKey, row.agentId, row.currentRevision)?.definition.channel?.connection_id }));
469
+ for (const other of others) {
470
+ if (!other.connection)
471
+ continue;
472
+ if (other.connection === connectionId) {
473
+ return [{ path: "channel.connection_id", code: "agent.channel_in_use", message: `${connectionId} already answers for the agent ${other.agentId}. An agent has one channel and a connection has one agent.` }];
474
+ }
475
+ if ((await identityOf(other.connection)) === mine) {
476
+ return [{ path: "channel.connection_id", code: "agent.channel_in_use", message: `${connectionId} reaches the same inbox or line as ${other.connection}, which ${other.agentId} already answers. One inbox has one agent.` }];
477
+ }
478
+ }
479
+ return [];
480
+ }
481
+ async validate(projectRoot, rawInput) {
482
+ const raw = this.prepareWebChat(await this.withGeneratedId(projectRoot, rawInput), "preview");
483
+ const parsed = parseAgentDefinition(raw);
484
+ if (!parsed.ok)
485
+ return { definition: null, issues: parsed.issues, warnings: [], yaml: null };
486
+ const conflicts = await this.channelConflicts(projectRoot, parsed.value);
487
+ const missing = agentMemoryIssues(parsed.value.memories);
488
+ if (conflicts.length > 0 || missing.length > 0)
489
+ return { definition: null, issues: [...conflicts, ...missing], warnings: [], yaml: null };
490
+ return {
491
+ definition: parsed.value,
492
+ issues: [],
493
+ warnings: await this.warningsFor(projectRoot, parsed.value),
494
+ yaml: serializeAgentDefinition(parsed.value),
495
+ };
496
+ }
497
+ parseOrThrow(raw) {
498
+ const parsed = parseAgentDefinition(raw);
499
+ if (!parsed.ok)
500
+ throw new AgentError("agent.invalid", "Agent definition is invalid", parsed.issues);
501
+ return parsed.value;
502
+ }
503
+ /* ---------- writes ---------- */
504
+ async create(projectRoot, rawInput, actor) {
505
+ const definition = this.parseOrThrow(this.prepareWebChat(await this.withGeneratedId(projectRoot, rawInput), "create"));
506
+ await this.exclusive(projectRoot, async () => {
507
+ await this.sync(projectRoot);
508
+ const conflicts = await this.channelConflicts(projectRoot, definition);
509
+ const missing = agentMemoryIssues(definition.memories);
510
+ if (conflicts.length > 0 || missing.length > 0)
511
+ throw new AgentError("agent.invalid", "Agent definition is invalid", [...conflicts, ...missing]);
512
+ const existing = this.store.getDefinition(agentProjectKey(projectRoot), definition.id);
513
+ const onDisk = await access(this.file(projectRoot, definition.id)).then(() => true, (error) => {
514
+ if (error.code === "ENOENT")
515
+ return false;
516
+ throw error;
517
+ });
518
+ if (onDisk || (existing && !existing.archived))
519
+ throw new AgentError("agent.exists");
520
+ // A new agent always starts disabled (R1); enabling is its own revision.
521
+ await this.writeRevision(projectRoot, { ...definition, enabled: false }, actor, "Created");
522
+ });
523
+ return this.get(projectRoot, definition.id);
524
+ }
525
+ async save(projectRoot, agentId, input, actor) {
526
+ // A client that leaves `web_chat` out (CLI, MCP, scripts) keeps the agent's current link and limits.
527
+ let incoming = input.definition;
528
+ if (typeof incoming === "object" && incoming !== null && !Array.isArray(incoming) && incoming.web_chat === undefined) {
529
+ const projectKey = agentProjectKey(projectRoot);
530
+ const row = this.store.getDefinition(projectKey, agentId);
531
+ const current = row && row.currentRevision > 0 ? this.store.getRevision(projectKey, agentId, row.currentRevision)?.definition : undefined;
532
+ if (current)
533
+ incoming = { ...incoming, web_chat: current.web_chat };
534
+ }
535
+ const definition = this.parseOrThrow(this.prepareWebChat(incoming, "save"));
536
+ if (definition.id !== agentId) {
537
+ throw new AgentError("agent.invalid", "The id cannot change", [
538
+ { path: "id", code: "agent.id_immutable", message: "The id is fixed after creation" },
539
+ ]);
540
+ }
541
+ await this.exclusive(projectRoot, async () => {
542
+ await this.sync(projectRoot);
543
+ const { row, revision } = this.currentRevision(projectRoot, agentId);
544
+ if (row.currentRevision !== input.baseRevision)
545
+ throw new AgentError("agent.conflict");
546
+ const conflicts = await this.channelConflicts(projectRoot, definition);
547
+ const missing = agentMemoryIssues(definition.memories);
548
+ if (conflicts.length > 0 || missing.length > 0)
549
+ throw new AgentError("agent.invalid", "Agent definition is invalid", [...conflicts, ...missing]);
550
+ if (sameDefinition(revision.definition, definition))
551
+ return;
552
+ await this.writeRevision(projectRoot, definition, actor, input.summary?.trim() || null);
553
+ });
554
+ return this.get(projectRoot, agentId);
555
+ }
556
+ async setEnabled(projectRoot, agentId, enabled, actor) {
557
+ await this.exclusive(projectRoot, async () => {
558
+ await this.sync(projectRoot);
559
+ const { row, revision } = this.currentRevision(projectRoot, agentId);
560
+ const missing = enabled ? agentMemoryIssues(revision.definition.memories) : [];
561
+ if (enabled && (row.issues.length > 0 || missing.length > 0)) {
562
+ throw new AgentError("agent.invalid", "Fix the agent file before enabling it", [...row.issues, ...missing]);
563
+ }
564
+ if (revision.definition.enabled === enabled)
565
+ return;
566
+ await this.writeRevision(projectRoot, { ...revision.definition, enabled }, actor, enabled ? "Enabled" : "Disabled");
567
+ });
568
+ return this.get(projectRoot, agentId);
569
+ }
570
+ archiveListeners = [];
571
+ /** Called after an agent is archived with the ids of its sessions, so a runtime can let go of what it holds for them. */
572
+ onArchived(listener) {
573
+ this.archiveListeners.push(listener);
574
+ }
575
+ /** Remove the file; history stays readable (R5). */
576
+ async archive(projectRoot, agentId) {
577
+ await this.exclusive(projectRoot, async () => {
578
+ await this.sync(projectRoot);
579
+ const row = this.store.getDefinition(agentProjectKey(projectRoot), agentId);
580
+ if (!row || row.archived)
581
+ throw new AgentError("agent.not_found");
582
+ this.syncedTree.delete(path.resolve(projectRoot));
583
+ await rm(this.file(projectRoot, agentId), { force: true });
584
+ this.store.updateDefinitionState(agentProjectKey(projectRoot), agentId, { archived: true, fileHash: null }, this.iso());
585
+ });
586
+ const sessionIds = this.store.sessionIdsOf(agentProjectKey(projectRoot), agentId);
587
+ for (const listener of this.archiveListeners) {
588
+ try {
589
+ listener(agentId, sessionIds);
590
+ }
591
+ catch {
592
+ // a listener must never break an archive
593
+ }
594
+ }
595
+ this.notifyChanged();
596
+ }
597
+ /* ---------- revisions ---------- */
598
+ async listRevisions(projectRoot, agentId) {
599
+ return this.exclusive(projectRoot, async () => {
600
+ await this.sync(projectRoot);
601
+ const projectKey = agentProjectKey(projectRoot);
602
+ if (!this.store.getDefinition(projectKey, agentId))
603
+ throw new AgentError("agent.not_found");
604
+ return this.store.listRevisions(projectKey, agentId);
605
+ });
606
+ }
607
+ async getRevision(projectRoot, agentId, revision) {
608
+ const found = this.store.getRevision(agentProjectKey(projectRoot), agentId, revision);
609
+ if (!found)
610
+ throw new AgentError("agent.revision_not_found");
611
+ return found;
612
+ }
613
+ async diffRevisions(projectRoot, agentId, from, to) {
614
+ const [before, after] = await Promise.all([
615
+ this.getRevision(projectRoot, agentId, from),
616
+ this.getRevision(projectRoot, agentId, to),
617
+ ]);
618
+ return diffLines(before.yaml, after.yaml);
619
+ }
620
+ /** Re-save an old revision as the newest one. Enabled state stays as it is now. */
621
+ async restore(projectRoot, agentId, input, actor) {
622
+ await this.exclusive(projectRoot, async () => {
623
+ await this.sync(projectRoot);
624
+ const { row, revision: current } = this.currentRevision(projectRoot, agentId);
625
+ if (row.currentRevision !== input.baseRevision)
626
+ throw new AgentError("agent.conflict");
627
+ const target = this.store.getRevision(agentProjectKey(projectRoot), agentId, input.revision);
628
+ if (!target)
629
+ throw new AgentError("agent.revision_not_found");
630
+ const definition = { ...target.definition, enabled: current.definition.enabled };
631
+ if (sameDefinition(definition, current.definition))
632
+ return;
633
+ await this.writeRevision(projectRoot, definition, { ...actor, source: "restore" }, `Restored v${input.revision}`, input.revision);
634
+ });
635
+ return this.get(projectRoot, agentId);
636
+ }
637
+ /* ---------- sessions ---------- */
638
+ async openSession(projectRoot, agentId, input) {
639
+ return this.exclusive(projectRoot, async () => {
640
+ await this.sync(projectRoot);
641
+ const { revision } = this.currentRevision(projectRoot, agentId);
642
+ if (!revision.definition.enabled)
643
+ throw new AgentError("agent.disabled");
644
+ const now = this.iso();
645
+ const session = {
646
+ id: id("ses"),
647
+ agentId,
648
+ revision: revision.revision,
649
+ backend: revision.definition.model.backend,
650
+ status: "open",
651
+ source: input.source,
652
+ sourceRef: input.sourceRef ?? null,
653
+ threadKey: input.threadKey ?? null,
654
+ title: input.title ?? null,
655
+ createdAt: now,
656
+ updatedAt: now,
657
+ };
658
+ this.store.insertSession(agentProjectKey(projectRoot), session);
659
+ return session;
660
+ });
661
+ }
662
+ /** Id of the session's pending approval, if a turn is waiting on a person. */
663
+ pendingApprovalFor(_projectRoot, sessionId) {
664
+ return this.store.listApprovals(sessionId).find((approval) => approval.status === "pending")?.id ?? null;
665
+ }
666
+ listSessionsForRef(projectRoot, agentId, source, sourceRef, limit) {
667
+ return this.store.listSessionsForRef(agentProjectKey(projectRoot), agentId, source, sourceRef, limit);
668
+ }
669
+ sessionInProject(projectRoot, sessionId) {
670
+ const session = this.store.getSession(sessionId);
671
+ if (!session || session.projectKey !== agentProjectKey(projectRoot)) {
672
+ throw new AgentError("agent.session_not_found");
673
+ }
674
+ return session;
675
+ }
676
+ async getSession(projectRoot, sessionId) {
677
+ this.expireApprovals();
678
+ const session = this.sessionInProject(projectRoot, sessionId);
679
+ const approvals = this.store.listApprovals(sessionId);
680
+ return {
681
+ session: this.store.getSession(sessionId),
682
+ messages: this.store.listMessages(sessionId),
683
+ turns: this.store.listTurns(sessionId),
684
+ spans: this.store.listSpansForSession(sessionId),
685
+ approvals,
686
+ pendingApproval: approvals.find((approval) => approval.status === "pending") ?? null,
687
+ evalScores: this.store.listEvalScores(session.id),
688
+ deliveries: this.store.listDeliveries(sessionId),
689
+ };
690
+ }
691
+ async listSessions(projectRoot, agentId, query) {
692
+ return this.exclusive(projectRoot, async () => {
693
+ await this.sync(projectRoot);
694
+ const projectKey = agentProjectKey(projectRoot);
695
+ if (!this.store.getDefinition(projectKey, agentId))
696
+ throw new AgentError("agent.not_found");
697
+ this.expireApprovals();
698
+ const since = new Date(this.now().getTime() - RECENT_WINDOW_MS).toISOString();
699
+ return {
700
+ ...this.store.listSessions(projectKey, agentId, query),
701
+ stats: this.store.sessionStats(projectKey, agentId, since),
702
+ };
703
+ });
704
+ }
705
+ /** Sessions of every agent in the project (or of one, when `agentId` is given), newest first, with matching stats. */
706
+ async listAllSessions(projectRoot, query, agentId) {
707
+ return this.exclusive(projectRoot, async () => {
708
+ await this.sync(projectRoot);
709
+ const projectKey = agentProjectKey(projectRoot);
710
+ this.expireApprovals();
711
+ const since = new Date(this.now().getTime() - RECENT_WINDOW_MS).toISOString();
712
+ return {
713
+ ...this.store.listSessions(projectKey, agentId, query),
714
+ stats: this.store.sessionStats(projectKey, agentId, since),
715
+ };
716
+ });
717
+ }
718
+ listSpans(projectRoot, sessionId) {
719
+ this.sessionInProject(projectRoot, sessionId);
720
+ return this.store.listSpansForSession(sessionId);
721
+ }
722
+ /** OTLP/JSON for every turn of a session (what "Export session traces" downloads). */
723
+ exportSessionOtlp(projectRoot, sessionId) {
724
+ const session = this.sessionInProject(projectRoot, sessionId);
725
+ const spans = this.store.listSpansForSession(sessionId);
726
+ return exportTurnsToOtlp(this.store.listTurns(sessionId).map((turn) => ({ turn, spans: spans.filter((span) => span.turnId === turn.id) })), { serviceName: "stageflow-agent-runtime", agentId: session.agentId });
727
+ }
728
+ /** OTLP/JSON for one finished turn, for live export to a collector. */
729
+ exportTurnOtlp(projectRoot, turnId) {
730
+ const turn = this.store.getTurn(turnId);
731
+ if (!turn)
732
+ throw new AgentError("agent.session_not_found");
733
+ const session = this.sessionInProject(projectRoot, turn.sessionId);
734
+ const spans = this.store.listSpansForSession(session.id).filter((span) => span.turnId === turnId);
735
+ return exportTurnsToOtlp([{ turn, spans }], { serviceName: "stageflow-agent-runtime", agentId: session.agentId });
736
+ }
737
+ /* ---------- turns ---------- */
738
+ /**
739
+ * Start a turn on a session's pinned revision: records the user message and
740
+ * marks the session running. The live agent state (enabled, not archived)
741
+ * gates new turns; the pinned revision supplies the configuration.
742
+ */
743
+ async beginTurn(projectRoot, sessionId, input) {
744
+ return this.exclusive(projectRoot, async () => {
745
+ await this.sync(projectRoot);
746
+ const session = this.sessionInProject(projectRoot, sessionId);
747
+ const { revision: live } = this.currentRevision(projectRoot, session.agentId);
748
+ if (!live.definition.enabled)
749
+ throw new AgentError("agent.disabled");
750
+ if (session.status === "running" || session.status === "waiting")
751
+ throw new AgentError("agent.session_busy");
752
+ const definition = await this.definitionAt(projectRoot, session.agentId, session.revision);
753
+ this.assertWithinLimits(definition, session);
754
+ const now = this.iso();
755
+ return this.store.transaction(() => {
756
+ // A retry takes over the earlier turn's unanswered message instead of adding it a second time.
757
+ const retried = input.resumeTurnId ? this.store.listMessages(sessionId).find((m) => m.turnId === input.resumeTurnId && m.role === "user") : undefined;
758
+ const history = this.store.listMessages(sessionId).filter((m) => m.id !== retried?.id);
759
+ const turn = this.store.insertTurn({
760
+ id: id("turn"),
761
+ sessionId,
762
+ revision: session.revision,
763
+ traceId: randomBytes(16).toString("hex"),
764
+ status: "running",
765
+ errorCode: null,
766
+ inputTokens: null,
767
+ outputTokens: null,
768
+ costUsd: null,
769
+ startedAt: now,
770
+ endedAt: null,
771
+ });
772
+ let userMessage;
773
+ if (retried) {
774
+ this.store.moveMessageToTurn(retried.id, turn.id);
775
+ userMessage = { ...retried, turnId: turn.id };
776
+ }
777
+ else {
778
+ userMessage = this.store.appendMessage({
779
+ id: id("msg"),
780
+ sessionId,
781
+ turnId: turn.id,
782
+ role: "user",
783
+ author: input.author ?? null,
784
+ text: input.text,
785
+ createdAt: now,
786
+ });
787
+ }
788
+ this.store.updateSession(sessionId, { status: "running", ...(session.title ? {} : { title: titleFrom(input.text) }) }, now);
789
+ return { session: this.store.getSession(sessionId), definition, turn, history, userMessage };
790
+ });
791
+ });
792
+ }
793
+ /** Session limits (`limits` in the pinned revision) are checked before each new turn. */
794
+ assertWithinLimits(definition, session) {
795
+ const { limits } = definition;
796
+ const turns = this.store.listTurns(session.id);
797
+ if (!limits.always_on) {
798
+ if (turns.length >= limits.max_turns) {
799
+ throw new AgentError("agent.limit_reached", `This session reached its limit of ${limits.max_turns} turns.`);
800
+ }
801
+ const ageMinutes = (this.now().getTime() - Date.parse(session.createdAt)) / 60_000;
802
+ if (ageMinutes >= limits.session_minutes) {
803
+ throw new AgentError("agent.limit_reached", `This session reached its limit of ${limits.session_minutes} minutes.`);
804
+ }
805
+ }
806
+ if (limits.cost_usd !== null) {
807
+ // An always-on session has no end, so its cap is per day instead of per session.
808
+ const since = limits.always_on ? this.now().getTime() - 24 * 60 * 60_000 : 0;
809
+ const spent = turns.filter((turn) => Date.parse(turn.startedAt) >= since).reduce((sum, turn) => sum + (turn.costUsd ?? 0), 0);
810
+ if (spent >= limits.cost_usd) {
811
+ throw new AgentError("agent.limit_reached", `This session reached its cost limit of $${limits.cost_usd}${limits.always_on ? " for today" : ""}.`);
812
+ }
813
+ }
814
+ }
815
+ /**
816
+ * A turn is about to pause for a person. Returns "waiting" once it is parked, or the approval's outcome when it was
817
+ * already decided in between (nothing to wait for), or "inactive" when the turn is no longer running.
818
+ */
819
+ async waitOnApproval(projectRoot, turnId, approvalId) {
820
+ const turn = this.store.getTurn(turnId);
821
+ if (!turn)
822
+ throw new AgentError("agent.session_not_found");
823
+ this.sessionInProject(projectRoot, turn.sessionId);
824
+ return this.exclusive(projectRoot, async () => this.lifecycle.wait(turnId, approvalId));
825
+ }
826
+ /** The wait is over (decided, expired): the turn runs again. */
827
+ async resumeTurn(projectRoot, turnId) {
828
+ const turn = this.store.getTurn(turnId);
829
+ if (!turn)
830
+ throw new AgentError("agent.session_not_found");
831
+ this.sessionInProject(projectRoot, turn.sessionId);
832
+ await this.exclusive(projectRoot, async () => {
833
+ this.lifecycle.resume(turnId);
834
+ });
835
+ }
836
+ /** A stopped turn leaves its ask-first approval behind; close it so it cannot be approved later. */
837
+ async closeApproval(projectRoot, approvalId) {
838
+ await this.exclusive(projectRoot, async () => {
839
+ const approval = this.store.getApproval(approvalId);
840
+ if (!approval)
841
+ return;
842
+ this.sessionInProject(projectRoot, approval.sessionId);
843
+ this.lifecycle.closeApproval(approvalId);
844
+ const session = this.store.getSession(approval.sessionId);
845
+ if (session?.status === "waiting")
846
+ this.store.updateSession(session.id, { status: "open" }, this.iso());
847
+ });
848
+ }
849
+ /**
850
+ * End a turn. Guarded: a turn that is already over is left as it is (the session detail is returned unchanged), so a
851
+ * late failure handler cannot rewrite a finished outcome.
852
+ */
853
+ async completeTurn(projectRoot, turnId, input) {
854
+ const turn = this.store.getTurn(turnId);
855
+ if (!turn)
856
+ throw new AgentError("agent.session_not_found");
857
+ const session = this.sessionInProject(projectRoot, turn.sessionId);
858
+ await this.exclusive(projectRoot, async () => {
859
+ this.lifecycle.finish(turnId, {
860
+ status: input.status,
861
+ errorCode: input.errorCode ?? null,
862
+ ...(input.assistantText ? { assistant: { id: id("msg"), author: session.agentId, text: input.assistantText } } : {}),
863
+ ...(input.usage ? { usage: input.usage } : {}),
864
+ ...(input.costUsd !== undefined ? { costUsd: input.costUsd } : {}),
865
+ ...(input.evalScores ? { evalScores: input.evalScores } : {}),
866
+ ...(input.sessionStatus ? { sessionStatus: input.sessionStatus } : {}),
867
+ });
868
+ });
869
+ return this.getSession(projectRoot, session.id);
870
+ }
871
+ recordSpan(projectRoot, span) {
872
+ const turn = this.store.getTurn(span.turnId);
873
+ if (!turn)
874
+ throw new AgentError("agent.session_not_found");
875
+ this.sessionInProject(projectRoot, turn.sessionId);
876
+ this.store.insertSpan(span);
877
+ }
878
+ /** Called at host start: turns that were running when the host stopped can't resume. */
879
+ interrupted = [];
880
+ recoverInterruptedTurns() {
881
+ this.interrupted = this.store.interruptedTurnIds();
882
+ return this.lifecycle.interruptedByRestart();
883
+ }
884
+ /**
885
+ * Turns a restart cut off on an always-on agent, on a channel where a reply goes back to the sender's own handle
886
+ * (iMessage). Each is retried once; the person's message is not asked again.
887
+ */
888
+ async resumableTurns(projectRoot) {
889
+ const resumable = [];
890
+ for (const turnId of this.interrupted.splice(0)) {
891
+ const turn = this.store.getTurn(turnId);
892
+ if (!turn)
893
+ continue;
894
+ const session = this.store.getSession(turn.sessionId);
895
+ if (!session || session.source !== "imessage" || !session.sourceRef || !session.threadKey)
896
+ continue;
897
+ const definition = await this.definitionAt(projectRoot, session.agentId, session.revision).catch(() => undefined);
898
+ if (!definition?.limits.always_on || !definition.enabled)
899
+ continue;
900
+ const messages = this.store.listMessages(session.id).filter((m) => m.turnId === turnId);
901
+ const user = messages.find((m) => m.role === "user");
902
+ if (!user || messages.some((m) => m.role === "assistant"))
903
+ continue;
904
+ const [, connectionId = "", threadId = ""] = session.threadKey.split("\0");
905
+ resumable.push({ agentId: session.agentId, sessionId: session.id, turnId, text: user.text, from: session.sourceRef, connectionId, provider: session.source, threadId });
906
+ }
907
+ return resumable;
908
+ }
909
+ /* ---------- channel deliveries ---------- */
910
+ /** Sends a restart cut off may or may not have arrived; they wait for a person. */
911
+ settleInterruptedDeliveries() {
912
+ const ids = this.store.settleInterruptedDeliveries(this.iso());
913
+ this.lifecycle.replyUnconfirmed(ids);
914
+ return ids;
915
+ }
916
+ /** A reply that did not get through makes its turn a failure; one that finally does puts it right. */
917
+ setReplyDelivered(turnId, delivered) {
918
+ this.lifecycle.replyDelivery(turnId, delivered);
919
+ }
920
+ latestUndelivered(projectRoot, agentId) {
921
+ return this.store.latestUndelivered(agentId, agentProjectKey(projectRoot));
922
+ }
923
+ /* ---------- approvals ---------- */
924
+ expireApprovals() {
925
+ this.lifecycle.expireDue();
926
+ }
927
+ async requestApproval(projectRoot, sessionId, input) {
928
+ return this.exclusive(projectRoot, async () => {
929
+ await this.sync(projectRoot);
930
+ this.expireApprovals();
931
+ const session = this.sessionInProject(projectRoot, sessionId);
932
+ const { revision: live } = this.currentRevision(projectRoot, session.agentId);
933
+ if (!live.definition.enabled)
934
+ throw new AgentError("agent.disabled");
935
+ const definition = await this.definitionAt(projectRoot, session.agentId, session.revision);
936
+ if (!isToolGranted(definition, input.tool))
937
+ throw new AgentError("agent.tool_not_granted");
938
+ if (!requiresAskFirst(definition, input.tool))
939
+ throw new AgentError("agent.approval_not_required");
940
+ const payloadHash = hashApprovalPayload(input.payload);
941
+ if (this.store.hasExecutedPayload(sessionId, payloadHash)) {
942
+ const prior = this.store.findApprovalByPayload(sessionId, payloadHash);
943
+ if (prior)
944
+ return prior;
945
+ }
946
+ const existing = this.store.getPendingApproval(sessionId);
947
+ if (existing) {
948
+ // One pending approval per session, and it only stands for the exact call it was raised for.
949
+ if (existing.tool !== input.tool || existing.payloadHash !== payloadHash)
950
+ throw new AgentError("agent.session_busy", "Another approval is already pending on this session");
951
+ return existing;
952
+ }
953
+ const now = this.now();
954
+ const approval = {
955
+ id: id("appr"),
956
+ sessionId,
957
+ turnId: input.turnId ?? null,
958
+ tool: input.tool,
959
+ payload: input.payload,
960
+ payloadHash,
961
+ status: "pending",
962
+ createdAt: now.toISOString(),
963
+ expiresAt: new Date(now.getTime() + definition.approvals.timeout_minutes * 60_000).toISOString(),
964
+ decidedAt: null,
965
+ actor: null,
966
+ };
967
+ this.store.transaction(() => {
968
+ this.store.insertApproval(approval);
969
+ this.store.updateSession(sessionId, { status: "waiting" }, approval.createdAt);
970
+ });
971
+ return approval;
972
+ });
973
+ }
974
+ async decideApproval(projectRoot, sessionId, decision, actor) {
975
+ return this.exclusive(projectRoot, async () => {
976
+ await this.sync(projectRoot);
977
+ const session = this.sessionInProject(projectRoot, sessionId);
978
+ const definition = await this.definitionAt(projectRoot, session.agentId, session.revision);
979
+ if (!canApprove(definition, actor))
980
+ throw new AgentError("agent.approval_forbidden");
981
+ const pending = this.store.getPendingApproval(sessionId);
982
+ if (!pending)
983
+ throw new AgentError("agent.approval_not_pending");
984
+ if (Date.parse(pending.expiresAt) <= this.now().getTime()) {
985
+ this.expireApprovals();
986
+ throw new AgentError("agent.approval_expired");
987
+ }
988
+ if (decision === "approve") {
989
+ // Disable while waiting refuses the action (R9); denial is always allowed.
990
+ const { revision: live } = this.currentRevision(projectRoot, session.agentId);
991
+ if (!live.definition.enabled)
992
+ throw new AgentError("agent.disabled");
993
+ }
994
+ const decided = this.lifecycle.decide(pending, decision, actor);
995
+ if (decided === "stale")
996
+ throw new AgentError("agent.approval_not_pending");
997
+ return decided.approval;
998
+ });
999
+ }
1000
+ async pendingApprovals(projectRoot) {
1001
+ this.expireApprovals();
1002
+ return this.store.listPendingApprovals(agentProjectKey(projectRoot));
1003
+ }
1004
+ /* ---------- communications ---------- */
1005
+ async receive(projectRoot, message) {
1006
+ return this.exclusive(projectRoot, async () => {
1007
+ await this.sync(projectRoot);
1008
+ const projectKey = agentProjectKey(projectRoot);
1009
+ const bound = this.store
1010
+ .listDefinitions(projectKey)
1011
+ .filter((row) => !row.archived && row.currentRevision > 0)
1012
+ .map((row) => this.store.getRevision(projectKey, row.agentId, row.currentRevision))
1013
+ .filter((revision) => revision.definition.channel?.connection_id === message.connectionId);
1014
+ const target = bound.find((revision) => revision.definition.enabled) ?? bound[0];
1015
+ if (!target)
1016
+ throw new AgentError("agent.unbound");
1017
+ if (!target.definition.enabled)
1018
+ throw new AgentError("agent.disabled");
1019
+ if (!senderAllowed(target.definition.channel?.allow_from ?? [], message.from))
1020
+ throw new AgentError("agent.sender_not_allowed");
1021
+ const threadKey = agentThreadKey(message);
1022
+ const now = this.iso();
1023
+ const existing = this.store.findSessionByThread(projectKey, target.agentId, threadKey);
1024
+ if (!this.store.rememberInboundEvent(projectKey, inboundDedupeKey(message), now)) {
1025
+ if (!existing)
1026
+ throw new AgentError("agent.session_not_found");
1027
+ return { agentId: target.agentId, sessionId: existing.id, duplicate: true };
1028
+ }
1029
+ if (existing)
1030
+ return { agentId: target.agentId, sessionId: existing.id, duplicate: false };
1031
+ const session = {
1032
+ id: id("ses"),
1033
+ agentId: target.agentId,
1034
+ revision: target.revision,
1035
+ backend: target.definition.model.backend,
1036
+ status: "open",
1037
+ source: message.provider,
1038
+ sourceRef: message.from ?? null,
1039
+ threadKey,
1040
+ title: null,
1041
+ createdAt: now,
1042
+ updatedAt: now,
1043
+ };
1044
+ this.store.insertSession(projectKey, session);
1045
+ return { agentId: target.agentId, sessionId: session.id, duplicate: false };
1046
+ });
1047
+ }
1048
+ /**
1049
+ * At-most-once outbound send keyed by operation. A send whose outcome is
1050
+ * unknown (crash between submit and record) is never retried automatically.
1051
+ */
1052
+ async sendOutbound(projectRoot, sessionId, operationKey, submit) {
1053
+ this.sessionInProject(projectRoot, sessionId);
1054
+ const existing = this.store.getOutboundState(sessionId, operationKey);
1055
+ if (existing === "submitted")
1056
+ return { state: "submitted", resent: false };
1057
+ if (existing === "unknown")
1058
+ throw new AgentError("agent.outbound_unknown");
1059
+ this.store.setOutboundState(sessionId, operationKey, "unknown", this.iso());
1060
+ try {
1061
+ await submit();
1062
+ }
1063
+ catch (error) {
1064
+ // Say why (a channel error code or system message, never the message text) so a lost reply can be diagnosed.
1065
+ const why = error?.code ?? (error instanceof Error ? error.message : String(error));
1066
+ throw new AgentError("agent.outbound_unknown", `The reply could not be sent: ${String(why)}`);
1067
+ }
1068
+ this.store.setOutboundState(sessionId, operationKey, "submitted", this.iso());
1069
+ return { state: "submitted", resent: true };
1070
+ }
1071
+ /* ---------- schedules ---------- */
1072
+ async scheduledAgents(projectRoot) {
1073
+ return this.exclusive(projectRoot, async () => {
1074
+ await this.sync(projectRoot);
1075
+ const projectKey = agentProjectKey(projectRoot);
1076
+ const rows = [];
1077
+ for (const row of this.store.listDefinitions(projectKey)) {
1078
+ if (row.archived || row.currentRevision === 0)
1079
+ continue;
1080
+ const definition = this.store.getRevision(projectKey, row.agentId, row.currentRevision)?.definition;
1081
+ if (definition?.enabled && definition.schedule)
1082
+ rows.push({ agentId: row.agentId, schedule: definition.schedule });
1083
+ }
1084
+ return rows;
1085
+ });
1086
+ }
1087
+ }
1088
+ export function hashApprovalPayload(payload) {
1089
+ return sha256(stableJson(payload));
1090
+ }
1091
+ function stableJson(value) {
1092
+ if (value === null || typeof value !== "object")
1093
+ return JSON.stringify(value);
1094
+ if (Array.isArray(value))
1095
+ return `[${value.map((item) => stableJson(item)).join(",")}]`;
1096
+ const record = value;
1097
+ return `{${Object.keys(record)
1098
+ .sort()
1099
+ .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
1100
+ .join(",")}}`;
1101
+ }
1102
+ function titleFrom(text) {
1103
+ const line = text.trim().split("\n")[0] ?? "";
1104
+ return line.length > 80 ? `${line.slice(0, 77)}…` : line;
1105
+ }