comisai 1.0.36 → 1.0.37

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 (239) hide show
  1. package/node_modules/@comis/agent/dist/background/auto-background-middleware.js +9 -0
  2. package/node_modules/@comis/agent/dist/background/background-task-manager.d.ts +22 -2
  3. package/node_modules/@comis/agent/dist/background/background-task-manager.js +48 -41
  4. package/node_modules/@comis/agent/dist/background/background-task-persistence.js +28 -5
  5. package/node_modules/@comis/agent/dist/background/background-task-types.d.ts +49 -0
  6. package/node_modules/@comis/agent/dist/background/completion-dispatcher.d.ts +130 -0
  7. package/node_modules/@comis/agent/dist/background/completion-dispatcher.js +215 -0
  8. package/node_modules/@comis/agent/dist/background/completion-runner.d.ts +10 -1
  9. package/node_modules/@comis/agent/dist/background/completion-runner.js +98 -15
  10. package/node_modules/@comis/agent/dist/background/index.d.ts +6 -1
  11. package/node_modules/@comis/agent/dist/background/index.js +2 -0
  12. package/node_modules/@comis/agent/dist/background/session-resolver.d.ts +85 -0
  13. package/node_modules/@comis/agent/dist/background/session-resolver.js +78 -0
  14. package/node_modules/@comis/agent/dist/bootstrap/sections/messaging-sections.js +1 -0
  15. package/node_modules/@comis/agent/dist/bootstrap/sections/tool-descriptions.js +3 -3
  16. package/node_modules/@comis/agent/dist/bootstrap/sections/tooling-sections.d.ts +30 -2
  17. package/node_modules/@comis/agent/dist/bootstrap/sections/tooling-sections.js +51 -2
  18. package/node_modules/@comis/agent/dist/bootstrap/system-prompt-assembler.d.ts +22 -0
  19. package/node_modules/@comis/agent/dist/bootstrap/system-prompt-assembler.js +2 -2
  20. package/node_modules/@comis/agent/dist/bridge/bridge-event-handlers.d.ts +1 -5
  21. package/node_modules/@comis/agent/dist/bridge/bridge-event-handlers.js +2 -14
  22. package/node_modules/@comis/agent/dist/bridge/bridge-metrics.d.ts +26 -0
  23. package/node_modules/@comis/agent/dist/bridge/bridge-metrics.js +3 -0
  24. package/node_modules/@comis/agent/dist/bridge/pi-event-bridge.d.ts +9 -0
  25. package/node_modules/@comis/agent/dist/bridge/pi-event-bridge.js +73 -2
  26. package/node_modules/@comis/agent/dist/context-engine/signature-surrogate-guard.d.ts +10 -10
  27. package/node_modules/@comis/agent/dist/context-engine/signature-surrogate-guard.js +14 -14
  28. package/node_modules/@comis/agent/dist/context-engine/thinking-block-cleaner.d.ts +11 -13
  29. package/node_modules/@comis/agent/dist/context-engine/thinking-block-cleaner.js +14 -15
  30. package/node_modules/@comis/agent/dist/executor/capability-index-context.d.ts +72 -0
  31. package/node_modules/@comis/agent/dist/executor/capability-index-context.js +329 -0
  32. package/node_modules/@comis/agent/dist/executor/drain-helper.d.ts +122 -0
  33. package/node_modules/@comis/agent/dist/executor/drain-helper.js +173 -0
  34. package/node_modules/@comis/agent/dist/executor/error-classifier.js +2 -2
  35. package/node_modules/@comis/agent/dist/executor/executor-post-execution.d.ts +48 -4
  36. package/node_modules/@comis/agent/dist/executor/executor-post-execution.js +134 -31
  37. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.d.ts +7 -0
  38. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.js +25 -4
  39. package/node_modules/@comis/agent/dist/executor/executor-tool-assembly.d.ts +18 -1
  40. package/node_modules/@comis/agent/dist/executor/executor-tool-assembly.js +19 -16
  41. package/node_modules/@comis/agent/dist/executor/jit-guide-injector.d.ts +11 -2
  42. package/node_modules/@comis/agent/dist/executor/jit-guide-injector.js +16 -2
  43. package/node_modules/@comis/agent/dist/executor/pi-executor.d.ts +8 -2
  44. package/node_modules/@comis/agent/dist/executor/pi-executor.js +25 -12
  45. package/node_modules/@comis/agent/dist/executor/prompt-assembly.d.ts +9 -1
  46. package/node_modules/@comis/agent/dist/executor/prompt-assembly.js +15 -1
  47. package/node_modules/@comis/agent/dist/executor/tool-deferral.d.ts +18 -27
  48. package/node_modules/@comis/agent/dist/executor/tool-deferral.js +29 -38
  49. package/node_modules/@comis/agent/dist/model/model-registry-adapter.js +1 -1
  50. package/node_modules/@comis/agent/dist/model/model-scanner.js +1 -1
  51. package/node_modules/@comis/agent/dist/safety/tool-retry-breaker.d.ts +11 -1
  52. package/node_modules/@comis/agent/dist/safety/tool-retry-breaker.js +19 -22
  53. package/node_modules/@comis/agent/dist/session/comis-session-manager.d.ts +16 -2
  54. package/node_modules/@comis/agent/dist/spawn/pi-mono-adapters.d.ts +1 -1
  55. package/node_modules/@comis/agent/dist/spawn/pi-mono-adapters.js +5 -5
  56. package/node_modules/@comis/agent/dist/workspace/data-env.d.ts +38 -0
  57. package/node_modules/@comis/agent/dist/workspace/data-env.js +56 -0
  58. package/node_modules/@comis/agent/dist/workspace/index.d.ts +1 -0
  59. package/node_modules/@comis/agent/dist/workspace/index.js +1 -0
  60. package/node_modules/@comis/agent/dist/workspace/templates.js +5 -1
  61. package/node_modules/@comis/agent/package.json +1 -1
  62. package/node_modules/@comis/channels/dist/index.d.ts +1 -1
  63. package/node_modules/@comis/channels/dist/index.js +1 -1
  64. package/node_modules/@comis/channels/dist/shared/channel-manager.d.ts +9 -3
  65. package/node_modules/@comis/channels/dist/shared/inbound-gate.d.ts +1 -1
  66. package/node_modules/@comis/channels/dist/shared/inbound-gate.js +22 -7
  67. package/node_modules/@comis/channels/dist/shared/inbound-pipeline.d.ts +10 -3
  68. package/node_modules/@comis/channels/dist/shared/inbound-route.d.ts +1 -1
  69. package/node_modules/@comis/channels/dist/shared/inbound-route.js +13 -2
  70. package/node_modules/@comis/channels/dist/shared/response-filter.d.ts +11 -24
  71. package/node_modules/@comis/channels/dist/shared/response-filter.js +25 -53
  72. package/node_modules/@comis/channels/package.json +1 -1
  73. package/node_modules/@comis/cli/dist/commands/providers.d.ts +1 -2
  74. package/node_modules/@comis/cli/dist/commands/providers.js +5 -6
  75. package/node_modules/@comis/cli/package.json +1 -1
  76. package/node_modules/@comis/core/dist/config/field-metadata.js +2 -0
  77. package/node_modules/@comis/core/dist/config/immutable-keys.js +4 -1
  78. package/node_modules/@comis/core/dist/config/index.d.ts +4 -0
  79. package/node_modules/@comis/core/dist/config/index.js +2 -0
  80. package/node_modules/@comis/core/dist/config/schema-agent.d.ts +0 -792
  81. package/node_modules/@comis/core/dist/config/schema-approvals.d.ts +0 -14
  82. package/node_modules/@comis/core/dist/config/schema-auto-reply-engine.d.ts +0 -6
  83. package/node_modules/@comis/core/dist/config/schema-background-tasks.d.ts +0 -12
  84. package/node_modules/@comis/core/dist/config/schema-browser.d.ts +0 -18
  85. package/node_modules/@comis/core/dist/config/schema-channel.d.ts +0 -158
  86. package/node_modules/@comis/core/dist/config/schema-coalescer.d.ts +0 -5
  87. package/node_modules/@comis/core/dist/config/schema-daemon.d.ts +0 -32
  88. package/node_modules/@comis/core/dist/config/schema-delivery.d.ts +0 -18
  89. package/node_modules/@comis/core/dist/config/schema-documentation.d.ts +0 -12
  90. package/node_modules/@comis/core/dist/config/schema-embedding.d.ts +0 -20
  91. package/node_modules/@comis/core/dist/config/schema-envelope.d.ts +0 -15
  92. package/node_modules/@comis/core/dist/config/schema-gateway.d.ts +0 -37
  93. package/node_modules/@comis/core/dist/config/schema-gemini-cache.d.ts +0 -2
  94. package/node_modules/@comis/core/dist/config/schema-integrations.d.ts +0 -318
  95. package/node_modules/@comis/core/dist/config/schema-lifecycle-reactions.d.ts +0 -18
  96. package/node_modules/@comis/core/dist/config/schema-memory-review.d.ts +0 -7
  97. package/node_modules/@comis/core/dist/config/schema-memory.d.ts +0 -16
  98. package/node_modules/@comis/core/dist/config/schema-messages.d.ts +0 -8
  99. package/node_modules/@comis/core/dist/config/schema-models.d.ts +0 -15
  100. package/node_modules/@comis/core/dist/config/schema-notification.d.ts +0 -5
  101. package/node_modules/@comis/core/dist/config/schema-oauth.d.ts +0 -5
  102. package/node_modules/@comis/core/dist/config/schema-observability.d.ts +0 -38
  103. package/node_modules/@comis/core/dist/config/schema-output-retention.d.ts +34 -0
  104. package/node_modules/@comis/core/dist/config/schema-output-retention.js +48 -0
  105. package/node_modules/@comis/core/dist/config/schema-plugins.d.ts +0 -8
  106. package/node_modules/@comis/core/dist/config/schema-providers.d.ts +0 -64
  107. package/node_modules/@comis/core/dist/config/schema-queue.d.ts +0 -58
  108. package/node_modules/@comis/core/dist/config/schema-response-prefix.d.ts +0 -2
  109. package/node_modules/@comis/core/dist/config/schema-retry.d.ts +0 -6
  110. package/node_modules/@comis/core/dist/config/schema-scheduler.d.ts +0 -39
  111. package/node_modules/@comis/core/dist/config/schema-secrets.d.ts +0 -3
  112. package/node_modules/@comis/core/dist/config/schema-security.d.ts +0 -18
  113. package/node_modules/@comis/core/dist/config/schema-send-policy.d.ts +0 -13
  114. package/node_modules/@comis/core/dist/config/schema-sender-trust-display.d.ts +0 -5
  115. package/node_modules/@comis/core/dist/config/schema-serializer.js +2 -0
  116. package/node_modules/@comis/core/dist/config/schema-skills.d.ts +0 -61
  117. package/node_modules/@comis/core/dist/config/schema-streaming.d.ts +0 -38
  118. package/node_modules/@comis/core/dist/config/schema-telegram-file-guard.d.ts +0 -3
  119. package/node_modules/@comis/core/dist/config/schema-tooling.d.ts +87 -0
  120. package/node_modules/@comis/core/dist/config/schema-tooling.js +152 -0
  121. package/node_modules/@comis/core/dist/config/schema-verbosity.d.ts +0 -12
  122. package/node_modules/@comis/core/dist/config/schema-webhooks.d.ts +0 -40
  123. package/node_modules/@comis/core/dist/config/schema.d.ts +41 -38
  124. package/node_modules/@comis/core/dist/config/schema.js +6 -0
  125. package/node_modules/@comis/core/dist/context/context.d.ts +0 -4
  126. package/node_modules/@comis/core/dist/domain/approval-request.d.ts +0 -17
  127. package/node_modules/@comis/core/dist/domain/background-task-origin.d.ts +0 -10
  128. package/node_modules/@comis/core/dist/domain/delivery-origin.d.ts +0 -5
  129. package/node_modules/@comis/core/dist/domain/execution-graph.d.ts +0 -48
  130. package/node_modules/@comis/core/dist/domain/memory-entry.d.ts +0 -3
  131. package/node_modules/@comis/core/dist/domain/model-compat.d.ts +0 -4
  132. package/node_modules/@comis/core/dist/domain/normalized-message.d.ts +0 -15
  133. package/node_modules/@comis/core/dist/domain/provider-capabilities.d.ts +0 -6
  134. package/node_modules/@comis/core/dist/domain/rich-message.d.ts +0 -14
  135. package/node_modules/@comis/core/dist/domain/subagent-context-config.d.ts +0 -22
  136. package/node_modules/@comis/core/dist/domain/subagent-context-types.d.ts +0 -8
  137. package/node_modules/@comis/core/dist/event-bus/events-agent.d.ts +31 -0
  138. package/node_modules/@comis/core/dist/event-bus/events-infra.d.ts +5 -0
  139. package/node_modules/@comis/core/dist/exports/config.d.ts +2 -2
  140. package/node_modules/@comis/core/dist/exports/config.js +3 -1
  141. package/node_modules/@comis/core/dist/exports/hooks.d.ts +1 -1
  142. package/node_modules/@comis/core/dist/exports/ports.d.ts +2 -2
  143. package/node_modules/@comis/core/dist/exports/ports.js +1 -1
  144. package/node_modules/@comis/core/dist/ports/channel-plugin.d.ts +0 -13
  145. package/node_modules/@comis/core/dist/ports/index.d.ts +2 -0
  146. package/node_modules/@comis/core/dist/ports/index.js +4 -0
  147. package/node_modules/@comis/core/dist/ports/no-op-tool-capability.d.ts +30 -0
  148. package/node_modules/@comis/core/dist/ports/no-op-tool-capability.js +47 -0
  149. package/node_modules/@comis/core/dist/ports/tool-capability.d.ts +165 -0
  150. package/node_modules/@comis/core/dist/ports/tool-capability.js +15 -0
  151. package/node_modules/@comis/core/dist/security/audit.d.ts +0 -11
  152. package/node_modules/@comis/core/dist/tool-metadata.d.ts +21 -1
  153. package/node_modules/@comis/core/dist/tool-metadata.js +1 -1
  154. package/node_modules/@comis/core/package.json +1 -1
  155. package/node_modules/@comis/daemon/bundled-skills/skill-creator/scripts/validate-skill.py +1 -1
  156. package/node_modules/@comis/daemon/dist/daemon.js +89 -14
  157. package/node_modules/@comis/daemon/dist/rpc/agent-inline-workspace.d.ts +1 -1
  158. package/node_modules/@comis/daemon/dist/rpc/agent-inline-workspace.js +1 -1
  159. package/node_modules/@comis/daemon/dist/rpc/builtin-provider-guard.js +2 -2
  160. package/node_modules/@comis/daemon/dist/rpc/credential-resolver.js +1 -1
  161. package/node_modules/@comis/daemon/dist/rpc/model-handlers.d.ts +1 -1
  162. package/node_modules/@comis/daemon/dist/rpc/model-handlers.js +2 -2
  163. package/node_modules/@comis/daemon/dist/sub-agent-runner.d.ts +18 -0
  164. package/node_modules/@comis/daemon/dist/sub-agent-runner.js +41 -9
  165. package/node_modules/@comis/daemon/dist/wiring/index.d.ts +2 -0
  166. package/node_modules/@comis/daemon/dist/wiring/index.js +1 -0
  167. package/node_modules/@comis/daemon/dist/wiring/setup-agents.d.ts +36 -2
  168. package/node_modules/@comis/daemon/dist/wiring/setup-agents.js +45 -8
  169. package/node_modules/@comis/daemon/dist/wiring/setup-background-completion-runner.d.ts +28 -9
  170. package/node_modules/@comis/daemon/dist/wiring/setup-background-completion-runner.js +36 -9
  171. package/node_modules/@comis/daemon/dist/wiring/setup-background-tasks.js +2 -2
  172. package/node_modules/@comis/daemon/dist/wiring/setup-channels.d.ts +9 -2
  173. package/node_modules/@comis/daemon/dist/wiring/setup-channels.js +15 -9
  174. package/node_modules/@comis/daemon/dist/wiring/setup-cross-session.d.ts +20 -5
  175. package/node_modules/@comis/daemon/dist/wiring/setup-cross-session.js +20 -15
  176. package/node_modules/@comis/daemon/dist/wiring/setup-delivery.js +14 -2
  177. package/node_modules/@comis/daemon/dist/wiring/setup-gateway.d.ts +4 -6
  178. package/node_modules/@comis/daemon/dist/wiring/setup-gateway.js +3 -5
  179. package/node_modules/@comis/daemon/dist/wiring/setup-heartbeat.d.ts +20 -5
  180. package/node_modules/@comis/daemon/dist/wiring/setup-heartbeat.js +11 -2
  181. package/node_modules/@comis/daemon/dist/wiring/setup-output-retention.d.ts +89 -0
  182. package/node_modules/@comis/daemon/dist/wiring/setup-output-retention.js +212 -0
  183. package/node_modules/@comis/daemon/dist/wiring/setup-tools.d.ts +18 -4
  184. package/node_modules/@comis/daemon/dist/wiring/setup-tools.js +29 -10
  185. package/node_modules/@comis/daemon/dist/wiring/tool-capability-adapter.d.ts +75 -0
  186. package/node_modules/@comis/daemon/dist/wiring/tool-capability-adapter.js +253 -0
  187. package/node_modules/@comis/daemon/package.json +1 -1
  188. package/node_modules/@comis/gateway/dist/webhook/webhook-endpoint.d.ts +0 -4
  189. package/node_modules/@comis/gateway/package.json +1 -1
  190. package/node_modules/@comis/infra/package.json +1 -1
  191. package/node_modules/@comis/memory/package.json +1 -1
  192. package/node_modules/@comis/scheduler/dist/cron/cron-types.d.ts +0 -42
  193. package/node_modules/@comis/scheduler/dist/heartbeat/agent-heartbeat-source.d.ts +29 -8
  194. package/node_modules/@comis/scheduler/dist/heartbeat/agent-heartbeat-source.js +19 -7
  195. package/node_modules/@comis/scheduler/dist/system-events/system-event-types.d.ts +0 -3
  196. package/node_modules/@comis/scheduler/dist/tasks/task-types.d.ts +0 -17
  197. package/node_modules/@comis/scheduler/package.json +1 -1
  198. package/node_modules/@comis/shared/dist/index.d.ts +3 -0
  199. package/node_modules/@comis/shared/dist/index.js +4 -0
  200. package/node_modules/@comis/shared/dist/mcp-tool-name.d.ts +78 -0
  201. package/node_modules/@comis/shared/dist/mcp-tool-name.js +92 -0
  202. package/node_modules/@comis/shared/dist/silent-tokens.d.ts +38 -0
  203. package/node_modules/@comis/shared/dist/silent-tokens.js +51 -0
  204. package/node_modules/@comis/shared/dist/visible-delivery.d.ts +28 -0
  205. package/node_modules/@comis/shared/dist/visible-delivery.js +16 -0
  206. package/node_modules/@comis/shared/package.json +1 -1
  207. package/node_modules/@comis/skills/dist/bridge/mcp-tool-bridge.d.ts +2 -13
  208. package/node_modules/@comis/skills/dist/bridge/mcp-tool-bridge.js +3 -21
  209. package/node_modules/@comis/skills/dist/bridge/tool-metadata-enforcement.js +1 -1
  210. package/node_modules/@comis/skills/dist/bridge/tool-metadata-registry.js +4 -4
  211. package/node_modules/@comis/skills/dist/builtin/exec-tool.d.ts +55 -9
  212. package/node_modules/@comis/skills/dist/builtin/exec-tool.js +383 -19
  213. package/node_modules/@comis/skills/dist/builtin/install-detour.d.ts +67 -0
  214. package/node_modules/@comis/skills/dist/builtin/install-detour.js +342 -0
  215. package/node_modules/@comis/skills/dist/builtin/platform/admin-manage-factory.js +5 -5
  216. package/node_modules/@comis/skills/dist/builtin/platform/agents-manage-tool.d.ts +2 -2
  217. package/node_modules/@comis/skills/dist/builtin/platform/agents-manage-tool.js +2 -2
  218. package/node_modules/@comis/skills/dist/builtin/platform/message-tool.js +18 -0
  219. package/node_modules/@comis/skills/dist/builtin/platform/messaging-factory.d.ts +18 -1
  220. package/node_modules/@comis/skills/dist/builtin/platform/messaging-factory.js +18 -2
  221. package/node_modules/@comis/skills/dist/builtin/platform/models-manage-tool.js +3 -3
  222. package/node_modules/@comis/skills/dist/builtin/process-registry.d.ts +14 -0
  223. package/node_modules/@comis/skills/dist/builtin/process-tool.d.ts +24 -4
  224. package/node_modules/@comis/skills/dist/builtin/process-tool.js +25 -7
  225. package/node_modules/@comis/skills/dist/builtin/sandbox/bwrap-provider.d.ts +1 -1
  226. package/node_modules/@comis/skills/dist/builtin/sandbox/bwrap-provider.js +9 -0
  227. package/node_modules/@comis/skills/dist/index.d.ts +4 -1
  228. package/node_modules/@comis/skills/dist/index.js +3 -1
  229. package/node_modules/@comis/skills/dist/manifest/capability-parser.d.ts +44 -0
  230. package/node_modules/@comis/skills/dist/manifest/capability-parser.js +68 -0
  231. package/node_modules/@comis/skills/dist/manifest/schema.d.ts +44 -37
  232. package/node_modules/@comis/skills/dist/manifest/schema.js +35 -0
  233. package/node_modules/@comis/skills/dist/registry/discovery.d.ts +8 -0
  234. package/node_modules/@comis/skills/dist/registry/discovery.js +10 -3
  235. package/node_modules/@comis/skills/dist/registry/skill-registry.d.ts +45 -1
  236. package/node_modules/@comis/skills/dist/registry/skill-registry.js +70 -7
  237. package/node_modules/@comis/skills/package.json +1 -1
  238. package/node_modules/@comis/web/package.json +1 -1
  239. package/package.json +21 -21
@@ -0,0 +1,215 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Completion dispatcher: routes background_task:completed/failed events
4
+ * through the BackgroundSessionState machine.
5
+ *
6
+ * Subscribes to background_task:completed and background_task:failed BEFORE
7
+ * the existing BackgroundCompletionRunner. On each event:
8
+ * 1. Reads `task.dispatchState`.
9
+ * 2. If "pending": transitions to "notified" only when the runner cannot
10
+ * re-enter the originating session (no active session for the formatted
11
+ * key, or recursion limit reached). Otherwise transitions to "dispatched"
12
+ * and lets the completion-runner perform re-entry.
13
+ * 3. If already "notified" or "dispatched": no-op (at-most-once).
14
+ *
15
+ * The runner is wired AFTER the dispatcher in setup-background-completion-
16
+ * runner.ts so its handler reads the updated `task.dispatchState` and skips
17
+ * its own work when state is "notified" (the dispatcher already fired
18
+ * fallback). This single-owner contract ensures the completion runner does
19
+ * not double-fire user-visible notifications: the dispatcher routes via
20
+ * persistent state instead of an in-memory event handler, and gates on
21
+ * state instead of unconditionally firing.
22
+ *
23
+ * **State persistence:** every transition calls `manager.transitionDispatch
24
+ * State(taskId, next)` (when the manager exposes it) which mutates the
25
+ * in-memory task AND calls persistTaskSync. Recovery-after-SIGKILL reads
26
+ * the persisted state and the manager skips re-emitting completion events
27
+ * for already-dispatched / already-notified tasks.
28
+ *
29
+ * **Failure isolation:** each handler is wrapped in suppressError so a
30
+ * single dispatch's failure does not tear down the subscription
31
+ * (AGENTS §2.1).
32
+ *
33
+ * @module
34
+ */
35
+ import { suppressError } from "@comis/shared";
36
+ // ---------------------------------------------------------------------------
37
+ // Runtime constants exported for downstream consumers (test surface + ops).
38
+ // ---------------------------------------------------------------------------
39
+ /**
40
+ * The 3-state typed enum as a runtime array. Order matches transition order:
41
+ * pending → (notified | dispatched).
42
+ *
43
+ * Exported as a `readonly string[]` so tests can assert
44
+ * `STATES === ["pending", "notified", "dispatched"]`.
45
+ */
46
+ export const STATES = [
47
+ "pending",
48
+ "notified",
49
+ "dispatched",
50
+ ];
51
+ /**
52
+ * Notification policy as a runtime object so it round-trips through
53
+ * JSON.parse(JSON.stringify(...)) preserving identity. A boolean would
54
+ * collapse to true/false on rehydrate and lose the distinction between
55
+ * "deferred" / "immediate" / "silent".
56
+ *
57
+ * The typed enum is the single source of truth. This runtime object is a
58
+ * discoverability surface (tests, debugging, logs); production code uses
59
+ * the type-only `BackgroundTaskNotificationPolicy` from
60
+ * `background-task-types.ts`.
61
+ */
62
+ export const BackgroundTaskNotificationPolicy = {
63
+ DEFERRED: "deferred",
64
+ IMMEDIATE: "immediate",
65
+ SILENT: "silent",
66
+ };
67
+ // ---------------------------------------------------------------------------
68
+ // Factory
69
+ // ---------------------------------------------------------------------------
70
+ /**
71
+ * Wire the completion dispatcher against an event bus + task manager.
72
+ * Subscriptions are installed synchronously; call shutdown() to remove them.
73
+ *
74
+ * At-most-once fallback: the state-machine transitions on
75
+ * `task.dispatchState` are the single source of truth. The dispatcher's
76
+ * synchronous transitionDispatchState runs BEFORE the completion-runner's
77
+ * handler reads the updated state, by virtue of the event-bus subscribing
78
+ * the dispatcher first (see setup-background-completion-runner.ts).
79
+ */
80
+ export function createCompletionDispatcher(deps) {
81
+ const log = deps.logger.child({ submodule: "completion-dispatcher" });
82
+ const fallback = deps.fallbackNotifyFn ?? deps.notifyFn;
83
+ let stopped = false;
84
+ let inflight = Promise.resolve();
85
+ const onCompleted = (data) => {
86
+ if (stopped)
87
+ return;
88
+ const promise = handleEvent(data.taskId, "completed");
89
+ inflight = inflight.then(() => promise).catch(() => undefined);
90
+ suppressError(promise, "completion dispatcher (completed)");
91
+ };
92
+ const onFailed = (data) => {
93
+ if (stopped)
94
+ return;
95
+ const promise = handleEvent(data.taskId, "failed");
96
+ inflight = inflight.then(() => promise).catch(() => undefined);
97
+ suppressError(promise, "completion dispatcher (failed)");
98
+ };
99
+ deps.eventBus.on("background_task:completed", onCompleted);
100
+ deps.eventBus.on("background_task:failed", onFailed);
101
+ async function handleEvent(taskId, kind) {
102
+ const task = deps.taskManager.getTask(taskId);
103
+ if (!task) {
104
+ log.debug({ taskId, kind, hint: "Task disappeared from manager before dispatcher could resolve it" }, "Completion dispatcher: task not in manager");
105
+ return;
106
+ }
107
+ const current = task.dispatchState ?? "pending";
108
+ // At-most-once: state machine is the single source of truth.
109
+ if (current === "notified" || current === "dispatched") {
110
+ log.debug({
111
+ taskId,
112
+ dispatchState: current,
113
+ // traceId from task.origin so dispatcher logs stay threaded with
114
+ // the originating request even when the dispatcher runs from a
115
+ // background ALS context.
116
+ traceId: task.origin?.traceId ?? undefined,
117
+ hint: "Task already dispatched/notified; no-op (at-most-once)",
118
+ }, "Completion dispatcher: at-most-once gate");
119
+ return;
120
+ }
121
+ // task.dispatchState === "pending". Decide which transition to make.
122
+ // origin is producer-required; read it directly.
123
+ const origin = task.origin;
124
+ // Hop cap (when configured). Recursion limit reached → fallback.
125
+ if (typeof deps.maxBackgroundHops === "number") {
126
+ const nextHopCount = (origin.backgroundHopCount ?? 0) + 1;
127
+ if (nextHopCount >= deps.maxBackgroundHops) {
128
+ transitionTo(taskId, "notified");
129
+ await fireFallback(task, `Background task "${task.toolName}" completed but follow-up was skipped — recursion limit reached. Run again or check the result manually.`);
130
+ return;
131
+ }
132
+ }
133
+ // Active-session check (when configured). No active session → fallback.
134
+ if (deps.sessionStore) {
135
+ const sessionExists = deps.sessionStore.loadByFormattedKey(origin.sessionKey) !== undefined;
136
+ if (!sessionExists) {
137
+ // The originating session is not currently registered. The
138
+ // completion-runner would skip re-entry (no streaming channel) —
139
+ // fire fallback so the user still sees a notification. The
140
+ // dispatcher transitions to "notified" so the runner does NOT
141
+ // also fire (single-owner contract).
142
+ transitionTo(taskId, "notified");
143
+ await fireFallback(task, `Background task "${task.toolName}" completed.`);
144
+ return;
145
+ }
146
+ }
147
+ // Active session exists (or sessionStore not wired): the runner will
148
+ // dispatch via re-entry. Transition to "dispatched" so the runner's
149
+ // handler — which reads task.dispatchState — sees the updated state.
150
+ // We do NOT fire fallback here (zero spurious outbound).
151
+ transitionTo(taskId, "dispatched");
152
+ log.debug({
153
+ taskId,
154
+ sessionKey: origin.sessionKey,
155
+ agentId: origin.agentId,
156
+ toolName: task.toolName,
157
+ // traceId from origin for log continuity.
158
+ traceId: origin.traceId ?? undefined,
159
+ hint: "Runner will re-enter the originating session",
160
+ }, "Completion dispatcher: marked dispatched");
161
+ }
162
+ function transitionTo(taskId, next) {
163
+ if (typeof deps.taskManager.transitionDispatchState === "function") {
164
+ deps.taskManager.transitionDispatchState(taskId, next);
165
+ return;
166
+ }
167
+ // No persistent transition wired — mutate the in-memory task directly so
168
+ // the runner (which receives the same event in the same tick) reads the
169
+ // updated state. Test fixtures take this branch.
170
+ const task = deps.taskManager.getTask(taskId);
171
+ if (task)
172
+ task.dispatchState = next;
173
+ }
174
+ async function fireFallback(task, message) {
175
+ if (!fallback) {
176
+ log.debug({
177
+ taskId: task.id,
178
+ // traceId from origin keeps log lines threaded.
179
+ traceId: task.origin?.traceId ?? undefined,
180
+ hint: "No fallbackNotifyFn wired; dispatcher cannot fire user-visible notification",
181
+ }, "Completion dispatcher: fallback skipped (no notifyFn)");
182
+ return;
183
+ }
184
+ try {
185
+ await fallback({
186
+ agentId: task.origin.agentId,
187
+ message,
188
+ priority: "normal",
189
+ origin: "background_task",
190
+ });
191
+ }
192
+ catch (err) {
193
+ log.warn({
194
+ taskId: task.id,
195
+ agentId: task.origin.agentId,
196
+ err,
197
+ // traceId from origin keeps the WARN line threaded.
198
+ traceId: task.origin?.traceId ?? undefined,
199
+ hint: "fallbackNotifyFn rejected; user will not see the completion notification for this task",
200
+ errorKind: "internal",
201
+ }, "Completion dispatcher: fallbackNotifyFn rejected");
202
+ }
203
+ }
204
+ return {
205
+ async shutdown() {
206
+ if (stopped)
207
+ return;
208
+ stopped = true;
209
+ deps.eventBus.off("background_task:completed", onCompleted);
210
+ deps.eventBus.off("background_task:failed", onFailed);
211
+ // Wait for any in-flight handler to settle before returning.
212
+ await inflight;
213
+ },
214
+ };
215
+ }
@@ -41,7 +41,16 @@ export interface BackgroundCompletionRunnerDeps {
41
41
  eventBus: TypedEventBus;
42
42
  getExecutor: (agentId: string) => AgentExecutor;
43
43
  sessionStore: RunnerSessionStore;
44
- taskManager: Pick<BackgroundTaskManager, "getTask">;
44
+ /**
45
+ * Includes `transitionDispatchState` in addition to `getTask`. fallbackForTask
46
+ * uses transitionDispatchState to persist `dispatchState = "notified"` BEFORE
47
+ * firing `fallbackNotifyFn`, so a daemon SIGKILL between persist and fire does
48
+ * NOT leak a duplicate notification on recovery (the at-most-once gate binds
49
+ * against on-disk state). The daemon-side wiring at
50
+ * setup-background-completion-runner.ts already passes a manager with
51
+ * both methods.
52
+ */
53
+ taskManager: Pick<BackgroundTaskManager, "getTask" | "transitionDispatchState">;
45
54
  fallbackNotifyFn: NotifyFn;
46
55
  maxBackgroundHops: number;
47
56
  logger: ComisLogger;
@@ -58,32 +58,72 @@ export function createBackgroundCompletionRunner(deps) {
58
58
  log.warn({ taskId, kind, hint: "Task disappeared from manager before runner could resolve it; no announcement injected", errorKind: "internal" }, "Background completion: task not in manager");
59
59
  return;
60
60
  }
61
- // Legacy task without origin -- emit fallback, keep file for audit.
62
- const origin = task.origin;
63
- if (!origin || !origin.sessionKey || !origin.agentId) {
64
- await fallbackForTask(task.toolName, task.origin?.agentId ?? "default", `Background task "${task.toolName}" completed.`);
61
+ // At-most-once: the dispatcher subscribed BEFORE this runner (see
62
+ // setup-background-completion-runner.ts) and already transitioned
63
+ // task.dispatchState. When state is "notified", the dispatcher fired
64
+ // the user-visible fallback; the runner stays out of the way to
65
+ // enforce single-owner notification routing (zero spurious outbound).
66
+ if (task.dispatchState === "notified") {
67
+ log.debug({
68
+ taskId,
69
+ dispatchState: task.dispatchState,
70
+ // Include originating traceId so operator log streams stay
71
+ // continuous across the dispatcher / runner boundary.
72
+ traceId: task.origin?.traceId ?? undefined,
73
+ hint: "Dispatcher already fired fallback notification (at-most-once)",
74
+ }, "Background completion runner: skipped (dispatcher fired fallback)");
65
75
  return;
66
76
  }
77
+ // origin is producer-required (background-task-manager promote()
78
+ // rejects missing-origin) so we read it directly.
79
+ const origin = task.origin;
67
80
  // Hop cap. Read incoming hop count from origin (schema field populated
68
81
  // by the originResolver).
69
82
  const nextHopCount = (origin.backgroundHopCount ?? 0) + 1;
70
83
  if (nextHopCount >= deps.maxBackgroundHops) {
71
- log.info({ taskId, toolName: task.toolName, agentId: origin.agentId, hopCount: nextHopCount, max: deps.maxBackgroundHops }, "Background completion: hop cap reached, falling back to user notification");
72
- await fallbackForTask(task.toolName, origin.agentId, `Background task "${task.toolName}" completed but follow-up was skipped — recursion limit reached. Run again or check the result manually.`);
84
+ log.info({
85
+ taskId,
86
+ toolName: task.toolName,
87
+ agentId: origin.agentId,
88
+ hopCount: nextHopCount,
89
+ max: deps.maxBackgroundHops,
90
+ // traceId from origin keeps operator logs threaded.
91
+ traceId: origin.traceId ?? undefined,
92
+ }, "Background completion: hop cap reached, falling back to user notification");
93
+ await fallbackForTask(task.id, origin.agentId, task.toolName, `Background task "${task.toolName}" completed but follow-up was skipped — recursion limit reached. Run again or check the result manually.`);
73
94
  return;
74
95
  }
75
- // Missing session -- session expired while the task ran. No channel to deliver
76
- // to, so skip fallback (which would only produce a WARN from notification-service).
96
+ // No active session for this sessionKey in the in-memory store. The
97
+ // originating session may have ended (user closed the channel) OR may live
98
+ // in JSONL but not be currently registered. Either way, there is no
99
+ // streaming channel to inject into, so skip fallback (which would only
100
+ // produce a WARN from notification-service).
77
101
  const sessionExists = deps.sessionStore.loadByFormattedKey(origin.sessionKey) !== undefined;
78
102
  if (!sessionExists) {
79
- log.info({ taskId, sessionKey: origin.sessionKey }, "Background completion: session expired, skipping re-entry");
103
+ log.info({
104
+ taskId,
105
+ sessionKey: origin.sessionKey,
106
+ // traceId from origin so this INFO log line stays threaded with the
107
+ // originating request's trace stream even though the runner runs in a
108
+ // background context (the ALS traceId at this point may differ from
109
+ // origin.traceId).
110
+ traceId: origin.traceId ?? undefined,
111
+ hint: "No active in-memory session for this sessionKey; runner will skip re-entry. Task result remains in JSONL for offline review.",
112
+ }, "Background completion: no active session for re-entry");
80
113
  return;
81
114
  }
82
115
  // Reconstruct the SessionKey object for executor.execute().
83
116
  const parsedKey = parseFormattedSessionKey(origin.sessionKey);
84
117
  if (!parsedKey) {
85
- log.warn({ taskId, sessionKey: origin.sessionKey, hint: "Persisted sessionKey is malformed; cannot route announcement", errorKind: "internal" }, "Background completion: invalid sessionKey");
86
- await fallbackForTask(task.toolName, origin.agentId, `Background task "${task.toolName}" completed (routing failed).`);
118
+ log.warn({
119
+ taskId,
120
+ sessionKey: origin.sessionKey,
121
+ // traceId from origin keeps operator logs threaded.
122
+ traceId: origin.traceId ?? undefined,
123
+ hint: "Persisted sessionKey is malformed; cannot route announcement",
124
+ errorKind: "internal",
125
+ }, "Background completion: invalid sessionKey");
126
+ await fallbackForTask(task.id, origin.agentId, task.toolName, `Background task "${task.toolName}" completed (routing failed).`);
87
127
  return;
88
128
  }
89
129
  // Format the announcement (byte-identical trailing instruction).
@@ -105,15 +145,27 @@ export function createBackgroundCompletionRunner(deps) {
105
145
  traceId: origin.traceId ?? undefined,
106
146
  },
107
147
  };
108
- log.debug({ taskId, sessionKey: origin.sessionKey, agentId: origin.agentId, toolName: task.toolName, hopCount: nextHopCount }, "Background completion runner: invoking executor");
148
+ log.debug({
149
+ taskId,
150
+ sessionKey: origin.sessionKey,
151
+ agentId: origin.agentId,
152
+ toolName: task.toolName,
153
+ hopCount: nextHopCount,
154
+ // traceId from origin keeps debug logs threaded.
155
+ traceId: origin.traceId ?? undefined,
156
+ }, "Background completion runner: invoking executor");
109
157
  // Emit background_task:reentered immediately before executor.execute().
110
158
  // Integration tests compute p95 latency from
111
159
  // background_task:completed.timestamp to this event's timestamp.
160
+ // Include traceId from origin so subscribers (and operator log streams)
161
+ // preserve the originating request's trace across the
162
+ // background_task:completed → :reentered boundary.
112
163
  deps.eventBus.emit("background_task:reentered", {
113
164
  taskId: task.id,
114
165
  agentId: origin.agentId,
115
166
  sessionKey: origin.sessionKey,
116
167
  hopCount: nextHopCount,
168
+ traceId: origin.traceId ?? null,
117
169
  timestamp: Date.now(),
118
170
  });
119
171
  // One turn per event. Existing session lock orders concurrent calls.
@@ -121,10 +173,41 @@ export function createBackgroundCompletionRunner(deps) {
121
173
  await deps.getExecutor(origin.agentId).execute(syntheticMsg, parsedKey, undefined, undefined, origin.agentId);
122
174
  }
123
175
  catch (err) {
124
- log.warn({ taskId, err, hint: "Executor failed mid-completion turn; subscription remains active", errorKind: "internal" }, "Background completion: executor.execute() rejected");
176
+ log.warn({
177
+ taskId,
178
+ err,
179
+ // traceId from origin keeps the WARN line threaded.
180
+ traceId: origin.traceId ?? undefined,
181
+ hint: "Executor failed mid-completion turn; subscription remains active",
182
+ errorKind: "internal",
183
+ }, "Background completion: executor.execute() rejected");
125
184
  }
126
185
  }
127
- async function fallbackForTask(toolName, agentId, message) {
186
+ /**
187
+ * Two-phase commit:
188
+ *
189
+ * 1. transitionDispatchState(taskId, "notified") — synchronously persists
190
+ * `dispatchState = "notified"` to disk (via persistTaskSync inside the
191
+ * manager). This MUST run before fallbackNotifyFn so a SIGKILL between
192
+ * persist and fire does NOT leak a duplicate on recovery: the at-most-
193
+ * once gate at the top of handleEvent (which reads task.dispatchState)
194
+ * sees "notified" and skips re-firing. Without this ordering, the gate
195
+ * misses and the user receives the notification twice.
196
+ *
197
+ * 2. fallbackNotifyFn(...) — actually deliver the user-visible
198
+ * notification. May reject (channel offline, rate-limited, etc.); the
199
+ * failure is logged at WARN. The persisted state stays at "notified" —
200
+ * the user did not see the notification, but the at-most-once contract
201
+ * takes precedence over delivery completeness.
202
+ */
203
+ async function fallbackForTask(taskId, agentId, toolName, message) {
204
+ // Phase 1: persist state. transitionDispatchState may return false if
205
+ // the task disappeared from the manager between handler entry and this
206
+ // call (e.g., explicit cleanup). In that case there is nothing to gate
207
+ // on; we still fire so the user sees the completion. The persist is
208
+ // synchronous so the on-disk state is updated before phase 2.
209
+ deps.taskManager.transitionDispatchState(taskId, "notified");
210
+ // Phase 2: fire user-visible notification.
128
211
  try {
129
212
  await deps.fallbackNotifyFn({
130
213
  agentId,
@@ -134,7 +217,7 @@ export function createBackgroundCompletionRunner(deps) {
134
217
  });
135
218
  }
136
219
  catch (err) {
137
- log.warn({ toolName, agentId, err, hint: "fallbackNotifyFn rejected; user will not see the completion notification for this task", errorKind: "internal" }, "Background completion: fallbackNotifyFn rejected");
220
+ log.warn({ taskId, toolName, agentId, err, hint: "fallbackNotifyFn rejected; user will not see the completion notification for this task. dispatchState already persisted as \"notified\" — no duplicate on recovery.", errorKind: "internal" }, "Background completion: fallbackNotifyFn rejected (post-persist)");
138
221
  }
139
222
  }
140
223
  return {
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * @module
5
5
  */
6
- export type { BackgroundTask, BackgroundTaskStatus, PersistedTaskState } from "./background-task-types.js";
6
+ export type { BackgroundTask, BackgroundTaskStatus, BackgroundSessionState, PersistedTaskState, } from "./background-task-types.js";
7
+ export type { BackgroundTaskNotificationPolicy as BackgroundTaskNotificationPolicyType } from "./background-task-types.js";
7
8
  export type { BackgroundTaskOrigin } from "@comis/core";
8
9
  export { persistTaskSync, loadTask, recoverTasks, removeTaskFile, TASK_DIR_NAME, } from "./background-task-persistence.js";
9
10
  export { createBackgroundTaskManager, } from "./background-task-manager.js";
@@ -13,3 +14,7 @@ export type { ToolDefinition, } from "./auto-background-middleware.js";
13
14
  export { formatCompletionAnnouncement, TRAILING_INSTRUCTION } from "./completion-formatter.js";
14
15
  export { createBackgroundCompletionRunner } from "./completion-runner.js";
15
16
  export type { BackgroundCompletionRunner, BackgroundCompletionRunnerDeps, RunnerSessionStore, } from "./completion-runner.js";
17
+ export { createCompletionDispatcher, STATES, BackgroundTaskNotificationPolicy, } from "./completion-dispatcher.js";
18
+ export type { CompletionDispatcher, CompletionDispatcherDeps, DispatcherSessionStore, DispatcherTaskManager, } from "./completion-dispatcher.js";
19
+ export { createBackgroundSessionResolver } from "./session-resolver.js";
20
+ export type { ActiveSessionKey, BackgroundSessionResolver, BackgroundSessionResolverDeps, } from "./session-resolver.js";
@@ -9,3 +9,5 @@ export { createBackgroundTaskManager, } from "./background-task-manager.js";
9
9
  export { wrapToolForAutoBackground, } from "./auto-background-middleware.js";
10
10
  export { formatCompletionAnnouncement, TRAILING_INSTRUCTION } from "./completion-formatter.js";
11
11
  export { createBackgroundCompletionRunner } from "./completion-runner.js";
12
+ export { createCompletionDispatcher, STATES, BackgroundTaskNotificationPolicy, } from "./completion-dispatcher.js";
13
+ export { createBackgroundSessionResolver } from "./session-resolver.js";
@@ -0,0 +1,85 @@
1
+ /**
2
+ * BackgroundSessionResolver: composite-key wrapper around ActiveRunRegistry.
3
+ *
4
+ * The underlying `activeRunRegistry.has(sessionKey)` and `.get(sessionKey)`
5
+ * take a single formatted-key string. That string would collapse two
6
+ * distinct sessions for the same `channelId` across different agents (or
7
+ * different channelTypes) into one bucket — a latent multi-agent /
8
+ * multi-channel correctness bug.
9
+ *
10
+ * This resolver makes the composite key explicit at every public call site:
11
+ * `(agentId, channelType, channelId)`. It internally composes the formatted
12
+ * key via `formatSessionKey` from `@comis/core` and delegates to the
13
+ * underlying registry.
14
+ *
15
+ * Runtime semantics do not change at the registry layer — what changes is
16
+ * the lookup-key signature surfaced to production callers. No production
17
+ * code outside *.test.ts should retain a single-arg `.has(...)` or
18
+ * `.get(...)` on `activeRunRegistry`.
19
+ *
20
+ * @module
21
+ */
22
+ import type { ActiveRunRegistry, RunHandle } from "../executor/active-run-registry.js";
23
+ /**
24
+ * Composite key for active-session lookup.
25
+ *
26
+ * The three fields uniquely identify a session at the inbound-routing layer:
27
+ * - `agentId` — distinguishes per-agent isolation (multi-agent safety)
28
+ * - `channelType` — distinguishes platform (telegram vs discord vs slack)
29
+ * - `channelId` — platform-specific chat / peer / group identifier
30
+ *
31
+ * The resolver internally composes a `SessionKey` and formats it via
32
+ * `formatSessionKey` so the underlying registry's string-keyed Map is
33
+ * addressed deterministically.
34
+ */
35
+ export interface ActiveSessionKey {
36
+ agentId: string;
37
+ channelType: string;
38
+ channelId: string;
39
+ }
40
+ /**
41
+ * Public-facing resolver returned by `createBackgroundSessionResolver`.
42
+ *
43
+ * The resolver exposes ONLY composite-key methods. There is no single-arg
44
+ * fallback — production callers MUST thread `(agentId, channelType,
45
+ * channelId)` end-to-end.
46
+ */
47
+ export interface BackgroundSessionResolver {
48
+ /**
49
+ * Look up the RunHandle for an active session.
50
+ *
51
+ * @param key - Composite key (agentId, channelType, channelId).
52
+ * @returns The RunHandle if a session is registered, otherwise undefined.
53
+ * @throws Error when any composite-key field is empty / falsy
54
+ * (programming error, parity with manager.promote's
55
+ * empty-string guards in background-task-manager.ts:96-107).
56
+ */
57
+ resolveActiveSession(key: ActiveSessionKey): RunHandle | undefined;
58
+ /**
59
+ * Check whether a session is registered for the composite key.
60
+ *
61
+ * @param key - Composite key (agentId, channelType, channelId).
62
+ * @returns true iff a RunHandle is registered, false otherwise.
63
+ * @throws Error when any composite-key field is empty / falsy.
64
+ */
65
+ hasActiveSession(key: ActiveSessionKey): boolean;
66
+ }
67
+ /**
68
+ * Dependencies required by the resolver.
69
+ *
70
+ * Only the registry is needed today — the resolver is a pure-function
71
+ * wrapper. No logger / event-bus injection (CLAUDE.md: NO logging in
72
+ * pure-function helpers).
73
+ */
74
+ export interface BackgroundSessionResolverDeps {
75
+ activeRunRegistry: ActiveRunRegistry;
76
+ }
77
+ /**
78
+ * Create a BackgroundSessionResolver wrapping an ActiveRunRegistry.
79
+ *
80
+ * Public-facing methods accept ONLY the composite key (agentId,
81
+ * channelType, channelId) — no single-arg fallback. Production callers
82
+ * no longer reach into `activeRunRegistry.has(...)` / `.get(...)`
83
+ * directly.
84
+ */
85
+ export declare function createBackgroundSessionResolver(deps: BackgroundSessionResolverDeps): BackgroundSessionResolver;
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * BackgroundSessionResolver: composite-key wrapper around ActiveRunRegistry.
4
+ *
5
+ * The underlying `activeRunRegistry.has(sessionKey)` and `.get(sessionKey)`
6
+ * take a single formatted-key string. That string would collapse two
7
+ * distinct sessions for the same `channelId` across different agents (or
8
+ * different channelTypes) into one bucket — a latent multi-agent /
9
+ * multi-channel correctness bug.
10
+ *
11
+ * This resolver makes the composite key explicit at every public call site:
12
+ * `(agentId, channelType, channelId)`. It internally composes the formatted
13
+ * key via `formatSessionKey` from `@comis/core` and delegates to the
14
+ * underlying registry.
15
+ *
16
+ * Runtime semantics do not change at the registry layer — what changes is
17
+ * the lookup-key signature surfaced to production callers. No production
18
+ * code outside *.test.ts should retain a single-arg `.has(...)` or
19
+ * `.get(...)` on `activeRunRegistry`.
20
+ *
21
+ * @module
22
+ */
23
+ import { formatSessionKey } from "@comis/core";
24
+ // ---------------------------------------------------------------------------
25
+ // Factory
26
+ // ---------------------------------------------------------------------------
27
+ /**
28
+ * Compose the formatted session-key string from a composite key.
29
+ *
30
+ * Mirrors the shape that production session-managers use when registering
31
+ * handles: `formatSessionKey({tenantId: agentId, channelId:
32
+ * "${channelType}:${channelId}", userId: channelId})`. The output is a
33
+ * deterministic colon-delimited string that round-trips through
34
+ * `parseFormattedSessionKey` (the channelType prefix on channelId is
35
+ * stable across format/parse).
36
+ *
37
+ * The triple is REQUIRED — empty fields are a programming error
38
+ * (parity with the empty-string guard in
39
+ * `background-task-manager.ts:promote()`).
40
+ */
41
+ function formatComposite(key) {
42
+ if (!key.agentId || !key.channelType || !key.channelId) {
43
+ throw new Error(`BackgroundSessionResolver: composite key requires non-empty agentId, channelType, channelId; got ${JSON.stringify(key)}`);
44
+ }
45
+ return formatSessionKey({
46
+ tenantId: key.agentId,
47
+ channelId: `${key.channelType}:${key.channelId}`,
48
+ userId: key.channelId,
49
+ });
50
+ }
51
+ /**
52
+ * Create a BackgroundSessionResolver wrapping an ActiveRunRegistry.
53
+ *
54
+ * Public-facing methods accept ONLY the composite key (agentId,
55
+ * channelType, channelId) — no single-arg fallback. Production callers
56
+ * no longer reach into `activeRunRegistry.has(...)` / `.get(...)`
57
+ * directly.
58
+ */
59
+ export function createBackgroundSessionResolver(deps) {
60
+ // Local alias: the resolver IS the abstraction over the underlying
61
+ // single-arg registry. We rename to `registry` so source-grep tooling
62
+ // (`activeRunRegistry.has|get(`) does not flag this file as a callsite
63
+ // to migrate -- the resolver IS the migration target. Invariant:
64
+ // *production callers* of `activeRunRegistry` go through this resolver;
65
+ // the resolver itself remains the sole consumer of the underlying
66
+ // single-arg surface.
67
+ const registry = deps.activeRunRegistry;
68
+ return {
69
+ resolveActiveSession(key) {
70
+ const formatted = formatComposite(key);
71
+ return registry.get(formatted);
72
+ },
73
+ hasActiveSession(key) {
74
+ const formatted = formatComposite(key);
75
+ return registry.has(formatted);
76
+ },
77
+ };
78
+ }
@@ -34,6 +34,7 @@ export function buildMessagingSection(toolNames, isMinimal, channelContext) {
34
34
  '- `[System Message]` blocks are internal context. If one reports completed work and asks for a user update, rewrite it in your normal assistant voice. Never forward raw system message text to users.',
35
35
  "- Never use shell execution, code execution, or file tools to send messages. Use only the messaging tools.",
36
36
  "- Do NOT use message(action=send) for progress updates, debug output, or placeholder text. The user sees every send as a phone notification. Work silently; deliver the result.",
37
+ "- Do NOT narrate intent before calling a tool. Avoid prefacing tool calls with phrases like \"I'll do X now…\" or \"Let me run Y…\" — invoke the tool directly. Pre-tool-text creates user-visible promises the agent may not be able to fulfill if the tool gets backgrounded.",
37
38
  "- Use `fetch` to read recent messages from a channel before responding to context you missed.",
38
39
  "- The `delete` action requires confirmation and cannot be undone.",
39
40
  ];
@@ -20,7 +20,7 @@
20
20
  import { getToolMetadata } from "@comis/core";
21
21
  import { getProviders } from "@mariozechner/pi-ai";
22
22
  // ---------------------------------------------------------------------------
23
- // Layer 1D (260430-vwt) -- live native-catalog provider list
23
+ // Live native-catalog provider list
24
24
  //
25
25
  // Computed once at module load time. Used by the providers_manage TOOL_GUIDE
26
26
  // "Built-in vs Custom Provider Check" block below so the text reflects
@@ -325,7 +325,7 @@ If the model IS built-in: skip provider creation. After credential pre-check pas
325
325
  If the model is NOT built-in: you need a custom provider. Proceed to the steps below, but first gather ALL required configuration.
326
326
 
327
327
  ### Choosing the \`type\` Field (POST AUTO-PROMOTE FLOW)
328
- After Layer 1C of the catalog-driven providers redesign, the \`type\` field follows two distinct rules depending on the provider name:
328
+ For catalog-driven providers, the \`type\` field follows two distinct rules depending on the provider name:
329
329
  - **If \`provider_id\` matches a built-in name** (use models_manage list_providers to verify): OMIT \`type\` entirely from the create config. The daemon auto-promotes \`type\` to the native catalog name when \`provider_id\` matches a native entry AND no custom \`baseUrl\` is supplied. Setting \`type:"openai"\` for a built-in name still works (auto-promoted), but omitting it is cleaner.
330
330
  - **If \`provider_id\` is a custom OpenAI-compatible proxy** (NVIDIA NIM, Together, Fireworks, etc.) NOT in the native catalog: set \`type:"openai"\` (or whatever wire-format API matches). Auto-promotion does not fire for non-catalog names.
331
331
  - **If \`baseUrl\` differs from the native catalog URL** for a built-in name: this signals you want the OpenAI-passthrough shape (custom proxy that masquerades as the built-in). Auto-promotion is suppressed; the entry stays as \`type:"openai"\`.
@@ -357,7 +357,7 @@ To switch an agent to a different provider/model, call agents_manage update with
357
357
  **Three preconditions the LLM MUST verify before issuing the update:**
358
358
  1. The target provider exists as a \`providers.entries.<provider_id>\` key. If it does not, call providers_manage create FIRST (and gateway env_set for the API key if needed). Patching an agent to a provider that has no entry resolves under the wrong provider family at the next session — the original bug.
359
359
  2. The model id matches a \`models[].id\` in that provider entry (or is a built-in known to the pi-ai catalog for that provider type). Otherwise \`registry.find(provider, model)\` returns undefined and the next session falls back with a "Model not found" message.
360
- 3. **Credential pre-check passed** (see top of this guide). The target provider's apiKeyName is non-empty AND \`gateway env_list filter:"<PROVIDER>*"\` confirmed the named secret exists in env. Skipping this step is the bug that causes "No API key found" failures at the next chat turn — verified production repro on 2026-05-01.
360
+ 3. **Credential pre-check passed** (see top of this guide). The target provider's apiKeyName is non-empty AND \`gateway env_list filter:"<PROVIDER>*"\` confirmed the named secret exists in env. Skipping this step is the bug that causes "No API key found" failures at the next chat turn.
361
361
 
362
362
  **Timing — the change is NOT hot-applied to the active session.**
363
363
  agents_manage update writes through persistToConfig WITHOUT a hot-update callback, which triggers a SIGUSR2 daemon restart (2-second debounce). The new provider/model takes effect on the next session, not the currently-running prompt. Tell the user the switch is queued and will take effect after the daemon settles.