@sayknow-cli/coding-agent 0.3.7 → 0.3.8

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 (248) 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 +105 -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/controllers/command-controller.d.ts +1 -0
  34. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  35. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  36. package/dist/types/modes/interactive-mode.d.ts +2 -0
  37. package/dist/types/modes/print-mode.d.ts +13 -1
  38. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  39. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  40. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  41. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  42. package/dist/types/modes/types.d.ts +1 -0
  43. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  44. package/dist/types/notifications/config.d.ts +14 -1
  45. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  46. package/dist/types/notifications/index.d.ts +6 -3
  47. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  48. package/dist/types/notifications/telegram-daemon.d.ts +23 -3
  49. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  50. package/dist/types/notifications/threaded-render.d.ts +6 -0
  51. package/dist/types/runtime-api-key.d.ts +7 -0
  52. package/dist/types/sdk.d.ts +3 -0
  53. package/dist/types/session/agent-session.d.ts +61 -6
  54. package/dist/types/session/agent-storage.d.ts +1 -0
  55. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  56. package/dist/types/session/messages.d.ts +4 -0
  57. package/dist/types/session/session-manager.d.ts +17 -1
  58. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  59. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  60. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  61. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  62. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  63. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  64. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  65. package/dist/types/skill-state/active-state.d.ts +18 -1
  66. package/dist/types/system-prompt.d.ts +18 -2
  67. package/dist/types/task/executor.d.ts +3 -0
  68. package/dist/types/task/fork-context-budget.d.ts +7 -0
  69. package/dist/types/task/token-log.d.ts +30 -0
  70. package/dist/types/task/token-log.test.d.ts +1 -0
  71. package/dist/types/tools/bisect.d.ts +81 -0
  72. package/dist/types/tools/computer.d.ts +2 -2
  73. package/dist/types/tools/index.d.ts +1 -0
  74. package/dist/types/tools/search.d.ts +2 -2
  75. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  76. package/dist/types/utils/file-mentions.d.ts +14 -0
  77. package/dist/types/utils/git.d.ts +32 -0
  78. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  79. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  80. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  81. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  82. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  83. package/package.json +8 -8
  84. package/src/append-only-mode.ts +58 -0
  85. package/src/autoresearch/prompt.md +1 -1
  86. package/src/autoresearch/tools/log-experiment.ts +1 -1
  87. package/src/capability/rule.ts +9 -0
  88. package/src/cli/completion-cli.ts +234 -0
  89. package/src/cli/config-cli.ts +61 -9
  90. package/src/cli/fast-help.ts +1 -0
  91. package/src/cli/fixture-report.test.ts +230 -0
  92. package/src/cli/fixture-report.ts +171 -0
  93. package/src/cli/nofile-limit.ts +48 -0
  94. package/src/cli/notify-cli.ts +92 -8
  95. package/src/cli/telegram-cli.ts +1 -1
  96. package/src/cli/update-cli.ts +85 -9
  97. package/src/cli.ts +29 -1
  98. package/src/commands/completion.ts +84 -0
  99. package/src/commands/config.ts +2 -0
  100. package/src/commands/contribution-prep.ts +1 -1
  101. package/src/commands/deep-interview.ts +2 -1
  102. package/src/config/file-lock.ts +73 -34
  103. package/src/config/keybindings.ts +2 -2
  104. package/src/config/model-profile-activation.ts +65 -0
  105. package/src/config/model-profiles.ts +31 -2
  106. package/src/config/settings-schema.ts +68 -1
  107. package/src/config/settings.ts +22 -0
  108. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  109. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  110. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  111. package/src/defaults/skc-defaults.ts +9 -0
  112. package/src/discovery/helpers.ts +10 -0
  113. package/src/discovery/index.ts +1 -0
  114. package/src/export/html/template.generated.ts +1 -1
  115. package/src/export/html/template.js +60 -2
  116. package/src/export/ttsr.ts +96 -18
  117. package/src/extensibility/extensions/runner.ts +183 -219
  118. package/src/extensibility/extensions/types.ts +9 -0
  119. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  120. package/src/goals/runtime.ts +10 -2
  121. package/src/hindsight/backend.ts +1 -3
  122. package/src/hooks/native-skill-hook.ts +124 -4
  123. package/src/internal-urls/docs-index.generated.ts +14 -5
  124. package/src/main.ts +95 -43
  125. package/src/memories/index.ts +12 -1
  126. package/src/modes/components/assistant-message.ts +241 -82
  127. package/src/modes/components/custom-editor.ts +1 -0
  128. package/src/modes/components/model-selector.ts +54 -11
  129. package/src/modes/components/queued-message-selector.ts +92 -0
  130. package/src/modes/components/settings-selector.ts +4 -0
  131. package/src/modes/components/status-line.ts +167 -54
  132. package/src/modes/components/tool-execution.ts +44 -19
  133. package/src/modes/components/welcome.ts +181 -44
  134. package/src/modes/controllers/command-controller.ts +40 -4
  135. package/src/modes/controllers/event-controller.ts +66 -47
  136. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  137. package/src/modes/controllers/input-controller.ts +180 -18
  138. package/src/modes/controllers/selector-controller.ts +127 -24
  139. package/src/modes/interactive-mode.ts +51 -16
  140. package/src/modes/print-mode.ts +43 -4
  141. package/src/modes/prompt-action-autocomplete.ts +21 -20
  142. package/src/modes/rpc/rpc-mode.ts +29 -3
  143. package/src/modes/rpc/rpc-types.ts +9 -0
  144. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  145. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  146. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  147. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  148. package/src/modes/types.ts +1 -0
  149. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  150. package/src/modes/utils/ui-helpers.ts +20 -7
  151. package/src/notifications/config.ts +25 -5
  152. package/src/notifications/daemon-paths.ts +2 -0
  153. package/src/notifications/index.ts +117 -14
  154. package/src/notifications/lifecycle-commands.ts +37 -7
  155. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  156. package/src/notifications/telegram-daemon-control.ts +5 -3
  157. package/src/notifications/telegram-daemon.ts +302 -51
  158. package/src/notifications/telegram-reference.ts +0 -16
  159. package/src/notifications/threaded-render.ts +17 -1
  160. package/src/prompts/agents/architect.md +19 -10
  161. package/src/prompts/agents/critic.md +16 -18
  162. package/src/prompts/agents/executor.md +14 -11
  163. package/src/prompts/agents/planner.md +18 -13
  164. package/src/prompts/ci-green-request.md +3 -8
  165. package/src/prompts/goals/goal-continuation.md +0 -5
  166. package/src/prompts/goals/goal-mode-active.md +0 -5
  167. package/src/prompts/system/agent-creation-architect.md +2 -2
  168. package/src/prompts/system/plan-mode-active.md +1 -1
  169. package/src/prompts/system/plan-mode-subagent.md +3 -3
  170. package/src/prompts/system/project-prompt.md +0 -13
  171. package/src/prompts/system/subagent-system-prompt.md +3 -2
  172. package/src/prompts/system/system-prompt.md +29 -17
  173. package/src/prompts/system/volatile-project-context.md +10 -0
  174. package/src/prompts/system/web-search.md +4 -7
  175. package/src/prompts/tools/apply-patch.md +6 -0
  176. package/src/prompts/tools/ast-edit.md +1 -0
  177. package/src/prompts/tools/bash.md +1 -1
  178. package/src/prompts/tools/bisect.md +22 -0
  179. package/src/prompts/tools/browser.md +1 -1
  180. package/src/prompts/tools/computer.md +2 -2
  181. package/src/prompts/tools/cron.md +0 -2
  182. package/src/prompts/tools/eval.md +2 -2
  183. package/src/prompts/tools/find.md +1 -1
  184. package/src/prompts/tools/github.md +6 -6
  185. package/src/prompts/tools/goal.md +1 -3
  186. package/src/prompts/tools/image-gen.md +12 -5
  187. package/src/prompts/tools/irc.md +5 -4
  188. package/src/prompts/tools/job.md +2 -2
  189. package/src/prompts/tools/lsp.md +1 -1
  190. package/src/prompts/tools/monitor.md +2 -2
  191. package/src/prompts/tools/patch.md +2 -2
  192. package/src/prompts/tools/replace.md +2 -21
  193. package/src/prompts/tools/rewind.md +1 -1
  194. package/src/prompts/tools/search-tool-bm25.md +1 -11
  195. package/src/prompts/tools/search.md +1 -1
  196. package/src/prompts/tools/skill.md +3 -2
  197. package/src/prompts/tools/ssh.md +2 -1
  198. package/src/prompts/tools/subagent.md +2 -2
  199. package/src/prompts/tools/task.md +4 -5
  200. package/src/prompts/tools/todo-write.md +1 -1
  201. package/src/prompts/tools/vim.md +1 -0
  202. package/src/prompts/tools/web-search.md +2 -0
  203. package/src/prompts/tools/write.md +8 -2
  204. package/src/runtime-api-key.ts +16 -0
  205. package/src/sdk.ts +52 -28
  206. package/src/session/agent-session.ts +1011 -141
  207. package/src/session/agent-storage.ts +4 -0
  208. package/src/session/file-mention-pruning.ts +64 -0
  209. package/src/session/messages.ts +4 -0
  210. package/src/session/session-manager.ts +103 -4
  211. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  212. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  213. package/src/skc-runtime/deep-interview-state.ts +44 -2
  214. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  215. package/src/skc-runtime/state-runtime.ts +12 -2
  216. package/src/skc-runtime/state-writer.ts +15 -0
  217. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  218. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  219. package/src/skc-runtime/ultragoal-runtime.ts +1663 -159
  220. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  221. package/src/skc-runtime/workflow-manifest.ts +74 -1
  222. package/src/skill-state/active-state.ts +141 -5
  223. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  224. package/src/slash-commands/builtin-registry.ts +218 -71
  225. package/src/system-prompt.ts +57 -16
  226. package/src/task/executor.ts +132 -24
  227. package/src/task/fork-context-advisory.ts +2 -7
  228. package/src/task/fork-context-budget.ts +9 -0
  229. package/src/task/index.ts +13 -6
  230. package/src/task/output-manager.ts +20 -8
  231. package/src/task/receipt.ts +26 -7
  232. package/src/task/token-log.test.ts +188 -0
  233. package/src/task/token-log.ts +179 -0
  234. package/src/tools/bisect.ts +367 -0
  235. package/src/tools/computer.ts +246 -43
  236. package/src/tools/index.ts +3 -0
  237. package/src/tools/output-meta.ts +116 -4
  238. package/src/tools/search.ts +8 -2
  239. package/src/tools/skill.ts +15 -0
  240. package/src/tools/telegram-send.ts +6 -0
  241. package/src/tools/ultragoal-ask-guard.ts +39 -5
  242. package/src/utils/file-mentions.ts +39 -7
  243. package/src/utils/git.ts +73 -0
  244. package/src/web/scrapers/youtube.ts +24 -12
  245. package/src/workflow/workflow-intent-diff.ts +29 -6
  246. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  247. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  248. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -8,7 +8,7 @@ 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";
11
+ import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
12
12
  import { parseInThreadConfigCommand } from "./config-commands";
13
13
  import { daemonPaths } from "./daemon-paths";
14
14
  import {
@@ -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,
@@ -48,7 +49,6 @@ import {
48
49
  buildActionMessage,
49
50
  type CallbackRoute,
50
51
  createAliasTable,
51
- type PendingAsk,
52
52
  readEndpoint,
53
53
  routeInboundUpdate,
54
54
  } from "./telegram-reference";
@@ -56,7 +56,7 @@ import { decideThreadedInbound, type InboundAttachment } from "./threaded-inboun
56
56
  import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
57
57
  import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
58
58
 
59
- export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled";
59
+ export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
60
60
 
61
61
  export interface DaemonState {
62
62
  pid: number;
@@ -126,6 +126,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
126
126
  // messages: queued on receipt, consumed once a turn picks the message up.
127
127
  const QUEUED_REACTION = "👀";
128
128
  const PENDING_TOPIC_FRAME_LIMIT = 20;
129
+ const SEEN_UPDATE_ID_LIMIT = 1_000;
129
130
  const CONSUMED_REACTION = "✅";
130
131
 
131
132
  function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
@@ -267,7 +268,7 @@ export async function registerNotificationRoot(input: {
267
268
  const fsImpl = input.fs ?? nodeFs;
268
269
  const paths = daemonPaths(input.settings.getAgentDir());
269
270
  await ensureDir(fsImpl, paths.dir);
270
- const root = path.join(input.cwd, ".skc", "state");
271
+ const root = notificationRootForCwd(input.cwd);
271
272
  await withFileLock(
272
273
  paths.roots,
273
274
  async () => {
@@ -286,6 +287,29 @@ export async function registerNotificationRoot(input: {
286
287
  return root;
287
288
  }
288
289
 
290
+ function notificationRootForCwd(cwd: string): string {
291
+ return path.join(cwd, ".skc", "state");
292
+ }
293
+
294
+ function ownerIdentityMatches(state: DaemonState, tokenFingerprint: string, chatId: string): boolean {
295
+ return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
296
+ }
297
+
298
+ function liveOwnerUsesDifferentIdentity(input: {
299
+ state: DaemonState | undefined;
300
+ tokenFingerprint: string;
301
+ chatId: string;
302
+ pidAlive: (pid: number) => boolean;
303
+ }): boolean {
304
+ const { state } = input;
305
+ return Boolean(
306
+ state &&
307
+ state.version === DAEMON_VERSION &&
308
+ !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
309
+ input.pidAlive(state.pid),
310
+ );
311
+ }
312
+
289
313
  export function isFreshLiveOwner(input: {
290
314
  state: DaemonState | undefined;
291
315
  now: number;
@@ -297,8 +321,7 @@ export function isFreshLiveOwner(input: {
297
321
  return Boolean(
298
322
  state &&
299
323
  state.version === DAEMON_VERSION &&
300
- state.tokenFingerprint === input.tokenFingerprint &&
301
- state.chatId === input.chatId &&
324
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
302
325
  input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
303
326
  input.pidAlive(state.pid),
304
327
  );
@@ -314,7 +337,13 @@ export async function acquireDaemonOwnership(input: {
314
337
  pid?: number;
315
338
  pidAlive?: (pid: number) => boolean;
316
339
  randomId?: () => string;
317
- }): Promise<{ acquired: boolean; ownerId?: string; attached?: boolean }> {
340
+ }): Promise<{
341
+ acquired: boolean;
342
+ ownerId?: string;
343
+ attached?: boolean;
344
+ blocked?: boolean;
345
+ reason?: "identity_mismatch";
346
+ }> {
318
347
  const fsImpl = input.fs ?? nodeFs;
319
348
  const now = input.now ?? Date.now;
320
349
  const pid = input.pid ?? process.pid;
@@ -324,6 +353,16 @@ export async function acquireDaemonOwnership(input: {
324
353
  const ownerId = input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
325
354
  const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
326
355
  const existing = await readJson<DaemonState>(fsImpl, paths.state);
356
+ if (
357
+ liveOwnerUsesDifferentIdentity({
358
+ state: existing,
359
+ tokenFingerprint: input.tokenFingerprint,
360
+ chatId: input.chatId,
361
+ pidAlive,
362
+ })
363
+ ) {
364
+ return { acquired: false, blocked: true, reason: "identity_mismatch" };
365
+ }
327
366
  if (
328
367
  isFreshLiveOwner({
329
368
  state: existing,
@@ -349,6 +388,16 @@ export async function acquireDaemonOwnership(input: {
349
388
  return { acquired: true, ownerId };
350
389
  }
351
390
  const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
391
+ if (
392
+ liveOwnerUsesDifferentIdentity({
393
+ state: afterLock,
394
+ tokenFingerprint: input.tokenFingerprint,
395
+ chatId: input.chatId,
396
+ pidAlive,
397
+ })
398
+ ) {
399
+ return { acquired: false, blocked: true, reason: "identity_mismatch" };
400
+ }
352
401
  if (
353
402
  isFreshLiveOwner({
354
403
  state: afterLock,
@@ -375,6 +424,16 @@ export async function acquireDaemonOwnership(input: {
375
424
  ) {
376
425
  return { acquired: false, attached: true };
377
426
  }
427
+ if (
428
+ liveOwnerUsesDifferentIdentity({
429
+ state: rechecked,
430
+ tokenFingerprint: input.tokenFingerprint,
431
+ chatId: input.chatId,
432
+ pidAlive,
433
+ })
434
+ ) {
435
+ return { acquired: false, blocked: true, reason: "identity_mismatch" };
436
+ }
378
437
  if (rechecked && pidAlive(rechecked.pid)) {
379
438
  return { acquired: false, attached: true };
380
439
  }
@@ -552,7 +611,16 @@ export async function spawnTelegramDaemonOwner(
552
611
  ownerId: ownership.ownerId ?? "",
553
612
  agentDir,
554
613
  });
555
- if (!ownership.acquired) return { result: "attached", runtime, warnings: [] };
614
+ if (!ownership.acquired) {
615
+ if (ownership.blocked) {
616
+ return {
617
+ result: "blocked",
618
+ runtime,
619
+ warnings: ["live telegram daemon uses a different bot token or chat; refusing to attach"],
620
+ };
621
+ }
622
+ return { result: "attached", runtime, warnings: [] };
623
+ }
556
624
  const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
557
625
  const child = spawnImpl(command, args, {
558
626
  detached: true,
@@ -568,13 +636,18 @@ export async function ensureTelegramDaemonRunning(
568
636
  deps: TelegramDaemonDeps = {},
569
637
  ): Promise<EnsureDaemonResult> {
570
638
  const cfg = getNotificationConfig(input.settings);
571
- if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return "disabled";
572
- const root = await registerNotificationRoot({ ...input, fs: deps.fs });
639
+ if (!isTelegramConfigured(cfg)) return "disabled";
640
+ const root = notificationRootForCwd(input.cwd);
573
641
  const fp = tokenFingerprint(cfg.botToken);
574
642
  const spawned = await spawnTelegramDaemonOwner(
575
643
  { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
576
644
  deps,
577
645
  );
646
+ if (spawned.result === "blocked") {
647
+ logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
648
+ return spawned.result;
649
+ }
650
+ await registerNotificationRoot({ ...input, fs: deps.fs });
578
651
  return spawned.result;
579
652
  }
580
653
 
@@ -796,6 +869,8 @@ interface PendingThreadedFrame {
796
869
  export class TelegramNotificationDaemon {
797
870
  readonly aliasTable: AliasTable;
798
871
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
872
+ /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
873
+ private readonly liveMessages = new Map<string, number>();
799
874
  readonly sessions = new Map<string, SessionSocket>();
800
875
  private readonly runtime: NotificationOperatorRuntime;
801
876
  private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
@@ -820,6 +895,8 @@ export class TelegramNotificationDaemon {
820
895
  private readonly flatIdentitySent = new Set<string>();
821
896
  /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
822
897
  private pairedChatPrivate: boolean | undefined;
898
+ /** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
899
+ private botUsername: string | undefined;
823
900
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
824
901
  private get busy(): Set<string> {
825
902
  return this.dispatchState.busy;
@@ -1078,8 +1155,10 @@ export class TelegramNotificationDaemon {
1078
1155
  text: string | undefined,
1079
1156
  updateId: number | undefined,
1080
1157
  threadId: number | undefined,
1158
+ commandCtx: { chatType?: string; botUsername?: string },
1081
1159
  ): Promise<boolean> {
1082
- if (!isLifecycleCommandText(text)) return false;
1160
+ if (!isLifecycleCommandText(text, commandCtx)) return false;
1161
+ if (!(await this.pairedChatIsPrivate())) return true;
1083
1162
  const reply = async (body: string): Promise<void> => {
1084
1163
  for (const text of splitTelegramPlainText(body)) {
1085
1164
  await this.botApi
@@ -1104,15 +1183,15 @@ export class TelegramNotificationDaemon {
1104
1183
  }
1105
1184
  };
1106
1185
 
1186
+ const parsed = parseLifecycleCommand(text, commandCtx);
1187
+ if (parsed.kind === "none") return false;
1107
1188
  if (!this.lifecycleControlActive) {
1108
1189
  await reply("Session lifecycle control is not available right now.");
1109
1190
  return true;
1110
1191
  }
1111
1192
  if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
1112
- if (updateId !== undefined) this.dispatchState.seenUpdateIds.add(updateId);
1193
+ if (updateId !== undefined) await this.rememberSeenUpdateId(updateId);
1113
1194
 
1114
- const parsed = parseLifecycleCommand(text);
1115
- if (parsed.kind === "none") return false;
1116
1195
  if (parsed.kind === "usage" || parsed.kind === "reject") {
1117
1196
  await reply(parsed.message);
1118
1197
  return true;
@@ -1145,6 +1224,17 @@ export class TelegramNotificationDaemon {
1145
1224
  return true;
1146
1225
  }
1147
1226
 
1227
+ private async refreshBotIdentity(): Promise<void> {
1228
+ try {
1229
+ const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } };
1230
+ const username = response.result?.username;
1231
+ this.botUsername =
1232
+ typeof username === "string" && username.trim() ? username.trim().replace(/^@/, "") : undefined;
1233
+ } catch {
1234
+ this.botUsername = undefined;
1235
+ }
1236
+ }
1237
+
1148
1238
  /** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
1149
1239
  private formatLifecycleResponse(r: SessionLifecycleResponse): string {
1150
1240
  return formatLifecycleOutcome(r);
@@ -1247,6 +1337,51 @@ export class TelegramNotificationDaemon {
1247
1337
  await writeJsonAtomic(this.fsImpl, paths.aliases, this.aliasTable.serialize());
1248
1338
  }
1249
1339
 
1340
+ async loadSeenUpdateIds(): Promise<void> {
1341
+ const raw = await readJson<{ updateIds?: unknown }>(
1342
+ this.fsImpl,
1343
+ daemonPaths(this.opts.settings.getAgentDir()).seenUpdates,
1344
+ );
1345
+ this.dispatchState.seenUpdateIds.clear();
1346
+ const updateIds = Array.isArray(raw?.updateIds) ? raw.updateIds : [];
1347
+ for (const updateId of updateIds) {
1348
+ if (Number.isSafeInteger(updateId) && Number(updateId) >= 0) {
1349
+ this.dispatchState.seenUpdateIds.add(Number(updateId));
1350
+ }
1351
+ }
1352
+ this.pruneSeenUpdateIds();
1353
+ }
1354
+
1355
+ async persistSeenUpdateIds(): Promise<void> {
1356
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
1357
+ await ensureDir(this.fsImpl, paths.dir);
1358
+ await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
1359
+ version: 1,
1360
+ updateIds: [...this.dispatchState.seenUpdateIds].slice(-SEEN_UPDATE_ID_LIMIT),
1361
+ });
1362
+ }
1363
+
1364
+ private pruneSeenUpdateIds(): void {
1365
+ let extra = this.dispatchState.seenUpdateIds.size - SEEN_UPDATE_ID_LIMIT;
1366
+ if (extra <= 0) return;
1367
+ for (const updateId of this.dispatchState.seenUpdateIds) {
1368
+ this.dispatchState.seenUpdateIds.delete(updateId);
1369
+ extra -= 1;
1370
+ if (extra <= 0) break;
1371
+ }
1372
+ }
1373
+
1374
+ private async rememberSeenUpdateId(updateId: number): Promise<void> {
1375
+ if (!Number.isSafeInteger(updateId) || updateId < 0) return;
1376
+ this.dispatchState.seenUpdateIds.add(updateId);
1377
+ this.pruneSeenUpdateIds();
1378
+ try {
1379
+ await this.persistSeenUpdateIds();
1380
+ } catch (err) {
1381
+ logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
1382
+ }
1383
+ }
1384
+
1250
1385
  async scanRoots(): Promise<void> {
1251
1386
  const paths = daemonPaths(this.opts.settings.getAgentDir());
1252
1387
  const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
@@ -1366,13 +1501,17 @@ export class TelegramNotificationDaemon {
1366
1501
  * (so a delayed old close cannot delete a replacement), and best-effort closes
1367
1502
  * the socket. `scanRoots()` then reconnects the session.
1368
1503
  */
1369
- private dropSession(session: SessionSocket, _reason: string): void {
1504
+ private dropSession(session: SessionSocket, reason: string): void {
1370
1505
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1371
1506
  if (session.pingTimer) {
1372
1507
  clearIntervalImpl(session.pingTimer);
1373
1508
  session.pingTimer = undefined;
1374
1509
  }
1375
- if (this.sessions.get(session.sessionId) === session) {
1510
+ const isCurrentSession = this.sessions.get(session.sessionId) === session;
1511
+ if (isCurrentSession || reason === "session_closed") {
1512
+ this.deleteMessageRoutes(session.sessionId);
1513
+ }
1514
+ if (isCurrentSession) {
1376
1515
  this.sessions.delete(session.sessionId);
1377
1516
  }
1378
1517
  if (session.ws.readyState !== WebSocket.CLOSED) {
@@ -1382,6 +1521,14 @@ export class TelegramNotificationDaemon {
1382
1521
  }
1383
1522
  }
1384
1523
 
1524
+ private deleteMessageRoutes(sessionId: string, actionId?: string): void {
1525
+ for (const [messageId, route] of this.messageRoutes.entries()) {
1526
+ if (route.sessionId === sessionId && (actionId === undefined || route.actionId === actionId)) {
1527
+ this.messageRoutes.delete(messageId);
1528
+ }
1529
+ }
1530
+ }
1531
+
1385
1532
  private static readonly THREADED_FRAMES = new Set([
1386
1533
  "identity_header",
1387
1534
  "context_update",
@@ -1434,6 +1581,13 @@ export class TelegramNotificationDaemon {
1434
1581
  return undefined;
1435
1582
  }
1436
1583
 
1584
+ private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
1585
+ const current = this.sessions.get(session.sessionId);
1586
+ if (current) return current === session;
1587
+ const ownerId = this.topicOwnerForIdentity(msg);
1588
+ return !ownerId || ownerId === session.sessionId;
1589
+ }
1590
+
1437
1591
  private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
1438
1592
  this.pool.submit({
1439
1593
  sessionId,
@@ -1444,6 +1598,11 @@ export class TelegramNotificationDaemon {
1444
1598
  await this.flushPool();
1445
1599
  }
1446
1600
 
1601
+ private async existingTopicForPrivateChat(sessionId: string): Promise<string | undefined> {
1602
+ if (!(await this.pairedChatIsPrivate())) return undefined;
1603
+ return this.topics.get(sessionId)?.topicId;
1604
+ }
1605
+
1447
1606
  private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
1448
1607
  const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
1449
1608
  frames.push({ send, msg });
@@ -1465,6 +1624,7 @@ export class TelegramNotificationDaemon {
1465
1624
  * one-time nudge) or drop fail-closed for a non-private chat.
1466
1625
  */
1467
1626
  private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
1627
+ if (!(await this.pairedChatIsPrivate())) return undefined;
1468
1628
  const existing = this.topics.get(sessionId);
1469
1629
  if (existing) return existing.topicId;
1470
1630
  try {
@@ -1508,6 +1668,9 @@ export class TelegramNotificationDaemon {
1508
1668
  })) as { ok?: boolean };
1509
1669
  if (res?.ok === false) return;
1510
1670
  this.topics.delete(sessionId);
1671
+ for (const k of [...this.liveMessages.keys()]) {
1672
+ if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
1673
+ }
1511
1674
  this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
1512
1675
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
1513
1676
  });
@@ -1637,10 +1800,25 @@ export class TelegramNotificationDaemon {
1637
1800
 
1638
1801
  /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
1639
1802
  private async flushPool(): Promise<void> {
1640
- for (const item of this.pool.drain()) {
1803
+ const batch = this.pool.drain();
1804
+ // Within a batch a finalized frame supersedes any still-queued live frame for
1805
+ // the same streamed message (finalized outranks live), so drop the stale live
1806
+ // edit — otherwise the authoritative final text could be overwritten by an
1807
+ // older partial delivered right after it.
1808
+ const finalizedKeys = new Set<string>();
1809
+ for (const item of batch) {
1810
+ if (item.lane === "finalized" && item.coalesceKey !== undefined) {
1811
+ finalizedKeys.add(`${item.sessionId}:${item.coalesceKey}`);
1812
+ }
1813
+ }
1814
+ for (const item of batch) {
1641
1815
  const { send, topicId } = item.payload;
1816
+ if (topicId && !(await this.pairedChatIsPrivate())) continue;
1642
1817
  // Threaded topic when available; otherwise deliver flat to the paired chat.
1643
1818
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
1819
+ const ckey = send.editable ? item.coalesceKey : undefined;
1820
+ const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
1821
+ if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
1644
1822
  try {
1645
1823
  if (send.method === "sendPhoto" && send.photoBase64) {
1646
1824
  // Real photo upload (the default botApi multiparts base64 -> file).
@@ -1663,21 +1841,79 @@ export class TelegramNotificationDaemon {
1663
1841
  parse_mode: TELEGRAM_PARSE_MODE,
1664
1842
  });
1665
1843
  } else if (send.text) {
1666
- for (const text of splitTelegramHtml(send.text)) {
1667
- await this.botApi.call("sendMessage", {
1844
+ const chunks = splitTelegramHtml(send.text);
1845
+ const existingId = editKey ? this.liveMessages.get(editKey) : undefined;
1846
+ if (editKey && existingId !== undefined && chunks.length === 1) {
1847
+ // In-place edit of the streamed message. An unchanged edit is
1848
+ // rejected by Telegram ("message is not modified") and swallowed.
1849
+ await this.botApi.call("editMessageText", {
1668
1850
  chat_id: this.opts.chatId,
1669
- ...threadField,
1670
- text,
1851
+ message_id: existingId,
1852
+ text: chunks[0],
1671
1853
  parse_mode: TELEGRAM_PARSE_MODE,
1672
1854
  });
1855
+ } else {
1856
+ // A single granted slot MUST map to a single Telegram send. When the
1857
+ // rendered text splits into multiple chunks (e.g. a long finalized
1858
+ // turn raised via SKC_NOTIFICATIONS_TURN_MAX), deliver the first
1859
+ // chunk on this token and re-submit the remaining chunks as their
1860
+ // own pool items so each consumes a token on a later drain.
1861
+ // Otherwise one frame would fan out into many sends against a single
1862
+ // slot, bypassing the per-chat rate-limit / fairness invariant.
1863
+ const res = (await this.botApi.call("sendMessage", {
1864
+ chat_id: this.opts.chatId,
1865
+ ...threadField,
1866
+ text: chunks[0]!,
1867
+ parse_mode: TELEGRAM_PARSE_MODE,
1868
+ })) as { result?: { message_id?: number } };
1869
+ for (let i = 1; i < chunks.length; i++) {
1870
+ // Continuation chunks are fresh, non-editable text sends: no
1871
+ // coalesce key (they neither replace nor are replaced by other
1872
+ // frames) and no media payload.
1873
+ this.pool.submit({
1874
+ sessionId: item.sessionId,
1875
+ lane: item.lane,
1876
+ payload: {
1877
+ send: {
1878
+ ...send,
1879
+ method: "sendMessage",
1880
+ text: chunks[i]!,
1881
+ editable: false,
1882
+ coalesceKey: undefined,
1883
+ photoBase64: undefined,
1884
+ documentBase64: undefined,
1885
+ },
1886
+ topicId,
1887
+ },
1888
+ });
1889
+ }
1890
+ const firstMessageId = res?.result?.message_id;
1891
+ if (editKey && ckey !== undefined && firstMessageId !== undefined) {
1892
+ this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
1893
+ }
1673
1894
  }
1674
1895
  }
1675
1896
  } catch {
1676
- // Best-effort: a failed send must never stop the daemon.
1897
+ // Best-effort: a failed send/edit must never stop the daemon.
1677
1898
  }
1678
1899
  }
1679
1900
  }
1680
1901
 
1902
+ /**
1903
+ * Track the Telegram message id backing a streamed `(sessionId, coalesceKey)`
1904
+ * so later live/finalized frames edit it in place. Evicts this session's stale
1905
+ * same-category entries (e.g. prior turns) so the map stays bounded.
1906
+ */
1907
+ private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
1908
+ const mapKey = `${sessionId}:${coalesceKey}`;
1909
+ const category = coalesceKey.split(":")[0] ?? "";
1910
+ const prefix = `${sessionId}:${category}:`;
1911
+ for (const k of [...this.liveMessages.keys()]) {
1912
+ if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
1913
+ }
1914
+ this.liveMessages.set(mapKey, messageId);
1915
+ }
1916
+
1681
1917
  /**
1682
1918
  * Threaded Mode is unavailable (the bot owner has not enabled forum topics in
1683
1919
  * @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
@@ -1697,9 +1933,10 @@ export class TelegramNotificationDaemon {
1697
1933
  }
1698
1934
 
1699
1935
  /**
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.
1936
+ * Resolve (and cache successful resolution of) whether the paired `chatId` is a
1937
+ * private chat. Topic and flat delivery are only safe in a private DM; any
1938
+ * non-private chat fails closed, while a transient `getChat` failure fails closed
1939
+ * for the current attempt and is retried later.
1703
1940
  */
1704
1941
  private async pairedChatIsPrivate(): Promise<boolean> {
1705
1942
  if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
@@ -1708,15 +1945,16 @@ export class TelegramNotificationDaemon {
1708
1945
  result?: { type?: string };
1709
1946
  };
1710
1947
  this.pairedChatPrivate = res.result?.type === "private";
1711
- } catch {
1712
- this.pairedChatPrivate = false;
1948
+ return this.pairedChatPrivate;
1949
+ } catch (e) {
1950
+ logger.warn(`notifications: getChat failed while checking Telegram chat privacy: ${String(e)}`);
1951
+ return false;
1713
1952
  }
1714
- return this.pairedChatPrivate;
1715
1953
  }
1716
1954
 
1717
1955
  /** Tell the user once (per daemon run) how to enable Threaded Mode. */
1718
1956
  private async notifyThreadedFallback(): Promise<void> {
1719
- if (this.threadedFallbackNoticeSent) return;
1957
+ if (this.threadedFallbackNoticeSent || !(await this.pairedChatIsPrivate())) return;
1720
1958
  this.threadedFallbackNoticeSent = true;
1721
1959
  try {
1722
1960
  await this.botApi.call("sendMessage", {
@@ -1761,7 +1999,7 @@ export class TelegramNotificationDaemon {
1761
1999
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
1762
2000
  private async sendTyping(sessionId: string): Promise<void> {
1763
2001
  const topicId = this.topics.get(sessionId)?.topicId;
1764
- if (!topicId) return;
2002
+ if (!topicId || !(await this.pairedChatIsPrivate())) return;
1765
2003
  try {
1766
2004
  await this.botApi.call("sendChatAction", {
1767
2005
  chat_id: this.opts.chatId,
@@ -1775,6 +2013,7 @@ export class TelegramNotificationDaemon {
1775
2013
 
1776
2014
  /** Set a native reaction on an inbound thread message (best-effort). */
1777
2015
  private async setReaction(messageId: number, emoji: string): Promise<void> {
2016
+ if (!(await this.pairedChatIsPrivate())) return;
1778
2017
  try {
1779
2018
  await this.botApi.call("setMessageReaction", {
1780
2019
  chat_id: this.opts.chatId,
@@ -1802,12 +2041,12 @@ export class TelegramNotificationDaemon {
1802
2041
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
1803
2042
  const send = renderThreadedFrame(msg);
1804
2043
  if (!send) return;
1805
- const existingTopic = this.topics.get(session.sessionId)?.topicId;
2044
+ const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
1806
2045
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
1807
2046
  this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
1808
2047
  return;
1809
2048
  }
1810
- if (send.identity) {
2049
+ if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
1811
2050
  const ownerId = this.topicOwnerForIdentity(msg);
1812
2051
  const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
1813
2052
  if (ownerId && ownerId !== session.sessionId && ownerTopic) {
@@ -1890,6 +2129,7 @@ export class TelegramNotificationDaemon {
1890
2129
  await this.persistAliases();
1891
2130
  } else if (msg.type === "action_resolved" && msg.id) {
1892
2131
  session.pending.delete(msg.id);
2132
+ this.deleteMessageRoutes(session.sessionId, msg.id);
1893
2133
  for (const [alias, route] of this.aliasTable.entries()) {
1894
2134
  if (route.sessionId === session.sessionId && route.actionId === msg.id) this.aliasTable.delete(alias);
1895
2135
  }
@@ -1897,19 +2137,22 @@ export class TelegramNotificationDaemon {
1897
2137
  }
1898
2138
  }
1899
2139
 
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());
2140
+ private async answerCallbackQueryBestEffort(callbackId: unknown, text?: string): Promise<void> {
2141
+ if (typeof callbackId !== "string") return;
2142
+ try {
2143
+ await this.botApi.call("answerCallbackQuery", {
2144
+ callback_query_id: callbackId,
2145
+ ...(text === undefined ? {} : { text }),
2146
+ });
2147
+ } catch {
2148
+ // Telegram callback acknowledgements only dismiss the client-side spinner;
2149
+ // they must never block the already-validated local reply path.
1905
2150
  }
1906
- return result;
1907
- };
2151
+ }
1908
2152
 
1909
2153
  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
- }
2154
+ await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
2155
+ if (!(await this.pairedChatIsPrivate())) return;
1913
2156
  await this.botApi.call("sendMessage", {
1914
2157
  chat_id: this.opts.chatId,
1915
2158
  text: "This button is stale after notification daemon restart. Please answer locally in the SKC session or wait for a fresh notification.",
@@ -1924,12 +2167,18 @@ export class TelegramNotificationDaemon {
1924
2167
  // session input.
1925
2168
  {
1926
2169
  const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
1927
- const chatId = (m?.chat as { id?: unknown } | undefined)?.id;
2170
+ const chat = m?.chat as { id?: unknown; type?: unknown } | undefined;
2171
+ const chatId = chat?.id;
2172
+ const chatType = typeof chat?.type === "string" ? chat.type : undefined;
1928
2173
  const cmdText = typeof m?.text === "string" ? m.text : undefined;
1929
- if (m !== undefined && String(chatId) === String(this.opts.chatId) && isLifecycleCommandText(cmdText)) {
1930
- 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;
2174
+ const commandCtx = { chatType, botUsername: this.botUsername };
2175
+ if (m !== undefined && String(chatId) === String(this.opts.chatId)) {
2176
+ if (chatType !== undefined && chatType !== "private" && isLifecycleCommandLikeText(cmdText)) return;
2177
+ if (isLifecycleCommandText(cmdText, commandCtx)) {
2178
+ const updateId = (update as { update_id?: number }).update_id;
2179
+ const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
2180
+ if (await this.handleLifecycleCommand(cmdText, updateId, threadId, commandCtx)) return;
2181
+ }
1933
2182
  }
1934
2183
  }
1935
2184
  // Threaded injection: a free-text message in a known topic (not a button
@@ -1954,7 +2203,6 @@ export class TelegramNotificationDaemon {
1954
2203
  });
1955
2204
  if (inbound.kind === "duplicate") return;
1956
2205
  if (inbound.kind === "inject") {
1957
- this.dispatchState.seenUpdateIds.add(inbound.updateId);
1958
2206
  const session = this.sessions.get(inbound.sessionId);
1959
2207
  if (session?.ws.readyState === WebSocket.OPEN) {
1960
2208
  const attachmentResult = inbound.attachment
@@ -1979,6 +2227,7 @@ export class TelegramNotificationDaemon {
1979
2227
  token: session.token,
1980
2228
  }),
1981
2229
  );
2230
+ await this.rememberSeenUpdateId(inbound.updateId);
1982
2231
  if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
1983
2232
  return;
1984
2233
  }
@@ -1997,6 +2246,7 @@ export class TelegramNotificationDaemon {
1997
2246
  },
1998
2247
  ),
1999
2248
  );
2249
+ await this.rememberSeenUpdateId(inbound.updateId);
2000
2250
  // User turns get a native delivery double-check: queued on receipt,
2001
2251
  // flipped to consumed when the session acks the turn that picks it
2002
2252
  // up. Config commands are not user turns and get no reaction.
@@ -2012,7 +2262,6 @@ export class TelegramNotificationDaemon {
2012
2262
  const decision = routeInboundUpdate(update, {
2013
2263
  aliasTable: this.aliasTable,
2014
2264
  messageRoutes: this.messageRoutes,
2015
- pendingBySession: this.pendingBySession,
2016
2265
  pairedChatId: this.opts.chatId,
2017
2266
  });
2018
2267
  if (decision.kind === "reply") {
@@ -2021,11 +2270,10 @@ export class TelegramNotificationDaemon {
2021
2270
  await this.sendStaleGuidance(callbackId);
2022
2271
  return;
2023
2272
  }
2024
- if (typeof callbackId === "string")
2025
- await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId });
2026
2273
  session.ws.send(
2027
2274
  JSON.stringify({ type: "reply", id: decision.actionId, answer: decision.answer, token: session.token }),
2028
2275
  );
2276
+ await this.answerCallbackQueryBestEffort(callbackId);
2029
2277
  } else if (decision.kind === "stale") {
2030
2278
  await this.sendStaleGuidance(callbackId);
2031
2279
  }
@@ -2068,9 +2316,11 @@ export class TelegramNotificationDaemon {
2068
2316
  this.startScanTimer();
2069
2317
  this.startTypingTimer();
2070
2318
  try {
2319
+ await this.refreshBotIdentity();
2071
2320
  await this.registerBotCommands();
2072
2321
  await this.loadAliases();
2073
2322
  await this.loadTopics();
2323
+ await this.loadSeenUpdateIds();
2074
2324
  await this.runScan();
2075
2325
  // Owner-only: start the session-lifecycle control server now that
2076
2326
  // ownership is confirmed (singleton-safe). Best-effort; degrades.
@@ -2131,6 +2381,7 @@ export class TelegramNotificationDaemon {
2131
2381
  // (e.g. after reload) reloads aliases/topics seamlessly.
2132
2382
  await this.persistAliases().catch(() => undefined);
2133
2383
  await this.persistTopics().catch(() => undefined);
2384
+ await this.persistSeenUpdateIds().catch(() => undefined);
2134
2385
  await this.opts.control?.clear?.(this.opts.ownerId).catch(() => undefined);
2135
2386
  await releaseDaemonOwnership({
2136
2387
  settings: this.opts.settings,