@gajae-code/coding-agent 0.11.11 → 0.12.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 (344) hide show
  1. package/CHANGELOG.md +131 -4
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/cli/read-cli.d.ts +1 -0
  4. package/dist/types/commands/deep-interview.d.ts +9 -0
  5. package/dist/types/commands/harness.d.ts +22 -0
  6. package/dist/types/commands/read.d.ts +6 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/provider-auth-health.d.ts +14 -0
  9. package/dist/types/config/provider-ranking.d.ts +59 -0
  10. package/dist/types/config/settings-schema.d.ts +83 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/exa/mcp-client.d.ts +10 -1
  13. package/dist/types/exec/bash-executor.d.ts +45 -12
  14. package/dist/types/extensibility/extensions/types.d.ts +38 -4
  15. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  17. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  18. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  19. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  20. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  21. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  22. package/dist/types/extensibility/shared-events.d.ts +6 -0
  23. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  24. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  25. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  26. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  27. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  28. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  29. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  30. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +70 -1
  31. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  32. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  33. package/dist/types/gjc-runtime/team-runtime.d.ts +27 -1
  34. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  35. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  36. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  37. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +17 -0
  38. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  39. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  40. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  41. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  42. package/dist/types/main.d.ts +1 -1
  43. package/dist/types/memories/index.d.ts +2 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +10 -19
  45. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  46. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  47. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  48. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  49. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  50. package/dist/types/modes/components/session-observer-overlay.d.ts +22 -0
  51. package/dist/types/modes/components/settings-selector.d.ts +11 -0
  52. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  53. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  54. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  55. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  56. package/dist/types/modes/interactive-mode.d.ts +2 -0
  57. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  58. package/dist/types/modes/types.d.ts +2 -0
  59. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  60. package/dist/types/reminders/star-reminder.d.ts +3 -13
  61. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  62. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  64. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  65. package/dist/types/sdk/broker/transport.d.ts +2 -0
  66. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  68. package/dist/types/sdk/bus/config.d.ts +6 -2
  69. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  70. package/dist/types/sdk/bus/index.d.ts +44 -1
  71. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +44 -0
  72. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  73. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  74. package/dist/types/sdk/bus/reconciliation-store.d.ts +69 -0
  75. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  76. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  77. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  78. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  79. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  80. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  81. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  82. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  83. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  84. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  85. package/dist/types/sdk/host/host.d.ts +2 -0
  86. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  87. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  88. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  89. package/dist/types/sdk/prompt-status.d.ts +23 -3
  90. package/dist/types/sdk/session.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +82 -12
  92. package/dist/types/session/artifacts.d.ts +8 -0
  93. package/dist/types/session/blob-store.d.ts +57 -4
  94. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  95. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  96. package/dist/types/session/session-manager.d.ts +48 -19
  97. package/dist/types/session/session-storage.d.ts +4 -0
  98. package/dist/types/session/streaming-output.d.ts +96 -12
  99. package/dist/types/setup/credential-import.d.ts +12 -0
  100. package/dist/types/skill-state/active-state.d.ts +12 -4
  101. package/dist/types/skill-state/workflow-hud.d.ts +9 -0
  102. package/dist/types/task/executor.d.ts +3 -2
  103. package/dist/types/task/index.d.ts +3 -1
  104. package/dist/types/task/provider-retry-status.d.ts +8 -0
  105. package/dist/types/task/receipt.d.ts +7 -1
  106. package/dist/types/task/types.d.ts +23 -0
  107. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  108. package/dist/types/tools/bash-interactive.d.ts +4 -1
  109. package/dist/types/tools/bash.d.ts +3 -1
  110. package/dist/types/tools/browser/launch.d.ts +7 -0
  111. package/dist/types/tools/fetch.d.ts +17 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -0
  113. package/dist/types/tools/index.d.ts +4 -0
  114. package/dist/types/tools/output-meta.d.ts +44 -3
  115. package/dist/types/tools/path-utils.d.ts +1 -0
  116. package/dist/types/tools/read-internals.d.ts +12 -0
  117. package/dist/types/tools/read.d.ts +45 -7
  118. package/dist/types/tools/resource-gc.d.ts +4 -0
  119. package/dist/types/tools/review.d.ts +5 -7
  120. package/dist/types/tools/subagent.d.ts +2 -0
  121. package/dist/types/tools/tool-result.d.ts +7 -1
  122. package/dist/types/utils/gh.d.ts +13 -0
  123. package/dist/types/utils/image-resize.d.ts +1 -0
  124. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  125. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  126. package/dist/types/web/search/providers/xai.d.ts +2 -0
  127. package/package.json +11 -13
  128. package/scripts/acp-conformance-agent.ts +218 -0
  129. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  130. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  131. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  132. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  133. package/scripts/resident-memory-bench.ts +1341 -0
  134. package/scripts/run-acp-conformance.ts +285 -0
  135. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  136. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  137. package/src/async/job-manager.ts +78 -20
  138. package/src/capability/index.ts +8 -0
  139. package/src/cli/fast-help.ts +1 -1
  140. package/src/cli/plugin-cli.ts +176 -18
  141. package/src/cli/read-cli.ts +2 -1
  142. package/src/cli.ts +1 -0
  143. package/src/commands/deep-interview.ts +29 -9
  144. package/src/commands/harness.ts +35 -12
  145. package/src/commands/read.ts +11 -2
  146. package/src/commands/team.ts +20 -2
  147. package/src/config/file-lock.ts +11 -2
  148. package/src/config/model-registry.ts +21 -2
  149. package/src/config/provider-auth-health.ts +42 -0
  150. package/src/config/provider-ranking.ts +119 -0
  151. package/src/config/settings-schema.ts +74 -1
  152. package/src/config/settings.ts +246 -108
  153. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  154. package/src/coordinator-mcp/server.ts +5 -12
  155. package/src/defaults/gjc/skills/deep-interview/SKILL.md +230 -82
  156. package/src/defaults/gjc/skills/ralplan/SKILL.md +77 -41
  157. package/src/defaults/gjc/skills/team/SKILL.md +3 -1
  158. package/src/defaults/gjc/skills/ultragoal/SKILL.md +62 -39
  159. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  160. package/src/defaults/gjc-defaults.ts +4 -4
  161. package/src/eval/py/executor.ts +7 -2
  162. package/src/exa/mcp-client.ts +12 -3
  163. package/src/exec/bash-executor.ts +144 -27
  164. package/src/extensibility/extensions/runner.ts +15 -0
  165. package/src/extensibility/extensions/types.ts +46 -5
  166. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  167. package/src/extensibility/gjc-plugins/index.ts +19 -2
  168. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  169. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  170. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  171. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  172. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  173. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  174. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  175. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  176. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  177. package/src/extensibility/gjc-plugins/types.ts +141 -0
  178. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  179. package/src/extensibility/shared-events.ts +5 -0
  180. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  181. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  182. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  183. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  184. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  185. package/src/gjc-runtime/gc-render.ts +1 -0
  186. package/src/gjc-runtime/gc-runtime.ts +11 -1
  187. package/src/gjc-runtime/launch-tmux.ts +25 -6
  188. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  189. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  190. package/src/gjc-runtime/ralplan-runtime.ts +1149 -98
  191. package/src/gjc-runtime/state-runtime.ts +2 -1
  192. package/src/gjc-runtime/state-writer.ts +70 -222
  193. package/src/gjc-runtime/team-launch.ts +42 -5
  194. package/src/gjc-runtime/team-runtime.ts +1085 -40
  195. package/src/gjc-runtime/team-store.ts +32 -0
  196. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  197. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  198. package/src/gjc-runtime/team-workers.ts +1 -0
  199. package/src/gjc-runtime/tmux-owner-isolation.ts +28 -0
  200. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  201. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  202. package/src/gjc-runtime/ultragoal-runtime.ts +414 -618
  203. package/src/gjc-runtime/workflow-manifest.generated.json +105 -449
  204. package/src/gjc-runtime/workflow-manifest.ts +38 -247
  205. package/src/hooks/native-skill-hook.ts +17 -4
  206. package/src/hooks/skill-state.ts +1 -23
  207. package/src/internal-urls/docs-index.generated.ts +21 -22
  208. package/src/internal-urls/local-root-gc.ts +127 -0
  209. package/src/main.ts +9 -5
  210. package/src/memories/index.ts +10 -0
  211. package/src/modes/DESIGN.md +98 -0
  212. package/src/modes/acp/acp-agent.ts +339 -191
  213. package/src/modes/acp/acp-event-mapper.ts +14 -7
  214. package/src/modes/acp/acp-mode.ts +39 -3
  215. package/src/modes/components/assistant-message.ts +123 -22
  216. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  217. package/src/modes/components/irc-sidebar.ts +176 -62
  218. package/src/modes/components/model-selector.ts +61 -4
  219. package/src/modes/components/notifications-settings-editor.ts +12 -1
  220. package/src/modes/components/oauth-selector.ts +96 -28
  221. package/src/modes/components/read-tool-group.ts +82 -14
  222. package/src/modes/components/session-observer-overlay.ts +457 -83
  223. package/src/modes/components/settings-selector.ts +116 -23
  224. package/src/modes/components/status-line/gh.ts +16 -0
  225. package/src/modes/components/tool-execution.ts +138 -27
  226. package/src/modes/components/tool-status-header.ts +3 -14
  227. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  228. package/src/modes/controllers/command-controller.ts +4 -0
  229. package/src/modes/controllers/event-controller.ts +109 -27
  230. package/src/modes/controllers/extension-ui-controller.ts +26 -2
  231. package/src/modes/controllers/input-controller.ts +18 -3
  232. package/src/modes/controllers/selector-controller.ts +127 -36
  233. package/src/modes/interactive-mode.ts +62 -3
  234. package/src/modes/irc-observation-ledger.ts +71 -15
  235. package/src/modes/runtime-init.ts +22 -2
  236. package/src/modes/types.ts +2 -0
  237. package/src/modes/utils/ui-helpers.ts +94 -1
  238. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  239. package/src/prompts/agents/architect.md +9 -1
  240. package/src/prompts/agents/critic.md +9 -0
  241. package/src/prompts/system/eager-todo.md +8 -4
  242. package/src/prompts/system/system-prompt.md +11 -5
  243. package/src/prompts/tools/bash.md +5 -5
  244. package/src/prompts/tools/read.md +4 -2
  245. package/src/prompts/tools/search-tool-bm25.md +5 -0
  246. package/src/prompts/tools/task.md +1 -1
  247. package/src/reminders/star-reminder.ts +4 -42
  248. package/src/runtime-mcp/manager.ts +19 -4
  249. package/src/runtime-mcp/smithery-auth.ts +17 -2
  250. package/src/runtime-mcp/smithery-connect.ts +11 -1
  251. package/src/sdk/acp/adapter.ts +22 -6
  252. package/src/sdk/broker/lifecycle.ts +25 -6
  253. package/src/sdk/broker/transport.ts +35 -3
  254. package/src/sdk/bus/attachment-registry.ts +5 -1
  255. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  256. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  257. package/src/sdk/bus/config.ts +18 -2
  258. package/src/sdk/bus/control-drain-lease.ts +104 -0
  259. package/src/sdk/bus/index.ts +984 -146
  260. package/src/sdk/bus/kind-aware-reconciliation.ts +317 -0
  261. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  262. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  263. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  264. package/src/sdk/bus/reconciliation-store.ts +303 -0
  265. package/src/sdk/bus/rich-draft.ts +1 -1
  266. package/src/sdk/bus/rich-render.ts +2 -2
  267. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  268. package/src/sdk/bus/telegram-cli.ts +33 -8
  269. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  270. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  271. package/src/sdk/bus/telegram-daemon.ts +916 -23
  272. package/src/sdk/bus/telegram-reference.ts +40 -3
  273. package/src/sdk/bus/threaded-render.ts +48 -3
  274. package/src/sdk/bus/topic-registry.ts +20 -0
  275. package/src/sdk/host/control/dispatch.ts +5 -1
  276. package/src/sdk/host/control/operations.ts +1 -1
  277. package/src/sdk/host/host.ts +23 -1
  278. package/src/sdk/host/query/handlers.ts +55 -0
  279. package/src/sdk/host/query/revision-store.ts +36 -2
  280. package/src/sdk/host/reverse-leases.ts +44 -7
  281. package/src/sdk/mcp/server.ts +46 -18
  282. package/src/sdk/prompt-status.ts +16 -3
  283. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  284. package/src/sdk/protocol/operation-registry.ts +7 -2
  285. package/src/sdk/session.ts +101 -10
  286. package/src/session/agent-session.ts +1286 -220
  287. package/src/session/artifacts.ts +22 -0
  288. package/src/session/auth-broker-config.ts +10 -3
  289. package/src/session/blob-store.ts +915 -24
  290. package/src/session/internal/managed-session-scope.ts +44 -6
  291. package/src/session/internal/managed-session-storage.ts +48 -6
  292. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  293. package/src/session/session-manager.ts +2364 -438
  294. package/src/session/session-storage.ts +9 -1
  295. package/src/session/streaming-output.ts +500 -111
  296. package/src/setup/credential-import.ts +75 -13
  297. package/src/setup/provider-onboarding.ts +19 -4
  298. package/src/skill-state/active-state.ts +32 -10
  299. package/src/skill-state/workflow-hud.ts +42 -2
  300. package/src/skill-state/workflow-mutation-guard.ts +560 -8
  301. package/src/task/executor.ts +122 -39
  302. package/src/task/index.ts +23 -14
  303. package/src/task/provider-retry-status.ts +16 -0
  304. package/src/task/receipt.ts +23 -7
  305. package/src/task/render.ts +6 -2
  306. package/src/task/types.ts +108 -0
  307. package/src/tools/ask-answer-registry.ts +12 -6
  308. package/src/tools/ask.ts +169 -118
  309. package/src/tools/ast-edit.ts +1 -0
  310. package/src/tools/ast-grep.ts +1 -0
  311. package/src/tools/bash-interactive.ts +8 -3
  312. package/src/tools/bash.ts +503 -60
  313. package/src/tools/browser/launch.ts +41 -6
  314. package/src/tools/fetch.ts +114 -23
  315. package/src/tools/find.ts +1 -0
  316. package/src/tools/image-gen.ts +29 -5
  317. package/src/tools/index.ts +4 -0
  318. package/src/tools/output-meta.ts +281 -20
  319. package/src/tools/path-utils.ts +8 -3
  320. package/src/tools/read-internals.ts +44 -0
  321. package/src/tools/read.ts +1560 -310
  322. package/src/tools/resource-gc.ts +182 -18
  323. package/src/tools/review.ts +5 -7
  324. package/src/tools/search.ts +1 -0
  325. package/src/tools/subagent-render.ts +6 -2
  326. package/src/tools/subagent.ts +6 -0
  327. package/src/tools/telegram-send.ts +6 -3
  328. package/src/tools/tool-result.ts +14 -1
  329. package/src/utils/gh.ts +39 -0
  330. package/src/utils/image-resize.ts +3 -0
  331. package/src/web/search/providers/anthropic.ts +4 -4
  332. package/src/web/search/providers/codex.ts +1 -1
  333. package/src/web/search/providers/kimi.ts +15 -5
  334. package/src/web/search/providers/searxng.ts +47 -4
  335. package/src/web/search/providers/xai.ts +7 -2
  336. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  337. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  338. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  339. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  340. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  341. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  342. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  343. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
  344. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Durable, non-routable sidecar store for "adopt a user-created Telegram forum
3
+ * topic into a preallocated session" intents.
4
+ *
5
+ * When a user creates a new forum topic, the daemon first records durable
6
+ * pending-topic authorization. After folder selection it preallocates an
7
+ * `intendedSessionId` and records an adoption intent here *before* spawning.
8
+ * Later, the authenticated `ensureTopic` create callback reads that intent and
9
+ * returns the user's existing topicId to `TopicRegistry.getOrCreateTopic`, which
10
+ * is the only authority that commits a topic binding. This keeps the intent out
11
+ * of routing/endpoint authority until commit, so a failed spawn or a late socket
12
+ * never deletes the user's topic and never strands a duplicate.
13
+ *
14
+ * Invariants (see `pending-approval.md`):
15
+ * - One file per intent, named `<intendedSessionId>.adoption-intent.json` under
16
+ * the daemon notifications dir. Sweep touches files only; never the Telegram API.
17
+ * - Only `{ intendedSessionId, topicId, chatId, target, createdAt, expiresAt }`
18
+ * is persisted. Bot/control tokens and endpoint digests are never stored.
19
+ * - Owner-only dir (0o700) and 0o600 files; atomic temp→fsync→chmod→rename→parent sync.
20
+ * - `tryClaim` is a synchronous, in-memory claim keyed by topicId; it excludes a
21
+ * second `intendedSessionId` from winning the same topic across awaits. It
22
+ * does not delete the sidecar; `releaseClaim` re-enables retry on commit failure.
23
+ */
24
+
25
+ import { randomUUID } from "node:crypto";
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import { daemonPaths } from "./daemon-paths";
29
+ import type { SessionCreateTarget } from "./index";
30
+
31
+ /** Persisted document version. */
32
+ export const TELEGRAM_ADOPTION_INTENT_VERSION = 1;
33
+ export type TelegramAdoptionTarget = Extract<SessionCreateTarget, { kind: "existing_path" }>;
34
+ /** Default intent TTL (configurable at write time). Plan starts at 10 minutes. */
35
+ export const DEFAULT_ADOPTION_INTENT_TTL_MS = 10 * 60 * 1000;
36
+ /** Per-intent filename prefix/suffix under the notifications dir. */
37
+ const INTENT_FILE_SUFFIX = ".adoption-intent.json";
38
+ const PENDING_TOPIC_FILE_SUFFIX = ".pending-topic.json";
39
+
40
+ /**
41
+ * Minimal async filesystem surface the store needs. Structurally a subset of the
42
+ * daemon's `TelegramDaemonFs` / `ConversationStoreFs`, so the daemon can pass its
43
+ * own `fs` straight through and tests can inject a fake. `open`/`sync`/`close`
44
+ * mirror `ConversationStoreFileHandle` so the atomic fsync path reuses the same
45
+ * shape as the conversation store.
46
+ */
47
+ export interface AdoptionIntentFs {
48
+ mkdir(directory: string, options: { recursive: true; mode: number }): Promise<unknown>;
49
+ chmod(target: string, mode: number): Promise<void>;
50
+ readFile(file: string, encoding: "utf8"): Promise<string>;
51
+ writeFile(file: string, data: string, options: { mode: number }): Promise<void>;
52
+ rename(from: string, to: string): Promise<void>;
53
+ unlink(file: string): Promise<unknown>;
54
+ readdir(directory: string): Promise<readonly string[]>;
55
+ open(file: string, flags: string): Promise<AdoptionIntentFileHandle>;
56
+ }
57
+
58
+ export interface AdoptionIntentFileHandle {
59
+ sync(): Promise<void>;
60
+ close(): Promise<void>;
61
+ }
62
+
63
+ const nodeFs: AdoptionIntentFs = {
64
+ mkdir: (dir, opts) => fs.promises.mkdir(dir, opts),
65
+ chmod: (target, mode) => fs.promises.chmod(target, mode),
66
+ readFile: (file, encoding) => fs.promises.readFile(file, encoding),
67
+ writeFile: (file, data, opts) => fs.promises.writeFile(file, data, opts),
68
+ rename: (from, to) => fs.promises.rename(from, to),
69
+ unlink: file => fs.promises.unlink(file),
70
+ readdir: dir => fs.promises.readdir(dir),
71
+ open: async (file, flags) => fs.promises.open(file, flags),
72
+ };
73
+
74
+ /** What gets persisted per intent. No tokens, no digests. */
75
+ export interface TelegramAdoptionIntent {
76
+ intendedSessionId: string;
77
+ topicId: number;
78
+ chatId: string;
79
+ target: TelegramAdoptionTarget;
80
+ createdAt: number;
81
+ expiresAt: number;
82
+ }
83
+
84
+ interface PersistedIntent {
85
+ version: typeof TELEGRAM_ADOPTION_INTENT_VERSION;
86
+ intent: TelegramAdoptionIntent;
87
+ }
88
+
89
+ /** Durable authorization that a topic was observed through forum_topic_created. */
90
+ export interface TelegramPendingTopic {
91
+ topicId: number;
92
+ chatId: string;
93
+ createdAt: number;
94
+ expiresAt: number;
95
+ }
96
+
97
+ interface PersistedPendingTopic {
98
+ version: typeof TELEGRAM_ADOPTION_INTENT_VERSION;
99
+ pendingTopic: TelegramPendingTopic;
100
+ }
101
+
102
+ function isRecord(value: unknown): value is Record<string, unknown> {
103
+ return typeof value === "object" && value !== null && !Array.isArray(value);
104
+ }
105
+
106
+ function isSessionCreateTarget(value: unknown): value is TelegramAdoptionTarget {
107
+ return isRecord(value) && value.kind === "existing_path" && typeof value.path === "string";
108
+ }
109
+
110
+ function isPersistedIntent(value: unknown): value is PersistedIntent {
111
+ if (!isRecord(value)) return false;
112
+ if (value.version !== TELEGRAM_ADOPTION_INTENT_VERSION) return false;
113
+ const intent = value.intent;
114
+ if (!isRecord(intent)) return false;
115
+ return (
116
+ typeof intent.intendedSessionId === "string" &&
117
+ intent.intendedSessionId.length > 0 &&
118
+ typeof intent.topicId === "number" &&
119
+ Number.isSafeInteger(intent.topicId) &&
120
+ intent.topicId > 0 &&
121
+ typeof intent.chatId === "string" &&
122
+ typeof intent.createdAt === "number" &&
123
+ Number.isFinite(intent.createdAt) &&
124
+ typeof intent.expiresAt === "number" &&
125
+ Number.isFinite(intent.expiresAt) &&
126
+ intent.expiresAt > intent.createdAt &&
127
+ isSessionCreateTarget(intent.target)
128
+ );
129
+ }
130
+
131
+ function isPersistedPendingTopic(value: unknown): value is PersistedPendingTopic {
132
+ if (!isRecord(value) || value.version !== TELEGRAM_ADOPTION_INTENT_VERSION) return false;
133
+ const pending = value.pendingTopic;
134
+ return (
135
+ isRecord(pending) &&
136
+ typeof pending.topicId === "number" &&
137
+ Number.isSafeInteger(pending.topicId) &&
138
+ pending.topicId > 0 &&
139
+ typeof pending.chatId === "string" &&
140
+ typeof pending.createdAt === "number" &&
141
+ Number.isFinite(pending.createdAt) &&
142
+ typeof pending.expiresAt === "number" &&
143
+ Number.isFinite(pending.expiresAt) &&
144
+ pending.expiresAt > pending.createdAt
145
+ );
146
+ }
147
+
148
+ /** Compose the per-intent sidecar path. Public so callers/tests can inspect it. */
149
+ export function adoptionIntentFilePath(agentDir: string, intendedSessionId: string): string {
150
+ return path.join(daemonPaths(agentDir).dir, `${intendedSessionId}${INTENT_FILE_SUFFIX}`);
151
+ }
152
+
153
+ /** Compose the sidecar path for a user-created topic awaiting a folder choice. */
154
+ export function pendingTopicFilePath(agentDir: string, topicId: number): string {
155
+ return path.join(daemonPaths(agentDir).dir, `${topicId}${PENDING_TOPIC_FILE_SUFFIX}`);
156
+ }
157
+
158
+ /** Build a fresh intent with `createdAt`/`expiresAt` derived from `now`/`ttlMs`. */
159
+ export function buildAdoptionIntent(input: {
160
+ intendedSessionId: string;
161
+ topicId: number;
162
+ chatId: string;
163
+ target: TelegramAdoptionTarget;
164
+ now?: number;
165
+ ttlMs?: number;
166
+ }): TelegramAdoptionIntent {
167
+ const createdAt = input.now ?? Date.now();
168
+ const ttl = input.ttlMs ?? DEFAULT_ADOPTION_INTENT_TTL_MS;
169
+ return {
170
+ intendedSessionId: input.intendedSessionId,
171
+ topicId: input.topicId,
172
+ chatId: input.chatId,
173
+ target: input.target,
174
+ createdAt,
175
+ expiresAt: createdAt + ttl,
176
+ };
177
+ }
178
+
179
+ /** Test-only: filename suffix used to recognize intent sidecars in a directory listing. */
180
+ export const ADOPTION_INTENT_FILENAME_SUFFIX = INTENT_FILE_SUFFIX;
181
+ export const PENDING_TOPIC_FILENAME_SUFFIX = PENDING_TOPIC_FILE_SUFFIX;
182
+
183
+ /**
184
+ * Durable, non-routable adoption-intent store. Owns durable sidecar files and
185
+ * the synchronous in-memory topicId claim table. The daemon owns Telegram and
186
+ * lifecycle wiring; this store never calls the Telegram API.
187
+ */
188
+ export class TelegramAdoptionIntentStore {
189
+ readonly #agentDir: string;
190
+ readonly #dir: string;
191
+ readonly #fsImpl: AdoptionIntentFs;
192
+ readonly #now: () => number;
193
+ readonly #platform: NodeJS.Platform;
194
+ /** intendedSessionId -> intent, rehydrated from disk. */
195
+ readonly #intents = new Map<string, TelegramAdoptionIntent>();
196
+ /** topicId -> intendedSessionId currently holding the synchronous claim. */
197
+ readonly #claims = new Map<number, string>();
198
+ /** topicId -> durable authorization observed from forum_topic_created. */
199
+ readonly #pendingTopics = new Map<number, TelegramPendingTopic>();
200
+
201
+ constructor(input: {
202
+ agentDir: string;
203
+ fs?: AdoptionIntentFs;
204
+ now?: () => number;
205
+ platform?: NodeJS.Platform;
206
+ }) {
207
+ this.#agentDir = input.agentDir;
208
+ this.#dir = daemonPaths(input.agentDir).dir;
209
+ this.#fsImpl = input.fs ?? nodeFs;
210
+ this.#now = input.now ?? Date.now;
211
+ this.#platform = input.platform ?? process.platform;
212
+ }
213
+
214
+ /** Directory holding the sidecar files. */
215
+ get directory(): string {
216
+ return this.#dir;
217
+ }
218
+
219
+ /** In-memory lookup by intendedSessionId (no disk read). undefined if absent/expired. */
220
+ bySession(intendedSessionId: string): TelegramAdoptionIntent | undefined {
221
+ const intent = this.#intents.get(intendedSessionId);
222
+ if (intent === undefined) return undefined;
223
+ return this.#now() < intent.expiresAt ? intent : undefined;
224
+ }
225
+
226
+ /** In-memory lookup of the (single) non-expired intent for a topicId. */
227
+ byTopic(topicId: number): TelegramAdoptionIntent | undefined {
228
+ for (const intent of this.#intents.values()) {
229
+ if (intent.topicId !== topicId) continue;
230
+ if (this.#now() >= intent.expiresAt) continue;
231
+ return intent;
232
+ }
233
+ return undefined;
234
+ }
235
+
236
+ /** `true` if a non-expired intent already references `topicId`. */
237
+ hasNonExpiredTopic(topicId: number): boolean {
238
+ return this.byTopic(topicId) !== undefined;
239
+ }
240
+
241
+ /** Durable authorization for an observed user-created topic, if still live. */
242
+ pendingTopic(topicId: number): TelegramPendingTopic | undefined {
243
+ const pending = this.#pendingTopics.get(topicId);
244
+ return pending !== undefined && this.#now() < pending.expiresAt ? pending : undefined;
245
+ }
246
+
247
+ hasPendingTopic(topicId: number, chatId: string): boolean {
248
+ return this.pendingTopic(topicId)?.chatId === chatId;
249
+ }
250
+
251
+ /**
252
+ * Synchronously claim `topicId` for `intendedSessionId`. Returns false if a
253
+ * *different* non-expired intendedSessionId already holds the claim (or has a
254
+ * non-expired intent for the topic). Same intendedSessionId re-claiming its
255
+ * own topic is idempotent and succeeds. The claim is in-memory only; it never
256
+ * deletes the sidecar, so a commit failure can `releaseClaim` and retry.
257
+ */
258
+ tryClaim(topicId: number, intendedSessionId: string): boolean {
259
+ const holder = this.#claims.get(topicId);
260
+ if (holder !== undefined) return holder === intendedSessionId;
261
+ // No live claim yet: refuse if a *different* non-expired intent already
262
+ // targets this topic (race between two pre-spawn intents on one topic).
263
+ const existing = this.byTopic(topicId);
264
+ if (existing !== undefined && existing.intendedSessionId !== intendedSessionId) return false;
265
+ this.#claims.set(topicId, intendedSessionId);
266
+ return true;
267
+ }
268
+
269
+ /** Release a claim held by `intendedSessionId` on `topicId`. No-op otherwise. */
270
+ releaseClaim(topicId: number, intendedSessionId: string): void {
271
+ if (this.#claims.get(topicId) === intendedSessionId) this.#claims.delete(topicId);
272
+ }
273
+
274
+ /**
275
+ * Durable write of one intent: temp→write→fsync→chmod(0600)→atomic rename→
276
+ * chmod(final, 0600)→parent-dir fsync, then track it in memory. The dir is
277
+ * created owner-only (0700). chmod-ing the final path after the rename makes
278
+ * the 0600 contract durable regardless of whether the platform's rename
279
+ * preserves the source mode (POSIX does; some test/network filesystems and
280
+ * the in-memory test double model rename as copy+delete and would otherwise
281
+ * drop it). The rename is still the atomic commit point; the final chmod only
282
+ * tightens permissions on the already-durable file. The in-memory map is only
283
+ * mutated after the atomic persist succeeds. Rejects (does not swallow) so the
284
+ * daemon can decide fail-closed behavior.
285
+ */
286
+ async put(intent: TelegramAdoptionIntent): Promise<void> {
287
+ const stored: TelegramAdoptionIntent = {
288
+ intendedSessionId: intent.intendedSessionId,
289
+ topicId: intent.topicId,
290
+ chatId: intent.chatId,
291
+ target: { kind: "existing_path", path: intent.target.path },
292
+ createdAt: intent.createdAt,
293
+ expiresAt: intent.expiresAt,
294
+ };
295
+ const file = adoptionIntentFilePath(this.#agentDir, stored.intendedSessionId);
296
+ const payload: PersistedIntent = { version: TELEGRAM_ADOPTION_INTENT_VERSION, intent: stored };
297
+ if (!isPersistedIntent(payload)) throw new Error("invalid Telegram adoption intent");
298
+ await this.#writeSidecar(file, payload);
299
+ this.#intents.set(stored.intendedSessionId, stored);
300
+ }
301
+
302
+ /** Persist authorization for a topic observed through forum_topic_created. */
303
+ async putPendingTopic(pendingTopic: TelegramPendingTopic): Promise<void> {
304
+ const stored: TelegramPendingTopic = {
305
+ topicId: pendingTopic.topicId,
306
+ chatId: pendingTopic.chatId,
307
+ createdAt: pendingTopic.createdAt,
308
+ expiresAt: pendingTopic.expiresAt,
309
+ };
310
+ const file = pendingTopicFilePath(this.#agentDir, stored.topicId);
311
+ const payload: PersistedPendingTopic = {
312
+ version: TELEGRAM_ADOPTION_INTENT_VERSION,
313
+ pendingTopic: stored,
314
+ };
315
+ if (!isPersistedPendingTopic(payload)) throw new Error("invalid Telegram pending topic");
316
+ await this.#writeSidecar(file, payload);
317
+ this.#pendingTopics.set(stored.topicId, stored);
318
+ }
319
+
320
+ async #writeSidecar(file: string, payload: PersistedIntent | PersistedPendingTopic): Promise<void> {
321
+ await this.#fsImpl.mkdir(this.#dir, { recursive: true, mode: 0o700 });
322
+ await this.#fsImpl.chmod(this.#dir, 0o700);
323
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
324
+ let renamed = false;
325
+ try {
326
+ await this.#fsImpl.writeFile(temporary, `${JSON.stringify(payload)}\n`, { mode: 0o600 });
327
+ await this.#fsImpl.chmod(temporary, 0o600);
328
+ const handle = await this.#fsImpl.open(temporary, "r+");
329
+ try {
330
+ await syncRequired(handle);
331
+ } finally {
332
+ await handle.close();
333
+ }
334
+ await this.#fsImpl.rename(temporary, file);
335
+ renamed = true;
336
+ await this.#fsImpl.chmod(file, 0o600);
337
+ await this.#syncParentDirectory();
338
+ } catch (error) {
339
+ const cleanupTarget = renamed ? file : temporary;
340
+ let cleanupError: unknown;
341
+ try {
342
+ await this.#fsImpl.unlink(cleanupTarget);
343
+ if (renamed) await this.#syncParentDirectory();
344
+ } catch (candidate) {
345
+ if (!isMissing(candidate)) cleanupError = candidate;
346
+ }
347
+ if (cleanupError) throw new AggregateError([error, cleanupError], "Adoption sidecar write and cleanup failed");
348
+ throw error;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Read a single intent sidecar from disk and track it if it is non-expired
354
+ * and well-formed. Returns the parsed intent (or undefined). Used by
355
+ * `rehydrate`; callers normally use `rehydrate` / `bySession`.
356
+ */
357
+ async readIntent(intendedSessionId: string): Promise<TelegramAdoptionIntent | undefined> {
358
+ const file = adoptionIntentFilePath(this.#agentDir, intendedSessionId);
359
+ let raw: string;
360
+ try {
361
+ raw = await this.#fsImpl.readFile(file, "utf8");
362
+ } catch (error) {
363
+ if (isMissing(error)) return undefined;
364
+ throw error;
365
+ }
366
+ let parsed: unknown;
367
+ try {
368
+ parsed = JSON.parse(raw);
369
+ } catch {
370
+ return undefined;
371
+ }
372
+ if (!isPersistedIntent(parsed) || parsed.intent.intendedSessionId !== intendedSessionId) return undefined;
373
+ if (this.#now() >= parsed.intent.expiresAt) return undefined;
374
+ this.#intents.set(intendedSessionId, parsed.intent);
375
+ return parsed.intent;
376
+ }
377
+
378
+ async readPendingTopic(topicId: number): Promise<TelegramPendingTopic | undefined> {
379
+ const file = pendingTopicFilePath(this.#agentDir, topicId);
380
+ const pending = await this.#readPendingSidecar(file);
381
+ if (!pending || pending.topicId !== topicId || this.#now() >= pending.expiresAt) return undefined;
382
+ this.#pendingTopics.set(topicId, pending);
383
+ return pending;
384
+ }
385
+
386
+ /**
387
+ * Rehydrate all non-expired intents from the notifications dir. Files are the
388
+ * only source: missing/corrupt/expired entries are skipped (never thrown).
389
+ * Returns the count of live intents loaded.
390
+ */
391
+ async rehydrate(): Promise<number> {
392
+ let names: readonly string[];
393
+ try {
394
+ names = await this.#fsImpl.readdir(this.#dir);
395
+ } catch (error) {
396
+ if (isMissing(error)) return 0;
397
+ throw error;
398
+ }
399
+ let loaded = 0;
400
+ for (const name of names) {
401
+ if (name.endsWith(INTENT_FILE_SUFFIX)) {
402
+ const intendedSessionId = name.slice(0, -INTENT_FILE_SUFFIX.length);
403
+ if (!intendedSessionId) continue;
404
+ const intent = await this.readIntent(intendedSessionId);
405
+ if (intent) loaded++;
406
+ continue;
407
+ }
408
+ if (!name.endsWith(PENDING_TOPIC_FILE_SUFFIX)) continue;
409
+ const rawTopicId = name.slice(0, -PENDING_TOPIC_FILE_SUFFIX.length);
410
+ const topicId = Number(rawTopicId);
411
+ if (!Number.isSafeInteger(topicId) || topicId <= 0) continue;
412
+ const pending = await this.readPendingTopic(topicId);
413
+ if (pending) loaded++;
414
+ }
415
+ return loaded;
416
+ }
417
+
418
+ /**
419
+ * Remove a single intent sidecar and drop its in-memory entry. Safe to call
420
+ * after a successful registry commit or a definite spawn failure. Missing
421
+ * files are not an error. Never calls the Telegram API.
422
+ */
423
+ async remove(intendedSessionId: string): Promise<void> {
424
+ const file = adoptionIntentFilePath(this.#agentDir, intendedSessionId);
425
+ try {
426
+ await this.#fsImpl.unlink(file);
427
+ } catch (error) {
428
+ if (!isMissing(error)) throw error;
429
+ }
430
+ const intent = this.#intents.get(intendedSessionId);
431
+ this.#intents.delete(intendedSessionId);
432
+ if (intent) this.releaseClaim(intent.topicId, intendedSessionId);
433
+ }
434
+
435
+ /** Remove a pending-topic authorization after an adoption intent is durable. */
436
+ async removePendingTopic(topicId: number): Promise<void> {
437
+ const file = pendingTopicFilePath(this.#agentDir, topicId);
438
+ try {
439
+ await this.#fsImpl.unlink(file);
440
+ } catch (error) {
441
+ if (!isMissing(error)) throw error;
442
+ }
443
+ this.#pendingTopics.delete(topicId);
444
+ }
445
+
446
+ /**
447
+ * Sweep expired intent sidecars from disk. Touches files only. Returns the
448
+ * count of expired sidecars removed. Safe to run at startup and periodically.
449
+ */
450
+ async sweepExpired(): Promise<number> {
451
+ let names: readonly string[];
452
+ try {
453
+ names = await this.#fsImpl.readdir(this.#dir);
454
+ } catch (error) {
455
+ if (isMissing(error)) return 0;
456
+ throw error;
457
+ }
458
+ const now = this.#now();
459
+ let removed = 0;
460
+ for (const name of names) {
461
+ const file = path.join(this.#dir, name);
462
+ if (name.endsWith(INTENT_FILE_SUFFIX)) {
463
+ const intendedSessionId = name.slice(0, -INTENT_FILE_SUFFIX.length);
464
+ if (!intendedSessionId) continue;
465
+ const intent = await this.#readSidecar(file);
466
+ if (intent && intent.intendedSessionId === intendedSessionId && now < intent.expiresAt) continue;
467
+ try {
468
+ await this.#fsImpl.unlink(file);
469
+ removed++;
470
+ } catch (error) {
471
+ if (!isMissing(error)) throw error;
472
+ }
473
+ if (intent) {
474
+ this.#intents.delete(intent.intendedSessionId);
475
+ this.releaseClaim(intent.topicId, intent.intendedSessionId);
476
+ }
477
+ continue;
478
+ }
479
+ if (!name.endsWith(PENDING_TOPIC_FILE_SUFFIX)) continue;
480
+ const rawTopicId = name.slice(0, -PENDING_TOPIC_FILE_SUFFIX.length);
481
+ const topicId = Number(rawTopicId);
482
+ const pending = await this.#readPendingSidecar(file);
483
+ if (Number.isSafeInteger(topicId) && topicId > 0 && pending?.topicId === topicId && now < pending.expiresAt)
484
+ continue;
485
+ try {
486
+ await this.#fsImpl.unlink(file);
487
+ removed++;
488
+ } catch (error) {
489
+ if (!isMissing(error)) throw error;
490
+ }
491
+ if (Number.isSafeInteger(topicId)) this.#pendingTopics.delete(topicId);
492
+ }
493
+ return removed;
494
+ }
495
+
496
+ /** Read and validate a sidecar at an arbitrary path (used by sweep). */
497
+ async #readSidecar(file: string): Promise<TelegramAdoptionIntent | undefined> {
498
+ let raw: string;
499
+ try {
500
+ raw = await this.#fsImpl.readFile(file, "utf8");
501
+ } catch (error) {
502
+ if (isMissing(error)) return undefined;
503
+ throw error;
504
+ }
505
+ let parsed: unknown;
506
+ try {
507
+ parsed = JSON.parse(raw);
508
+ } catch {
509
+ return undefined;
510
+ }
511
+ return isPersistedIntent(parsed) ? parsed.intent : undefined;
512
+ }
513
+
514
+ async #readPendingSidecar(file: string): Promise<TelegramPendingTopic | undefined> {
515
+ let raw: string;
516
+ try {
517
+ raw = await this.#fsImpl.readFile(file, "utf8");
518
+ } catch (error) {
519
+ if (isMissing(error)) return undefined;
520
+ throw error;
521
+ }
522
+ let parsed: unknown;
523
+ try {
524
+ parsed = JSON.parse(raw);
525
+ } catch {
526
+ return undefined;
527
+ }
528
+ return isPersistedPendingTopic(parsed) ? parsed.pendingTopic : undefined;
529
+ }
530
+
531
+ /** fsync the parent dir so the rename survives a crash; best-effort on win32. */
532
+ async #syncParentDirectory(): Promise<void> {
533
+ let handle: AdoptionIntentFileHandle;
534
+ try {
535
+ handle = await this.#fsImpl.open(this.#dir, "r");
536
+ } catch (error) {
537
+ if (this.#platform === "win32" && isUnsupportedDirectoryBarrierError(error)) return;
538
+ throw error;
539
+ }
540
+ let syncError: unknown;
541
+ try {
542
+ await syncRequired(handle);
543
+ } catch (error) {
544
+ if (!(this.#platform === "win32" && isUnsupportedDirectoryBarrierError(error))) syncError = error;
545
+ }
546
+ let closeError: unknown;
547
+ try {
548
+ await handle.close();
549
+ } catch (error) {
550
+ closeError = error;
551
+ }
552
+ if (syncError && closeError)
553
+ throw new AggregateError([syncError, closeError], "Parent directory sync and close failed");
554
+ if (syncError) throw syncError;
555
+ if (closeError) throw closeError;
556
+ }
557
+ }
558
+
559
+ async function syncRequired(handle: AdoptionIntentFileHandle): Promise<void> {
560
+ if (typeof handle.sync !== "function")
561
+ throw new Error("adoption sidecar durability requires filesystem sync support");
562
+ await handle.sync();
563
+ }
564
+
565
+ function isMissing(error: unknown): error is NodeJS.ErrnoException {
566
+ return isRecord(error) && error.code === "ENOENT";
567
+ }
568
+
569
+ function isUnsupportedDirectoryBarrierError(error: unknown): boolean {
570
+ return (
571
+ isRecord(error) &&
572
+ (error.code === "EINVAL" || error.code === "ENOTSUP" || error.code === "EOPNOTSUPP" || error.code === "EPERM")
573
+ );
574
+ }
@@ -19,10 +19,10 @@
19
19
 
20
20
  import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
- import { getAgentDir } from "@gajae-code/utils";
22
+ import { $credentialEnv, getAgentDir } from "@gajae-code/utils";
23
23
  import { tokenFingerprint } from "./config";
24
24
  import { isFreshLiveOwner, readOwnerFreshnessSnapshot } from "./telegram-daemon";
25
- import { runTelegramReferenceClient } from "./telegram-reference";
25
+ import { runTelegramReferenceClient, type TelegramNotificationSound } from "./telegram-reference";
26
26
 
27
27
  interface CliArgs {
28
28
  botToken?: string;
@@ -31,11 +31,14 @@ interface CliArgs {
31
31
  sessionId?: string;
32
32
  repo: string;
33
33
  apiBase?: string;
34
+ sound: TelegramNotificationSound;
34
35
  force: boolean;
35
36
  }
36
37
 
37
- function parseArgs(argv: string[]): CliArgs {
38
- const args: CliArgs = { repo: process.cwd(), force: false };
38
+ class CliUsageError extends Error {}
39
+
40
+ export function parseArgs(argv: string[]): CliArgs {
41
+ const args: CliArgs = { repo: process.cwd(), sound: "all", force: false };
39
42
  for (let i = 0; i < argv.length; i++) {
40
43
  const next = () => argv[++i];
41
44
  switch (argv[i]) {
@@ -57,6 +60,14 @@ function parseArgs(argv: string[]): CliArgs {
57
60
  case "--api-base":
58
61
  args.apiBase = next();
59
62
  break;
63
+ case "--sound": {
64
+ const sound = next();
65
+ if (sound !== "all" && sound !== "important" && sound !== "none") {
66
+ throw new CliUsageError("--sound must be all, important, or none");
67
+ }
68
+ args.sound = sound;
69
+ break;
70
+ }
60
71
  case "--force":
61
72
  args.force = true;
62
73
  break;
@@ -82,6 +93,7 @@ function printHelpAndExit(): never {
82
93
  " --session-id <id> Resolve <repo>/.gjc/state/sdk/<id>.json",
83
94
  " --repo <dir> Repo root for endpoint discovery (default: cwd)",
84
95
  " --api-base <url> Telegram API base (default: https://api.telegram.org)",
96
+ " --sound <all|important|none> Telegram notification sound (default: all)",
85
97
  " --force Bypass active daemon guard (debug only; may cause Telegram 409 conflicts)",
86
98
  "",
87
99
  ].join("\n"),
@@ -149,9 +161,22 @@ async function activeDaemonOwnsToken(input: { botToken: string; chatId: string }
149
161
  }
150
162
 
151
163
  async function main(): Promise<void> {
152
- const args = parseArgs(process.argv.slice(2));
164
+ let args: CliArgs;
165
+ try {
166
+ args = parseArgs(process.argv.slice(2));
167
+ } catch (error) {
168
+ if (error instanceof CliUsageError) {
169
+ process.stderr.write(`error: ${error.message}\n`);
170
+ process.exit(2);
171
+ }
172
+ throw error;
173
+ }
153
174
  const apiBase = args.apiBase ?? "https://api.telegram.org";
154
- const botToken = args.botToken ?? process.env.GJC_TG_BOT_TOKEN;
175
+ // Trusted sources only: this token is the bot the client talks to, so whatever
176
+ // can set it receives the session's notifications and the operator's replies.
177
+ // `$env` merges the caller's `cwd/.env` into `process.env`, and this client is
178
+ // normally run from inside a repository.
179
+ const botToken = args.botToken ?? $credentialEnv("GJC_TG_BOT_TOKEN");
155
180
  if (!botToken) {
156
181
  process.stderr.write("error: --bot-token (or GJC_TG_BOT_TOKEN) is required\n");
157
182
  process.exit(2);
@@ -168,7 +193,7 @@ async function main(): Promise<void> {
168
193
  process.exit(2);
169
194
  }
170
195
 
171
- const chatId = args.chatId ?? process.env.GJC_TG_CHAT_ID ?? (await resolveChatId(botToken, apiBase));
196
+ const chatId = args.chatId ?? $credentialEnv("GJC_TG_CHAT_ID") ?? (await resolveChatId(botToken, apiBase));
172
197
  if (!args.force && (await activeDaemonOwnsToken({ botToken, chatId }))) {
173
198
  process.stderr.write(
174
199
  "an active gjc notifications daemon already owns this bot token; running a second poller will cause Telegram 409 conflicts. Re-run with --force to override.\n",
@@ -176,7 +201,7 @@ async function main(): Promise<void> {
176
201
  process.exit(1);
177
202
  }
178
203
  process.stderr.write(`notifications: bridging ${endpointFile} <-> Telegram chat ${chatId}\n`);
179
- await runTelegramReferenceClient({ botToken, chatId, endpointFile, apiBase });
204
+ await runTelegramReferenceClient({ botToken, chatId, endpointFile, apiBase, sound: args.sound });
180
205
  }
181
206
 
182
207
  if (import.meta.main) {
@@ -232,6 +232,7 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
232
232
  botToken: cfg.botToken,
233
233
  chatId: cfg.chatId,
234
234
  idleTimeoutMs: cfg.idleTimeoutMs,
235
+ sound: cfg.sound,
235
236
  rich: cfg.rich,
236
237
  richDraft: cfg.richDraft,
237
238
  toolActivity: cfg.toolActivity,