@vellumai/assistant 0.11.1 → 0.11.2-staging.2

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 (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Withdraw a Telegram approval card when its underlying guardian request
3
+ * resolves.
4
+ *
5
+ * Telegram bots cannot re-read a delivered message, so the Slack shape
6
+ * (fetch the original content, strip the buttons, append a status line in
7
+ * one edit) is not available here. The closest faithful projection is:
8
+ *
9
+ * 1. Remove the inline keyboard in place (`editMessageReplyMarkup`), which
10
+ * preserves the card's text for the audit trail while dropping every
11
+ * live affordance.
12
+ * 2. Post the terminal outcome as a silent reply quoting the card, so the
13
+ * message thread durably shows what was decided. The reply is skipped
14
+ * when the decision originated on Telegram: the guardian reply router
15
+ * already delivers its own outcome message in that chat, and a second
16
+ * notice would read as a duplicate.
17
+ *
18
+ * Bot API wrappers differ on the "remove markup" form, so the edit tries the
19
+ * explicit `null` first and falls back to an empty inline keyboard, mirroring
20
+ * the gateway's callback-time clearing. "message is not modified" means the
21
+ * keyboard is already gone (e.g. the gateway cleared it when the deciding tap
22
+ * arrived) and is treated as success. Unlike the gateway's tap path, a failed
23
+ * edit never deletes the message: withdrawal must preserve the audit trail,
24
+ * and a tap on a leftover button already gets the stale "already resolved"
25
+ * handling.
26
+ */
27
+
28
+ import {
29
+ isParkAction,
30
+ resolveDecisionStatusWord,
31
+ } from "../../../runtime/channel-approval-types.js";
32
+ import { getLogger } from "../../../util/logger.js";
33
+ import { callTelegramBotApi } from "./api.js";
34
+
35
+ const log = getLogger("telegram-withdraw");
36
+
37
+ const STATUS_GLYPH: Record<string, string> = {
38
+ approved: "✅",
39
+ denied: "❌",
40
+ expired: "⌛",
41
+ cancelled: "\u{1f6ab}",
42
+ };
43
+
44
+ /**
45
+ * Glyph for a parked (leave-unverified) decision: a neutral "on hold" mark
46
+ * rather than the `denied` cross, since the sender was neither trusted nor
47
+ * kept out.
48
+ */
49
+ const PARK_STATUS_GLYPH = "⏸️";
50
+
51
+ export interface WithdrawTelegramApprovalCardParams {
52
+ /** Telegram chat the approval message lives in. */
53
+ chatId: string;
54
+ /** Channel-native id of the approval message to withdraw. */
55
+ messageId: string;
56
+ /** Terminal status of the request (e.g. "approved", "denied", "expired"). */
57
+ status: string;
58
+ /**
59
+ * The action the guardian took, when known. A `denied` status reached by a
60
+ * park action (`leave_unverified`) reads as the neutral park label (see
61
+ * {@link resolveDecisionStatusWord}) rather than "Denied"; `block`/`reject`
62
+ * stay a denial. Omitted for status-only transitions (e.g. the expiry
63
+ * sweep).
64
+ */
65
+ decidedAction?: string;
66
+ /**
67
+ * Whether to post the quoted status reply. False when the decision
68
+ * originated on Telegram, where the guardian reply router already delivers
69
+ * the outcome message in the same chat.
70
+ */
71
+ postStatusReply: boolean;
72
+ }
73
+
74
+ /** Build the plain-text outcome line for the quoted status reply. */
75
+ function buildStatusText(status: string, decidedAction?: string): string {
76
+ const park = status === "denied" && isParkAction(decidedAction);
77
+ const glyph = park ? PARK_STATUS_GLYPH : (STATUS_GLYPH[status] ?? "");
78
+ const word = resolveDecisionStatusWord(status, decidedAction);
79
+ return glyph ? `${glyph} ${word}` : word;
80
+ }
81
+
82
+ /** True for the Bot API's "message is not modified" no-op edit rejection. */
83
+ function isNoOpMarkupError(err: unknown): boolean {
84
+ const msg = err instanceof Error ? err.message : String(err);
85
+ return msg.includes("message is not modified");
86
+ }
87
+
88
+ /**
89
+ * Remove the card's inline keyboard in place, keeping its text. Tries the
90
+ * explicit `reply_markup: null` form first, then an empty inline keyboard.
91
+ * Returns true when the keyboard is gone (including the already-cleared
92
+ * no-op case); false when both edit forms failed.
93
+ */
94
+ async function clearInlineKeyboard(
95
+ chatId: string,
96
+ messageId: number,
97
+ ): Promise<boolean> {
98
+ const basePayload = { chat_id: chatId, message_id: messageId };
99
+ try {
100
+ await callTelegramBotApi("editMessageReplyMarkup", {
101
+ ...basePayload,
102
+ reply_markup: null,
103
+ });
104
+ return true;
105
+ } catch (primaryErr) {
106
+ if (isNoOpMarkupError(primaryErr)) {
107
+ return true;
108
+ }
109
+ try {
110
+ await callTelegramBotApi("editMessageReplyMarkup", {
111
+ ...basePayload,
112
+ reply_markup: { inline_keyboard: [] },
113
+ });
114
+ return true;
115
+ } catch (fallbackErr) {
116
+ if (isNoOpMarkupError(fallbackErr)) {
117
+ return true;
118
+ }
119
+ log.warn(
120
+ { primaryErr, fallbackErr, chatId, messageId },
121
+ "Failed to clear inline keyboard on resolved Telegram approval card",
122
+ );
123
+ return false;
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Project a resolved guardian request onto its Telegram approval card:
130
+ * drop the inline keyboard in place and (unless suppressed) post a silent
131
+ * reply quoting the card with the terminal outcome.
132
+ *
133
+ * Both steps are attempted independently, so a failed keyboard edit does not
134
+ * lose the durable outcome notice. Throws only if the status reply send
135
+ * fails, which the caller treats as non-fatal.
136
+ */
137
+ export async function withdrawTelegramApprovalCard(
138
+ params: WithdrawTelegramApprovalCardParams,
139
+ ): Promise<void> {
140
+ const parsedMessageId = Number(params.messageId);
141
+ if (!Number.isFinite(parsedMessageId)) {
142
+ log.warn(
143
+ { chatId: params.chatId, messageId: params.messageId },
144
+ "Skipping Telegram card withdrawal due to invalid message id",
145
+ );
146
+ return;
147
+ }
148
+
149
+ await clearInlineKeyboard(params.chatId, parsedMessageId);
150
+
151
+ if (!params.postStatusReply) {
152
+ return;
153
+ }
154
+
155
+ // Silent (no push) reply quoting the card. `allow_sending_without_reply`
156
+ // keeps the outcome notice deliverable even if the card was deleted.
157
+ await callTelegramBotApi("sendMessage", {
158
+ chat_id: params.chatId,
159
+ text: buildStatusText(params.status, params.decidedAction),
160
+ reply_parameters: {
161
+ message_id: parsedMessageId,
162
+ allow_sending_without_reply: true,
163
+ },
164
+ disable_notification: true,
165
+ });
166
+ }
@@ -8,7 +8,13 @@ import { closeSync, mkdtempSync, openSync, rmSync, writeSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { Database } from "bun:sqlite";
11
- import { afterEach, beforeEach, expect, test } from "bun:test";
11
+ import {
12
+ afterEach,
13
+ beforeEach,
14
+ expect,
15
+ setDefaultTimeout,
16
+ test,
17
+ } from "bun:test";
12
18
 
13
19
  import { assertNotLiveDb } from "../../__tests__/assert-not-live-db.js";
14
20
  import {
@@ -17,6 +23,10 @@ import {
17
23
  runIntegrityCheck,
18
24
  } from "../db-integrity-check.js";
19
25
 
26
+ // quick_check and the cold `bun run` subprocess are disk-bound; loaded CI
27
+ // runners blow past the 5s default.
28
+ setDefaultTimeout(30_000);
29
+
20
30
  let dir: string;
21
31
  let dbPath: string;
22
32
 
@@ -36,9 +46,13 @@ function seedDb(walMode: boolean): void {
36
46
  db.exec(`PRAGMA journal_mode=${walMode ? "WAL" : "DELETE"}`);
37
47
  db.exec("CREATE TABLE t (id TEXT PRIMARY KEY, v TEXT)");
38
48
  const ins = db.prepare("INSERT INTO t (id, v) VALUES (?, ?)");
39
- for (let i = 0; i < 200; i++) {
40
- ins.run(`k-${i}`, `v-${i}`);
41
- }
49
+ // One transaction: 200 per-insert commits are fsync-bound and take
50
+ // seconds on loaded CI runners.
51
+ db.transaction(() => {
52
+ for (let i = 0; i < 200; i++) {
53
+ ins.run(`k-${i}`, `v-${i}`);
54
+ }
55
+ })();
42
56
  } finally {
43
57
  db.close();
44
58
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Tests for the descriptor poller's pure layers: limit parsing and the pressure
3
+ * decision. The collection functions read fixed `/proc` paths, so only the
4
+ * parsing and threshold logic are exercised here.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+
9
+ import {
10
+ createFdPressureState,
11
+ evaluateFdPressure,
12
+ parseOpenFileLimits,
13
+ type ProcessFdPressure,
14
+ type ProcessFdUsage,
15
+ } from "../file-descriptors.js";
16
+
17
+ const LIMITS = `Limit Soft Limit Hard Limit Units
18
+ Max cpu time unlimited unlimited seconds
19
+ Max file size unlimited unlimited bytes
20
+ Max open files 1024 1048576 files
21
+ Max locked memory 8388608 8388608 bytes
22
+ `;
23
+
24
+ /** A process whose soft limit is known, so it carries a comparable ratio. */
25
+ function usage(fields: {
26
+ pid?: number;
27
+ command?: string;
28
+ openCount: number;
29
+ softLimit?: number;
30
+ }): ProcessFdPressure {
31
+ const {
32
+ pid = 1,
33
+ command = "daemon-main",
34
+ openCount,
35
+ softLimit = 1024,
36
+ } = fields;
37
+ return {
38
+ pid,
39
+ command,
40
+ openCount,
41
+ softLimit,
42
+ hardLimit: 1_048_576,
43
+ ratio: openCount / softLimit,
44
+ };
45
+ }
46
+
47
+ describe("parseOpenFileLimits", () => {
48
+ test("reads the soft and hard open-file limits", () => {
49
+ expect(parseOpenFileLimits(LIMITS)).toEqual({
50
+ soft: 1024,
51
+ hard: 1_048_576,
52
+ });
53
+ });
54
+
55
+ test("reports null for an unlimited descriptor limit", () => {
56
+ expect(
57
+ parseOpenFileLimits(
58
+ "Max open files unlimited unlimited files\n",
59
+ ),
60
+ ).toEqual({ soft: null, hard: null });
61
+ });
62
+
63
+ test("reports null when the row is absent or the file is empty", () => {
64
+ expect(
65
+ parseOpenFileLimits("Max cpu time unlimited unlimited seconds\n"),
66
+ ).toEqual({ soft: null, hard: null });
67
+ expect(parseOpenFileLimits("")).toEqual({ soft: null, hard: null });
68
+ });
69
+ });
70
+
71
+ describe("evaluateFdPressure", () => {
72
+ const options = { thresholdRatio: 0.8, warnCooldownMs: 60_000 };
73
+
74
+ test("stays quiet while every process is under the threshold", () => {
75
+ const result = evaluateFdPressure(
76
+ [usage({ openCount: 700 }), usage({ pid: 2, openCount: 100 })],
77
+ options,
78
+ createFdPressureState(),
79
+ 1_000,
80
+ );
81
+ expect(result.action).toEqual({ kind: "none" });
82
+ expect(result.state).toEqual({ lastWarnAt: 0, overThreshold: false });
83
+ });
84
+
85
+ test("warns on the crossing, naming the over-threshold processes", () => {
86
+ const hot = usage({ pid: 7, openCount: 900 });
87
+ const result = evaluateFdPressure(
88
+ [hot, usage({ pid: 2, openCount: 10 })],
89
+ options,
90
+ createFdPressureState(),
91
+ 5_000,
92
+ );
93
+ expect(result.action).toEqual({ kind: "warn", processes: [hot] });
94
+ expect(result.state).toEqual({ lastWarnAt: 5_000, overThreshold: true });
95
+ });
96
+
97
+ test("throttles repeats to one warn per cooldown while usage stays over", () => {
98
+ const hot = usage({ openCount: 1000 });
99
+ const warned = { lastWarnAt: 5_000, overThreshold: true };
100
+
101
+ const cooling = evaluateFdPressure([hot], options, warned, 30_000);
102
+ expect(cooling.action).toEqual({ kind: "none" });
103
+ // The warn timestamp is preserved so the cooldown measures from the warn.
104
+ expect(cooling.state).toEqual(warned);
105
+
106
+ const reWarn = evaluateFdPressure([hot], options, warned, 65_000);
107
+ expect(reWarn.action).toEqual({ kind: "warn", processes: [hot] });
108
+ expect(reWarn.state).toEqual({ lastWarnAt: 65_000, overThreshold: true });
109
+ });
110
+
111
+ test("reports recovery once, then goes quiet", () => {
112
+ const cool = usage({ openCount: 10 });
113
+ const recovered = evaluateFdPressure(
114
+ [cool],
115
+ options,
116
+ { lastWarnAt: 5_000, overThreshold: true },
117
+ 70_000,
118
+ );
119
+ expect(recovered.action).toEqual({ kind: "recovered" });
120
+ expect(recovered.state).toEqual({ lastWarnAt: 0, overThreshold: false });
121
+
122
+ expect(
123
+ evaluateFdPressure([cool], options, recovered.state, 80_000).action,
124
+ ).toEqual({ kind: "none" });
125
+ });
126
+
127
+ test("ignores processes whose soft limit is unknown", () => {
128
+ const unlimited: ProcessFdUsage = {
129
+ pid: 3,
130
+ command: "vellum-qdrant",
131
+ openCount: 100_000,
132
+ softLimit: null,
133
+ hardLimit: null,
134
+ ratio: null,
135
+ };
136
+ const result = evaluateFdPressure(
137
+ [unlimited],
138
+ options,
139
+ createFdPressureState(),
140
+ 1_000,
141
+ );
142
+ expect(result.action).toEqual({ kind: "none" });
143
+ });
144
+ });
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Open file-descriptor accounting for the container's processes, polled by the
3
+ * resource monitor.
4
+ *
5
+ * Descriptor exhaustion is the memory limit's quieter twin: a process that
6
+ * reaches its `RLIMIT_NOFILE` soft limit starts failing every `open()`,
7
+ * `accept()`, and `socket()` with EMFILE, so a leak surfaces as unrelated
8
+ * connection and file errors scattered across the daemon rather than as a
9
+ * single recognizable failure. The limit is per process (unlike the cgroup
10
+ * memory limit, which is shared), so usage is measured per PID: the open count
11
+ * from `/proc/<pid>/fd` against the soft limit from `/proc/<pid>/limits`.
12
+ *
13
+ * Enumerating descriptors costs a readdir plus a small read per process, which
14
+ * is why this polls on its own slow timer instead of riding the 250ms resource
15
+ * sampler. Descriptor counts climb over minutes, not milliseconds.
16
+ *
17
+ * Reads are best-effort: a process that exits mid-walk, or one owned by another
18
+ * user, is skipped rather than failing the pass.
19
+ */
20
+
21
+ import { readdirSync, readFileSync } from "node:fs";
22
+
23
+ import type { MonitoringConfig } from "../config/schemas/monitoring.js";
24
+ import { getLogger } from "../util/logger.js";
25
+ import { readProcessCommand } from "../util/process-tree.js";
26
+
27
+ const log = getLogger("file-descriptors");
28
+
29
+ /** How many over-threshold processes a single warn line reports. */
30
+ const WARN_PROCESS_LIMIT = 5;
31
+
32
+ export interface OpenFileLimits {
33
+ /** The limit actually enforced on `open()`; null when unlimited or absent. */
34
+ soft: number | null;
35
+ /** Ceiling the process may raise its soft limit to; null when unlimited. */
36
+ hard: number | null;
37
+ }
38
+
39
+ export interface ProcessFdUsage {
40
+ pid: number;
41
+ /** Redacted process descriptor (see {@link readProcessCommand}). */
42
+ command: string;
43
+ /** Entries in `/proc/<pid>/fd`. */
44
+ openCount: number;
45
+ softLimit: number | null;
46
+ hardLimit: number | null;
47
+ /** openCount / softLimit, or null when the soft limit is unknown. */
48
+ ratio: number | null;
49
+ }
50
+
51
+ /** A process whose soft limit is known, so its usage ratio is comparable. */
52
+ export type ProcessFdPressure = ProcessFdUsage & { ratio: number };
53
+
54
+ /** `Max open files <soft> <hard> files` in `/proc/<pid>/limits`. */
55
+ const MAX_OPEN_FILES_RE = /^Max open files\s+(\S+)\s+(\S+)/m;
56
+
57
+ /**
58
+ * Parse the descriptor limits out of `/proc/<pid>/limits`. Both fields are null
59
+ * when the row is missing, and an individual field is null when the kernel
60
+ * reports `unlimited`.
61
+ */
62
+ export function parseOpenFileLimits(raw: string): OpenFileLimits {
63
+ const match = MAX_OPEN_FILES_RE.exec(raw);
64
+ if (!match) {
65
+ return { soft: null, hard: null };
66
+ }
67
+ const count = (field: string) => {
68
+ const parsed = parseInt(field, 10);
69
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
70
+ };
71
+ return { soft: count(match[1]), hard: count(match[2]) };
72
+ }
73
+
74
+ /**
75
+ * Descriptor usage for one PID, or null when the process is gone or its `/proc`
76
+ * entry is unreadable. The count includes the descriptor `readdirSync` itself
77
+ * holds when reading this process's own `/proc/self/fd`, so a process's
78
+ * self-measurement runs one high.
79
+ */
80
+ function readProcessFdUsage(pid: number): ProcessFdUsage | null {
81
+ let openCount: number;
82
+ try {
83
+ openCount = readdirSync(`/proc/${pid}/fd`).length;
84
+ } catch {
85
+ // Exited between readdir and read, or owned by another user.
86
+ return null;
87
+ }
88
+ const command = readProcessCommand(pid);
89
+ if (command == null) {
90
+ return null;
91
+ }
92
+ let limits: OpenFileLimits = { soft: null, hard: null };
93
+ try {
94
+ limits = parseOpenFileLimits(readFileSync(`/proc/${pid}/limits`, "utf-8"));
95
+ } catch {
96
+ // Limits unreadable; the raw count is still worth reporting.
97
+ }
98
+ return {
99
+ pid,
100
+ command,
101
+ openCount,
102
+ softLimit: limits.soft,
103
+ hardLimit: limits.hard,
104
+ ratio: limits.soft != null ? openCount / limits.soft : null,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Descriptor usage for every readable process, closest to its soft limit first
110
+ * (processes with an unknown limit sort last, by raw count). Empty when
111
+ * `/proc` is unavailable, e.g. on macOS.
112
+ */
113
+ export function collectFdUsage(): ProcessFdUsage[] {
114
+ let entries: string[];
115
+ try {
116
+ entries = readdirSync("/proc").filter((entry) => /^\d+$/.test(entry));
117
+ } catch {
118
+ return [];
119
+ }
120
+
121
+ const rows: ProcessFdUsage[] = [];
122
+ for (const entry of entries) {
123
+ const usage = readProcessFdUsage(Number(entry));
124
+ if (usage != null) {
125
+ rows.push(usage);
126
+ }
127
+ }
128
+
129
+ rows.sort(
130
+ (a, b) => (b.ratio ?? -1) - (a.ratio ?? -1) || b.openCount - a.openCount,
131
+ );
132
+ return rows;
133
+ }
134
+
135
+ /** The top `limit` processes by descriptor pressure, most pressured first. */
136
+ export function topProcessesByFd(limit: number): ProcessFdUsage[] {
137
+ return collectFdUsage().slice(0, limit);
138
+ }
139
+
140
+ /** What a pass concluded about the container's descriptor pressure. */
141
+ export type FdPressureAction =
142
+ | { kind: "none" }
143
+ | { kind: "warn"; processes: ProcessFdPressure[] }
144
+ | { kind: "recovered" };
145
+
146
+ /** Pass-to-pass state, so a sustained condition warns on a cooldown. */
147
+ export interface FdPressureState {
148
+ /** When the last warn was emitted; 0 when none has been. */
149
+ lastWarnAt: number;
150
+ /** Whether the previous pass found any process over the threshold. */
151
+ overThreshold: boolean;
152
+ }
153
+
154
+ export function createFdPressureState(): FdPressureState {
155
+ return { lastWarnAt: 0, overThreshold: false };
156
+ }
157
+
158
+ /**
159
+ * Decide what a pass should report, given the pressure it measured and the
160
+ * previous pass's state. Crossing the threshold warns immediately; staying over
161
+ * it re-warns at most once per `warnCooldownMs`, so a leak logs a rising count
162
+ * instead of a line per poll. Dropping back under reports a single recovery.
163
+ */
164
+ export function evaluateFdPressure(
165
+ usage: readonly ProcessFdUsage[],
166
+ options: { thresholdRatio: number; warnCooldownMs: number },
167
+ state: FdPressureState,
168
+ now: number,
169
+ ): { action: FdPressureAction; state: FdPressureState } {
170
+ const over = usage.filter(
171
+ (p): p is ProcessFdPressure =>
172
+ p.ratio != null && p.ratio >= options.thresholdRatio,
173
+ );
174
+
175
+ if (over.length === 0) {
176
+ return {
177
+ action: state.overThreshold ? { kind: "recovered" } : { kind: "none" },
178
+ state: createFdPressureState(),
179
+ };
180
+ }
181
+
182
+ if (state.overThreshold && now - state.lastWarnAt < options.warnCooldownMs) {
183
+ return { action: { kind: "none" }, state };
184
+ }
185
+
186
+ return {
187
+ action: { kind: "warn", processes: over },
188
+ state: { lastWarnAt: now, overThreshold: true },
189
+ };
190
+ }
191
+
192
+ function logFdPressure(action: FdPressureAction, thresholdRatio: number): void {
193
+ if (action.kind === "warn") {
194
+ log.warn(
195
+ {
196
+ thresholdRatio,
197
+ processCount: action.processes.length,
198
+ // PID only, no command: a command line can carry secrets passed as
199
+ // arguments, and this line lands in the rotating workspace log.
200
+ processes: action.processes.slice(0, WARN_PROCESS_LIMIT).map((p) => ({
201
+ pid: p.pid,
202
+ openCount: p.openCount,
203
+ softLimit: p.softLimit,
204
+ hardLimit: p.hardLimit,
205
+ ratio: Math.round(p.ratio * 1000) / 1000,
206
+ })),
207
+ },
208
+ "Processes near their open file-descriptor limit",
209
+ );
210
+ return;
211
+ }
212
+ if (action.kind === "recovered") {
213
+ log.info(
214
+ { thresholdRatio },
215
+ "Open file-descriptor usage back under threshold",
216
+ );
217
+ }
218
+ }
219
+
220
+ export interface FileDescriptorMonitorHandle {
221
+ stop: () => void;
222
+ }
223
+
224
+ /**
225
+ * Start the descriptor poller. The first pass runs one interval in, not at
226
+ * boot: descriptor counts are still ramping while the daemon opens its sockets
227
+ * and database handles, and the walk is I/O the boot path does not need.
228
+ *
229
+ * The timer is unref'd: the resource sampler is the monitor process's
230
+ * keep-alive, and this loop must not extend its life on its own.
231
+ */
232
+ export function startFileDescriptorMonitor(
233
+ config: MonitoringConfig,
234
+ clock: () => number = Date.now,
235
+ ): FileDescriptorMonitorHandle {
236
+ let state = createFdPressureState();
237
+
238
+ const poll = () => {
239
+ try {
240
+ const result = evaluateFdPressure(
241
+ collectFdUsage(),
242
+ {
243
+ thresholdRatio: config.highFdThresholdRatio,
244
+ warnCooldownMs: config.fdWarnCooldownMs,
245
+ },
246
+ state,
247
+ clock(),
248
+ );
249
+ state = result.state;
250
+ logFdPressure(result.action, config.highFdThresholdRatio);
251
+ } catch (err) {
252
+ log.warn({ err }, "File-descriptor poll failed");
253
+ }
254
+ };
255
+
256
+ const timer = setInterval(poll, config.fdPollIntervalMs);
257
+ timer.unref?.();
258
+
259
+ return {
260
+ stop: () => clearInterval(timer),
261
+ };
262
+ }
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { readdirSync, readFileSync } from "node:fs";
13
13
 
14
- import { deriveName } from "../util/process-tree.js";
14
+ import { readProcessCommand } from "../util/process-tree.js";
15
15
 
16
16
  /** Linux page size assumed when converting `/proc/<pid>/statm` pages to bytes. */
17
17
  const PAGE_SIZE_BYTES = 4096;
@@ -101,16 +101,9 @@ export function topProcessesByMemory(limit: number): ProcessMemory[] {
101
101
  if (mem == null) {
102
102
  continue;
103
103
  }
104
- // Redact the raw command line via deriveName to strip secrets (tokens,
105
- // API keys, database URLs) commonly passed as process arguments. The
106
- // raw command line is read here but never stored.
107
- let command: string;
108
- try {
109
- const raw = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
110
- command =
111
- deriveName(raw.split("\0").filter(Boolean).join(" ")) || `pid ${pid}`;
112
- } catch {
113
- // Process exited between readdir and read — skip.
104
+ const command = readProcessCommand(pid);
105
+ if (command == null) {
106
+ // Process exited between readdir and read; skip it.
114
107
  continue;
115
108
  }
116
109
  rows.push({ pid, command, ...mem });
@@ -36,6 +36,7 @@ import { getLogger } from "../util/logger.js";
36
36
  import { getMonitoringDataDir } from "../util/platform.js";
37
37
  import { buildProcessTree, listProcesses } from "../util/process-tree.js";
38
38
  import { readActiveConversations } from "./active-conversations.js";
39
+ import { topProcessesByFd } from "./file-descriptors.js";
39
40
  import { getTrackedDataFiles, readFileResidency } from "./page-cache.js";
40
41
  import { topProcessesByMemory } from "./process-memory.js";
41
42
  import { prunePrefixedJsonFiles } from "./prune-snapshots.js";
@@ -176,6 +177,10 @@ export async function writeSnapshot(
176
177
  // Slab memory belongs to no process; without this, cgroup usage that
177
178
  // exceeds the per-process sum has no visible owner.
178
179
  topSlabCaches: topSlabCaches(10),
180
+ // Descriptor pressure, ranked against each process's own soft limit. An
181
+ // EMFILE storm and a memory spike look alike from the outside (unrelated
182
+ // failures across the daemon), so the capture records both.
183
+ topProcessesByFd: topProcessesByFd(15),
179
184
  processTree: tree,
180
185
  };
181
186