@gajae-code/coding-agent 0.10.2 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (476) hide show
  1. package/CHANGELOG.md +58 -2
  2. package/dist/types/append-only-mode.d.ts +1 -1
  3. package/dist/types/async/job-manager.d.ts +46 -7
  4. package/dist/types/cli/args.d.ts +4 -5
  5. package/dist/types/cli/daemon-cli.d.ts +12 -1
  6. package/dist/types/cli/notify-cli.d.ts +21 -1
  7. package/dist/types/cli.d.ts +3 -0
  8. package/dist/types/commands/daemon.d.ts +39 -0
  9. package/dist/types/commands/launch.d.ts +3 -0
  10. package/dist/types/commands/notify.d.ts +28 -1
  11. package/dist/types/commands/sdk.d.ts +33 -0
  12. package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
  13. package/dist/types/config/model-profile-activation.d.ts +35 -15
  14. package/dist/types/config/model-profiles.d.ts +11 -5
  15. package/dist/types/config/model-registry.d.ts +3 -3
  16. package/dist/types/config/model-resolver.d.ts +39 -6
  17. package/dist/types/config/model-selector-value.d.ts +17 -0
  18. package/dist/types/config/models-config-schema.d.ts +10 -6
  19. package/dist/types/config/settings-schema.d.ts +133 -6
  20. package/dist/types/config/settings.d.ts +63 -35
  21. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
  22. package/dist/types/coordinator-mcp/server.d.ts +6 -49
  23. package/dist/types/daemon/builtin.d.ts +6 -5
  24. package/dist/types/daemon/control-types.d.ts +1 -1
  25. package/dist/types/discovery/mcp-json.d.ts +12 -1
  26. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  27. package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
  28. package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
  29. package/dist/types/extensibility/extensions/loader.d.ts +10 -1
  30. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  31. package/dist/types/extensibility/extensions/types.d.ts +141 -16
  32. package/dist/types/extensibility/hooks/types.d.ts +1 -1
  33. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
  34. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  35. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
  37. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
  38. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
  39. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
  40. package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
  41. package/dist/types/harness-control-plane/operate.d.ts +5 -5
  42. package/dist/types/harness-control-plane/owner.d.ts +18 -4
  43. package/dist/types/harness-control-plane/receipts.d.ts +0 -1
  44. package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
  45. package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
  46. package/dist/types/harness-control-plane/types.d.ts +3 -4
  47. package/dist/types/lsp/index.d.ts +1 -1
  48. package/dist/types/main.d.ts +32 -38
  49. package/dist/types/modes/acp/acp-agent.d.ts +224 -53
  50. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
  51. package/dist/types/modes/acp/acp-mode.d.ts +6 -15
  52. package/dist/types/modes/acp/permission-mode.d.ts +4 -0
  53. package/dist/types/modes/acp/startup-options.d.ts +6 -0
  54. package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
  55. package/dist/types/modes/components/model-selector.d.ts +1 -0
  56. package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
  57. package/dist/types/modes/components/settings-selector.d.ts +6 -4
  58. package/dist/types/modes/components/welcome.d.ts +1 -1
  59. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  60. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
  61. package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
  62. package/dist/types/modes/index.d.ts +0 -4
  63. package/dist/types/modes/interactive-mode.d.ts +5 -2
  64. package/dist/types/modes/runtime-init.d.ts +2 -2
  65. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
  66. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
  67. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
  68. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
  69. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
  70. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +170 -30
  71. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
  72. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
  73. package/dist/types/modes/types.d.ts +7 -2
  74. package/dist/types/runtime-mcp/client.d.ts +2 -0
  75. package/dist/types/runtime-mcp/config.d.ts +5 -1
  76. package/dist/types/runtime-mcp/manager.d.ts +8 -1
  77. package/dist/types/runtime-mcp/types.d.ts +4 -0
  78. package/dist/types/sdk/acp/adapter.d.ts +67 -0
  79. package/dist/types/sdk/acp/index.d.ts +2 -0
  80. package/dist/types/sdk/broker/broker.d.ts +53 -0
  81. package/dist/types/sdk/broker/discovery.d.ts +30 -0
  82. package/dist/types/sdk/broker/ensure.d.ts +42 -0
  83. package/dist/types/sdk/broker/identity.d.ts +3 -0
  84. package/dist/types/sdk/broker/index.d.ts +8 -0
  85. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
  86. package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
  87. package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
  88. package/dist/types/sdk/broker/runtime.d.ts +33 -0
  89. package/dist/types/sdk/broker/session-index.d.ts +57 -0
  90. package/dist/types/sdk/broker/state-version.d.ts +8 -0
  91. package/dist/types/sdk/broker/transport.d.ts +9 -0
  92. package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
  93. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
  94. package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
  95. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
  96. package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
  97. package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
  98. package/dist/types/sdk/bus/config.d.ts +274 -0
  99. package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
  100. package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
  101. package/dist/types/sdk/bus/discord-daemon.d.ts +50 -0
  102. package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
  103. package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
  104. package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
  105. package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
  106. package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
  107. package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
  108. package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
  109. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
  110. package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
  111. package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
  112. package/dist/types/sdk/bus/session-control.d.ts +83 -0
  113. package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
  114. package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
  115. package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
  116. package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
  117. package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
  118. package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
  119. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
  120. package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
  121. package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +78 -17
  122. package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
  123. package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
  124. package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
  125. package/dist/types/sdk/cli/index.d.ts +1 -0
  126. package/dist/types/sdk/cli/session-cli.d.ts +19 -0
  127. package/dist/types/sdk/client/client.d.ts +1 -0
  128. package/dist/types/sdk/client/discovery.d.ts +27 -0
  129. package/dist/types/sdk/client/index.d.ts +2 -0
  130. package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
  131. package/dist/types/sdk/host/control/index.d.ts +2 -0
  132. package/dist/types/sdk/host/control/operations.d.ts +65 -0
  133. package/dist/types/sdk/host/events.d.ts +35 -0
  134. package/dist/types/sdk/host/host.d.ts +35 -0
  135. package/dist/types/sdk/host/index.d.ts +4 -0
  136. package/dist/types/sdk/host/query/cursor.d.ts +62 -0
  137. package/dist/types/sdk/host/query/handlers.d.ts +70 -0
  138. package/dist/types/sdk/host/query/index.d.ts +3 -0
  139. package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
  140. package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
  141. package/dist/types/sdk/host/types.d.ts +32 -0
  142. package/dist/types/sdk/index.d.ts +6 -0
  143. package/dist/types/sdk/lifecycle-session.d.ts +14 -0
  144. package/dist/types/sdk/mcp/index.d.ts +1 -0
  145. package/dist/types/sdk/mcp/server.d.ts +37 -0
  146. package/dist/types/sdk/models.d.ts +44 -0
  147. package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
  148. package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
  149. package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
  150. package/dist/types/sdk/startup-capability.d.ts +50 -0
  151. package/dist/types/session/agent-session.d.ts +114 -21
  152. package/dist/types/session/default-model-selection.d.ts +25 -0
  153. package/dist/types/session/fallback-chain-controller.d.ts +52 -0
  154. package/dist/types/session/session-manager.d.ts +58 -1
  155. package/dist/types/setup/credential-auto-import.d.ts +36 -6
  156. package/dist/types/tools/ask-answer-registry.d.ts +1 -1
  157. package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
  158. package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
  159. package/dist/types/tools/browser/launch.d.ts +22 -0
  160. package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
  161. package/dist/types/tools/browser/profile-posture.d.ts +37 -0
  162. package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
  163. package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
  164. package/dist/types/tools/browser/registry.d.ts +19 -0
  165. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  166. package/dist/types/tools/index.d.ts +8 -8
  167. package/dist/types/tools/subagent-render.d.ts +1 -0
  168. package/dist/types/tools/subagent.d.ts +6 -1
  169. package/package.json +34 -13
  170. package/scripts/build-sdk-package-smoke.ts +99 -0
  171. package/scripts/capture-notifications-settings-showcase.ts +435 -0
  172. package/scripts/compile-args.ts +4 -2
  173. package/scripts/g005-daemon-smoke.ts +4 -5
  174. package/scripts/g011-daemon-path-smoke.ts +5 -5
  175. package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
  176. package/scripts/generate-sdk-operation-inventory.ts +867 -0
  177. package/scripts/generate-telegram-baseline-manifest.ts +128 -0
  178. package/scripts/manifest-schema.ts +124 -0
  179. package/scripts/run-test-manifest.ts +153 -0
  180. package/scripts/run-threat-model-check.ts +157 -0
  181. package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
  182. package/scripts/verify-gjc-sdk-rename.ts +90 -0
  183. package/src/append-only-mode.ts +1 -1
  184. package/src/async/job-manager.ts +406 -63
  185. package/src/cli/args.ts +43 -12
  186. package/src/cli/auth-gateway-cli.ts +2 -2
  187. package/src/cli/config-cli.ts +2 -1
  188. package/src/cli/daemon-cli.ts +63 -14
  189. package/src/cli/list-models.ts +1 -1
  190. package/src/cli/notify-cli.ts +288 -286
  191. package/src/cli.ts +21 -2
  192. package/src/commands/daemon.ts +50 -6
  193. package/src/commands/harness.ts +93 -36
  194. package/src/commands/launch.ts +5 -2
  195. package/src/commands/mcp-serve.ts +19 -12
  196. package/src/commands/notify.ts +36 -3
  197. package/src/commands/sdk.ts +426 -0
  198. package/src/commit/agentic/agent.ts +1 -0
  199. package/src/config/atomic-yaml-patch.ts +423 -0
  200. package/src/config/model-profile-activation.ts +240 -89
  201. package/src/config/model-profiles.ts +18 -15
  202. package/src/config/model-registry.ts +51 -26
  203. package/src/config/model-resolver.ts +138 -51
  204. package/src/config/model-selector-value.ts +50 -0
  205. package/src/config/models-config-schema.ts +14 -21
  206. package/src/config/settings-schema.ts +128 -10
  207. package/src/config/settings.ts +1120 -158
  208. package/src/coordinator-mcp/model-preset.ts +74 -20
  209. package/src/coordinator-mcp/server.ts +1597 -3052
  210. package/src/daemon/builtin.ts +8 -5
  211. package/src/daemon/control-types.ts +1 -1
  212. package/src/defaults/gjc/skills/team/SKILL.md +4 -4
  213. package/src/discovery/mcp-json.ts +327 -20
  214. package/src/extensibility/custom-tools/types.ts +1 -1
  215. package/src/extensibility/extensions/compact-handler.ts +3 -3
  216. package/src/extensibility/extensions/get-commands-handler.ts +2 -2
  217. package/src/extensibility/extensions/loader.ts +69 -5
  218. package/src/extensibility/extensions/runner.ts +112 -4
  219. package/src/extensibility/extensions/types.ts +176 -17
  220. package/src/extensibility/hooks/types.ts +1 -1
  221. package/src/gjc-runtime/launch-tmux.ts +170 -86
  222. package/src/gjc-runtime/psmux-detect.ts +82 -4
  223. package/src/gjc-runtime/session-state-sidecar.ts +41 -31
  224. package/src/gjc-runtime/team-runtime.ts +54 -35
  225. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
  226. package/src/gjc-runtime/tmux-owner-isolation.ts +1 -1
  227. package/src/gjc-runtime/tmux-sessions.ts +19 -13
  228. package/src/gjc-runtime/ultragoal-guard.ts +108 -14
  229. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
  230. package/src/gjc-runtime/ultragoal-runtime.ts +227 -11
  231. package/src/gjc-runtime/windows-powershell-command.ts +58 -0
  232. package/src/harness-control-plane/operate.ts +9 -9
  233. package/src/harness-control-plane/owner.ts +283 -39
  234. package/src/harness-control-plane/receipts.ts +0 -1
  235. package/src/harness-control-plane/sdk-transport.ts +395 -0
  236. package/src/harness-control-plane/session-transport.ts +94 -0
  237. package/src/harness-control-plane/types.ts +1 -1
  238. package/src/internal-urls/docs-index.generated.ts +34 -35
  239. package/src/lsp/client.ts +42 -6
  240. package/src/lsp/index.ts +2 -2
  241. package/src/main.ts +299 -204
  242. package/src/modes/DESIGN.md +258 -0
  243. package/src/modes/acp/acp-agent.ts +1228 -2612
  244. package/src/modes/acp/acp-client-bridge.ts +2 -20
  245. package/src/modes/acp/acp-event-mapper.ts +18 -2
  246. package/src/modes/acp/acp-mode.ts +8 -36
  247. package/src/modes/acp/permission-mode.ts +25 -0
  248. package/src/modes/acp/startup-options.ts +6 -0
  249. package/src/modes/components/agent-dashboard.ts +37 -17
  250. package/src/modes/components/model-selector.ts +93 -35
  251. package/src/modes/components/notifications-settings-editor.ts +1379 -0
  252. package/src/modes/components/settings-selector.ts +60 -11
  253. package/src/modes/components/welcome.ts +4 -4
  254. package/src/modes/controllers/command-controller.ts +10 -17
  255. package/src/modes/controllers/event-controller.ts +11 -16
  256. package/src/modes/controllers/extension-ui-controller.ts +396 -28
  257. package/src/modes/controllers/selector-controller.ts +674 -36
  258. package/src/modes/index.ts +0 -22
  259. package/src/modes/interactive-mode.ts +87 -36
  260. package/src/modes/runtime-init.ts +298 -6
  261. package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
  262. package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
  263. package/src/modes/shared/agent-wire/event-contract.ts +7 -8
  264. package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
  265. package/src/modes/shared/agent-wire/event-observation.ts +16 -7
  266. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1258 -87
  267. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
  268. package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
  269. package/src/modes/types.ts +7 -2
  270. package/src/runtime-mcp/client.ts +94 -19
  271. package/src/runtime-mcp/config.ts +27 -15
  272. package/src/runtime-mcp/manager.ts +343 -105
  273. package/src/runtime-mcp/oauth-flow.ts +42 -12
  274. package/src/runtime-mcp/transports/http.ts +49 -19
  275. package/src/runtime-mcp/transports/stdio.ts +47 -28
  276. package/src/runtime-mcp/types.ts +10 -0
  277. package/src/sdk/acp/adapter.ts +418 -0
  278. package/src/sdk/acp/index.ts +2 -0
  279. package/src/sdk/broker/broker.ts +642 -0
  280. package/src/sdk/broker/discovery.ts +113 -0
  281. package/src/sdk/broker/ensure.ts +307 -0
  282. package/src/sdk/broker/identity.ts +42 -0
  283. package/src/sdk/broker/index.ts +8 -0
  284. package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
  285. package/src/sdk/broker/internal-source.bunfig.toml +1 -0
  286. package/src/sdk/broker/lifecycle-ledger.ts +286 -0
  287. package/src/sdk/broker/lifecycle.ts +2157 -0
  288. package/src/sdk/broker/process-incarnation.ts +159 -0
  289. package/src/sdk/broker/runtime.ts +164 -0
  290. package/src/sdk/broker/session-index.ts +318 -0
  291. package/src/sdk/broker/state-version.ts +23 -0
  292. package/src/sdk/broker/transport.ts +125 -0
  293. package/src/sdk/bus/chat-command-policy.ts +107 -0
  294. package/src/sdk/bus/chat-daemon-cli.ts +214 -0
  295. package/src/sdk/bus/chat-daemon-control.ts +713 -0
  296. package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
  297. package/src/sdk/bus/chat-effect-journal.ts +355 -0
  298. package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
  299. package/src/sdk/bus/config.ts +433 -0
  300. package/src/sdk/bus/conversation-store.ts +391 -0
  301. package/src/sdk/bus/discord-conversation.ts +85 -0
  302. package/src/sdk/bus/discord-daemon.ts +2292 -0
  303. package/src/sdk/bus/discord-live-provider.ts +521 -0
  304. package/src/sdk/bus/discord-provider.ts +56 -0
  305. package/src/{notifications → sdk/bus}/html-format.ts +2 -2
  306. package/src/sdk/bus/index.ts +3979 -0
  307. package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
  308. package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
  309. package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
  310. package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
  311. package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
  312. package/src/sdk/bus/natives-augment.d.ts +38 -0
  313. package/src/sdk/bus/notification-orchestration.ts +535 -0
  314. package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
  315. package/src/sdk/bus/session-control.ts +333 -0
  316. package/src/sdk/bus/slack-conversation.ts +74 -0
  317. package/src/sdk/bus/slack-daemon.ts +2144 -0
  318. package/src/sdk/bus/slack-live-provider.ts +380 -0
  319. package/src/sdk/bus/slack-provider.ts +76 -0
  320. package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
  321. package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
  322. package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
  323. package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
  324. package/src/{notifications → sdk/bus}/telegram-daemon.ts +603 -63
  325. package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
  326. package/src/sdk/bus/telegram-setup.ts +663 -0
  327. package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
  328. package/src/sdk/cli/index.ts +1 -0
  329. package/src/sdk/cli/session-cli.ts +275 -0
  330. package/src/sdk/client/client.ts +1 -0
  331. package/src/sdk/client/discovery.ts +108 -0
  332. package/src/sdk/client/index.ts +2 -0
  333. package/src/sdk/host/control/dispatch.ts +367 -0
  334. package/src/sdk/host/control/index.ts +10 -0
  335. package/src/sdk/host/control/operations.ts +63 -0
  336. package/src/sdk/host/events.ts +91 -0
  337. package/src/sdk/host/host.ts +296 -0
  338. package/src/sdk/host/index.ts +4 -0
  339. package/src/sdk/host/query/cursor.ts +189 -0
  340. package/src/sdk/host/query/handlers.ts +533 -0
  341. package/src/sdk/host/query/index.ts +3 -0
  342. package/src/sdk/host/query/revision-store.ts +1126 -0
  343. package/src/sdk/host/reverse-leases.ts +325 -0
  344. package/src/sdk/host/types.ts +29 -0
  345. package/src/sdk/index.ts +13 -0
  346. package/src/sdk/lifecycle-session.ts +39 -0
  347. package/src/sdk/mcp/index.ts +1 -0
  348. package/src/sdk/mcp/server.ts +332 -0
  349. package/src/sdk/models.ts +168 -0
  350. package/src/sdk/protocol/adapter-validation.ts +48 -0
  351. package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
  352. package/src/sdk/protocol/operation-registry.ts +312 -0
  353. package/src/{sdk.ts → sdk/session.ts} +339 -212
  354. package/src/sdk/startup-capability.ts +189 -0
  355. package/src/session/agent-session.ts +2650 -944
  356. package/src/session/default-model-selection.ts +93 -0
  357. package/src/session/fallback-chain-controller.ts +136 -0
  358. package/src/session/session-manager.ts +438 -11
  359. package/src/session/session-storage.ts +8 -1
  360. package/src/setup/credential-auto-import.ts +290 -35
  361. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
  362. package/src/slash-commands/builtin-registry.ts +2 -1
  363. package/src/task/executor.ts +62 -13
  364. package/src/task/index.ts +26 -16
  365. package/src/tools/ask-answer-registry.ts +1 -1
  366. package/src/tools/ask.ts +13 -8
  367. package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
  368. package/src/tools/browser/benchmark/detector-report.ts +157 -0
  369. package/src/tools/browser/benchmark/run-suite.ts +39 -0
  370. package/src/tools/browser/launch.ts +124 -19
  371. package/src/tools/browser/profile-discovery.ts +63 -0
  372. package/src/tools/browser/profile-posture.ts +59 -0
  373. package/src/tools/browser/profile-reuse.ts +102 -0
  374. package/src/tools/browser/profile-warmup.ts +109 -0
  375. package/src/tools/browser/registry.ts +87 -11
  376. package/src/tools/browser/tab-protocol.ts +1 -0
  377. package/src/tools/browser/tab-supervisor.ts +1 -0
  378. package/src/tools/browser/tab-worker.ts +1 -1
  379. package/src/tools/browser.ts +6 -0
  380. package/src/tools/index.ts +8 -8
  381. package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
  382. package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
  383. package/src/tools/subagent-render.ts +19 -8
  384. package/src/tools/subagent.ts +84 -32
  385. package/src/tools/telegram-send.ts +2 -2
  386. package/vendor/insane-search/MANIFEST.json +2 -1
  387. package/vendor/insane-search/engine/bias_check.py +4 -1
  388. package/vendor/insane-search/engine/phase0.py +26 -1
  389. package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
  390. package/dist/types/modes/bridge/auth.d.ts +0 -12
  391. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
  392. package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
  393. package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
  394. package/dist/types/modes/bridge/event-stream.d.ts +0 -8
  395. package/dist/types/modes/rpc/host-tools.d.ts +0 -1
  396. package/dist/types/modes/rpc/host-uris.d.ts +0 -1
  397. package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
  398. package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
  399. package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
  400. package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
  401. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
  402. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
  403. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
  404. package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
  405. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
  406. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
  407. package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
  408. package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
  409. package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
  410. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
  411. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
  412. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
  413. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
  414. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
  415. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
  416. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
  417. package/dist/types/notifications/config.d.ts +0 -132
  418. package/src/harness-control-plane/rpc-adapter.ts +0 -290
  419. package/src/modes/bridge/auth.ts +0 -41
  420. package/src/modes/bridge/bridge-client-bridge.ts +0 -47
  421. package/src/modes/bridge/bridge-mode.ts +0 -675
  422. package/src/modes/bridge/bridge-ui-context.ts +0 -200
  423. package/src/modes/bridge/event-stream.ts +0 -70
  424. package/src/modes/rpc/host-tools.ts +0 -1
  425. package/src/modes/rpc/host-uris.ts +0 -1
  426. package/src/modes/rpc/rpc-client.ts +0 -1166
  427. package/src/modes/rpc/rpc-mode.ts +0 -1029
  428. package/src/modes/rpc/rpc-socket-security.ts +0 -115
  429. package/src/modes/rpc/rpc-types.ts +0 -637
  430. package/src/modes/shared/agent-wire/command-contract.ts +0 -18
  431. package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
  432. package/src/modes/shared/agent-wire/command-validation.ts +0 -181
  433. package/src/modes/shared/agent-wire/handshake.ts +0 -157
  434. package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
  435. package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
  436. package/src/modes/shared/agent-wire/protocol.ts +0 -46
  437. package/src/modes/shared/agent-wire/responses.ts +0 -17
  438. package/src/modes/shared/agent-wire/scopes.ts +0 -94
  439. package/src/modes/shared/agent-wire/session-registry.ts +0 -109
  440. package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
  441. package/src/modes/shared/agent-wire/ui-result.ts +0 -48
  442. package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
  443. package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
  444. package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
  445. package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
  446. package/src/notifications/config.ts +0 -219
  447. package/src/notifications/index.ts +0 -1835
  448. package/src/notifications/telegram-daemon-cli.ts +0 -243
  449. package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
  450. /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
  451. /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
  452. /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
  453. /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
  454. /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
  455. /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
  456. /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
  457. /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
  458. /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
  459. /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
  460. /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
  461. /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
  462. /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
  463. /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
  464. /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
  465. /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
  466. /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
  467. /package/src/{notifications → sdk/bus}/engine.ts +0 -0
  468. /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
  469. /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
  470. /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
  471. /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
  472. /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
  473. /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
  474. /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
  475. /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
  476. /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
@@ -4,14 +4,21 @@ import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { logger } from "@gajae-code/utils";
7
- import { withFileLock } from "../config/file-lock";
8
- import type { Settings } from "../config/settings";
9
- import type { DaemonRuntimeInfo } from "../daemon/control-types";
10
- import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
7
+ import { withFileLock } from "../../config/file-lock";
8
+ import type { Settings } from "../../config/settings";
9
+ import type { DaemonRuntimeInfo } from "../../daemon/control-types";
10
+ import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
11
11
  import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
12
12
  import { parseInThreadConfigCommand, parseRichToggleCommand, parseTelegramControlCommand } from "./config-commands";
13
13
  import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
14
+ import { sanitizeDiagnostic } from "./notification-service";
15
+ import { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
16
+ import { withTelegramSetupLease } from "./telegram-setup";
17
+
18
+ export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
19
+
14
20
  import {
21
+ buildButtonGrid,
15
22
  buildCompactChoiceGrid,
16
23
  code,
17
24
  splitTelegramHtml,
@@ -41,6 +48,7 @@ import {
41
48
  type LifecycleControlServer,
42
49
  type LifecycleControlServerFactory,
43
50
  } from "./lifecycle-control-runtime";
51
+ import type { OrchestratorDeps } from "./lifecycle-orchestrator";
44
52
  import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
45
53
  import { RateLimitPool } from "./rate-limit-pool";
46
54
  import { listRecentSessions } from "./recent-activity";
@@ -61,6 +69,8 @@ import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
61
69
  import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
62
70
 
63
71
  export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
72
+ /** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
73
+ export type EnsureTelegramDaemonDetailedResult = "spawned" | "reloaded" | "attached" | "disabled" | "blocked_identity";
64
74
 
65
75
  export interface DaemonState {
66
76
  pid: number;
@@ -123,21 +133,9 @@ export { HEARTBEAT_TTL_MS };
123
133
  export const DAEMON_VERSION = 1;
124
134
  /** Capability token advertised when the server supports app-level ping/pong. */
125
135
  export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
126
- /** Protocol version the daemon advertises in its ClientHello. */
127
- export const NOTIFICATION_PROTOCOL_VERSION = 3;
128
136
  /** Capability required for typed controls and semantic Selected acknowledgement frames. */
129
137
  export const ASK_SELECTED_ACK_CAPABILITY = "ask_selected_ack_v1";
130
138
  export const ASK_CONTROLS_CAPABILITY = "ask_controls_v1";
131
- /**
132
- * Operational generation the current daemon build speaks, persisted into
133
- * {@link DaemonState.generation} on ownership acquisition. It is tied to the
134
- * wire {@link NOTIFICATION_PROTOCOL_VERSION} so any protocol bump (which is what
135
- * gates capabilities like {@link ASK_SELECTED_ACK_CAPABILITY}) also bumps the
136
- * generation, letting a freshly-upgraded host recognise an older, still-live
137
- * daemon and reload it instead of silently attaching to it. Distinct from the
138
- * persisted schema {@link DAEMON_VERSION}, which did not change across #1999.
139
- */
140
- export const DAEMON_GENERATION = NOTIFICATION_PROTOCOL_VERSION;
141
139
 
142
140
  const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
143
141
 
@@ -177,6 +175,36 @@ const PENDING_TOPIC_FRAME_LIMIT = 20;
177
175
  const SEEN_UPDATE_ID_LIMIT = 1_000;
178
176
  const ORPHAN_TOPIC_GRACE_MS = 60_000;
179
177
  const CONSUMED_REACTION = "✅";
178
+ const MODEL_CALLBACK_PREFIX = "m:";
179
+ const MODEL_CHOICE_TTL_MS = 10 * 60 * 1_000;
180
+
181
+ const MODEL_BUTTON_LABEL_MAX_BYTES = 48;
182
+ const SENSITIVE_MODEL_LABEL =
183
+ /(?:\b(?:https?|wss?):\/\/|\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b|\b(?:api[-_ ]?key|access[-_ ]?token|bearer|secret|password|account(?:\s*id)?|email|exception|stack trace)\b|\b(?:sk|pk|rk)-[A-Za-z0-9_-]{12,}\b)/i;
184
+
185
+ /** Inline-keyboard labels are plain text, bounded, and stripped of visual control characters. */
186
+ function safeModelButtonLabel(value: unknown): string | undefined {
187
+ if (typeof value !== "string") return undefined;
188
+ const normalized = value
189
+ .replace(/[\u0000-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/g, " ")
190
+ .replace(/\s+/g, " ")
191
+ .trim();
192
+ if (!normalized) return undefined;
193
+ if (SENSITIVE_MODEL_LABEL.test(normalized)) return undefined;
194
+ if (Buffer.byteLength(normalized, "utf8") <= MODEL_BUTTON_LABEL_MAX_BYTES) return normalized;
195
+
196
+ const marker = "…";
197
+ const markerBytes = Buffer.byteLength(marker, "utf8");
198
+ let label = "";
199
+ let labelBytes = 0;
200
+ for (const char of normalized) {
201
+ const charBytes = Buffer.byteLength(char, "utf8");
202
+ if (labelBytes + charBytes + markerBytes > MODEL_BUTTON_LABEL_MAX_BYTES) break;
203
+ label += char;
204
+ labelBytes += charBytes;
205
+ }
206
+ return `${label}${marker}`;
207
+ }
180
208
 
181
209
  function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
182
210
  if (text.length <= max) return [text];
@@ -254,6 +282,9 @@ async function fetchWithRetry(
254
282
  }
255
283
 
256
284
  export { type DaemonPaths, daemonPaths } from "./daemon-paths";
285
+ export function deriveLifecycleAuditRedactionKey(botToken: string): Uint8Array {
286
+ return crypto.createHmac("sha256", botToken).update("gjc.lifecycle.audit.v2.key", "utf8").digest();
287
+ }
257
288
 
258
289
  /**
259
290
  * Attach session-lifecycle control (create/close/resume) to the running daemon.
@@ -265,19 +296,23 @@ export { type DaemonPaths, daemonPaths } from "./daemon-paths";
265
296
  * (NotificationControlServer) is owned/started by the daemon process; this
266
297
  * function only connects it to policy. Returns the orchestrator deps for tests.
267
298
  */
299
+
268
300
  export function startDaemonLifecycleControl(input: {
269
301
  controlServer: ControlServerLike;
270
302
  pairedChatId: string;
271
303
  agentDir: string;
304
+ auditRedactionKey: Uint8Array;
272
305
  env?: NodeJS.ProcessEnv;
273
- }): void {
306
+ }): OrchestratorDeps {
274
307
  const deps = buildOrchestratorDeps({
275
308
  pairedChatId: input.pairedChatId,
276
309
  agentNotificationsDir: daemonPaths(input.agentDir).dir,
277
310
  sessionsRoot: path.join(input.agentDir, "sessions"),
311
+ auditRedactionKey: input.auditRedactionKey,
278
312
  env: input.env,
279
313
  });
280
314
  attachLifecycleControl(input.controlServer, deps);
315
+ return deps;
281
316
  }
282
317
 
283
318
  async function ensureDir(fsImpl: TelegramDaemonFs, dir: string): Promise<void> {
@@ -340,6 +375,44 @@ export async function registerNotificationRoot(input: {
340
375
  return root;
341
376
  }
342
377
 
378
+ export interface UnregisterNotificationRootResult {
379
+ root: string;
380
+ remainingRoots: number;
381
+ }
382
+
383
+ /** Remove one session's Telegram scan root without disturbing other live session roots. */
384
+ export async function unregisterNotificationRoot(input: {
385
+ settings: Settings;
386
+ cwd: string;
387
+ sessionId: string;
388
+ fs?: TelegramDaemonFs;
389
+ }): Promise<UnregisterNotificationRootResult> {
390
+ const fsImpl = input.fs ?? nodeFs;
391
+ const paths = daemonPaths(input.settings.getAgentDir());
392
+ const root = notificationRootForCwd(input.cwd);
393
+ await ensureDir(fsImpl, paths.dir);
394
+ let remainingRoots = 0;
395
+ await withFileLock(
396
+ paths.roots,
397
+ async () => {
398
+ const current = await readJson<{ roots?: string[]; sessions?: Record<string, string> }>(fsImpl, paths.roots);
399
+ if (!current) return;
400
+ const sessions = { ...(current.sessions ?? {}) };
401
+ delete sessions[input.sessionId];
402
+ const rootStillReferenced = Object.values(sessions).includes(root);
403
+ const roots = (current.roots ?? []).filter(candidate => candidate !== root || rootStillReferenced);
404
+ remainingRoots = roots.length;
405
+ await writeJsonAtomic(fsImpl, paths.roots, {
406
+ version: 1,
407
+ roots: Array.from(new Set(roots)).sort(),
408
+ sessions,
409
+ });
410
+ },
411
+ { staleMs: 10_000 },
412
+ );
413
+ return { root, remainingRoots };
414
+ }
415
+
343
416
  function notificationRootForCwd(cwd: string): string {
344
417
  return path.join(cwd, ".gjc", "state");
345
418
  }
@@ -541,14 +614,17 @@ export async function releaseDaemonOwnership(input: {
541
614
 
542
615
  /** Read the persisted daemon ownership state (or undefined when absent). */
543
616
  export async function readDaemonState(
544
- settings: Settings,
617
+ settings: Pick<Settings, "getAgentDir">,
545
618
  fs: TelegramDaemonFs = nodeFs,
546
619
  ): Promise<DaemonState | undefined> {
547
620
  return readJson<DaemonState>(fs, daemonPaths(settings.getAgentDir()).state);
548
621
  }
549
622
 
550
623
  /** Read the persisted notification roots list. */
551
- export async function readDaemonRoots(settings: Settings, fs: TelegramDaemonFs = nodeFs): Promise<string[]> {
624
+ export async function readDaemonRoots(
625
+ settings: Pick<Settings, "getAgentDir">,
626
+ fs: TelegramDaemonFs = nodeFs,
627
+ ): Promise<string[]> {
552
628
  const roots = await readJson<{ roots?: string[] }>(fs, daemonPaths(settings.getAgentDir()).roots);
553
629
  return roots?.roots ?? [];
554
630
  }
@@ -688,35 +764,57 @@ export async function spawnTelegramDaemonOwner(
688
764
  return { result: "owner_spawned", ownerId: ownership.ownerId, runtime, warnings: [] };
689
765
  }
690
766
 
691
- export async function ensureTelegramDaemonRunning(
767
+ /**
768
+ * Ensure a configured daemon owns this session root, preserving ownership safety
769
+ * while exposing whether a #2028 generation handoff was required.
770
+ */
771
+ export async function ensureTelegramDaemonRunningDetailed(
692
772
  input: { settings: Settings; cwd: string; sessionId: string },
693
773
  deps: TelegramDaemonDeps = {},
694
- ): Promise<EnsureDaemonResult> {
774
+ ): Promise<EnsureTelegramDaemonDetailedResult> {
695
775
  const cfg = getNotificationConfig(input.settings);
696
776
  if (!isTelegramConfigured(cfg)) return "disabled";
697
- const root = notificationRootForCwd(input.cwd);
698
- const fp = tokenFingerprint(cfg.botToken);
699
- const spawned = await spawnTelegramDaemonOwner(
700
- { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
701
- deps,
702
- );
703
- if (spawned.result === "blocked") {
704
- logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
705
- return spawned.result;
706
- }
707
- if (spawned.reloadRequired) {
708
- // A still-live owner is running an OLDER daemon generation than this host
709
- // (e.g. a pre-upgrade daemon reused in place across an in-place upgrade).
710
- // Attaching would silently drop this host's newer ask-ack / control frames,
711
- // so register this session's root first — so the replacement daemon serves
712
- // it — then hand off through the tested SIGTERM/control reload path to bring
713
- // up a fresh current-generation daemon.
777
+ return await withTelegramSetupLease(cfg.botToken, async () => {
778
+ const root = notificationRootForCwd(input.cwd);
779
+ const fp = tokenFingerprint(cfg.botToken);
780
+ const spawned = await spawnTelegramDaemonOwner(
781
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
782
+ deps,
783
+ );
784
+ if (spawned.result === "blocked") {
785
+ logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
786
+ return "blocked_identity";
787
+ }
788
+ if (spawned.reloadRequired) {
789
+ await registerNotificationRoot({ ...input, fs: deps.fs });
790
+ await reloadStaleGenerationOwner(input.settings, deps);
791
+ return "reloaded";
792
+ }
714
793
  await registerNotificationRoot({ ...input, fs: deps.fs });
715
- await reloadStaleGenerationOwner(input.settings, deps);
716
- return "owner_spawned";
794
+ return spawned.result === "owner_spawned" ? "spawned" : "attached";
795
+ });
796
+ }
797
+
798
+ /**
799
+ * Legacy compatibility mapping for callers that only distinguish ownership from
800
+ * attachment. New orchestration should use {@link ensureTelegramDaemonRunningDetailed}.
801
+ */
802
+ export async function ensureTelegramDaemonRunning(
803
+ input: { settings: Settings; cwd: string; sessionId: string },
804
+ deps: TelegramDaemonDeps = {},
805
+ ): Promise<EnsureDaemonResult> {
806
+ const result = await ensureTelegramDaemonRunningDetailed(input, deps);
807
+ switch (result) {
808
+ case "spawned":
809
+ case "reloaded":
810
+ return "owner_spawned";
811
+ case "attached":
812
+ return "attached";
813
+ case "disabled":
814
+ return "disabled";
815
+ case "blocked_identity":
816
+ return "blocked";
717
817
  }
718
- await registerNotificationRoot({ ...input, fs: deps.fs });
719
- return spawned.result;
720
818
  }
721
819
 
722
820
  /**
@@ -843,23 +941,96 @@ type PairedChatPrivacy = "private" | "non-private" | "indeterminate";
843
941
 
844
942
  export type TelegramUpdateOutcome = "consumed" | "retry";
845
943
 
944
+ export type TelegramPollResult =
945
+ | { kind: "success"; updateCount: number }
946
+ | { kind: "aborted" }
947
+ | { kind: "getUpdates_failed"; error: string }
948
+ | { kind: "api_failure"; errorCode?: number; description: string }
949
+ | { kind: "conflict"; description: string; backoffMs: number };
950
+
846
951
  export interface TelegramUpdatePollerOptions {
847
952
  botApi: BotApi;
848
953
  runtime: NotificationOperatorRuntime;
849
954
  backoff: OperatorBackoffPolicy;
850
955
  processUpdate: (update: unknown) => Promise<TelegramUpdateOutcome>;
956
+ health?: TelegramPollHealth;
957
+ }
958
+
959
+ type TelegramPollHealthStatus = "healthy" | "getUpdates_failed" | "api_failure" | "conflict";
960
+
961
+ export class TelegramPollHealth {
962
+ #status: TelegramPollHealthStatus = "healthy";
963
+ #suppressedCount = 0;
964
+
965
+ record(result: TelegramPollResult): void {
966
+ if (result.kind === "aborted") return;
967
+ if (result.kind === "success") {
968
+ if (this.#status !== "healthy") {
969
+ logger.info("notifications daemon: Telegram getUpdates recovered", {
970
+ from: this.#status,
971
+ suppressedCount: this.#suppressedCount,
972
+ updateCount: result.updateCount,
973
+ });
974
+ }
975
+ this.#status = "healthy";
976
+ this.#suppressedCount = 0;
977
+ return;
978
+ }
979
+
980
+ const previousStatus = this.#status;
981
+ if (previousStatus === result.kind) {
982
+ this.#suppressedCount += 1;
983
+ return;
984
+ }
985
+
986
+ const suppressedCount = this.#suppressedCount;
987
+ this.#status = result.kind;
988
+ this.#suppressedCount = 0;
989
+ if (result.kind === "conflict") {
990
+ logger.error("notifications daemon: Telegram getUpdates 409 conflict", {
991
+ description: result.description,
992
+ backoffMs: result.backoffMs,
993
+ previousStatus,
994
+ suppressedCount,
995
+ });
996
+ return;
997
+ }
998
+ if (result.kind === "api_failure") {
999
+ logger.error("notifications daemon: Telegram getUpdates API failed", {
1000
+ errorCode: result.errorCode,
1001
+ description: result.description,
1002
+ previousStatus,
1003
+ suppressedCount,
1004
+ });
1005
+ return;
1006
+ }
1007
+
1008
+ logger.error("notifications daemon: getUpdates failed", {
1009
+ error: result.error,
1010
+ previousStatus,
1011
+ suppressedCount,
1012
+ });
1013
+ }
851
1014
  }
852
1015
 
853
1016
  /** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
854
1017
  export class TelegramUpdatePoller {
855
1018
  #offset = 0;
856
1019
  #opts: TelegramUpdatePollerOptions;
1020
+ #health: TelegramPollHealth;
857
1021
 
858
1022
  constructor(opts: TelegramUpdatePollerOptions) {
859
1023
  this.#opts = opts;
1024
+ this.#health = opts.health ?? new TelegramPollHealth();
860
1025
  }
861
1026
 
862
1027
  async pollOnce(signal?: AbortSignal): Promise<number> {
1028
+ const result = await this.pollOnceResult(signal);
1029
+ this.#health.record(result);
1030
+ return result.kind === "success" ? result.updateCount : 0;
1031
+ }
1032
+
1033
+ async pollOnceResult(signal?: AbortSignal): Promise<TelegramPollResult> {
863
1034
  let body: {
864
1035
  ok?: boolean;
865
1036
  error_code?: number;
@@ -874,24 +1045,37 @@ export class TelegramUpdatePoller {
874
1045
  )) as typeof body;
875
1046
  } catch (err) {
876
1047
  // A cooperative stop aborts the in-flight long poll; treat as a clean wake.
877
- if (isAbortError(err)) return 0;
1048
+ if (isAbortError(err)) return { kind: "aborted" };
878
1049
  // A transient Telegram API failure must never crash the daemon.
879
- logger.error("notifications daemon: getUpdates failed", { error: String(err) });
880
1050
  await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
881
- return 0;
1051
+ return { kind: "getUpdates_failed", error: sanitizeDiagnostic(String(err)) };
882
1052
  }
883
1053
  // Telegram allows only one active getUpdates poller per bot. A 409 means
884
1054
  // another poller is live; back off boundedly instead of hot-looping.
885
1055
  if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
886
1056
  const backoffMs = this.#opts.backoff.next();
887
- logger.error(
888
- `notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${backoffMs}ms`,
889
- );
890
1057
  await this.#opts.runtime.sleep(backoffMs, signal);
891
- return 0;
1058
+ return { kind: "conflict", description: sanitizeDiagnostic(body.description ?? "no description"), backoffMs };
1059
+ }
1060
+ if (body?.ok !== true || !Array.isArray(body.result)) {
1061
+ await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
1062
+ return {
1063
+ kind: "api_failure",
1064
+ errorCode: typeof body?.error_code === "number" ? body.error_code : undefined,
1065
+ description: sanitizeDiagnostic(body?.description ?? "Malformed getUpdates response"),
1066
+ };
892
1067
  }
893
1068
  this.#opts.backoff.reset();
894
- for (const update of body.result ?? []) {
1069
+ let malformedSeen = false;
1070
+ for (const update of body.result) {
1071
+ // A single malformed update_id must not wedge the poller. Skip the
1072
+ // bad entry (surfaced below as an api_failure health signal) while
1073
+ // still processing valid updates and advancing the offset past them,
1074
+ // so one poisoned item cannot stall an otherwise-valid stream.
1075
+ if (!Number.isSafeInteger(update?.update_id)) {
1076
+ malformedSeen = true;
1077
+ continue;
1078
+ }
895
1079
  try {
896
1080
  const outcome = await this.#opts.processUpdate(update);
897
1081
  if (outcome === "retry") {
@@ -900,11 +1084,17 @@ export class TelegramUpdatePoller {
900
1084
  }
901
1085
  this.#offset = update.update_id + 1;
902
1086
  } catch (err) {
903
- logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
1087
+ logger.error("notifications daemon: handleTelegramUpdate failed", {
1088
+ error: sanitizeDiagnostic(String(err)),
1089
+ });
904
1090
  this.#offset = update.update_id + 1;
905
1091
  }
906
1092
  }
907
- return body.result?.length ?? 0;
1093
+ if (malformedSeen) {
1094
+ await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
1095
+ return { kind: "api_failure", description: "Malformed getUpdates response" };
1096
+ }
1097
+ return { kind: "success", updateCount: body.result.length };
908
1098
  }
909
1099
  }
910
1100
 
@@ -955,6 +1145,16 @@ export interface TelegramDaemonOptions {
955
1145
  * default applies (e.g. lifecycle control disabled), no control server starts.
956
1146
  */
957
1147
  createLifecycleControlServer?: LifecycleControlServerFactory | null;
1148
+ /**
1149
+ * Test seam for observing the exact production lifecycle dependency input.
1150
+ * Production defaults to {@link buildOrchestratorDeps}.
1151
+ */
1152
+ createLifecycleOrchestratorDeps?: (input: {
1153
+ pairedChatId: string;
1154
+ agentNotificationsDir: string;
1155
+ sessionsRoot: string;
1156
+ auditRedactionKey: Uint8Array;
1157
+ }) => OrchestratorDeps;
958
1158
  /** Rich text promotion (enabled by default; see rich-render.ts). */
959
1159
  rich?: { enabled: boolean };
960
1160
  /** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
@@ -968,7 +1168,10 @@ export interface TelegramDaemonOptions {
968
1168
  }
969
1169
 
970
1170
  interface SessionSocket {
1171
+ /** Immutable key of the transport endpoint that owns this socket. */
971
1172
  sessionId: string;
1173
+ /** Current logical session carried by valid threaded frames on this transport. */
1174
+ logicalSessionId: string;
972
1175
  token: string;
973
1176
  endpointKey: string;
974
1177
  ws: WebSocket;
@@ -981,6 +1184,25 @@ interface SessionSocket {
981
1184
  awaitingNonce: string | undefined;
982
1185
  /** Per-session liveness interval handle (only set for capable sessions). */
983
1186
  pingTimer: ReturnType<typeof setInterval> | undefined;
1187
+ /** Correlation id for the startup replay barrier. */
1188
+ replayId: string;
1189
+ /** Queues live frames until startup replay is applied. */
1190
+ replayPending: boolean;
1191
+ replayQueue: Record<string, unknown>[];
1192
+ }
1193
+
1194
+ interface ModelChoiceRoute {
1195
+ /** Exact transport socket that rendered this choice. */
1196
+ session: SessionSocket;
1197
+ /** Logical session current when this choice was rendered. */
1198
+ sessionId: string;
1199
+ selector: string;
1200
+ expiresAt: number;
1201
+ }
1202
+
1203
+ interface RenderedModelChoice {
1204
+ selector: string;
1205
+ label: string;
984
1206
  }
985
1207
 
986
1208
  interface PendingThreadedFrame {
@@ -1017,6 +1239,9 @@ export class TelegramNotificationDaemon {
1017
1239
  /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
1018
1240
  private readonly liveMessages = new Map<string, number>();
1019
1241
  readonly sessions = new Map<string, SessionSocket>();
1242
+ /** Ephemeral aliases for model choices; deliberately never serialized across daemon restarts. */
1243
+ #modelChoiceAliases = new Map<string, ModelChoiceRoute>();
1244
+
1020
1245
  private readonly runtime: NotificationOperatorRuntime;
1021
1246
  private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
1022
1247
  private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
@@ -1140,10 +1365,11 @@ export class TelegramNotificationDaemon {
1140
1365
  const token = crypto.randomBytes(32).toString("base64url");
1141
1366
  const agentDir = this.opts.settings.getAgentDir();
1142
1367
  server = factory({ token, ownerId: this.opts.ownerId, agentDir });
1143
- const deps = buildOrchestratorDeps({
1368
+ const deps = (this.opts.createLifecycleOrchestratorDeps ?? buildOrchestratorDeps)({
1144
1369
  pairedChatId: this.opts.chatId,
1145
1370
  agentNotificationsDir: daemonPaths(agentDir).dir,
1146
1371
  sessionsRoot: path.join(agentDir, "sessions"),
1372
+ auditRedactionKey: deriveLifecycleAuditRedactionKey(this.opts.botToken),
1147
1373
  });
1148
1374
  // Register the lifecycle-request handler BEFORE start(): the native
1149
1375
  // control server captures the callback at start time, so wiring must
@@ -1697,6 +1923,26 @@ export class TelegramNotificationDaemon {
1697
1923
  logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
1698
1924
  }
1699
1925
  }
1926
+ private async rememberSeenUpdateIdForUnavailableNotice(updateId: number): Promise<boolean> {
1927
+ if (!Number.isSafeInteger(updateId) || updateId < 0) return false;
1928
+ const candidate = new Set(this.dispatchState.seenUpdateIds);
1929
+ candidate.add(updateId);
1930
+ while (candidate.size > SEEN_UPDATE_ID_LIMIT) candidate.delete(candidate.values().next().value!);
1931
+ try {
1932
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
1933
+ await ensureDir(this.fsImpl, paths.dir);
1934
+ await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
1935
+ version: 1,
1936
+ updateIds: [...candidate],
1937
+ });
1938
+ } catch {
1939
+ logger.warn("notifications: unavailable-control notice state publication failed");
1940
+ return false;
1941
+ }
1942
+ this.dispatchState.seenUpdateIds.clear();
1943
+ for (const seenId of candidate) this.dispatchState.seenUpdateIds.add(seenId);
1944
+ return true;
1945
+ }
1700
1946
 
1701
1947
  async scanRoots(): Promise<void> {
1702
1948
  const paths = daemonPaths(this.opts.settings.getAgentDir());
@@ -1704,7 +1950,7 @@ export class TelegramNotificationDaemon {
1704
1950
  const endpointSessionIds = new Set<string>();
1705
1951
  let allRootsReadable = true;
1706
1952
  for (const root of rootState?.roots ?? []) {
1707
- const dir = path.join(root, "notifications");
1953
+ const dir = path.join(root, "sdk");
1708
1954
  let files: string[];
1709
1955
  try {
1710
1956
  files = await this.fsImpl.readdir(dir);
@@ -1748,8 +1994,14 @@ export class TelegramNotificationDaemon {
1748
1994
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
1749
1995
  const endpointKey = endpointGenerationKey(url, token);
1750
1996
  this.closedEndpointKeys.delete(sessionId);
1997
+ const existing = this.sessions.get(sessionId);
1998
+ if (existing) this.#clearModelChoiceAliasesForSocket(existing);
1999
+ this.#clearModelChoiceAliases(sessionId);
2000
+
1751
2001
  const session: SessionSocket = {
1752
2002
  sessionId,
2003
+ logicalSessionId: sessionId,
2004
+
1753
2005
  token,
1754
2006
  endpointKey,
1755
2007
  ws,
@@ -1758,12 +2010,18 @@ export class TelegramNotificationDaemon {
1758
2010
  lastPongAt: 0,
1759
2011
  awaitingNonce: undefined,
1760
2012
  pingTimer: undefined,
2013
+ replayId: `telegram-startup-replay:${sessionId}`,
2014
+ replayPending: false,
2015
+ replayQueue: [],
1761
2016
  };
1762
2017
  this.sessions.set(sessionId, session);
1763
2018
  // Bidirectional capability advertisement: announce client_ping_pong once the
1764
2019
  // socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
1765
2020
  // while CONNECTING — and liveness starts only after a capable ServerHello.
1766
2021
  ws.addEventListener("open", () => {
2022
+ session.replayPending = true;
2023
+ session.replayQueue = [];
2024
+ const replayCursor = this.topics.replayCursor(sessionId);
1767
2025
  if (session.ws.readyState === WebSocket.OPEN) {
1768
2026
  try {
1769
2027
  session.ws.send(
@@ -1774,6 +2032,16 @@ export class TelegramNotificationDaemon {
1774
2032
  }),
1775
2033
  );
1776
2034
  } catch {}
2035
+ try {
2036
+ session.ws.send(
2037
+ JSON.stringify({
2038
+ type: "event_replay",
2039
+ id: session.replayId,
2040
+ sinceGeneration: replayCursor?.generation ?? 1,
2041
+ sinceSeq: replayCursor?.seq ?? 0,
2042
+ }),
2043
+ );
2044
+ } catch {}
1777
2045
  }
1778
2046
  // Eagerly create the session's Telegram topic as soon as it connects, so
1779
2047
  // a thread exists the moment a notifications-enabled session is live —
@@ -1843,8 +2111,10 @@ export class TelegramNotificationDaemon {
1843
2111
  this.deleteMessageRoutes(session.sessionId);
1844
2112
  }
1845
2113
  if (isCurrentSession) {
2114
+ this.#clearModelChoiceAliasesForSocket(session);
1846
2115
  this.sessions.delete(session.sessionId);
1847
2116
  }
2117
+
1848
2118
  for (const item of new Set(this.selectedAckPending.values())) {
1849
2119
  if (item.session !== session) continue;
1850
2120
  if (item.state === "queued") this.pool.removeById(item.itemId);
@@ -1866,6 +2136,39 @@ export class TelegramNotificationDaemon {
1866
2136
  }
1867
2137
  }
1868
2138
 
2139
+ #logicalSessionId(session: SessionSocket): string {
2140
+ return session.logicalSessionId ?? session.sessionId;
2141
+ }
2142
+
2143
+ #clearModelChoiceAliases(sessionId: string): void {
2144
+ for (const [alias, route] of this.#modelChoiceAliases) {
2145
+ if (route.sessionId === sessionId) this.#modelChoiceAliases.delete(alias);
2146
+ }
2147
+ }
2148
+
2149
+ #clearModelChoiceAliasesForSocket(session: SessionSocket): void {
2150
+ for (const [alias, route] of this.#modelChoiceAliases) {
2151
+ if (route.session === session) this.#modelChoiceAliases.delete(alias);
2152
+ }
2153
+ }
2154
+
2155
+ #sweepExpiredModelChoiceAliases(): void {
2156
+ const now = this.runtime.now();
2157
+ for (const [alias, route] of this.#modelChoiceAliases) {
2158
+ if (route.expiresAt <= now) this.#modelChoiceAliases.delete(alias);
2159
+ }
2160
+ }
2161
+
2162
+ #putModelChoiceAlias(route: Omit<ModelChoiceRoute, "expiresAt">): string {
2163
+ this.#sweepExpiredModelChoiceAliases();
2164
+ let alias: string;
2165
+ do {
2166
+ alias = `${MODEL_CALLBACK_PREFIX}${crypto.randomBytes(16).toString("base64url")}`;
2167
+ } while (this.#modelChoiceAliases.has(alias));
2168
+ this.#modelChoiceAliases.set(alias, { ...route, expiresAt: this.runtime.now() + MODEL_CHOICE_TTL_MS });
2169
+ return alias;
2170
+ }
2171
+
1869
2172
  private static readonly THREADED_FRAMES = new Set([
1870
2173
  "identity_header",
1871
2174
  "context_update",
@@ -1876,6 +2179,22 @@ export class TelegramNotificationDaemon {
1876
2179
  "control_command_result",
1877
2180
  ]);
1878
2181
 
2182
+ /** Rekey model controls before a valid threaded frame can render or a silent config update can route output. */
2183
+ #updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): void {
2184
+ if (
2185
+ typeof msg.type !== "string" ||
2186
+ !TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type) ||
2187
+ typeof msg.sessionId !== "string" ||
2188
+ !msg.sessionId.trim() ||
2189
+ msg.sessionId === this.#logicalSessionId(session) ||
2190
+ (msg.type !== "config_update" && !renderThreadedFrame(msg))
2191
+ )
2192
+ return;
2193
+ this.#clearModelChoiceAliasesForSocket(session);
2194
+ this.#clearModelChoiceAliases(msg.sessionId);
2195
+ session.logicalSessionId = msg.sessionId;
2196
+ }
2197
+
1879
2198
  private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
1880
2199
  const repo = typeof msg?.repo === "string" && msg.repo ? msg.repo : undefined;
1881
2200
  const branch = typeof msg?.branch === "string" && msg.branch ? msg.branch : undefined;
@@ -2134,7 +2453,7 @@ export class TelegramNotificationDaemon {
2134
2453
  if (!res.ok) return undefined;
2135
2454
  return Buffer.from(await res.arrayBuffer());
2136
2455
  } catch (e) {
2137
- logger.warn(`notifications: file download failed: ${String(e)}`);
2456
+ logger.warn(`notifications: file download failed: ${sanitizeDiagnostic(String(e))}`);
2138
2457
  return undefined;
2139
2458
  }
2140
2459
  }
@@ -2664,8 +2983,129 @@ export class TelegramNotificationDaemon {
2664
2983
  this.runtime.stopInterval("telegram-typing");
2665
2984
  }
2666
2985
 
2986
+ /** Render successful `/model` lists as session-bound, one-shot inline choices. */
2987
+ async #renderModelChoices(session: SessionSocket, msg: Record<string, unknown>): Promise<boolean> {
2988
+ const logicalSessionId = this.#logicalSessionId(session);
2989
+ if (
2990
+ msg.type !== "control_command_result" ||
2991
+ msg.status !== "ok" ||
2992
+ msg.sessionId !== logicalSessionId ||
2993
+ !Array.isArray(msg.modelChoices) ||
2994
+ this.sessions.get(session.sessionId) !== session
2995
+ )
2996
+ return false;
2997
+
2998
+ const choices: RenderedModelChoice[] = [];
2999
+ for (const choice of msg.modelChoices) {
3000
+ if (!choice || typeof choice !== "object") continue;
3001
+ const { selector, label } = choice as { selector?: unknown; label?: unknown };
3002
+ const safeLabel = safeModelButtonLabel(label);
3003
+ if (typeof selector !== "string" || !selector.trim() || !safeLabel) continue;
3004
+ choices.push({ selector, label: safeLabel });
3005
+ }
3006
+ if (choices.length === 0) return false;
3007
+
3008
+ const rendered = renderThreadedFrame({ ...msg, type: "control_command_result" });
3009
+ if (!rendered?.text) return false;
3010
+ const topicId =
3011
+ (await this.existingTopicForPrivateChat(session.sessionId)) ??
3012
+ (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
3013
+ if (!topicId) return false;
3014
+
3015
+ // Each logical session owns only its most recently rendered menu.
3016
+ this.#clearModelChoiceAliases(logicalSessionId);
3017
+ const aliases = choices.map(choice =>
3018
+ this.#putModelChoiceAlias({ session, sessionId: logicalSessionId, selector: choice.selector }),
3019
+ );
3020
+ const inline_keyboard = buildButtonGrid(
3021
+ choices.map(choice => choice.label),
3022
+ index => aliases[index]!,
3023
+ );
3024
+ try {
3025
+ const response = await this.botApi.call("sendMessage", {
3026
+ chat_id: this.opts.chatId,
3027
+ message_thread_id: Number(topicId),
3028
+ text: rendered.text,
3029
+ parse_mode: TELEGRAM_PARSE_MODE,
3030
+ reply_markup: { inline_keyboard },
3031
+ });
3032
+ if (response && typeof response === "object" && (response as { ok?: unknown }).ok === false) {
3033
+ for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
3034
+ logger.warn("notifications: failed to send model selection keyboard");
3035
+ return false;
3036
+ }
3037
+ } catch {
3038
+ for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
3039
+ logger.warn("notifications: failed to send model selection keyboard");
3040
+ return false;
3041
+ }
3042
+ return true;
3043
+ }
3044
+
2667
3045
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
3046
+ if (session.replayPending) {
3047
+ const matchingReplay = msg?.type === "event_replay_result" && msg.id === session.replayId;
3048
+ if (!matchingReplay) {
3049
+ session.replayQueue.push(msg as Record<string, unknown>);
3050
+ return;
3051
+ }
3052
+ session.replayPending = false;
3053
+ const replayValid =
3054
+ Number.isSafeInteger(msg.generation) &&
3055
+ msg.generation >= 1 &&
3056
+ Number.isSafeInteger(msg.lastSeq) &&
3057
+ msg.lastSeq >= 0 &&
3058
+ Array.isArray(msg.events);
3059
+ const replayed: Record<string, unknown>[] = replayValid
3060
+ ? (msg.events as unknown[]).flatMap((event: unknown): Record<string, unknown>[] => {
3061
+ if (!event || typeof event !== "object" || Array.isArray(event)) return [];
3062
+ const envelope = event as Record<string, unknown>;
3063
+ const payload = envelope.payload;
3064
+ return [
3065
+ payload && typeof payload === "object" && !Array.isArray(payload)
3066
+ ? (payload as Record<string, unknown>)
3067
+ : envelope,
3068
+ ];
3069
+ })
3070
+ : [];
3071
+ // Replay restores durable attachment state only. Live notification effects
3072
+ // (turn streams, context updates, lifecycle messages) may already have been
3073
+ // delivered before a reconnect and must never be rendered a second time.
3074
+ const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
3075
+ const currentGeneration = identityIndex < 0 ? replayed : replayed.slice(identityIndex);
3076
+ const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
3077
+ const latestActions = new Map<string, Record<string, unknown>>();
3078
+ for (const frame of currentGeneration) {
3079
+ if ((frame.type === "action_needed" || frame.type === "action_resolved") && typeof frame.id === "string")
3080
+ latestActions.set(frame.id, frame);
3081
+ }
3082
+ const replayState = [...(latestIdentity ? [latestIdentity] : []), ...latestActions.values()];
3083
+ const replayCounts = new Map<string, number>();
3084
+ for (const frame of replayState) {
3085
+ const fingerprint = JSON.stringify(frame);
3086
+ replayCounts.set(fingerprint, (replayCounts.get(fingerprint) ?? 0) + 1);
3087
+ await this.handleSessionMessage(session, frame);
3088
+ }
3089
+ const queued = session.replayQueue.splice(0);
3090
+ for (const frame of queued) {
3091
+ const fingerprint = JSON.stringify(frame);
3092
+ const remaining = replayCounts.get(fingerprint) ?? 0;
3093
+ if (remaining > 0) {
3094
+ if (remaining === 1) replayCounts.delete(fingerprint);
3095
+ else replayCounts.set(fingerprint, remaining - 1);
3096
+ continue;
3097
+ }
3098
+ await this.handleSessionMessage(session, frame);
3099
+ }
3100
+ if (replayValid && this.topics.markReplayCursor(session.sessionId, msg.generation, msg.lastSeq))
3101
+ await this.persistTopics();
3102
+ return;
3103
+ }
3104
+ if (msg?.type === "event_replay_result") return;
3105
+ if (msg && typeof msg === "object") this.#updateLogicalSessionForThreadedFrame(session, msg);
2668
3106
  if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
3107
+ if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
3108
+
2669
3109
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
2670
3110
  const send = renderThreadedFrame(msg);
2671
3111
  if (!send) return;
@@ -2864,6 +3304,80 @@ export class TelegramNotificationDaemon {
2864
3304
  }
2865
3305
  }
2866
3306
 
3307
+ /** Claim and forward one session-bound model alias before acknowledging its callback. */
3308
+ async #handleModelChoiceCallback(update: unknown, callbackId: unknown): Promise<boolean> {
3309
+ const callback = (
3310
+ update as {
3311
+ callback_query?: { data?: unknown; message?: { chat?: { id?: unknown } } };
3312
+ }
3313
+ ).callback_query;
3314
+ const alias = callback?.data;
3315
+ if (typeof alias !== "string" || !alias.startsWith(MODEL_CALLBACK_PREFIX)) return false;
3316
+ this.#sweepExpiredModelChoiceAliases();
3317
+ if (String(callback?.message?.chat?.id) !== String(this.opts.chatId)) {
3318
+ await this.answerCallbackQueryBestEffort(callbackId, "Not authorized");
3319
+ return true;
3320
+ }
3321
+ if (!(await this.pairedChatIsPrivate())) {
3322
+ await this.answerCallbackQueryBestEffort(callbackId, "Not authorized");
3323
+ return true;
3324
+ }
3325
+
3326
+ const route = this.#modelChoiceAliases.get(alias);
3327
+ if (!route) {
3328
+ await this.#sendModelStaleGuidance(callbackId);
3329
+ return true;
3330
+ }
3331
+ const session = route.session;
3332
+ if (
3333
+ this.sessions.get(session.sessionId) !== session ||
3334
+ this.#logicalSessionId(session) !== route.sessionId ||
3335
+ session.ws.readyState !== WebSocket.OPEN
3336
+ ) {
3337
+ this.#modelChoiceAliases.delete(alias);
3338
+ await this.#sendModelStaleGuidance(callbackId);
3339
+ return true;
3340
+ }
3341
+
3342
+ // Delete before send so a duplicate tap can only become stale, never duplicate a control command.
3343
+ this.#modelChoiceAliases.delete(alias);
3344
+ const updateId = (update as { update_id?: unknown }).update_id;
3345
+ const safeUpdateId =
3346
+ typeof updateId === "number" && Number.isSafeInteger(updateId) && updateId >= 0 ? updateId : undefined;
3347
+ try {
3348
+ session.ws.send(
3349
+ JSON.stringify({
3350
+ type: "control_command",
3351
+ sessionId: route.sessionId,
3352
+ token: session.token,
3353
+ requestId: safeUpdateId === undefined ? `tg:model:${alias}` : `tg:model:${safeUpdateId}`,
3354
+ ...(safeUpdateId === undefined ? {} : { updateId: safeUpdateId }),
3355
+ command: { name: "model", action: "set", selector: route.selector },
3356
+ }),
3357
+ );
3358
+ } catch {
3359
+ await this.#sendModelStaleGuidance(callbackId);
3360
+ return true;
3361
+ }
3362
+ await this.answerCallbackQueryBestEffort(callbackId);
3363
+ return true;
3364
+ }
3365
+
3366
+ async #sendModelStaleGuidance(callbackId: unknown): Promise<void> {
3367
+ const text = "Button is stale. Run /model again.";
3368
+ await this.answerCallbackQueryBestEffort(callbackId, text);
3369
+ if (!(await this.pairedChatIsPrivate())) return;
3370
+ try {
3371
+ await this.botApi.call("sendMessage", {
3372
+ chat_id: this.opts.chatId,
3373
+ text,
3374
+ parse_mode: TELEGRAM_PARSE_MODE,
3375
+ });
3376
+ } catch {
3377
+ // Best-effort stale guidance must not reject a callback.
3378
+ }
3379
+ }
3380
+
2867
3381
  private async sendStaleGuidance(callbackId: unknown): Promise<void> {
2868
3382
  await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
2869
3383
  if (!(await this.pairedChatIsPrivate())) return;
@@ -3040,7 +3554,7 @@ export class TelegramNotificationDaemon {
3040
3554
  // update_id dedupe are all enforced by decideThreadedInbound.
3041
3555
  const raw = update as {
3042
3556
  callback_query?: unknown;
3043
- message?: { reply_to_message?: { message_id?: unknown } };
3557
+ message?: { text?: unknown; reply_to_message?: { message_id?: unknown } };
3044
3558
  };
3045
3559
  // A reply to a known ask message routes to that ask (below). Any OTHER
3046
3560
  // message in a topic (plain text, or a reply to a non-ask message) is a
@@ -3048,7 +3562,12 @@ export class TelegramNotificationDaemon {
3048
3562
  const replyTo = raw.message?.reply_to_message?.message_id;
3049
3563
  const isAskReply =
3050
3564
  replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
3051
- if (!raw.callback_query && !isAskReply) {
3565
+ const directControl =
3566
+ typeof raw.message?.text === "string"
3567
+ ? parseTelegramControlCommand(raw.message.text, this.botUsername)
3568
+ : ({ kind: "none" } as const);
3569
+ const interceptAskControl = isAskReply && directControl.kind !== "none";
3570
+ if (!raw.callback_query && (!isAskReply || interceptAskControl)) {
3052
3571
  const inbound = decideThreadedInbound(update as never, {
3053
3572
  pairedChatId: this.opts.chatId,
3054
3573
  topicToSession: t => this.topics.sessionForTopic(t),
@@ -3056,7 +3575,27 @@ export class TelegramNotificationDaemon {
3056
3575
  });
3057
3576
  if (inbound.kind === "duplicate") return;
3058
3577
  if (inbound.kind === "inject") {
3578
+ if (!(await this.pairedChatIsPrivate())) return;
3579
+ const preliminaryControl = inbound.attachment
3580
+ ? { kind: "none" as const }
3581
+ : parseTelegramControlCommand(inbound.text, this.botUsername);
3582
+ if (preliminaryControl.kind === "ignored") return;
3059
3583
  const session = this.sessions.get(inbound.sessionId);
3584
+ if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
3585
+ if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
3586
+ if (await this.rememberSeenUpdateIdForUnavailableNotice(inbound.updateId)) {
3587
+ try {
3588
+ await this.botApi.call("sendMessage", {
3589
+ chat_id: this.opts.chatId,
3590
+ message_thread_id: Number(inbound.threadId),
3591
+ text: "Session control unavailable: this local GJC session is disconnected.",
3592
+ });
3593
+ } catch {
3594
+ logger.warn("notifications: unavailable-control notice delivery failed");
3595
+ }
3596
+ }
3597
+ return;
3598
+ }
3060
3599
  if (session?.ws.readyState === WebSocket.OPEN) {
3061
3600
  const attachmentResult = inbound.attachment
3062
3601
  ? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
@@ -3075,9 +3614,7 @@ export class TelegramNotificationDaemon {
3075
3614
  const injectedText = repliedOriginal
3076
3615
  ? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
3077
3616
  : baseInjectedText;
3078
- const control = hasMedia
3079
- ? { kind: "none" as const }
3080
- : parseTelegramControlCommand(inbound.text, this.botUsername);
3617
+ const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
3081
3618
  if (control.kind !== "none") {
3082
3619
  await this.rememberSeenUpdateId(inbound.updateId);
3083
3620
  const sendControlNotice = async (body: string): Promise<void> => {
@@ -3092,7 +3629,6 @@ export class TelegramNotificationDaemon {
3092
3629
  // Best-effort control feedback; never convert to user input.
3093
3630
  }
3094
3631
  };
3095
- if (control.kind === "ignored") return;
3096
3632
  if (control.kind === "invalid") {
3097
3633
  await sendControlNotice(control.usage);
3098
3634
  return;
@@ -3104,7 +3640,7 @@ export class TelegramNotificationDaemon {
3104
3640
  session.ws.send(
3105
3641
  JSON.stringify({
3106
3642
  type: "control_command",
3107
- sessionId: inbound.sessionId,
3643
+ sessionId: this.#logicalSessionId(session),
3108
3644
  token: session.token,
3109
3645
  requestId: `tg:${inbound.updateId}`,
3110
3646
  updateId: inbound.updateId,
@@ -3161,6 +3697,7 @@ export class TelegramNotificationDaemon {
3161
3697
  }
3162
3698
  }
3163
3699
  const callbackId = (update as { callback_query?: { id?: unknown } }).callback_query?.id;
3700
+ if (await this.#handleModelChoiceCallback(update, callbackId)) return;
3164
3701
  const decision = routeInboundUpdate(update, {
3165
3702
  aliasTable: this.aliasTable,
3166
3703
  messageRoutes: this.messageRoutes,
@@ -3196,6 +3733,7 @@ export class TelegramNotificationDaemon {
3196
3733
  { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
3197
3734
  { command: "reasoning", description: "Show or change reasoning effort in this session" },
3198
3735
  { command: "usage", description: "Show provider/local usage for this session" },
3736
+ { command: "model", description: "Select a model for this session" },
3199
3737
  { command: "context", description: "Show current context usage for this session" },
3200
3738
  { command: "compact", description: "Compact this session: /compact [instructions]" },
3201
3739
  { command: "session_create", description: "Create a GJC session: path, worktree, or dir [--mpreset]" },
@@ -3268,7 +3806,9 @@ export class TelegramNotificationDaemon {
3268
3806
  // Back off (bounded, below the heartbeat TTL) and keep renewing
3269
3807
  // ownership at the loop top.
3270
3808
  const backoffMs = this.loopBackoff.next();
3271
- logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
3809
+ logger.warn(
3810
+ `notifications: getUpdates failed, backing off ${backoffMs}ms: ${sanitizeDiagnostic(String(e))}`,
3811
+ );
3272
3812
  await this.runtime.sleep(backoffMs);
3273
3813
  continue;
3274
3814
  } finally {