@sayknow-cli/coding-agent 0.3.7 → 0.3.9

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 (272) hide show
  1. package/dist/types/append-only-mode.d.ts +31 -0
  2. package/dist/types/capability/rule.d.ts +8 -0
  3. package/dist/types/cli/completion-cli.d.ts +41 -0
  4. package/dist/types/cli/config-cli.d.ts +1 -0
  5. package/dist/types/cli/fixture-report.d.ts +20 -0
  6. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  7. package/dist/types/cli/nofile-limit.d.ts +11 -0
  8. package/dist/types/cli/notify-cli.d.ts +7 -0
  9. package/dist/types/cli/update-cli.d.ts +22 -0
  10. package/dist/types/cli.d.ts +124 -1
  11. package/dist/types/commands/completion.d.ts +29 -0
  12. package/dist/types/commands/config.d.ts +3 -0
  13. package/dist/types/commands/deep-interview.d.ts +3 -0
  14. package/dist/types/config/file-lock.d.ts +7 -6
  15. package/dist/types/config/keybindings.d.ts +2 -2
  16. package/dist/types/config/model-profile-activation.d.ts +6 -0
  17. package/dist/types/config/model-profiles.d.ts +1 -0
  18. package/dist/types/config/settings-schema.d.ts +119 -1
  19. package/dist/types/config/settings.d.ts +8 -0
  20. package/dist/types/discovery/index.d.ts +1 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -3
  23. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  24. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  25. package/dist/types/goals/runtime.d.ts +1 -0
  26. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  27. package/dist/types/main.d.ts +4 -1
  28. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  29. package/dist/types/modes/components/model-selector.d.ts +4 -0
  30. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  31. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  32. package/dist/types/modes/components/status-line.d.ts +9 -0
  33. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  34. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  35. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  36. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  38. package/dist/types/modes/interactive-mode.d.ts +4 -0
  39. package/dist/types/modes/print-mode.d.ts +13 -1
  40. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  41. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  42. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  44. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  45. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  46. package/dist/types/modes/types.d.ts +3 -0
  47. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  48. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  49. package/dist/types/notifications/config-commands.d.ts +9 -0
  50. package/dist/types/notifications/config.d.ts +30 -1
  51. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  52. package/dist/types/notifications/index.d.ts +8 -3
  53. package/dist/types/notifications/lifecycle-commands.d.ts +12 -3
  54. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  55. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  56. package/dist/types/notifications/rich-draft.d.ts +68 -0
  57. package/dist/types/notifications/rich-render.d.ts +90 -0
  58. package/dist/types/notifications/telegram-daemon.d.ts +45 -3
  59. package/dist/types/notifications/telegram-reference.d.ts +7 -5
  60. package/dist/types/notifications/threaded-render.d.ts +14 -0
  61. package/dist/types/runtime-api-key.d.ts +7 -0
  62. package/dist/types/sdk.d.ts +3 -0
  63. package/dist/types/session/agent-session.d.ts +61 -6
  64. package/dist/types/session/agent-storage.d.ts +1 -0
  65. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  66. package/dist/types/session/messages.d.ts +4 -0
  67. package/dist/types/session/session-manager.d.ts +17 -1
  68. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  69. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  70. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  71. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  72. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  73. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  74. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  75. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  76. package/dist/types/skill-state/active-state.d.ts +18 -1
  77. package/dist/types/system-prompt.d.ts +18 -2
  78. package/dist/types/task/executor.d.ts +3 -0
  79. package/dist/types/task/fork-context-budget.d.ts +7 -0
  80. package/dist/types/task/token-log.d.ts +30 -0
  81. package/dist/types/task/token-log.test.d.ts +1 -0
  82. package/dist/types/tools/bisect.d.ts +81 -0
  83. package/dist/types/tools/computer.d.ts +2 -2
  84. package/dist/types/tools/index.d.ts +1 -0
  85. package/dist/types/tools/search.d.ts +2 -2
  86. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  87. package/dist/types/utils/file-mentions.d.ts +14 -0
  88. package/dist/types/utils/git.d.ts +32 -0
  89. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  90. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  91. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  92. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  93. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  94. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  95. package/package.json +8 -8
  96. package/src/append-only-mode.ts +58 -0
  97. package/src/autoresearch/prompt.md +1 -1
  98. package/src/autoresearch/tools/log-experiment.ts +1 -1
  99. package/src/capability/rule.ts +9 -0
  100. package/src/cli/completion-cli.ts +234 -0
  101. package/src/cli/config-cli.ts +61 -9
  102. package/src/cli/fast-help.ts +1 -0
  103. package/src/cli/fixture-report.test.ts +230 -0
  104. package/src/cli/fixture-report.ts +171 -0
  105. package/src/cli/nofile-limit.ts +48 -0
  106. package/src/cli/notify-cli.ts +103 -17
  107. package/src/cli/telegram-cli.ts +1 -1
  108. package/src/cli/update-cli.ts +148 -33
  109. package/src/cli.ts +29 -1
  110. package/src/commands/completion.ts +84 -0
  111. package/src/commands/config.ts +2 -0
  112. package/src/commands/contribution-prep.ts +1 -1
  113. package/src/commands/deep-interview.ts +2 -1
  114. package/src/config/file-lock.ts +73 -34
  115. package/src/config/keybindings.ts +3 -3
  116. package/src/config/model-profile-activation.ts +65 -0
  117. package/src/config/model-profiles.ts +31 -2
  118. package/src/config/settings-schema.ts +76 -1
  119. package/src/config/settings.ts +22 -0
  120. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  121. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  122. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  123. package/src/defaults/skc-defaults.ts +9 -0
  124. package/src/discovery/helpers.ts +10 -0
  125. package/src/discovery/index.ts +1 -0
  126. package/src/export/html/template.generated.ts +1 -1
  127. package/src/export/html/template.js +60 -2
  128. package/src/export/ttsr.ts +96 -18
  129. package/src/extensibility/extensions/runner.ts +183 -219
  130. package/src/extensibility/extensions/types.ts +9 -0
  131. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  132. package/src/goals/runtime.ts +10 -2
  133. package/src/hindsight/backend.ts +1 -3
  134. package/src/hooks/native-skill-hook.ts +124 -4
  135. package/src/internal-urls/docs-index.generated.ts +17 -8
  136. package/src/lsp/client.ts +1 -0
  137. package/src/main.ts +95 -43
  138. package/src/memories/index.ts +12 -1
  139. package/src/modes/components/assistant-message.ts +241 -82
  140. package/src/modes/components/custom-editor.ts +1 -0
  141. package/src/modes/components/footer.ts +7 -2
  142. package/src/modes/components/model-selector.ts +54 -11
  143. package/src/modes/components/queued-message-selector.ts +92 -0
  144. package/src/modes/components/settings-selector.ts +4 -0
  145. package/src/modes/components/status-line.ts +167 -54
  146. package/src/modes/components/thinking-selector.ts +10 -8
  147. package/src/modes/components/tool-execution.ts +44 -19
  148. package/src/modes/components/welcome.ts +181 -44
  149. package/src/modes/controllers/command-controller.ts +40 -4
  150. package/src/modes/controllers/event-controller.ts +77 -49
  151. package/src/modes/controllers/extension-ui-controller.ts +12 -11
  152. package/src/modes/controllers/input-controller.ts +192 -35
  153. package/src/modes/controllers/selector-controller.ts +168 -24
  154. package/src/modes/interactive-mode.ts +56 -16
  155. package/src/modes/print-mode.ts +43 -4
  156. package/src/modes/prompt-action-autocomplete.ts +21 -20
  157. package/src/modes/rpc/rpc-mode.ts +32 -12
  158. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  159. package/src/modes/rpc/rpc-types.ts +9 -0
  160. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  161. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  162. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  163. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  164. package/src/modes/types.ts +3 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  166. package/src/modes/utils/injected-user-submission.ts +94 -0
  167. package/src/modes/utils/ui-helpers.ts +20 -7
  168. package/src/notifications/config-commands.ts +20 -0
  169. package/src/notifications/config.ts +52 -5
  170. package/src/notifications/daemon-paths.ts +2 -0
  171. package/src/notifications/index.ts +144 -18
  172. package/src/notifications/lifecycle-commands.ts +74 -23
  173. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  174. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  175. package/src/notifications/reply-sent-store.ts +134 -0
  176. package/src/notifications/rich-draft.ts +107 -0
  177. package/src/notifications/rich-render.ts +142 -0
  178. package/src/notifications/telegram-daemon-control.ts +5 -3
  179. package/src/notifications/telegram-daemon.ts +606 -98
  180. package/src/notifications/telegram-reference.ts +17 -16
  181. package/src/notifications/threaded-render.ts +45 -2
  182. package/src/prompts/agents/architect.md +19 -10
  183. package/src/prompts/agents/critic.md +16 -18
  184. package/src/prompts/agents/executor.md +14 -11
  185. package/src/prompts/agents/planner.md +18 -13
  186. package/src/prompts/ci-green-request.md +3 -8
  187. package/src/prompts/goals/goal-continuation.md +0 -5
  188. package/src/prompts/goals/goal-mode-active.md +0 -5
  189. package/src/prompts/system/agent-creation-architect.md +2 -2
  190. package/src/prompts/system/plan-mode-active.md +1 -1
  191. package/src/prompts/system/plan-mode-subagent.md +3 -3
  192. package/src/prompts/system/project-prompt.md +0 -13
  193. package/src/prompts/system/subagent-system-prompt.md +3 -2
  194. package/src/prompts/system/system-prompt.md +29 -17
  195. package/src/prompts/system/volatile-project-context.md +10 -0
  196. package/src/prompts/system/web-search.md +4 -7
  197. package/src/prompts/tools/apply-patch.md +6 -0
  198. package/src/prompts/tools/ast-edit.md +1 -0
  199. package/src/prompts/tools/bash.md +1 -1
  200. package/src/prompts/tools/bisect.md +22 -0
  201. package/src/prompts/tools/browser.md +1 -1
  202. package/src/prompts/tools/computer.md +2 -2
  203. package/src/prompts/tools/cron.md +0 -2
  204. package/src/prompts/tools/eval.md +2 -2
  205. package/src/prompts/tools/find.md +1 -1
  206. package/src/prompts/tools/github.md +6 -6
  207. package/src/prompts/tools/goal.md +1 -3
  208. package/src/prompts/tools/image-gen.md +12 -5
  209. package/src/prompts/tools/irc.md +5 -4
  210. package/src/prompts/tools/job.md +2 -2
  211. package/src/prompts/tools/lsp.md +1 -1
  212. package/src/prompts/tools/monitor.md +2 -2
  213. package/src/prompts/tools/patch.md +2 -2
  214. package/src/prompts/tools/replace.md +2 -21
  215. package/src/prompts/tools/rewind.md +1 -1
  216. package/src/prompts/tools/search-tool-bm25.md +6 -11
  217. package/src/prompts/tools/search.md +1 -1
  218. package/src/prompts/tools/skill.md +3 -2
  219. package/src/prompts/tools/ssh.md +2 -1
  220. package/src/prompts/tools/subagent.md +2 -2
  221. package/src/prompts/tools/task.md +4 -5
  222. package/src/prompts/tools/todo-write.md +1 -1
  223. package/src/prompts/tools/vim.md +1 -0
  224. package/src/prompts/tools/web-search.md +2 -0
  225. package/src/prompts/tools/write.md +8 -2
  226. package/src/runtime-api-key.ts +16 -0
  227. package/src/sdk.ts +88 -36
  228. package/src/session/agent-session.ts +1013 -141
  229. package/src/session/agent-storage.ts +4 -0
  230. package/src/session/file-mention-pruning.ts +64 -0
  231. package/src/session/messages.ts +4 -0
  232. package/src/session/session-manager.ts +103 -4
  233. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  234. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  235. package/src/skc-runtime/deep-interview-state.ts +44 -2
  236. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  237. package/src/skc-runtime/state-runtime.ts +12 -2
  238. package/src/skc-runtime/state-writer.ts +15 -0
  239. package/src/skc-runtime/tmux-common.ts +1 -0
  240. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  241. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  242. package/src/skc-runtime/ultragoal-runtime.ts +1672 -159
  243. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  244. package/src/skc-runtime/workflow-manifest.ts +74 -1
  245. package/src/skill-state/active-state.ts +141 -5
  246. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  247. package/src/slash-commands/builtin-registry.ts +296 -72
  248. package/src/system-prompt.ts +57 -16
  249. package/src/task/executor.ts +132 -24
  250. package/src/task/fork-context-advisory.ts +2 -7
  251. package/src/task/fork-context-budget.ts +9 -0
  252. package/src/task/index.ts +13 -6
  253. package/src/task/output-manager.ts +20 -8
  254. package/src/task/receipt.ts +26 -7
  255. package/src/task/token-log.test.ts +188 -0
  256. package/src/task/token-log.ts +179 -0
  257. package/src/tools/bisect.ts +367 -0
  258. package/src/tools/computer.ts +246 -43
  259. package/src/tools/index.ts +3 -0
  260. package/src/tools/output-meta.ts +116 -4
  261. package/src/tools/search.ts +8 -2
  262. package/src/tools/skill.ts +15 -0
  263. package/src/tools/telegram-send.ts +6 -0
  264. package/src/tools/ultragoal-ask-guard.ts +39 -5
  265. package/src/utils/file-mentions.ts +39 -7
  266. package/src/utils/git.ts +73 -0
  267. package/src/utils/pasted-image-path.ts +170 -0
  268. package/src/web/scrapers/youtube.ts +24 -12
  269. package/src/workflow/workflow-intent-diff.ts +29 -6
  270. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  271. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  272. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -8,8 +8,8 @@ import { withFileLock } from "../config/file-lock";
8
8
  import type { Settings } from "../config/settings";
9
9
  import type { DaemonRuntimeInfo } from "../daemon/control-types";
10
10
  import { resolveSkcRuntimeSpawnInfo } from "../daemon/runtime";
11
- import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
12
- import { parseInThreadConfigCommand } from "./config-commands";
11
+ import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
12
+ import { parseInThreadConfigCommand, parseRichToggleCommand } from "./config-commands";
13
13
  import { daemonPaths } from "./daemon-paths";
14
14
  import {
15
15
  buildCompactChoiceGrid,
@@ -27,6 +27,7 @@ import type {
27
27
  } from "./index";
28
28
  import {
29
29
  formatLifecycleOutcome,
30
+ isLifecycleCommandLikeText,
30
31
  isLifecycleCommandText,
31
32
  lifecycleUsage,
32
33
  parseLifecycleCommand,
@@ -43,12 +44,15 @@ import {
43
44
  import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
44
45
  import { RateLimitPool } from "./rate-limit-pool";
45
46
  import { listRecentSessions } from "./recent-activity";
47
+ import { ReplySentStore } from "./reply-sent-store";
48
+ import { DraftStreamState, deliverDraft, shouldStreamDraft } from "./rich-draft";
49
+ import { deliverRichActionWithFallback, deliverRichWithFallback, shouldPromoteRich } from "./rich-render";
46
50
  import {
47
51
  type AliasTable,
52
+ buildActionMarkdown,
48
53
  buildActionMessage,
49
54
  type CallbackRoute,
50
55
  createAliasTable,
51
- type PendingAsk,
52
56
  readEndpoint,
53
57
  routeInboundUpdate,
54
58
  } from "./telegram-reference";
@@ -56,7 +60,7 @@ import { decideThreadedInbound, type InboundAttachment } from "./threaded-inboun
56
60
  import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
57
61
  import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
58
62
 
59
- export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled";
63
+ export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
60
64
 
61
65
  export interface DaemonState {
62
66
  pid: number;
@@ -126,6 +130,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
126
130
  // messages: queued on receipt, consumed once a turn picks the message up.
127
131
  const QUEUED_REACTION = "👀";
128
132
  const PENDING_TOPIC_FRAME_LIMIT = 20;
133
+ const SEEN_UPDATE_ID_LIMIT = 1_000;
129
134
  const CONSUMED_REACTION = "✅";
130
135
 
131
136
  function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
@@ -267,7 +272,7 @@ export async function registerNotificationRoot(input: {
267
272
  const fsImpl = input.fs ?? nodeFs;
268
273
  const paths = daemonPaths(input.settings.getAgentDir());
269
274
  await ensureDir(fsImpl, paths.dir);
270
- const root = path.join(input.cwd, ".skc", "state");
275
+ const root = notificationRootForCwd(input.cwd);
271
276
  await withFileLock(
272
277
  paths.roots,
273
278
  async () => {
@@ -286,6 +291,29 @@ export async function registerNotificationRoot(input: {
286
291
  return root;
287
292
  }
288
293
 
294
+ function notificationRootForCwd(cwd: string): string {
295
+ return path.join(cwd, ".skc", "state");
296
+ }
297
+
298
+ function ownerIdentityMatches(state: DaemonState, tokenFingerprint: string, chatId: string): boolean {
299
+ return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
300
+ }
301
+
302
+ function liveOwnerUsesDifferentIdentity(input: {
303
+ state: DaemonState | undefined;
304
+ tokenFingerprint: string;
305
+ chatId: string;
306
+ pidAlive: (pid: number) => boolean;
307
+ }): boolean {
308
+ const { state } = input;
309
+ return Boolean(
310
+ state &&
311
+ state.version === DAEMON_VERSION &&
312
+ !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
313
+ input.pidAlive(state.pid),
314
+ );
315
+ }
316
+
289
317
  export function isFreshLiveOwner(input: {
290
318
  state: DaemonState | undefined;
291
319
  now: number;
@@ -297,8 +325,7 @@ export function isFreshLiveOwner(input: {
297
325
  return Boolean(
298
326
  state &&
299
327
  state.version === DAEMON_VERSION &&
300
- state.tokenFingerprint === input.tokenFingerprint &&
301
- state.chatId === input.chatId &&
328
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
302
329
  input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
303
330
  input.pidAlive(state.pid),
304
331
  );
@@ -314,7 +341,13 @@ export async function acquireDaemonOwnership(input: {
314
341
  pid?: number;
315
342
  pidAlive?: (pid: number) => boolean;
316
343
  randomId?: () => string;
317
- }): Promise<{ acquired: boolean; ownerId?: string; attached?: boolean }> {
344
+ }): Promise<{
345
+ acquired: boolean;
346
+ ownerId?: string;
347
+ attached?: boolean;
348
+ blocked?: boolean;
349
+ reason?: "identity_mismatch";
350
+ }> {
318
351
  const fsImpl = input.fs ?? nodeFs;
319
352
  const now = input.now ?? Date.now;
320
353
  const pid = input.pid ?? process.pid;
@@ -324,6 +357,16 @@ export async function acquireDaemonOwnership(input: {
324
357
  const ownerId = input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
325
358
  const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
326
359
  const existing = await readJson<DaemonState>(fsImpl, paths.state);
360
+ if (
361
+ liveOwnerUsesDifferentIdentity({
362
+ state: existing,
363
+ tokenFingerprint: input.tokenFingerprint,
364
+ chatId: input.chatId,
365
+ pidAlive,
366
+ })
367
+ ) {
368
+ return { acquired: false, blocked: true, reason: "identity_mismatch" };
369
+ }
327
370
  if (
328
371
  isFreshLiveOwner({
329
372
  state: existing,
@@ -349,6 +392,16 @@ export async function acquireDaemonOwnership(input: {
349
392
  return { acquired: true, ownerId };
350
393
  }
351
394
  const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
395
+ if (
396
+ liveOwnerUsesDifferentIdentity({
397
+ state: afterLock,
398
+ tokenFingerprint: input.tokenFingerprint,
399
+ chatId: input.chatId,
400
+ pidAlive,
401
+ })
402
+ ) {
403
+ return { acquired: false, blocked: true, reason: "identity_mismatch" };
404
+ }
352
405
  if (
353
406
  isFreshLiveOwner({
354
407
  state: afterLock,
@@ -375,6 +428,16 @@ export async function acquireDaemonOwnership(input: {
375
428
  ) {
376
429
  return { acquired: false, attached: true };
377
430
  }
431
+ if (
432
+ liveOwnerUsesDifferentIdentity({
433
+ state: rechecked,
434
+ tokenFingerprint: input.tokenFingerprint,
435
+ chatId: input.chatId,
436
+ pidAlive,
437
+ })
438
+ ) {
439
+ return { acquired: false, blocked: true, reason: "identity_mismatch" };
440
+ }
378
441
  if (rechecked && pidAlive(rechecked.pid)) {
379
442
  return { acquired: false, attached: true };
380
443
  }
@@ -552,7 +615,16 @@ export async function spawnTelegramDaemonOwner(
552
615
  ownerId: ownership.ownerId ?? "",
553
616
  agentDir,
554
617
  });
555
- if (!ownership.acquired) return { result: "attached", runtime, warnings: [] };
618
+ if (!ownership.acquired) {
619
+ if (ownership.blocked) {
620
+ return {
621
+ result: "blocked",
622
+ runtime,
623
+ warnings: ["live telegram daemon uses a different bot token or chat; refusing to attach"],
624
+ };
625
+ }
626
+ return { result: "attached", runtime, warnings: [] };
627
+ }
556
628
  const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
557
629
  const child = spawnImpl(command, args, {
558
630
  detached: true,
@@ -568,13 +640,18 @@ export async function ensureTelegramDaemonRunning(
568
640
  deps: TelegramDaemonDeps = {},
569
641
  ): Promise<EnsureDaemonResult> {
570
642
  const cfg = getNotificationConfig(input.settings);
571
- if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return "disabled";
572
- const root = await registerNotificationRoot({ ...input, fs: deps.fs });
643
+ if (!isTelegramConfigured(cfg)) return "disabled";
644
+ const root = notificationRootForCwd(input.cwd);
573
645
  const fp = tokenFingerprint(cfg.botToken);
574
646
  const spawned = await spawnTelegramDaemonOwner(
575
647
  { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
576
648
  deps,
577
649
  );
650
+ if (spawned.result === "blocked") {
651
+ logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
652
+ return spawned.result;
653
+ }
654
+ await registerNotificationRoot({ ...input, fs: deps.fs });
578
655
  return spawned.result;
579
656
  }
580
657
 
@@ -770,6 +847,10 @@ export interface TelegramDaemonOptions {
770
847
  * default applies (e.g. lifecycle control disabled), no control server starts.
771
848
  */
772
849
  createLifecycleControlServer?: LifecycleControlServerFactory | null;
850
+ /** Rich text promotion (enabled by default; see rich-render.ts). */
851
+ rich?: { enabled: boolean };
852
+ /** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
853
+ richDraft?: { enabled: boolean };
773
854
  }
774
855
 
775
856
  interface SessionSocket {
@@ -796,6 +877,8 @@ interface PendingThreadedFrame {
796
877
  export class TelegramNotificationDaemon {
797
878
  readonly aliasTable: AliasTable;
798
879
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
880
+ /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
881
+ private readonly liveMessages = new Map<string, number>();
799
882
  readonly sessions = new Map<string, SessionSocket>();
800
883
  private readonly runtime: NotificationOperatorRuntime;
801
884
  private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
@@ -808,6 +891,10 @@ export class TelegramNotificationDaemon {
808
891
  private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
809
892
  private readonly poller: TelegramUpdatePoller;
810
893
  private readonly dispatchState = new TelegramEventDispatchState();
894
+ /** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
895
+ private readonly replyStore: ReplySentStore;
896
+ /** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
897
+ private readonly draftStream = new DraftStreamState();
811
898
  /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
812
899
  private readonly topicOwnerByIdentity = new Map<string, string>();
813
900
  /** Non-identity frames held until identity creates the correct thread. */
@@ -820,6 +907,8 @@ export class TelegramNotificationDaemon {
820
907
  private readonly flatIdentitySent = new Set<string>();
821
908
  /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
822
909
  private pairedChatPrivate: boolean | undefined;
910
+ /** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
911
+ private botUsername: string | undefined;
823
912
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
824
913
  private get busy(): Set<string> {
825
914
  return this.dispatchState.busy;
@@ -1043,7 +1132,7 @@ export class TelegramNotificationDaemon {
1043
1132
  /** Build an authenticated lifecycle frame from a parsed command + identity. */
1044
1133
  private buildLifecycleFrame(
1045
1134
  parsed:
1046
- | { kind: "create"; target: SessionCreateTarget }
1135
+ | { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
1047
1136
  | { kind: "close"; target: SessionCloseTarget }
1048
1137
  | { kind: "resume"; target: SessionResumeTarget },
1049
1138
  updateId: number,
@@ -1061,6 +1150,7 @@ export class TelegramNotificationDaemon {
1061
1150
  chatId,
1062
1151
  token,
1063
1152
  target: parsed.target,
1153
+ modelPreset: parsed.modelPreset,
1064
1154
  };
1065
1155
  }
1066
1156
  if (parsed.kind === "close") {
@@ -1078,8 +1168,10 @@ export class TelegramNotificationDaemon {
1078
1168
  text: string | undefined,
1079
1169
  updateId: number | undefined,
1080
1170
  threadId: number | undefined,
1171
+ commandCtx: { chatType?: string; botUsername?: string },
1081
1172
  ): Promise<boolean> {
1082
- if (!isLifecycleCommandText(text)) return false;
1173
+ if (!isLifecycleCommandText(text, commandCtx)) return false;
1174
+ if (!(await this.pairedChatIsPrivate())) return true;
1083
1175
  const reply = async (body: string): Promise<void> => {
1084
1176
  for (const text of splitTelegramPlainText(body)) {
1085
1177
  await this.botApi
@@ -1104,15 +1196,15 @@ export class TelegramNotificationDaemon {
1104
1196
  }
1105
1197
  };
1106
1198
 
1199
+ const parsed = parseLifecycleCommand(text, commandCtx);
1200
+ if (parsed.kind === "none") return false;
1107
1201
  if (!this.lifecycleControlActive) {
1108
1202
  await reply("Session lifecycle control is not available right now.");
1109
1203
  return true;
1110
1204
  }
1111
1205
  if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
1112
- if (updateId !== undefined) this.dispatchState.seenUpdateIds.add(updateId);
1206
+ if (updateId !== undefined) await this.rememberSeenUpdateId(updateId);
1113
1207
 
1114
- const parsed = parseLifecycleCommand(text);
1115
- if (parsed.kind === "none") return false;
1116
1208
  if (parsed.kind === "usage" || parsed.kind === "reject") {
1117
1209
  await reply(parsed.message);
1118
1210
  return true;
@@ -1145,6 +1237,17 @@ export class TelegramNotificationDaemon {
1145
1237
  return true;
1146
1238
  }
1147
1239
 
1240
+ private async refreshBotIdentity(): Promise<void> {
1241
+ try {
1242
+ const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } };
1243
+ const username = response.result?.username;
1244
+ this.botUsername =
1245
+ typeof username === "string" && username.trim() ? username.trim().replace(/^@/, "") : undefined;
1246
+ } catch {
1247
+ this.botUsername = undefined;
1248
+ }
1249
+ }
1250
+
1148
1251
  /** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
1149
1252
  private formatLifecycleResponse(r: SessionLifecycleResponse): string {
1150
1253
  return formatLifecycleOutcome(r);
@@ -1152,6 +1255,7 @@ export class TelegramNotificationDaemon {
1152
1255
 
1153
1256
  constructor(private readonly opts: TelegramDaemonOptions) {
1154
1257
  this.fsImpl = opts.fs ?? nodeFs;
1258
+ this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
1155
1259
  this.aliasTable = createAliasTable();
1156
1260
  this.botApi =
1157
1261
  opts.botApi ??
@@ -1247,6 +1351,51 @@ export class TelegramNotificationDaemon {
1247
1351
  await writeJsonAtomic(this.fsImpl, paths.aliases, this.aliasTable.serialize());
1248
1352
  }
1249
1353
 
1354
+ async loadSeenUpdateIds(): Promise<void> {
1355
+ const raw = await readJson<{ updateIds?: unknown }>(
1356
+ this.fsImpl,
1357
+ daemonPaths(this.opts.settings.getAgentDir()).seenUpdates,
1358
+ );
1359
+ this.dispatchState.seenUpdateIds.clear();
1360
+ const updateIds = Array.isArray(raw?.updateIds) ? raw.updateIds : [];
1361
+ for (const updateId of updateIds) {
1362
+ if (Number.isSafeInteger(updateId) && Number(updateId) >= 0) {
1363
+ this.dispatchState.seenUpdateIds.add(Number(updateId));
1364
+ }
1365
+ }
1366
+ this.pruneSeenUpdateIds();
1367
+ }
1368
+
1369
+ async persistSeenUpdateIds(): Promise<void> {
1370
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
1371
+ await ensureDir(this.fsImpl, paths.dir);
1372
+ await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
1373
+ version: 1,
1374
+ updateIds: [...this.dispatchState.seenUpdateIds].slice(-SEEN_UPDATE_ID_LIMIT),
1375
+ });
1376
+ }
1377
+
1378
+ private pruneSeenUpdateIds(): void {
1379
+ let extra = this.dispatchState.seenUpdateIds.size - SEEN_UPDATE_ID_LIMIT;
1380
+ if (extra <= 0) return;
1381
+ for (const updateId of this.dispatchState.seenUpdateIds) {
1382
+ this.dispatchState.seenUpdateIds.delete(updateId);
1383
+ extra -= 1;
1384
+ if (extra <= 0) break;
1385
+ }
1386
+ }
1387
+
1388
+ private async rememberSeenUpdateId(updateId: number): Promise<void> {
1389
+ if (!Number.isSafeInteger(updateId) || updateId < 0) return;
1390
+ this.dispatchState.seenUpdateIds.add(updateId);
1391
+ this.pruneSeenUpdateIds();
1392
+ try {
1393
+ await this.persistSeenUpdateIds();
1394
+ } catch (err) {
1395
+ logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
1396
+ }
1397
+ }
1398
+
1250
1399
  async scanRoots(): Promise<void> {
1251
1400
  const paths = daemonPaths(this.opts.settings.getAgentDir());
1252
1401
  const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
@@ -1366,13 +1515,17 @@ export class TelegramNotificationDaemon {
1366
1515
  * (so a delayed old close cannot delete a replacement), and best-effort closes
1367
1516
  * the socket. `scanRoots()` then reconnects the session.
1368
1517
  */
1369
- private dropSession(session: SessionSocket, _reason: string): void {
1518
+ private dropSession(session: SessionSocket, reason: string): void {
1370
1519
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1371
1520
  if (session.pingTimer) {
1372
1521
  clearIntervalImpl(session.pingTimer);
1373
1522
  session.pingTimer = undefined;
1374
1523
  }
1375
- if (this.sessions.get(session.sessionId) === session) {
1524
+ const isCurrentSession = this.sessions.get(session.sessionId) === session;
1525
+ if (isCurrentSession || reason === "session_closed") {
1526
+ this.deleteMessageRoutes(session.sessionId);
1527
+ }
1528
+ if (isCurrentSession) {
1376
1529
  this.sessions.delete(session.sessionId);
1377
1530
  }
1378
1531
  if (session.ws.readyState !== WebSocket.CLOSED) {
@@ -1382,6 +1535,14 @@ export class TelegramNotificationDaemon {
1382
1535
  }
1383
1536
  }
1384
1537
 
1538
+ private deleteMessageRoutes(sessionId: string, actionId?: string): void {
1539
+ for (const [messageId, route] of this.messageRoutes.entries()) {
1540
+ if (route.sessionId === sessionId && (actionId === undefined || route.actionId === actionId)) {
1541
+ this.messageRoutes.delete(messageId);
1542
+ }
1543
+ }
1544
+ }
1545
+
1385
1546
  private static readonly THREADED_FRAMES = new Set([
1386
1547
  "identity_header",
1387
1548
  "context_update",
@@ -1434,6 +1595,13 @@ export class TelegramNotificationDaemon {
1434
1595
  return undefined;
1435
1596
  }
1436
1597
 
1598
+ private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
1599
+ const current = this.sessions.get(session.sessionId);
1600
+ if (current) return current === session;
1601
+ const ownerId = this.topicOwnerForIdentity(msg);
1602
+ return !ownerId || ownerId === session.sessionId;
1603
+ }
1604
+
1437
1605
  private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
1438
1606
  this.pool.submit({
1439
1607
  sessionId,
@@ -1444,6 +1612,11 @@ export class TelegramNotificationDaemon {
1444
1612
  await this.flushPool();
1445
1613
  }
1446
1614
 
1615
+ private async existingTopicForPrivateChat(sessionId: string): Promise<string | undefined> {
1616
+ if (!(await this.pairedChatIsPrivate())) return undefined;
1617
+ return this.topics.get(sessionId)?.topicId;
1618
+ }
1619
+
1447
1620
  private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
1448
1621
  const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
1449
1622
  frames.push({ send, msg });
@@ -1465,6 +1638,7 @@ export class TelegramNotificationDaemon {
1465
1638
  * one-time nudge) or drop fail-closed for a non-private chat.
1466
1639
  */
1467
1640
  private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
1641
+ if (!(await this.pairedChatIsPrivate())) return undefined;
1468
1642
  const existing = this.topics.get(sessionId);
1469
1643
  if (existing) return existing.topicId;
1470
1644
  try {
@@ -1508,6 +1682,9 @@ export class TelegramNotificationDaemon {
1508
1682
  })) as { ok?: boolean };
1509
1683
  if (res?.ok === false) return;
1510
1684
  this.topics.delete(sessionId);
1685
+ for (const k of [...this.liveMessages.keys()]) {
1686
+ if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
1687
+ }
1511
1688
  this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
1512
1689
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
1513
1690
  });
@@ -1635,13 +1812,81 @@ export class TelegramNotificationDaemon {
1635
1812
  return { images, fileNotes };
1636
1813
  }
1637
1814
 
1815
+ /**
1816
+ * Serialize all pool flushes. Every caller (`submitThreadedFrame`, the flat
1817
+ * fallback, the drain timer's `void this.flushPool()`, topic teardown) goes
1818
+ * through one promise chain, so two flushes never interleave — a live send can
1819
+ * never be in-flight while a finalized flush reads `liveMessages` and decides
1820
+ * to post a fresh (duplicate) final. Errors are swallowed so one failed flush
1821
+ * never poisons the queue (each flush is already best-effort internally).
1822
+ */
1823
+ private flushChain: Promise<void> = Promise.resolve();
1824
+ private flushPool(): Promise<void> {
1825
+ const next = this.flushChain.then(() => this.flushPoolInner());
1826
+ this.flushChain = next.catch(() => {});
1827
+ return next;
1828
+ }
1829
+
1638
1830
  /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
1639
- private async flushPool(): Promise<void> {
1640
- for (const item of this.pool.drain()) {
1831
+ private async flushPoolInner(): Promise<void> {
1832
+ const batch = this.pool.drain();
1833
+ // Within a batch a finalized frame supersedes any still-queued live frame for
1834
+ // the same streamed message (finalized outranks live), so drop the stale live
1835
+ // edit — otherwise the authoritative final text could be overwritten by an
1836
+ // older partial delivered right after it.
1837
+ const finalizedKeys = new Set<string>();
1838
+ for (const item of batch) {
1839
+ if (item.lane === "finalized" && item.coalesceKey !== undefined) {
1840
+ finalizedKeys.add(`${item.sessionId}:${item.coalesceKey}`);
1841
+ }
1842
+ }
1843
+ // Cross-batch protection: also purge any live frame still QUEUED for a
1844
+ // message whose finalized frame is in this batch, so a stale live edit can
1845
+ // never be delivered on a later drain after the authoritative final.
1846
+ if (finalizedKeys.size > 0) {
1847
+ this.pool.removeWhere(
1848
+ it =>
1849
+ it.lane === "live" &&
1850
+ it.coalesceKey !== undefined &&
1851
+ finalizedKeys.has(`${it.sessionId}:${it.coalesceKey}`),
1852
+ );
1853
+ }
1854
+ for (const item of batch) {
1641
1855
  const { send, topicId } = item.payload;
1856
+ if (topicId && !(await this.pairedChatIsPrivate())) continue;
1642
1857
  // Threaded topic when available; otherwise deliver flat to the paired chat.
1643
1858
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
1859
+ const ckey = send.editable ? item.coalesceKey : undefined;
1860
+ const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
1861
+ if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
1644
1862
  try {
1863
+ // Draft streaming (opt-in, off by default): stream a live turn frame as a
1864
+ // best-effort rich-draft preview, debounced to >=1.5s per session through
1865
+ // this same rate-limited drain; a finalized frame ends the turn's draft
1866
+ // window. Entirely inert when richDraft is off (the enabled gate /
1867
+ // shouldStreamDraft fail closed), so off-state HTML request bodies stay
1868
+ // byte-identical.
1869
+ if (this.opts.richDraft?.enabled === true && this.opts.rich?.enabled !== false) {
1870
+ if (send.lane === "finalized" && send.method === "sendMessage") {
1871
+ this.draftStream.reset(item.sessionId);
1872
+ } else if (
1873
+ shouldStreamDraft({
1874
+ enabled: this.opts.richDraft.enabled,
1875
+ send,
1876
+ })
1877
+ ) {
1878
+ const draftId = this.draftStream.tryClaim(item.sessionId, this.opts.now?.() ?? Date.now());
1879
+ if (draftId !== undefined) {
1880
+ await deliverDraft(
1881
+ this.botApi,
1882
+ { chat_id: this.opts.chatId, ...threadField },
1883
+ draftId,
1884
+ send.richDraftMarkdown!,
1885
+ logger,
1886
+ );
1887
+ }
1888
+ }
1889
+ }
1645
1890
  if (send.method === "sendPhoto" && send.photoBase64) {
1646
1891
  // Real photo upload (the default botApi multiparts base64 -> file).
1647
1892
  await this.botApi.call("sendPhoto", {
@@ -1663,21 +1908,165 @@ export class TelegramNotificationDaemon {
1663
1908
  parse_mode: TELEGRAM_PARSE_MODE,
1664
1909
  });
1665
1910
  } else if (send.text) {
1666
- for (const text of splitTelegramHtml(send.text)) {
1667
- await this.botApi.call("sendMessage", {
1668
- chat_id: this.opts.chatId,
1669
- ...threadField,
1670
- text,
1671
- parse_mode: TELEGRAM_PARSE_MODE,
1672
- });
1911
+ // Rich pre-branch: promote stable non-editable finalized text to a fresh
1912
+ // sendRichMessage when enabled. Off/miss falls through to the unchanged
1913
+ // upstream edit/send path, so off behavior is byte-identical.
1914
+ if (
1915
+ shouldPromoteRich({
1916
+ enabled: this.opts.rich?.enabled === false ? false : true,
1917
+ send,
1918
+ })
1919
+ ) {
1920
+ const sendHtmlFallback = async () => {
1921
+ // Fairness: this frame consumed exactly one token, so send only the
1922
+ // first HTML chunk now and requeue any continuations as their own
1923
+ // non-editable, HTML-only pool items (rich markers stripped) — same
1924
+ // per-token discipline as the non-rich split path.
1925
+ const chunks = splitTelegramHtml(send.text!);
1926
+ await this.botApi.call("sendMessage", {
1927
+ chat_id: this.opts.chatId,
1928
+ ...threadField,
1929
+ text: chunks[0]!,
1930
+ parse_mode: TELEGRAM_PARSE_MODE,
1931
+ });
1932
+ for (let i = 1; i < chunks.length; i++) {
1933
+ this.pool.submit({
1934
+ sessionId: item.sessionId,
1935
+ lane: item.lane,
1936
+ payload: {
1937
+ send: {
1938
+ ...send,
1939
+ method: "sendMessage",
1940
+ text: chunks[i]!,
1941
+ editable: false,
1942
+ coalesceKey: undefined,
1943
+ photoBase64: undefined,
1944
+ documentBase64: undefined,
1945
+ richMarkdown: undefined,
1946
+ richDraftMarkdown: undefined,
1947
+ richClass: undefined,
1948
+ },
1949
+ topicId,
1950
+ },
1951
+ });
1952
+ }
1953
+ };
1954
+ const richMessageId = await deliverRichWithFallback(
1955
+ this.botApi,
1956
+ { chat_id: this.opts.chatId, ...threadField },
1957
+ send,
1958
+ sendHtmlFallback,
1959
+ logger,
1960
+ );
1961
+ // Index the sent rich message so an inbound reply to it can restore
1962
+ // the original markdown as context (Telegram does not echo it back).
1963
+ if (richMessageId !== undefined) {
1964
+ await this.replyStore.record({
1965
+ chatId: this.opts.chatId,
1966
+ messageId: richMessageId,
1967
+ text: send.richMarkdown!,
1968
+ });
1969
+ }
1970
+ } else {
1971
+ const chunks = splitTelegramHtml(send.text);
1972
+ const existingId = editKey ? this.liveMessages.get(editKey) : undefined;
1973
+ let firstMessageId: number | undefined;
1974
+ if (editKey && existingId !== undefined) {
1975
+ // Edit the existing streamed message in place with the first chunk
1976
+ // so a finalized turn never leaves a stale live preview. A LOCAL
1977
+ // try/catch keeps a failed edit from aborting the continuation
1978
+ // requeue below; "message is not modified" is a success (the message
1979
+ // already shows this text); a missing/deleted backing message (or a
1980
+ // transport error) resends so the first chunk is never lost.
1981
+ let edited = false;
1982
+ try {
1983
+ const res = (await this.botApi.call("editMessageText", {
1984
+ chat_id: this.opts.chatId,
1985
+ message_id: existingId,
1986
+ text: chunks[0],
1987
+ parse_mode: TELEGRAM_PARSE_MODE,
1988
+ })) as { ok?: boolean; description?: string } | null;
1989
+ edited = res?.ok !== false || /not modified/i.test(String(res?.description ?? ""));
1990
+ } catch {
1991
+ edited = false;
1992
+ }
1993
+ if (edited) {
1994
+ firstMessageId = existingId;
1995
+ } else {
1996
+ const res = (await this.botApi.call("sendMessage", {
1997
+ chat_id: this.opts.chatId,
1998
+ ...threadField,
1999
+ text: chunks[0]!,
2000
+ parse_mode: TELEGRAM_PARSE_MODE,
2001
+ })) as { result?: { message_id?: number } };
2002
+ firstMessageId = res?.result?.message_id;
2003
+ }
2004
+ } else {
2005
+ // No streamed message to edit: a single granted slot maps to a
2006
+ // single Telegram send.
2007
+ const res = (await this.botApi.call("sendMessage", {
2008
+ chat_id: this.opts.chatId,
2009
+ ...threadField,
2010
+ text: chunks[0]!,
2011
+ parse_mode: TELEGRAM_PARSE_MODE,
2012
+ })) as { result?: { message_id?: number } };
2013
+ firstMessageId = res?.result?.message_id;
2014
+ }
2015
+ // Continuation chunks are FINALIZED-lane only. A live preview is a
2016
+ // single edit-safe chunk (its authoritative full text arrives with the
2017
+ // finalized frame), so a split live frame never fans out into stale,
2018
+ // non-coalesced continuation messages. Finalized continuations are
2019
+ // fresh, non-editable, HTML-only sends (rich markers stripped) so they
2020
+ // can never be re-promoted to a duplicate sendRichMessage.
2021
+ if (item.lane !== "live") {
2022
+ for (let i = 1; i < chunks.length; i++) {
2023
+ this.pool.submit({
2024
+ sessionId: item.sessionId,
2025
+ lane: item.lane,
2026
+ payload: {
2027
+ send: {
2028
+ ...send,
2029
+ method: "sendMessage",
2030
+ text: chunks[i]!,
2031
+ editable: false,
2032
+ coalesceKey: undefined,
2033
+ photoBase64: undefined,
2034
+ documentBase64: undefined,
2035
+ richMarkdown: undefined,
2036
+ richDraftMarkdown: undefined,
2037
+ richClass: undefined,
2038
+ },
2039
+ topicId,
2040
+ },
2041
+ });
2042
+ }
2043
+ }
2044
+ if (editKey && ckey !== undefined && firstMessageId !== undefined) {
2045
+ this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
2046
+ }
1673
2047
  }
1674
2048
  }
1675
2049
  } catch {
1676
- // Best-effort: a failed send must never stop the daemon.
2050
+ // Best-effort: a failed send/edit must never stop the daemon.
1677
2051
  }
1678
2052
  }
1679
2053
  }
1680
2054
 
2055
+ /**
2056
+ * Track the Telegram message id backing a streamed `(sessionId, coalesceKey)`
2057
+ * so later live/finalized frames edit it in place. Evicts this session's stale
2058
+ * same-category entries (e.g. prior turns) so the map stays bounded.
2059
+ */
2060
+ private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
2061
+ const mapKey = `${sessionId}:${coalesceKey}`;
2062
+ const category = coalesceKey.split(":")[0] ?? "";
2063
+ const prefix = `${sessionId}:${category}:`;
2064
+ for (const k of [...this.liveMessages.keys()]) {
2065
+ if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
2066
+ }
2067
+ this.liveMessages.set(mapKey, messageId);
2068
+ }
2069
+
1681
2070
  /**
1682
2071
  * Threaded Mode is unavailable (the bot owner has not enabled forum topics in
1683
2072
  * @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
@@ -1697,9 +2086,10 @@ export class TelegramNotificationDaemon {
1697
2086
  }
1698
2087
 
1699
2088
  /**
1700
- * Resolve once (cached) whether the paired `chatId` is a private chat. Flat
1701
- * fallback is only safe in a private DM; any non-private chat or an unresolvable
1702
- * `getChat` is treated as not-private so delivery fails closed.
2089
+ * Resolve (and cache successful resolution of) whether the paired `chatId` is a
2090
+ * private chat. Topic and flat delivery are only safe in a private DM; any
2091
+ * non-private chat fails closed, while a transient `getChat` failure fails closed
2092
+ * for the current attempt and is retried later.
1703
2093
  */
1704
2094
  private async pairedChatIsPrivate(): Promise<boolean> {
1705
2095
  if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
@@ -1708,20 +2098,21 @@ export class TelegramNotificationDaemon {
1708
2098
  result?: { type?: string };
1709
2099
  };
1710
2100
  this.pairedChatPrivate = res.result?.type === "private";
1711
- } catch {
1712
- this.pairedChatPrivate = false;
2101
+ return this.pairedChatPrivate;
2102
+ } catch (e) {
2103
+ logger.warn(`notifications: getChat failed while checking Telegram chat privacy: ${String(e)}`);
2104
+ return false;
1713
2105
  }
1714
- return this.pairedChatPrivate;
1715
2106
  }
1716
2107
 
1717
2108
  /** Tell the user once (per daemon run) how to enable Threaded Mode. */
1718
2109
  private async notifyThreadedFallback(): Promise<void> {
1719
- if (this.threadedFallbackNoticeSent) return;
2110
+ if (this.threadedFallbackNoticeSent || !(await this.pairedChatIsPrivate())) return;
1720
2111
  this.threadedFallbackNoticeSent = true;
1721
2112
  try {
1722
2113
  await this.botApi.call("sendMessage", {
1723
2114
  chat_id: this.opts.chatId,
1724
- text: "turn on threaded mode from botfather miniapp to receive skc notification!",
2115
+ text: "Flat Telegram private chat supports outbound notifications and inline ask buttons only. Enable Threaded Mode in @BotFather > Bot Settings > Threads Settings for free-text replies and session commands.",
1725
2116
  parse_mode: TELEGRAM_PARSE_MODE,
1726
2117
  });
1727
2118
  } catch {
@@ -1761,7 +2152,7 @@ export class TelegramNotificationDaemon {
1761
2152
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
1762
2153
  private async sendTyping(sessionId: string): Promise<void> {
1763
2154
  const topicId = this.topics.get(sessionId)?.topicId;
1764
- if (!topicId) return;
2155
+ if (!topicId || !(await this.pairedChatIsPrivate())) return;
1765
2156
  try {
1766
2157
  await this.botApi.call("sendChatAction", {
1767
2158
  chat_id: this.opts.chatId,
@@ -1775,6 +2166,7 @@ export class TelegramNotificationDaemon {
1775
2166
 
1776
2167
  /** Set a native reaction on an inbound thread message (best-effort). */
1777
2168
  private async setReaction(messageId: number, emoji: string): Promise<void> {
2169
+ if (!(await this.pairedChatIsPrivate())) return;
1778
2170
  try {
1779
2171
  await this.botApi.call("setMessageReaction", {
1780
2172
  chat_id: this.opts.chatId,
@@ -1802,12 +2194,12 @@ export class TelegramNotificationDaemon {
1802
2194
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
1803
2195
  const send = renderThreadedFrame(msg);
1804
2196
  if (!send) return;
1805
- const existingTopic = this.topics.get(session.sessionId)?.topicId;
2197
+ const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
1806
2198
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
1807
2199
  this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
1808
2200
  return;
1809
2201
  }
1810
- if (send.identity) {
2202
+ if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
1811
2203
  const ownerId = this.topicOwnerForIdentity(msg);
1812
2204
  const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
1813
2205
  if (ownerId && ownerId !== session.sessionId && ownerTopic) {
@@ -1869,27 +2261,62 @@ export class TelegramNotificationDaemon {
1869
2261
  });
1870
2262
  const options = Array.isArray(msg.options) ? msg.options : [];
1871
2263
  // Daemon keyboards use alias callback data with compact one-based tap targets;
1872
- // full option text is rendered in the message body by buildActionMessage.
2264
+ // full option text is rendered in the message body by buildActionMessage/buildActionMarkdown.
1873
2265
  const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
1874
2266
  this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
1875
2267
  );
1876
- const chunks = splitTelegramHtml(rendered.text);
1877
- let result: { result?: { message_id?: number } } = {};
1878
- for (let i = 0; i < chunks.length; i++) {
1879
- result = (await this.botApi.call("sendMessage", {
1880
- chat_id: this.opts.chatId,
1881
- ...threadField,
1882
- text: chunks[i]!,
1883
- parse_mode: TELEGRAM_PARSE_MODE,
1884
- ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
1885
- })) as { result?: { message_id?: number } };
2268
+ // HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
2269
+ // returns the last chunk's message_id (the reply-routable message).
2270
+ const sendHtmlChunks = async (): Promise<number | undefined> => {
2271
+ const chunks = splitTelegramHtml(rendered.text);
2272
+ let result: { result?: { message_id?: number } } = {};
2273
+ for (let i = 0; i < chunks.length; i++) {
2274
+ result = (await this.botApi.call("sendMessage", {
2275
+ chat_id: this.opts.chatId,
2276
+ ...threadField,
2277
+ text: chunks[i]!,
2278
+ parse_mode: TELEGRAM_PARSE_MODE,
2279
+ ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
2280
+ })) as { result?: { message_id?: number } };
2281
+ }
2282
+ return result.result?.message_id;
2283
+ };
2284
+ const kind = msg.kind === "idle" ? "idle" : "ask";
2285
+ if (this.opts.rich?.enabled !== false) {
2286
+ // Rich (default on): promote to sendRichMessage with a top-level
2287
+ // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
2288
+
2289
+ const outcome = await deliverRichActionWithFallback(
2290
+ this.botApi,
2291
+ { chat_id: this.opts.chatId, ...threadField },
2292
+ {
2293
+ markdown: buildActionMarkdown({
2294
+ kind,
2295
+ question: msg.question,
2296
+ options: msg.options,
2297
+ summary: msg.summary,
2298
+ }),
2299
+ replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
2300
+ requireMessageId: kind === "ask",
2301
+ },
2302
+ sendHtmlChunks,
2303
+ logger,
2304
+ );
2305
+ // Only asks are reply-routable; idle pings register no route.
2306
+ if (kind === "ask" && outcome.messageId !== undefined)
2307
+ this.messageRoutes.set(String(outcome.messageId), { sessionId: session.sessionId, actionId: msg.id });
2308
+ } else {
2309
+ // Off: byte-identical to the pre-rich HTML path.
2310
+ const messageId = await sendHtmlChunks();
2311
+ // Only asks are reply-routable; idle pings register no route (parity
2312
+ // with the rich branch and correct even in the byte-identical off path).
2313
+ if (kind === "ask" && messageId !== undefined)
2314
+ this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
1886
2315
  }
1887
- const messageId = result.result?.message_id;
1888
- if (messageId !== undefined)
1889
- this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
1890
2316
  await this.persistAliases();
1891
2317
  } else if (msg.type === "action_resolved" && msg.id) {
1892
2318
  session.pending.delete(msg.id);
2319
+ this.deleteMessageRoutes(session.sessionId, msg.id);
1893
2320
  for (const [alias, route] of this.aliasTable.entries()) {
1894
2321
  if (route.sessionId === session.sessionId && route.actionId === msg.id) this.aliasTable.delete(alias);
1895
2322
  }
@@ -1897,19 +2324,22 @@ export class TelegramNotificationDaemon {
1897
2324
  }
1898
2325
  }
1899
2326
 
1900
- pendingBySession = (sessionId?: string): PendingAsk[] => {
1901
- const result: PendingAsk[] = [];
1902
- for (const session of this.sessions.values()) {
1903
- if (sessionId && session.sessionId !== sessionId) continue;
1904
- result.push(...session.pending.values());
2327
+ private async answerCallbackQueryBestEffort(callbackId: unknown, text?: string): Promise<void> {
2328
+ if (typeof callbackId !== "string") return;
2329
+ try {
2330
+ await this.botApi.call("answerCallbackQuery", {
2331
+ callback_query_id: callbackId,
2332
+ ...(text === undefined ? {} : { text }),
2333
+ });
2334
+ } catch {
2335
+ // Telegram callback acknowledgements only dismiss the client-side spinner;
2336
+ // they must never block the already-validated local reply path.
1905
2337
  }
1906
- return result;
1907
- };
2338
+ }
1908
2339
 
1909
2340
  private async sendStaleGuidance(callbackId: unknown): Promise<void> {
1910
- if (typeof callbackId === "string") {
1911
- await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId, text: "Button is stale" });
1912
- }
2341
+ await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
2342
+ if (!(await this.pairedChatIsPrivate())) return;
1913
2343
  await this.botApi.call("sendMessage", {
1914
2344
  chat_id: this.opts.chatId,
1915
2345
  text: "This button is stale after notification daemon restart. Please answer locally in the SKC session or wait for a fresh notification.",
@@ -1924,12 +2354,77 @@ export class TelegramNotificationDaemon {
1924
2354
  // session input.
1925
2355
  {
1926
2356
  const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
1927
- const chatId = (m?.chat as { id?: unknown } | undefined)?.id;
2357
+ const chat = m?.chat as { id?: unknown; type?: unknown } | undefined;
2358
+ const chatId = chat?.id;
2359
+ const chatType = typeof chat?.type === "string" ? chat.type : undefined;
1928
2360
  const cmdText = typeof m?.text === "string" ? m.text : undefined;
1929
- if (m !== undefined && String(chatId) === String(this.opts.chatId) && isLifecycleCommandText(cmdText)) {
2361
+ const commandCtx = { chatType, botUsername: this.botUsername };
2362
+ if (m !== undefined && String(chatId) === String(this.opts.chatId)) {
2363
+ if (chatType !== undefined && chatType !== "private" && isLifecycleCommandLikeText(cmdText)) return;
2364
+ if (isLifecycleCommandText(cmdText, commandCtx)) {
2365
+ const updateId = (update as { update_id?: number }).update_id;
2366
+ const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
2367
+ if (await this.handleLifecycleCommand(cmdText, updateId, threadId, commandCtx)) return;
2368
+ }
2369
+ }
2370
+ }
2371
+ // Rich-message toggle (/rich on|off): daemon-local delivery policy, NOT a
2372
+ // session config forward. Handled at paired-chat pre-routing, before threaded
2373
+ // injection and independent of any session WebSocket, so it works even when
2374
+ // no session is connected and never becomes an ask answer.
2375
+ {
2376
+ const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
2377
+ const chat = m?.chat as { id?: unknown } | undefined;
2378
+ const cmdText = typeof m?.text === "string" ? m.text : undefined;
2379
+ const rawFirst = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
2380
+ // Fail-closed: intercept ANY "/rich" or "/rich@<anything>" form (Telegram
2381
+ // appends @botname in groups; the bot username may be unknown if getMe
2382
+ // failed) so a rich command is never leaked into threaded injection / an
2383
+ // ask answer. Argument validity is decided by parseRichToggleCommand below.
2384
+ const isRichCommand = rawFirst?.split("@")[0] === "/rich";
2385
+ if (m !== undefined && String(chat?.id) === String(this.opts.chatId) && isRichCommand) {
2386
+ // Fail-closed: /rich mutates global config, so honor it ONLY in a PRIVATE
2387
+ // paired chat — the same contract as session delivery and lifecycle
2388
+ // commands. A group/supergroup chatId (legacy or hand-edited) must never
2389
+ // let an arbitrary chat member toggle the owner's notification config.
2390
+ if (!(await this.pairedChatIsPrivate())) return;
1930
2391
  const updateId = (update as { update_id?: number }).update_id;
1931
- const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
1932
- if (await this.handleLifecycleCommand(cmdText, updateId, threadId)) return;
2392
+ // Dedupe redelivered updates so a toggle+confirmation runs at most once.
2393
+ if (typeof updateId === "number") {
2394
+ if (this.dispatchState.seenUpdateIds.has(updateId)) return;
2395
+ await this.rememberSeenUpdateId(updateId);
2396
+ }
2397
+ const threadField =
2398
+ typeof m.message_thread_id === "number" ? { message_thread_id: m.message_thread_id as number } : {};
2399
+ const reply = async (body: string): Promise<void> => {
2400
+ try {
2401
+ await this.botApi.call("sendMessage", {
2402
+ chat_id: this.opts.chatId,
2403
+ ...threadField,
2404
+ text: body,
2405
+ parse_mode: TELEGRAM_PARSE_MODE,
2406
+ });
2407
+ } catch {
2408
+ // Best-effort confirmation; never block on the notice.
2409
+ }
2410
+ };
2411
+ const desired = parseRichToggleCommand(cmdText ?? "");
2412
+ if (desired === undefined) {
2413
+ await reply("Usage: /rich on|off");
2414
+ return;
2415
+ }
2416
+ try {
2417
+ await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
2418
+ } catch (err) {
2419
+ logger.warn(
2420
+ `notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
2421
+ );
2422
+ await reply("Rich messages: unchanged (settings write failed)");
2423
+ return;
2424
+ }
2425
+ this.opts.rich = { enabled: desired };
2426
+ await reply(desired ? "Rich messages: on" : "Rich messages: off");
2427
+ return;
1933
2428
  }
1934
2429
  }
1935
2430
  // Threaded injection: a free-text message in a known topic (not a button
@@ -1954,7 +2449,6 @@ export class TelegramNotificationDaemon {
1954
2449
  });
1955
2450
  if (inbound.kind === "duplicate") return;
1956
2451
  if (inbound.kind === "inject") {
1957
- this.dispatchState.seenUpdateIds.add(inbound.updateId);
1958
2452
  const session = this.sessions.get(inbound.sessionId);
1959
2453
  if (session?.ws.readyState === WebSocket.OPEN) {
1960
2454
  const attachmentResult = inbound.attachment
@@ -1963,7 +2457,17 @@ export class TelegramNotificationDaemon {
1963
2457
  const images = attachmentResult?.images ?? [];
1964
2458
  const fileNotes = attachmentResult?.fileNotes ?? [];
1965
2459
  const hasMedia = images.length > 0 || fileNotes.length > 0;
1966
- const injectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
2460
+ const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
2461
+ // A reply to a rich message we sent (not an ask route) loses its original
2462
+ // text: Telegram does not echo it in reply_to_message. Restore it from the
2463
+ // reply index as a labeled context prefix; a miss leaves the turn unchanged.
2464
+ const repliedOriginal =
2465
+ typeof replyTo === "number"
2466
+ ? this.replyStore.lookup({ chatId: this.opts.chatId, messageId: replyTo })
2467
+ : undefined;
2468
+ const injectedText = repliedOriginal
2469
+ ? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
2470
+ : baseInjectedText;
1967
2471
  const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
1968
2472
  // A plain (non-config) message while an ask is pending for this session
1969
2473
  // answers that ask as free-input — instead of starting a new user turn.
@@ -1979,6 +2483,7 @@ export class TelegramNotificationDaemon {
1979
2483
  token: session.token,
1980
2484
  }),
1981
2485
  );
2486
+ await this.rememberSeenUpdateId(inbound.updateId);
1982
2487
  if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
1983
2488
  return;
1984
2489
  }
@@ -1997,6 +2502,7 @@ export class TelegramNotificationDaemon {
1997
2502
  },
1998
2503
  ),
1999
2504
  );
2505
+ await this.rememberSeenUpdateId(inbound.updateId);
2000
2506
  // User turns get a native delivery double-check: queued on receipt,
2001
2507
  // flipped to consumed when the session acks the turn that picks it
2002
2508
  // up. Config commands are not user turns and get no reaction.
@@ -2012,7 +2518,6 @@ export class TelegramNotificationDaemon {
2012
2518
  const decision = routeInboundUpdate(update, {
2013
2519
  aliasTable: this.aliasTable,
2014
2520
  messageRoutes: this.messageRoutes,
2015
- pendingBySession: this.pendingBySession,
2016
2521
  pairedChatId: this.opts.chatId,
2017
2522
  });
2018
2523
  if (decision.kind === "reply") {
@@ -2021,11 +2526,10 @@ export class TelegramNotificationDaemon {
2021
2526
  await this.sendStaleGuidance(callbackId);
2022
2527
  return;
2023
2528
  }
2024
- if (typeof callbackId === "string")
2025
- await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId });
2026
2529
  session.ws.send(
2027
2530
  JSON.stringify({ type: "reply", id: decision.actionId, answer: decision.answer, token: session.token }),
2028
2531
  );
2532
+ await this.answerCallbackQueryBestEffort(callbackId);
2029
2533
  } else if (decision.kind === "stale") {
2030
2534
  await this.sendStaleGuidance(callbackId);
2031
2535
  }
@@ -2043,7 +2547,8 @@ export class TelegramNotificationDaemon {
2043
2547
  { command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
2044
2548
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
2045
2549
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
2046
- { command: "session_create", description: "Create a SKC session: path, worktree, or dir" },
2550
+ { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
2551
+ { command: "session_create", description: "Create a SKC session: path, worktree, or dir [--mpreset]" },
2047
2552
  { command: "session_recent", description: "List recent SKC sessions" },
2048
2553
  { command: "session_close", description: "Close a SKC-managed session" },
2049
2554
  { command: "session_resume", description: "Resume or reattach a session" },
@@ -2068,9 +2573,12 @@ export class TelegramNotificationDaemon {
2068
2573
  this.startScanTimer();
2069
2574
  this.startTypingTimer();
2070
2575
  try {
2576
+ await this.refreshBotIdentity();
2071
2577
  await this.registerBotCommands();
2072
2578
  await this.loadAliases();
2073
2579
  await this.loadTopics();
2580
+ await this.loadSeenUpdateIds();
2581
+ await this.replyStore.load();
2074
2582
  await this.runScan();
2075
2583
  // Owner-only: start the session-lifecycle control server now that
2076
2584
  // ownership is confirmed (singleton-safe). Best-effort; degrades.
@@ -2091,31 +2599,30 @@ export class TelegramNotificationDaemon {
2091
2599
  await this.runScan();
2092
2600
  if (await this.controlStopRequested()) break;
2093
2601
  const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
2094
- if (this.sessions.size === 0 && !this.lifecycleControlActive) {
2095
- // No sessions and no lifecycle control: idle-exit on timeout.
2096
- if (idleElapsed) break;
2097
- } else {
2098
- // Poll getUpdates when sessions exist OR lifecycle control is active
2099
- // (so phone /session_* commands are received even with zero sessions).
2100
- // With zero sessions, still idle-exit after the timeout so the owner
2101
- // does not run forever; an active session resets the idle window.
2102
- if (this.sessions.size > 0) idleSince = this.runtime.now();
2103
- else if (idleElapsed) break;
2104
- const activePoll = this.runtime.createAbortController();
2105
- try {
2106
- await this.pollOnce(activePoll.signal);
2107
- this.loopBackoff.reset();
2108
- } catch (e) {
2109
- // A transient getUpdates/network failure must not kill the
2110
- // daemon. Back off (bounded, below the heartbeat TTL) and keep
2111
- // renewing ownership at the loop top.
2112
- const backoffMs = this.loopBackoff.next();
2113
- logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
2114
- await this.runtime.sleep(backoffMs);
2115
- continue;
2116
- } finally {
2117
- this.runtime.clearAbortController(activePoll);
2118
- }
2602
+ if (this.sessions.size > 0) {
2603
+ idleSince = this.runtime.now();
2604
+ } else if (idleElapsed) {
2605
+ // Zero sessions past the idle window: exit so the owner does not run
2606
+ // forever. An active session resets the idle window above.
2607
+ break;
2608
+ }
2609
+ // Poll getUpdates whenever the daemon owns the token — even with zero
2610
+ // sessions and no lifecycle control — so daemon-local commands (/rich,
2611
+ // /session_*) are always received until idle-exit.
2612
+ const activePoll = this.runtime.createAbortController();
2613
+ try {
2614
+ await this.pollOnce(activePoll.signal);
2615
+ this.loopBackoff.reset();
2616
+ } catch (e) {
2617
+ // A transient getUpdates/network failure must not kill the daemon.
2618
+ // Back off (bounded, below the heartbeat TTL) and keep renewing
2619
+ // ownership at the loop top.
2620
+ const backoffMs = this.loopBackoff.next();
2621
+ logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
2622
+ await this.runtime.sleep(backoffMs);
2623
+ continue;
2624
+ } finally {
2625
+ this.runtime.clearAbortController(activePoll);
2119
2626
  }
2120
2627
  if (await this.controlStopRequested()) break;
2121
2628
  await this.runtime.sleep(10);
@@ -2131,6 +2638,7 @@ export class TelegramNotificationDaemon {
2131
2638
  // (e.g. after reload) reloads aliases/topics seamlessly.
2132
2639
  await this.persistAliases().catch(() => undefined);
2133
2640
  await this.persistTopics().catch(() => undefined);
2641
+ await this.persistSeenUpdateIds().catch(() => undefined);
2134
2642
  await this.opts.control?.clear?.(this.opts.ownerId).catch(() => undefined);
2135
2643
  await releaseDaemonOwnership({
2136
2644
  settings: this.opts.settings,