@gajae-code/coding-agent 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +54 -0
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +18 -0
  16. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  17. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  18. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  19. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  21. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  22. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  23. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  24. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  25. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  26. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  27. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  28. package/dist/types/reminders/star-reminder.d.ts +3 -13
  29. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  30. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  31. package/dist/types/sdk/broker/transport.d.ts +2 -0
  32. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  33. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  34. package/dist/types/sdk/bus/config.d.ts +6 -2
  35. package/dist/types/sdk/bus/index.d.ts +29 -0
  36. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  37. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  38. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  39. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  40. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  41. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  42. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  43. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  44. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  45. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  46. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  47. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  48. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  49. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  50. package/dist/types/sdk/prompt-status.d.ts +23 -3
  51. package/dist/types/sdk/session.d.ts +6 -0
  52. package/dist/types/session/agent-session.d.ts +38 -2
  53. package/dist/types/session/artifacts.d.ts +8 -0
  54. package/dist/types/session/blob-store.d.ts +57 -4
  55. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  56. package/dist/types/session/session-manager.d.ts +23 -6
  57. package/dist/types/session/session-storage.d.ts +4 -0
  58. package/dist/types/session/streaming-output.d.ts +36 -7
  59. package/dist/types/setup/credential-import.d.ts +12 -0
  60. package/dist/types/skill-state/active-state.d.ts +12 -0
  61. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  62. package/dist/types/task/executor.d.ts +3 -2
  63. package/dist/types/task/index.d.ts +3 -1
  64. package/dist/types/task/provider-retry-status.d.ts +8 -0
  65. package/dist/types/task/receipt.d.ts +7 -1
  66. package/dist/types/task/types.d.ts +23 -0
  67. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  68. package/dist/types/tools/bash-interactive.d.ts +4 -1
  69. package/dist/types/tools/bash.d.ts +3 -1
  70. package/dist/types/tools/image-gen.d.ts +4 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/output-meta.d.ts +23 -2
  73. package/dist/types/tools/path-utils.d.ts +1 -0
  74. package/dist/types/tools/read.d.ts +0 -6
  75. package/dist/types/tools/review.d.ts +5 -7
  76. package/dist/types/tools/subagent.d.ts +2 -0
  77. package/dist/types/utils/gh.d.ts +13 -0
  78. package/dist/types/utils/image-resize.d.ts +1 -0
  79. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  80. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  81. package/dist/types/web/search/providers/xai.d.ts +2 -0
  82. package/package.json +10 -8
  83. package/scripts/acp-conformance-agent.ts +218 -0
  84. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  85. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  86. package/scripts/resident-memory-bench.ts +1341 -0
  87. package/scripts/run-acp-conformance.ts +285 -0
  88. package/src/async/job-manager.ts +78 -20
  89. package/src/cli/fast-help.ts +1 -1
  90. package/src/cli/plugin-cli.ts +176 -18
  91. package/src/commands/team.ts +19 -1
  92. package/src/config/file-lock.ts +11 -2
  93. package/src/config/settings-schema.ts +51 -0
  94. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  95. package/src/coordinator-mcp/server.ts +5 -12
  96. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  97. package/src/defaults/gjc/skills/ralplan/SKILL.md +19 -16
  98. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  99. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -24
  100. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  101. package/src/defaults/gjc-defaults.ts +4 -4
  102. package/src/exa/mcp-client.ts +12 -3
  103. package/src/exec/bash-executor.ts +144 -27
  104. package/src/extensibility/extensions/runner.ts +15 -0
  105. package/src/extensibility/extensions/types.ts +18 -1
  106. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  107. package/src/extensibility/gjc-plugins/index.ts +19 -2
  108. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  109. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  110. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  111. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  112. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  113. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  114. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  115. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  116. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  117. package/src/extensibility/gjc-plugins/types.ts +141 -0
  118. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  119. package/src/gjc-runtime/launch-tmux.ts +25 -6
  120. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  121. package/src/gjc-runtime/ralplan-runtime.ts +411 -17
  122. package/src/gjc-runtime/team-launch.ts +8 -0
  123. package/src/gjc-runtime/team-runtime.ts +140 -32
  124. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  126. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  127. package/src/gjc-runtime/ultragoal-runtime.ts +401 -611
  128. package/src/gjc-runtime/workflow-manifest.generated.json +8 -99
  129. package/src/gjc-runtime/workflow-manifest.ts +4 -68
  130. package/src/internal-urls/docs-index.generated.ts +15 -15
  131. package/src/modes/DESIGN.md +51 -0
  132. package/src/modes/acp/acp-agent.ts +298 -186
  133. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  134. package/src/modes/components/notifications-settings-editor.ts +12 -1
  135. package/src/modes/components/oauth-selector.ts +68 -30
  136. package/src/modes/components/session-observer-overlay.ts +305 -86
  137. package/src/modes/components/settings-selector.ts +56 -5
  138. package/src/modes/components/status-line/gh.ts +16 -0
  139. package/src/modes/components/tool-status-header.ts +3 -14
  140. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  141. package/src/modes/controllers/command-controller.ts +4 -0
  142. package/src/modes/controllers/extension-ui-controller.ts +7 -0
  143. package/src/modes/controllers/selector-controller.ts +45 -0
  144. package/src/modes/runtime-init.ts +3 -0
  145. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  146. package/src/prompts/system/eager-todo.md +8 -4
  147. package/src/prompts/system/system-prompt.md +11 -4
  148. package/src/prompts/tools/bash.md +5 -5
  149. package/src/prompts/tools/search-tool-bm25.md +5 -0
  150. package/src/prompts/tools/task.md +1 -1
  151. package/src/reminders/star-reminder.ts +4 -42
  152. package/src/runtime-mcp/manager.ts +19 -4
  153. package/src/sdk/acp/adapter.ts +22 -6
  154. package/src/sdk/broker/transport.ts +35 -3
  155. package/src/sdk/bus/attachment-registry.ts +5 -1
  156. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  157. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  158. package/src/sdk/bus/config.ts +18 -2
  159. package/src/sdk/bus/index.ts +661 -126
  160. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  161. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  162. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  163. package/src/sdk/bus/reconciliation-store.ts +95 -16
  164. package/src/sdk/bus/rich-draft.ts +1 -1
  165. package/src/sdk/bus/rich-render.ts +2 -2
  166. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  167. package/src/sdk/bus/telegram-cli.ts +33 -8
  168. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  169. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  170. package/src/sdk/bus/telegram-daemon.ts +916 -23
  171. package/src/sdk/bus/telegram-reference.ts +40 -3
  172. package/src/sdk/bus/threaded-render.ts +48 -3
  173. package/src/sdk/bus/topic-registry.ts +20 -0
  174. package/src/sdk/host/query/revision-store.ts +36 -2
  175. package/src/sdk/host/reverse-leases.ts +44 -7
  176. package/src/sdk/prompt-status.ts +16 -3
  177. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  178. package/src/sdk/session.ts +88 -9
  179. package/src/session/agent-session.ts +822 -172
  180. package/src/session/artifacts.ts +22 -0
  181. package/src/session/auth-broker-config.ts +10 -3
  182. package/src/session/blob-store.ts +915 -24
  183. package/src/session/internal/managed-session-scope.ts +44 -6
  184. package/src/session/internal/managed-session-storage.ts +48 -6
  185. package/src/session/session-manager.ts +1614 -435
  186. package/src/session/session-storage.ts +9 -1
  187. package/src/session/streaming-output.ts +238 -68
  188. package/src/setup/credential-import.ts +75 -13
  189. package/src/skill-state/active-state.ts +32 -0
  190. package/src/skill-state/workflow-hud.ts +17 -0
  191. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  192. package/src/task/executor.ts +122 -39
  193. package/src/task/index.ts +23 -14
  194. package/src/task/provider-retry-status.ts +16 -0
  195. package/src/task/receipt.ts +23 -7
  196. package/src/task/render.ts +6 -2
  197. package/src/task/types.ts +108 -0
  198. package/src/tools/ask-answer-registry.ts +12 -6
  199. package/src/tools/ask.ts +86 -55
  200. package/src/tools/ast-edit.ts +1 -0
  201. package/src/tools/ast-grep.ts +1 -0
  202. package/src/tools/bash-interactive.ts +8 -3
  203. package/src/tools/bash.ts +503 -60
  204. package/src/tools/find.ts +1 -0
  205. package/src/tools/image-gen.ts +29 -5
  206. package/src/tools/index.ts +4 -0
  207. package/src/tools/output-meta.ts +108 -14
  208. package/src/tools/path-utils.ts +3 -2
  209. package/src/tools/read.ts +18 -0
  210. package/src/tools/review.ts +5 -7
  211. package/src/tools/search.ts +1 -0
  212. package/src/tools/subagent-render.ts +6 -2
  213. package/src/tools/subagent.ts +6 -0
  214. package/src/tools/telegram-send.ts +6 -3
  215. package/src/utils/gh.ts +39 -0
  216. package/src/utils/image-resize.ts +3 -0
  217. package/src/web/search/providers/anthropic.ts +4 -4
  218. package/src/web/search/providers/kimi.ts +15 -5
  219. package/src/web/search/providers/searxng.ts +47 -4
  220. package/src/web/search/providers/xai.ts +7 -2
  221. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  222. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  223. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -9,6 +9,7 @@ import { withFileLock } from "../../config/file-lock";
9
9
  import type { Settings } from "../../config/settings";
10
10
  import type { DaemonRuntimeInfo } from "../../daemon/control-types";
11
11
  import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
12
+ import { resizeImageBuffer } from "../../utils/image-resize";
12
13
  import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
13
14
  import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
14
15
  import {
@@ -63,6 +64,7 @@ import {
63
64
  isLifecycleCommandText,
64
65
  type LifecycleCommandVerb,
65
66
  lifecycleUsage,
67
+ normalizeLifecyclePath,
66
68
  parseLifecycleCommand,
67
69
  validateLifecycleTarget,
68
70
  } from "./lifecycle-commands";
@@ -77,7 +79,7 @@ import {
77
79
  import type { OrchestratorDeps } from "./lifecycle-orchestrator";
78
80
  import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
79
81
  import { RateLimitPool } from "./rate-limit-pool";
80
- import { listRecentSessions } from "./recent-activity";
82
+ import { type ListRecentSessionsResult, listRecentSessions } from "./recent-activity";
81
83
  import { ReplySentStore } from "./reply-sent-store";
82
84
  import { DraftStreamState, deliverDraft, shouldStreamDraft } from "./rich-draft";
83
85
  import {
@@ -86,6 +88,13 @@ import {
86
88
  isBtwRichEligible,
87
89
  shouldPromoteRich,
88
90
  } from "./rich-render";
91
+ import {
92
+ type AdoptionIntentFs,
93
+ buildAdoptionIntent,
94
+ DEFAULT_ADOPTION_INTENT_TTL_MS,
95
+ TelegramAdoptionIntentStore,
96
+ type TelegramAdoptionTarget,
97
+ } from "./telegram-adoption-intent";
89
98
  import {
90
99
  type AliasTable,
91
100
  buildActionMarkdown,
@@ -94,9 +103,10 @@ import {
94
103
  createAliasTable,
95
104
  readEndpoint,
96
105
  routeInboundUpdate,
106
+ telegramDisableNotification,
97
107
  } from "./telegram-reference";
98
108
  import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
99
- import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
109
+ import { renderThreadedFrame, supportsTelegramPhotoUpload, type ThreadedSend } from "./threaded-render";
100
110
  import { type TopicEndpointBinding, TopicRegistry, type TopicRegistryState } from "./topic-registry";
101
111
 
102
112
  export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
@@ -145,7 +155,14 @@ export interface TelegramDaemonFs {
145
155
  open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
146
156
  readdir(path: string): Promise<string[]>;
147
157
  chmod(path: string, mode: number): Promise<void>;
148
- stat?(path: string): Promise<{ mtimeMs: number; size?: number; dev?: number; ino?: number; ctimeMs?: number }>;
158
+ stat?(path: string): Promise<{
159
+ mtimeMs: number;
160
+ size?: number;
161
+ dev?: number;
162
+ ino?: number;
163
+ ctimeMs?: number;
164
+ isDirectory?: () => boolean;
165
+ }>;
149
166
  readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
150
167
  exactUnlink?(path: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
151
168
  }
@@ -301,6 +318,8 @@ const CONSUMED_REACTION = "✅";
301
318
  const MODEL_CALLBACK_PREFIX = "m:";
302
319
  const MODEL_CHOICE_TTL_MS = 10 * 60 * 1_000;
303
320
  const BTW_PENDING_TTL_MS = 300_000;
321
+ const PICKER_CALLBACK_PREFIX = "p:";
322
+ const ADOPTION_INTENT_SWEEP_INTERVAL_MS = 60_000;
304
323
  const BTW_MAX_PENDING = 256;
305
324
  const BTW_SHUTDOWN_JOIN_MS = 1_000;
306
325
  const BTW_USAGE_TEXT = "Usage: /btw <question>";
@@ -311,11 +330,45 @@ const TELEGRAM_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
311
330
  const TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT = 20;
312
331
  const TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024;
313
332
  const TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 60_000;
333
+ const TELEGRAM_PHOTO_MAX_BYTES = 10 * 1024 * 1024;
334
+ const TELEGRAM_PHOTO_MAX_DIMENSION = 10_000;
335
+ const TELEGRAM_PHOTO_MAX_ASPECT_RATIO = 20;
314
336
  const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar values and 16384 UTF-8 bytes.";
315
337
  type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
316
338
  type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
317
339
  class ThreadedModeCapabilityRefusal extends Error {}
318
340
 
341
+ async function prepareTelegramImageAttachment(frame: Record<string, unknown>): Promise<Record<string, unknown>> {
342
+ if (frame.type !== "image_attachment") return frame;
343
+ const mime = typeof frame.mime === "string" ? frame.mime : undefined;
344
+ const data = typeof frame.data === "string" ? frame.data : undefined;
345
+ if (!data || supportsTelegramPhotoUpload(mime) || !mime?.toLowerCase().startsWith("image/")) return frame;
346
+ try {
347
+ const converted = await resizeImageBuffer(Buffer.from(data, "base64"), mime, {
348
+ maxWidth: TELEGRAM_PHOTO_MAX_DIMENSION,
349
+ maxHeight: TELEGRAM_PHOTO_MAX_DIMENSION,
350
+ maxBytes: TELEGRAM_PHOTO_MAX_BYTES,
351
+ jpegQuality: 90,
352
+ excludeWebP: true,
353
+ forceReencode: true,
354
+ });
355
+ const shortestEdge = Math.min(converted.width, converted.height);
356
+ const longestEdge = Math.max(converted.width, converted.height);
357
+ if (
358
+ !supportsTelegramPhotoUpload(converted.mimeType) ||
359
+ converted.buffer.byteLength > TELEGRAM_PHOTO_MAX_BYTES ||
360
+ shortestEdge <= 0 ||
361
+ longestEdge / shortestEdge > TELEGRAM_PHOTO_MAX_ASPECT_RATIO
362
+ ) {
363
+ return frame;
364
+ }
365
+ return { ...frame, mime: converted.mimeType, data: converted.data };
366
+ } catch (error) {
367
+ logger.warn(`notifications: image conversion failed; falling back to document: ${String(error)}`);
368
+ return frame;
369
+ }
370
+ }
371
+
319
372
  /** Only an explicit Bot API capability refusal may enable flat private-chat delivery. */
320
373
  function isThreadedModeCapabilityRefusal(response: unknown): boolean {
321
374
  if (!response || typeof response !== "object") return false;
@@ -3529,12 +3582,14 @@ export class TelegramBotTransport implements BotApi {
3529
3582
  mime?: string;
3530
3583
  caption?: string;
3531
3584
  parse_mode?: string;
3585
+ disable_notification?: boolean;
3532
3586
  };
3533
3587
  const form = new FormData();
3534
3588
  form.set("chat_id", String(b.chat_id));
3535
3589
  if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
3536
3590
  if (b.caption) form.set("caption", b.caption);
3537
3591
  if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
3592
+ if (b.disable_notification === true) form.set("disable_notification", "true");
3538
3593
  form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
3539
3594
  const res = await fetchWithRetry(
3540
3595
  fetchImpl,
@@ -3555,12 +3610,14 @@ export class TelegramBotTransport implements BotApi {
3555
3610
  fileName?: string;
3556
3611
  caption?: string;
3557
3612
  parse_mode?: string;
3613
+ disable_notification?: boolean;
3558
3614
  };
3559
3615
  const form = new FormData();
3560
3616
  form.set("chat_id", String(b.chat_id));
3561
3617
  if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
3562
3618
  if (b.caption) form.set("caption", b.caption);
3563
3619
  if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
3620
+ if (b.disable_notification === true) form.set("disable_notification", "true");
3564
3621
  form.set(
3565
3622
  "document",
3566
3623
  new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
@@ -3818,6 +3875,8 @@ export interface TelegramDaemonOptions {
3818
3875
  clearIntervalImpl?: typeof clearInterval;
3819
3876
  btw?: { enabled: boolean };
3820
3877
  idleTimeoutMs?: number;
3878
+ /** TTL for durable topic-adoption intents (default 10 minutes). Observed, not a fixed contract. */
3879
+ adoptionIntentTtlMs?: number;
3821
3880
  scanIntervalMs?: number;
3822
3881
  pid?: number;
3823
3882
  /** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
@@ -3848,6 +3907,8 @@ export interface TelegramDaemonOptions {
3848
3907
  richDraft?: { enabled: boolean };
3849
3908
  /** Tool start/completion messages (off by default; explicit opt-in only). */
3850
3909
  toolActivity?: { enabled: boolean };
3910
+ /** Controls which Telegram sends play an audible notification. Defaults to all. */
3911
+ sound?: "all" | "important" | "none";
3851
3912
  /**
3852
3913
  * Per-session Telegram forum-topic naming. `nameTemplate` supports the
3853
3914
  * `{repo}`, `{branch}`, and `{title}` placeholders; unset preserves the
@@ -3912,6 +3973,24 @@ interface RenderedModelChoice {
3912
3973
  selector: string;
3913
3974
  label: string;
3914
3975
  }
3976
+ type TopicPickerAction = { kind: "select"; target: TelegramAdoptionTarget } | { kind: "recent" } | { kind: "direct" };
3977
+
3978
+ interface TopicPickerRoute {
3979
+ /** Telegram forum topic id the user created and that this picker controls. */
3980
+ topicId: number;
3981
+ /** Preallocated session id that will adopt this topic once created. */
3982
+ intendedSessionId: string;
3983
+ /** Configured chat id this picker was rendered for; replay into another chat is refused. */
3984
+ chatId: string;
3985
+ action: TopicPickerAction;
3986
+ expiresAt: number;
3987
+ }
3988
+
3989
+ interface PendingAdoptionPathInput {
3990
+ chatId: string;
3991
+ intendedSessionId: string;
3992
+ expiresAt: number;
3993
+ }
3915
3994
 
3916
3995
  interface TopicAuthorityLease {
3917
3996
  sessionId: string;
@@ -4127,6 +4206,18 @@ export class TelegramNotificationDaemon {
4127
4206
  readonly sessions = new Map<string, SessionSocket>();
4128
4207
  /** Ephemeral aliases for model choices; deliberately never serialized across daemon restarts. */
4129
4208
  #modelChoiceAliases = new Map<string, ModelChoiceRoute>();
4209
+ /** One-shot picker aliases for user-created topic adoption; consumed before submit. */
4210
+ #adoptionPickerAliases = new Map<string, TopicPickerRoute>();
4211
+ /** Topics whose next authenticated text message is treated as an explicitly requested path. */
4212
+ #pendingAdoptionPathInputs = new Map<number, PendingAdoptionPathInput>();
4213
+ /** Durable, non-routable topic-adoption intents; sole claim authority is getOrCreateTopic. */
4214
+ #adoptionIntents: TelegramAdoptionIntentStore;
4215
+ /** Configured adoption-intent TTL (default 10 minutes); observed, not a fixed contract. */
4216
+ #adoptionIntentTtlMs: number;
4217
+ /** Periodic sweep handle for expired adoption intents. */
4218
+ #adoptionSweepTimer: NodeJS.Timeout | undefined;
4219
+ /** In-flight submissions close the pre-sidecar race across picker and direct-path entry points. */
4220
+ readonly #adoptionStartingTopics = new Set<number>();
4130
4221
 
4131
4222
  private readonly runtime: NotificationOperatorRuntime;
4132
4223
  private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
@@ -4437,8 +4528,8 @@ export class TelegramNotificationDaemon {
4437
4528
  type: "session_lifecycle_error",
4438
4529
  requestId: frame.requestId,
4439
4530
  status: "error",
4440
- reason: "terminal_uncertain",
4441
- message: "lifecycle control unavailable",
4531
+ reason: "spawn_failed",
4532
+ message: "lifecycle control unavailable; no session was started",
4442
4533
  });
4443
4534
  return;
4444
4535
  }
@@ -4462,8 +4553,8 @@ export class TelegramNotificationDaemon {
4462
4553
  type: "session_lifecycle_error",
4463
4554
  requestId: frame.requestId,
4464
4555
  status: "error",
4465
- reason: "terminal_uncertain",
4466
- message: `lifecycle send failed: ${String(e)}`,
4556
+ reason: "spawn_failed",
4557
+ message: `lifecycle send failed before session start: ${String(e)}`,
4467
4558
  });
4468
4559
  }
4469
4560
  });
@@ -4481,6 +4572,7 @@ export class TelegramNotificationDaemon {
4481
4572
  | { kind: "close"; target: SessionCloseTarget }
4482
4573
  | { kind: "resume"; target: SessionResumeTarget },
4483
4574
  updateId: number,
4575
+ intendedSessionId?: string,
4484
4576
  ): SessionLifecycleRequest {
4485
4577
  const requestId = this.nextLifecycleRequestId();
4486
4578
  const token = this.controlToken ?? "";
@@ -4490,7 +4582,7 @@ export class TelegramNotificationDaemon {
4490
4582
  type: "session_create",
4491
4583
  requestId,
4492
4584
  lifecycleRequestId: requestId,
4493
- intendedSessionId: `s${crypto.randomBytes(6).toString("hex")}`,
4585
+ intendedSessionId: intendedSessionId ?? `s${crypto.randomBytes(6).toString("hex")}`,
4494
4586
  updateId,
4495
4587
  chatId,
4496
4588
  token,
@@ -4524,6 +4616,9 @@ export class TelegramNotificationDaemon {
4524
4616
  chat_id: this.opts.chatId,
4525
4617
  ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
4526
4618
  text,
4619
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
4620
+ ? { disable_notification: true }
4621
+ : {}),
4527
4622
  })
4528
4623
  .catch(() => undefined);
4529
4624
  }
@@ -4536,6 +4631,9 @@ export class TelegramNotificationDaemon {
4536
4631
  ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
4537
4632
  text,
4538
4633
  parse_mode: TELEGRAM_PARSE_MODE,
4634
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
4635
+ ? { disable_notification: true }
4636
+ : {}),
4539
4637
  })
4540
4638
  .catch(() => undefined);
4541
4639
  }
@@ -4650,6 +4748,30 @@ export class TelegramNotificationDaemon {
4650
4748
  this.fsImpl = opts.fs ?? nodeFs;
4651
4749
  this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
4652
4750
  this.aliasTable = createAliasTable();
4751
+ this.#adoptionIntentTtlMs = opts.adoptionIntentTtlMs ?? DEFAULT_ADOPTION_INTENT_TTL_MS;
4752
+ const adoptionFs: AdoptionIntentFs = {
4753
+ mkdir: (directory, options) => this.fsImpl.mkdir(directory, options),
4754
+ chmod: (target, mode) => this.fsImpl.chmod(target, mode),
4755
+ readFile: (file, encoding) => this.fsImpl.readFile(file, encoding),
4756
+ writeFile: (file, data, options) => this.fsImpl.writeFile(file, data, options),
4757
+ rename: (from, to) => this.fsImpl.rename(from, to),
4758
+ unlink: file => this.fsImpl.unlink(file),
4759
+ readdir: directory => this.fsImpl.readdir(directory),
4760
+ open: async (file, flags) => {
4761
+ const handle = await this.fsImpl.open(file, flags);
4762
+ const sync = (handle as { sync?: () => Promise<void> }).sync;
4763
+ if (typeof sync !== "function") {
4764
+ await handle.close();
4765
+ throw new Error("adoption sidecar durability requires filesystem sync support");
4766
+ }
4767
+ return { sync: () => sync.call(handle), close: () => handle.close() };
4768
+ },
4769
+ };
4770
+ this.#adoptionIntents = new TelegramAdoptionIntentStore({
4771
+ agentDir: opts.settings.getAgentDir(),
4772
+ now: opts.now,
4773
+ fs: adoptionFs,
4774
+ });
4653
4775
  const rawBotApi =
4654
4776
  opts.botApi ??
4655
4777
  new TelegramBotTransport({
@@ -5160,7 +5282,7 @@ export class TelegramNotificationDaemon {
5160
5282
  }
5161
5283
  }
5162
5284
 
5163
- connectSession(sessionId: string, url: string, token: string): void {
5285
+ connectSession(sessionId: string, url: string, token: string): SessionSocket {
5164
5286
  const WS = this.opts.WebSocketImpl ?? WebSocket;
5165
5287
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
5166
5288
  const endpointKey = endpointGenerationKey(url, token);
@@ -5264,6 +5386,7 @@ export class TelegramNotificationDaemon {
5264
5386
  ws.addEventListener("close", () => {
5265
5387
  this.dropSession(session, "socket_closed");
5266
5388
  });
5389
+ return session;
5267
5390
  }
5268
5391
 
5269
5392
  /**
@@ -5649,6 +5772,9 @@ export class TelegramNotificationDaemon {
5649
5772
  reply_parameters: { message_id: input.messageId },
5650
5773
  text: input.text,
5651
5774
  ...(input.parseMode ? { parse_mode: input.parseMode } : {}),
5775
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
5776
+ ? { disable_notification: true }
5777
+ : {}),
5652
5778
  },
5653
5779
  {
5654
5780
  noRetry: true,
@@ -6632,12 +6758,42 @@ export class TelegramNotificationDaemon {
6632
6758
  let acceptedTopicCompensated = false;
6633
6759
  let acceptedTopicDeleteAttempted = false;
6634
6760
  let creationSuppressed = false;
6761
+ /** User-created topicId adopted in this callback, if any (for sidecar reconciliation). */
6762
+ let adoptedTopicId: number | undefined;
6763
+ const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId);
6635
6764
  try {
6636
6765
  const rec = await this.topics.getOrCreateTopic(
6637
6766
  sessionId,
6638
6767
  async () => {
6639
6768
  if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
6640
6769
  throw new Error("topic authority was revoked during creation");
6770
+ // Adoption path: if a durable non-routable intent targets this
6771
+ // session, synchronously claim its user-created topicId, validate
6772
+ // chat/binding/pure availability, and RETURN it so getOrCreateTopic
6773
+ // commits the full record exactly once. createForumTopic is never
6774
+ // called for an adopted topic; acceptedTopicId stays unset so no
6775
+ // compensation path can delete the user topic.
6776
+ const adoptionIntent = adoptionIntentCandidate ? this.#adoptionIntents.bySession(sessionId) : undefined;
6777
+ if (adoptionIntentCandidate && !adoptionIntent) {
6778
+ throw new Error("topic adoption refused: intent expired before consumption");
6779
+ }
6780
+ if (adoptionIntent) {
6781
+ const chatId = String(this.opts.chatId);
6782
+ const topicIdStr = String(adoptionIntent.topicId);
6783
+ const chatMatches = adoptionIntent.chatId === chatId;
6784
+ const bindingChatMatches = !creationBinding || creationBinding.chatId === chatId;
6785
+ const available = this.topics.isTopicIdAvailable(topicIdStr);
6786
+ if (!chatMatches || !bindingChatMatches || !available) {
6787
+ // Fail closed: never fall back to remote-create over a user topic.
6788
+ throw new Error("topic adoption refused: intent chat/binding/topic unavailable");
6789
+ }
6790
+ if (!this.#adoptionIntents.tryClaim(adoptionIntent.topicId, sessionId)) {
6791
+ throw new Error("topic adoption refused: concurrent claim held");
6792
+ }
6793
+ this.#malformedTopicCreateEndpoints.delete(sessionId);
6794
+ adoptedTopicId = adoptionIntent.topicId;
6795
+ return topicIdStr;
6796
+ }
6641
6797
  const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as
6642
6798
  | { result?: { message_thread_id?: unknown } }
6643
6799
  | undefined;
@@ -6696,7 +6852,25 @@ export class TelegramNotificationDaemon {
6696
6852
  creationBinding,
6697
6853
  () => this.persistTopics(),
6698
6854
  session,
6855
+ adoptionIntentCandidate ? "user_created" : undefined,
6699
6856
  );
6857
+ // Adoption commit success: remove the durable sidecar. A cleanup failure
6858
+ // cannot roll back the committed record; topic success is retained and the
6859
+ // stale sidecar is left for startup reconciliation to remove safely.
6860
+ if (adoptedTopicId !== undefined) {
6861
+ if (adoptionIntentCandidate)
6862
+ logger.info(
6863
+ `notifications: topic-adoption committed age_ms=${Math.max(0, this.runtime.now() - adoptionIntentCandidate.createdAt)}`,
6864
+ );
6865
+ try {
6866
+ await this.#adoptionIntents.remove(sessionId);
6867
+ await this.#adoptionIntents.removePendingTopic(adoptedTopicId);
6868
+ } catch (error) {
6869
+ logger.warn(
6870
+ `notifications: topic-adoption sidecar cleanup failed; retained for reconciliation: ${sanitizeDiagnostic(String(error))}`,
6871
+ );
6872
+ }
6873
+ }
6700
6874
  // getOrCreateTopic deduplicates callers, so an accepted create can be
6701
6875
  // observed by a successor after its initiating socket was revoked. Check
6702
6876
  // the immutable lease again before exposing that record to frame delivery.
@@ -6729,6 +6903,11 @@ export class TelegramNotificationDaemon {
6729
6903
  return rec.topicId;
6730
6904
  } catch (err) {
6731
6905
  if (creationSuppressed || err instanceof ThreadedModeCapabilityRefusal) return undefined;
6906
+ // Adoption commit failure: release the in-memory claim so a retry can
6907
+ // re-claim, and retain the durable sidecar (never delete the user topic).
6908
+ if (adoptedTopicId !== undefined) {
6909
+ this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId);
6910
+ }
6732
6911
  if (
6733
6912
  acceptedTopicId &&
6734
6913
  !acceptedTopicCompensated &&
@@ -6833,6 +7012,26 @@ export class TelegramNotificationDaemon {
6833
7012
  try {
6834
7013
  await this.flushPool();
6835
7014
  if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
7015
+ if (record.topicOrigin === "user_created") {
7016
+ this.topics.settleDelete(sessionId, record.topicId);
7017
+ for (const k of [...this.liveMessages.keys()])
7018
+ if (k.startsWith(`${sessionId}:`)) {
7019
+ this.liveMessages.delete(k);
7020
+ this.toolActivityOwners.delete(k);
7021
+ }
7022
+ this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
7023
+ if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
7024
+ });
7025
+ this.pendingThreadedFrames.delete(sessionId);
7026
+ try {
7027
+ await this.persistTopics();
7028
+ return "settled";
7029
+ } catch {
7030
+ this.topics.restoreDeleteFence(deleteSnapshot);
7031
+ await this.#persistTopicsWithRetry().catch(() => undefined);
7032
+ return "post_dispatch_pending";
7033
+ }
7034
+ }
6836
7035
  const res = (await this.botApi.call("deleteForumTopic", {
6837
7036
  chat_id: this.opts.chatId,
6838
7037
  message_thread_id: Number(record.topicId),
@@ -6950,6 +7149,56 @@ export class TelegramNotificationDaemon {
6950
7149
  }
6951
7150
  }
6952
7151
 
7152
+ /**
7153
+ * Rehydrate durable adoption intents after restart, then reconcile: a
7154
+ * non-expired sidecar whose target session already has a committed topic
7155
+ * record is a stale leftover (commit succeeded but sidecar cleanup failed)
7156
+ * and is removed safely without touching the user topic.
7157
+ */
7158
+ async loadAdoptionIntents(): Promise<void> {
7159
+ await this.#adoptionIntents.rehydrate();
7160
+ const expired = await this.#adoptionIntents.sweepExpired();
7161
+ if (expired > 0) logger.info(`notifications: topic-adoption expired count=${expired}`);
7162
+ for (const sessionId of this.topics.sessionIds()) {
7163
+ const intent = this.#adoptionIntents.bySession(sessionId);
7164
+ if (!intent) continue;
7165
+ const record = this.topics.get(sessionId);
7166
+ if (record && record.topicId === String(intent.topicId)) {
7167
+ try {
7168
+ await this.#adoptionIntents.remove(sessionId);
7169
+ await this.#adoptionIntents.removePendingTopic(intent.topicId);
7170
+ } catch (error) {
7171
+ logger.warn(
7172
+ `notifications: stale topic-adoption sidecar cleanup failed: ${sanitizeDiagnostic(String(error))}`,
7173
+ );
7174
+ }
7175
+ }
7176
+ }
7177
+ }
7178
+
7179
+ /** Periodic sweep of expired adoption-intent sidecars (files only; no Telegram API). */
7180
+ private startAdoptionSweepTimer(): void {
7181
+ const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
7182
+ this.#adoptionSweepTimer = setIntervalImpl(() => {
7183
+ this.#sweepExpiredAdoptionPickerAliases();
7184
+ void this.#adoptionIntents
7185
+ .sweepExpired()
7186
+ .then(expired => {
7187
+ if (expired > 0) logger.info(`notifications: topic-adoption expired count=${expired}`);
7188
+ })
7189
+ .catch(error => {
7190
+ logger.warn(`notifications: adoption-intent sweep failed: ${sanitizeDiagnostic(String(error))}`);
7191
+ });
7192
+ }, ADOPTION_INTENT_SWEEP_INTERVAL_MS);
7193
+ }
7194
+
7195
+ private stopAdoptionSweepTimer(): void {
7196
+ if (this.#adoptionSweepTimer !== undefined) {
7197
+ (this.opts.clearIntervalImpl ?? clearInterval)(this.#adoptionSweepTimer);
7198
+ this.#adoptionSweepTimer = undefined;
7199
+ }
7200
+ }
7201
+
6953
7202
  /** Retry crash-interrupted topic deletes; only a definite Telegram result clears the durable fence. */
6954
7203
  private async reconcilePendingTopicDeletes(): Promise<void> {
6955
7204
  for (const sessionId of this.topics.deletePendingSessionIds()) await this.deleteTopic(sessionId);
@@ -7345,6 +7594,9 @@ export class TelegramNotificationDaemon {
7345
7594
  chat_id: this.opts.chatId,
7346
7595
  ...(topicLease ? { message_thread_id: Number(topicLease.topicId) } : {}),
7347
7596
  text: "Selected!",
7597
+ ...(telegramDisableNotification(this.opts.sound, item.lane) === true
7598
+ ? { disable_notification: true }
7599
+ : {}),
7348
7600
  },
7349
7601
  { signal: controller.signal, noRetry: true },
7350
7602
  );
@@ -7448,7 +7700,13 @@ export class TelegramNotificationDaemon {
7448
7700
  if (draftId !== undefined) {
7449
7701
  await deliverDraft(
7450
7702
  this.botApi,
7451
- { chat_id: this.opts.chatId, ...threadField },
7703
+ {
7704
+ chat_id: this.opts.chatId,
7705
+ ...threadField,
7706
+ ...(telegramDisableNotification(this.opts.sound, "live") === true
7707
+ ? { disable_notification: true }
7708
+ : {}),
7709
+ },
7452
7710
  draftId,
7453
7711
  send.richDraftMarkdown!,
7454
7712
  logger,
@@ -7463,6 +7721,7 @@ export class TelegramNotificationDaemon {
7463
7721
  disposition = "rejected";
7464
7722
  continue;
7465
7723
  }
7724
+ const disableNotification = telegramDisableNotification(this.opts.sound, send.lane);
7466
7725
  if (send.method === "sendPhoto" && send.photoBase64) {
7467
7726
  // Real photo upload (the default botApi multiparts base64 -> file).
7468
7727
  await itemBotApi.call("sendPhoto", {
@@ -7472,6 +7731,7 @@ export class TelegramNotificationDaemon {
7472
7731
  mime: send.mime,
7473
7732
  caption: send.text,
7474
7733
  parse_mode: TELEGRAM_PARSE_MODE,
7734
+ ...(disableNotification === true ? { disable_notification: true } : {}),
7475
7735
  });
7476
7736
  } else if (send.method === "sendDocument" && send.documentBase64) {
7477
7737
  await itemBotApi.call("sendDocument", {
@@ -7482,11 +7742,11 @@ export class TelegramNotificationDaemon {
7482
7742
  fileName: send.fileName,
7483
7743
  caption: send.text,
7484
7744
  parse_mode: TELEGRAM_PARSE_MODE,
7745
+ ...(disableNotification === true ? { disable_notification: true } : {}),
7485
7746
  });
7486
7747
  } else if (send.text) {
7487
7748
  // Rich pre-branch: promote stable non-editable finalized text to a fresh
7488
- // sendRichMessage when enabled. Off/miss falls through to the unchanged
7489
- // upstream edit/send path, so off behavior is byte-identical.
7749
+ // sendRichMessage when enabled. Off/miss falls through to the HTML path.
7490
7750
  if (
7491
7751
  shouldPromoteRich({
7492
7752
  enabled: this.opts.rich?.enabled !== false,
@@ -7509,6 +7769,7 @@ export class TelegramNotificationDaemon {
7509
7769
  ...threadField,
7510
7770
  text: chunks[0]!,
7511
7771
  parse_mode: TELEGRAM_PARSE_MODE,
7772
+ ...(disableNotification === true ? { disable_notification: true } : {}),
7512
7773
  });
7513
7774
  for (let i = 1; i < chunks.length; i++) {
7514
7775
  this.submitPool({
@@ -7535,7 +7796,11 @@ export class TelegramNotificationDaemon {
7535
7796
  };
7536
7797
  const richMessageId = await deliverRichWithFallback(
7537
7798
  itemBotApi,
7538
- { chat_id: this.opts.chatId, ...threadField },
7799
+ {
7800
+ chat_id: this.opts.chatId,
7801
+ ...threadField,
7802
+ ...(disableNotification === true ? { disable_notification: true } : {}),
7803
+ },
7539
7804
  send,
7540
7805
  AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
7541
7806
  sendHtmlFallback,
@@ -7589,6 +7854,7 @@ export class TelegramNotificationDaemon {
7589
7854
  ...threadField,
7590
7855
  text: chunks[0]!,
7591
7856
  parse_mode: TELEGRAM_PARSE_MODE,
7857
+ ...(disableNotification === true ? { disable_notification: true } : {}),
7592
7858
  })) as { result?: { message_id?: number } };
7593
7859
  firstMessageId = res?.result?.message_id;
7594
7860
  }
@@ -7605,6 +7871,7 @@ export class TelegramNotificationDaemon {
7605
7871
  ...threadField,
7606
7872
  text: chunks[0]!,
7607
7873
  parse_mode: TELEGRAM_PARSE_MODE,
7874
+ ...(disableNotification === true ? { disable_notification: true } : {}),
7608
7875
  })) as { result?: { message_id?: number } };
7609
7876
  firstMessageId = res?.result?.message_id;
7610
7877
  }
@@ -7906,6 +8173,9 @@ export class TelegramNotificationDaemon {
7906
8173
  chat_id: this.opts.chatId,
7907
8174
  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.",
7908
8175
  parse_mode: TELEGRAM_PARSE_MODE,
8176
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
8177
+ ? { disable_notification: true }
8178
+ : {}),
7909
8179
  });
7910
8180
  } catch {
7911
8181
  // Best-effort nudge; never block delivery.
@@ -8083,6 +8353,7 @@ export class TelegramNotificationDaemon {
8083
8353
  text: rendered.text,
8084
8354
  parse_mode: TELEGRAM_PARSE_MODE,
8085
8355
  reply_markup: { inline_keyboard },
8356
+ ...(telegramDisableNotification(this.opts.sound, "ask") === true ? { disable_notification: true } : {}),
8086
8357
  });
8087
8358
  if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
8088
8359
  for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
@@ -8393,6 +8664,9 @@ export class TelegramNotificationDaemon {
8393
8664
  ...(index === 0 ? { reply_parameters: { message_id: pending.messageId } } : {}),
8394
8665
  text,
8395
8666
  parse_mode: TELEGRAM_PARSE_MODE,
8667
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
8668
+ ? { disable_notification: true }
8669
+ : {}),
8396
8670
  },
8397
8671
  });
8398
8672
  if (outcome === "accepted") {
@@ -8411,6 +8685,9 @@ export class TelegramNotificationDaemon {
8411
8685
  message_thread_id: Number(pending.threadId),
8412
8686
  reply_parameters: { message_id: pending.messageId },
8413
8687
  rich_message: { markdown, skip_entity_detection: true },
8688
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
8689
+ ? { disable_notification: true }
8690
+ : {}),
8414
8691
  });
8415
8692
  if (outcome === "accepted") {
8416
8693
  deliveryOutcome = "accepted";
@@ -8560,7 +8837,8 @@ export class TelegramNotificationDaemon {
8560
8837
  toolActivity?.phase === "terminal" && !this.toolActivitySummariesAreCurrent(toolActivity)
8561
8838
  ? summaryFreeToolFrame
8562
8839
  : threadedFrame;
8563
- const send = this.renderThreadedFrame(renderedFrame);
8840
+ const preparedFrame = await prepareTelegramImageAttachment(renderedFrame);
8841
+ const send = this.renderThreadedFrame(preparedFrame);
8564
8842
  if (toolActivity?.phase === "terminal") {
8565
8843
  const summaryFreeSend = this.renderThreadedFrame(summaryFreeToolFrame);
8566
8844
  if (summaryFreeSend) toolActivity.summaryFreeSend = summaryFreeSend;
@@ -8709,18 +8987,32 @@ export class TelegramNotificationDaemon {
8709
8987
  (control as { enabled?: unknown }).enabled === true,
8710
8988
  )
8711
8989
  : [];
8990
+ const options: string[] = Array.isArray(msg.options)
8991
+ ? msg.options.filter((option: unknown): option is string => typeof option === "string")
8992
+ : [];
8993
+ const multiSelect = Array.isArray(msg.selectedOptionIndices);
8994
+ const selectedOptionIndices = new Set(
8995
+ multiSelect
8996
+ ? msg.selectedOptionIndices.filter(
8997
+ (index: unknown): index is number =>
8998
+ Number.isInteger(index) && Number(index) >= 0 && Number(index) < options.length,
8999
+ )
9000
+ : [],
9001
+ );
9002
+ const displayOptions = options.map((option, index) =>
9003
+ multiSelect ? `${selectedOptionIndices.has(index) ? "☑" : "☐"} ${option}` : option,
9004
+ );
8712
9005
  const rendered = buildActionMessage({
8713
9006
  kind: msg.kind ?? "ask",
8714
9007
  id: msg.id,
8715
9008
  question: msg.question,
8716
- options: msg.options,
9009
+ options: displayOptions,
8717
9010
  recommendedIndex: msg.recommendedIndex,
8718
9011
  controls,
8719
9012
  summary: msg.summary,
8720
9013
  });
8721
- const options = Array.isArray(msg.options) ? msg.options : [];
8722
9014
  const inline_keyboard = [
8723
- ...buildCompactChoiceGrid(options, (i: number) =>
9015
+ ...buildCompactChoiceGrid(displayOptions, (i: number) =>
8724
9016
  this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
8725
9017
  ),
8726
9018
  ...controls.map(control => [
@@ -8734,6 +9026,7 @@ export class TelegramNotificationDaemon {
8734
9026
  },
8735
9027
  ]),
8736
9028
  ];
9029
+ const kind = msg.kind === "idle" ? "idle" : "ask";
8737
9030
  // HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
8738
9031
  // returns the last chunk's message_id (the reply-routable message).
8739
9032
  const sendHtmlChunks = async (): Promise<number | undefined> => {
@@ -8747,13 +9040,15 @@ export class TelegramNotificationDaemon {
8747
9040
  ...threadField,
8748
9041
  text: chunks[i]!,
8749
9042
  parse_mode: TELEGRAM_PARSE_MODE,
9043
+ ...(telegramDisableNotification(this.opts.sound, kind, i === chunks.length - 1) === true
9044
+ ? { disable_notification: true }
9045
+ : {}),
8750
9046
  ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
8751
9047
  })) as { result?: { message_id?: number } } | undefined;
8752
9048
  if (result === undefined) return undefined;
8753
9049
  }
8754
9050
  return result?.result?.message_id;
8755
9051
  };
8756
- const kind = msg.kind === "idle" ? "idle" : "ask";
8757
9052
  if (this.opts.rich?.enabled !== false) {
8758
9053
  // Rich (default on): promote to sendRichMessage with a top-level
8759
9054
  // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
@@ -8761,12 +9056,18 @@ export class TelegramNotificationDaemon {
8761
9056
  if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease))) return;
8762
9057
  const outcome = await deliverRichActionWithFallback(
8763
9058
  this.botApi,
8764
- { chat_id: this.opts.chatId, ...threadField },
9059
+ {
9060
+ chat_id: this.opts.chatId,
9061
+ ...threadField,
9062
+ ...(telegramDisableNotification(this.opts.sound, kind) === true
9063
+ ? { disable_notification: true }
9064
+ : {}),
9065
+ },
8765
9066
  {
8766
9067
  markdown: buildActionMarkdown({
8767
9068
  kind,
8768
9069
  question: msg.question,
8769
- options: msg.options,
9070
+ options: displayOptions,
8770
9071
  recommendedIndex: msg.recommendedIndex,
8771
9072
  summary: msg.summary,
8772
9073
  }),
@@ -8780,10 +9081,9 @@ export class TelegramNotificationDaemon {
8780
9081
  if (kind === "ask" && outcome.messageId !== undefined)
8781
9082
  this.messageRoutes.set(String(outcome.messageId), { sessionId: logicalSessionId, actionId: msg.id });
8782
9083
  } else {
8783
- // Off: byte-identical to the pre-rich HTML path.
9084
+ // Rich disabled: deliver through the HTML chunk path.
8784
9085
  const messageId = await sendHtmlChunks();
8785
- // Only asks are reply-routable; idle pings register no route (parity
8786
- // with the rich branch and correct even in the byte-identical off path).
9086
+ // Only asks are reply-routable; idle pings register no route.
8787
9087
  if (kind === "ask" && messageId !== undefined)
8788
9088
  this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
8789
9089
  }
@@ -8889,6 +9189,9 @@ export class TelegramNotificationDaemon {
8889
9189
  chat_id: this.opts.chatId,
8890
9190
  text,
8891
9191
  parse_mode: TELEGRAM_PARSE_MODE,
9192
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
9193
+ ? { disable_notification: true }
9194
+ : {}),
8892
9195
  });
8893
9196
  } catch {
8894
9197
  // Best-effort stale guidance must not reject a callback.
@@ -8902,7 +9205,570 @@ export class TelegramNotificationDaemon {
8902
9205
  chat_id: this.opts.chatId,
8903
9206
  text: "This button is stale after notification daemon restart. Please answer locally in the GJC session or wait for a fresh notification.",
8904
9207
  parse_mode: TELEGRAM_PARSE_MODE,
9208
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true ? { disable_notification: true } : {}),
9209
+ });
9210
+ }
9211
+ /**
9212
+ * Consume a user-created forum topic (`forum_topic_created` service message)
9213
+ * before edited/text routing. Authenticates the chat, user, privacy, and
9214
+ * lifecycle state; offers home, recent folders, or explicit path entry. The
9215
+ * selected path (or an exact `/session_create path <dir>`) creates a session
9216
+ * that adopts this topic — never a duplicate topic. Non-routable durable
9217
+ * intents prevent duplicate creation across concurrent first frames.
9218
+ */
9219
+ private async handleForumTopicCreatedUpdate(update: unknown): Promise<"not-topic" | TelegramUpdateOutcome> {
9220
+ const parsed = update as {
9221
+ update_id?: unknown;
9222
+ message?: {
9223
+ chat?: { id?: unknown };
9224
+ from?: { id?: unknown; is_bot?: unknown };
9225
+ message_thread_id?: unknown;
9226
+ forum_topic_created?: { name?: unknown };
9227
+ };
9228
+ };
9229
+ const message = parsed.message;
9230
+ if (!message?.forum_topic_created) return "not-topic";
9231
+ const updateId = parsed.update_id;
9232
+ if (typeof updateId !== "number" || !Number.isSafeInteger(updateId) || updateId < 0) return "consumed";
9233
+ if (this.dispatchState.seenUpdateIds.has(updateId)) return "consumed";
9234
+ const configuredUserId = Number(this.opts.chatId);
9235
+ if (
9236
+ !Number.isSafeInteger(configuredUserId) ||
9237
+ typeof message.chat?.id !== "number" ||
9238
+ message.chat.id !== configuredUserId ||
9239
+ message.from?.id !== configuredUserId ||
9240
+ message.from?.is_bot !== false
9241
+ )
9242
+ return "consumed";
9243
+ const privacy = await this.resolvePairedChatPrivacy();
9244
+ if (privacy === "indeterminate") return "retry";
9245
+ if (privacy !== "private") return "consumed";
9246
+ const threadId = message.message_thread_id;
9247
+ if (typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0) return "consumed";
9248
+ if (!this.lifecycleControlActive) return "consumed";
9249
+ // A non-expired intent for this topic means a session is already starting;
9250
+ // do not mint a new intendedSessionId or render a second picker.
9251
+ if (this.#adoptionIntents.hasNonExpiredTopic(threadId)) {
9252
+ try {
9253
+ await this.botApi.call("sendMessage", {
9254
+ chat_id: this.opts.chatId,
9255
+ message_thread_id: threadId,
9256
+ text: "A session is already starting in this topic.",
9257
+ });
9258
+ } catch {
9259
+ // Best-effort notice; never block on the side message.
9260
+ }
9261
+ await this.rememberSeenUpdateId(updateId);
9262
+ return "consumed";
9263
+ }
9264
+ // The topic may already be bound to an active session (late duplicate of a
9265
+ // created update whose adoption already committed). Do not re-offer.
9266
+ if (this.topics.sessionForTopic(String(threadId))) {
9267
+ await this.rememberSeenUpdateId(updateId);
9268
+ return "consumed";
9269
+ }
9270
+ let pendingTopic = this.#adoptionIntents.pendingTopic(threadId);
9271
+ if (!pendingTopic) {
9272
+ const now = this.runtime.now();
9273
+ pendingTopic = {
9274
+ topicId: threadId,
9275
+ chatId: String(this.opts.chatId),
9276
+ createdAt: now,
9277
+ expiresAt: now + this.#adoptionIntentTtlMs,
9278
+ };
9279
+ try {
9280
+ await this.#adoptionIntents.putPendingTopic(pendingTopic);
9281
+ } catch (error) {
9282
+ logger.warn(
9283
+ `notifications: failed to persist pending user topic: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
9284
+ );
9285
+ return "retry";
9286
+ }
9287
+ }
9288
+ if (!(await this.#renderAdoptionPicker(threadId))) return "retry";
9289
+ await this.rememberSeenUpdateId(updateId);
9290
+ return "consumed";
9291
+ }
9292
+
9293
+ /** Render the top-level folder source choices for a user-created topic. */
9294
+ async #renderAdoptionPicker(threadId: number): Promise<boolean> {
9295
+ const intendedSessionId = `s${crypto.randomBytes(6).toString("hex")}`;
9296
+ const chatId = String(this.opts.chatId);
9297
+ const choices: Array<{ label: string; action: TopicPickerAction }> = [
9298
+ { label: "1. Home (~)", action: { kind: "select", target: { kind: "existing_path", path: os.homedir() } } },
9299
+ { label: "2. Recent work folders", action: { kind: "recent" } },
9300
+ { label: "3. Enter a path", action: { kind: "direct" } },
9301
+ ];
9302
+ const aliases = choices.map(choice => ({
9303
+ ...choice,
9304
+ alias: this.#putAdoptionPickerAlias({
9305
+ topicId: threadId,
9306
+ intendedSessionId,
9307
+ chatId,
9308
+ action: choice.action,
9309
+ }),
9310
+ }));
9311
+ try {
9312
+ const response = await this.botApi.call("sendMessage", {
9313
+ chat_id: this.opts.chatId,
9314
+ message_thread_id: threadId,
9315
+ text: "Choose where to start the GJC session:",
9316
+ reply_markup: {
9317
+ inline_keyboard: aliases.map(choice => [{ text: choice.label, callback_data: choice.alias }]),
9318
+ },
9319
+ });
9320
+ if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
9321
+ for (const choice of aliases) this.#adoptionPickerAliases.delete(choice.alias);
9322
+ return false;
9323
+ }
9324
+ return true;
9325
+ } catch {
9326
+ for (const choice of aliases) this.#adoptionPickerAliases.delete(choice.alias);
9327
+ logger.warn("notifications: failed to send topic-adoption picker keyboard");
9328
+ return false;
9329
+ }
9330
+ }
9331
+
9332
+ /** Render verified recent work folders as one-shot picker buttons. */
9333
+ async #renderRecentFolderChoices(threadId: number, intendedSessionId: string): Promise<boolean> {
9334
+ let recent: ListRecentSessionsResult;
9335
+ try {
9336
+ recent = await listRecentSessions({
9337
+ cwd: process.cwd(),
9338
+ agentDir: this.opts.settings.getAgentDir(),
9339
+ limit: 8,
9340
+ includeInternal: false,
9341
+ allWorkspaces: true,
9342
+ });
9343
+ } catch (error) {
9344
+ logger.warn(
9345
+ `notifications: failed to list recent folders for topic adoption: ${sanitizeDiagnostic(String(error))}`,
9346
+ );
9347
+ return false;
9348
+ }
9349
+ const entries = recent.kind === "complete" ? recent.entries.filter(entry => entry.path) : [];
9350
+ const chatId = String(this.opts.chatId);
9351
+ if (entries.length === 0) {
9352
+ const text =
9353
+ recent.kind === "error"
9354
+ ? `Recent work folders could not be verified: ${recent.message}. Choose “Enter a path” from a new topic or use /session_create path <dir>.`
9355
+ : "No recent verified work folders were found. Choose “Enter a path” from a new topic or use /session_create path <dir>.";
9356
+ try {
9357
+ const response = await this.botApi.call("sendMessage", {
9358
+ chat_id: this.opts.chatId,
9359
+ message_thread_id: threadId,
9360
+ text,
9361
+ });
9362
+ return Boolean(response && typeof response === "object" && (response as { ok?: unknown }).ok === true);
9363
+ } catch {
9364
+ return false;
9365
+ }
9366
+ }
9367
+ const aliases = entries.map(entry => {
9368
+ const target = { kind: "existing_path" as const, path: entry.path! };
9369
+ const alias = this.#putAdoptionPickerAlias({
9370
+ topicId: threadId,
9371
+ intendedSessionId,
9372
+ chatId,
9373
+ action: { kind: "select", target },
9374
+ });
9375
+ return { alias, label: this.#pickerLabel(entry) };
8905
9376
  });
9377
+ const inline_keyboard = buildButtonGrid(
9378
+ aliases.map(a => a.label),
9379
+ index => aliases[index]!.alias,
9380
+ );
9381
+ try {
9382
+ const response = await this.botApi.call("sendMessage", {
9383
+ chat_id: this.opts.chatId,
9384
+ message_thread_id: threadId,
9385
+ text: "Pick a recent work folder to start a session here:",
9386
+ reply_markup: { inline_keyboard },
9387
+ });
9388
+ if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
9389
+ for (const a of aliases) this.#adoptionPickerAliases.delete(a.alias);
9390
+ return false;
9391
+ }
9392
+ return true;
9393
+ } catch {
9394
+ for (const a of aliases) this.#adoptionPickerAliases.delete(a.alias);
9395
+ logger.warn("notifications: failed to send recent-folder picker keyboard");
9396
+ return false;
9397
+ }
9398
+ }
9399
+
9400
+ #pickerLabel(entry: { sessionId: string; path?: string; branch?: string; title?: string }): string {
9401
+ const base = path.basename(entry.path ?? entry.sessionId);
9402
+ const raw = entry.branch ? `${base} (${entry.branch})` : base;
9403
+ return safeModelButtonLabel(raw) ?? "workspace";
9404
+ }
9405
+
9406
+ #sweepExpiredAdoptionPickerAliases(): void {
9407
+ const now = this.runtime.now();
9408
+ for (const [alias, route] of this.#adoptionPickerAliases) {
9409
+ if (route.expiresAt <= now) this.#adoptionPickerAliases.delete(alias);
9410
+ }
9411
+ for (const [topicId, pending] of this.#pendingAdoptionPathInputs) {
9412
+ if (pending.expiresAt <= now) this.#pendingAdoptionPathInputs.delete(topicId);
9413
+ }
9414
+ }
9415
+
9416
+ #putAdoptionPickerAlias(route: Omit<TopicPickerRoute, "expiresAt">): string {
9417
+ this.#sweepExpiredAdoptionPickerAliases();
9418
+ let alias: string;
9419
+ do {
9420
+ alias = `${PICKER_CALLBACK_PREFIX}${crypto.randomBytes(16).toString("base64url")}`;
9421
+ } while (this.#adoptionPickerAliases.has(alias));
9422
+ this.#adoptionPickerAliases.set(alias, { ...route, expiresAt: this.runtime.now() + this.#adoptionIntentTtlMs });
9423
+ return alias;
9424
+ }
9425
+ #deleteAdoptionPickerAliasesForTopic(topicId: number): void {
9426
+ for (const [alias, route] of this.#adoptionPickerAliases) {
9427
+ if (route.topicId === topicId) this.#adoptionPickerAliases.delete(alias);
9428
+ }
9429
+ }
9430
+
9431
+ /** Handle a one-shot picker button tap. Re-validates chat/topic/expiry/target before submit. */
9432
+ async #handleAdoptionPickerCallback(update: unknown, callbackId: unknown): Promise<boolean> {
9433
+ const parsed = update as {
9434
+ update_id?: unknown;
9435
+ callback_query?: {
9436
+ data?: unknown;
9437
+ from?: { id?: unknown; is_bot?: unknown };
9438
+ message?: { chat?: { id?: unknown }; message_id?: unknown; message_thread_id?: unknown };
9439
+ };
9440
+ };
9441
+ const callback = parsed.callback_query;
9442
+ const alias = callback?.data;
9443
+ if (typeof alias !== "string" || !alias.startsWith(PICKER_CALLBACK_PREFIX)) return false;
9444
+ this.#sweepExpiredAdoptionPickerAliases();
9445
+ const route = this.#adoptionPickerAliases.get(alias);
9446
+ if (!route) {
9447
+ await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
9448
+ return true;
9449
+ }
9450
+ const configuredUserId = Number(this.opts.chatId);
9451
+ const updateId = parsed.update_id;
9452
+ if (
9453
+ !Number.isSafeInteger(configuredUserId) ||
9454
+ String(callback?.message?.chat?.id) !== route.chatId ||
9455
+ route.chatId !== String(this.opts.chatId) ||
9456
+ callback?.from?.id !== configuredUserId ||
9457
+ callback?.from?.is_bot !== false ||
9458
+ callback?.message?.message_thread_id !== route.topicId ||
9459
+ typeof updateId !== "number" ||
9460
+ !Number.isSafeInteger(updateId) ||
9461
+ updateId < 0 ||
9462
+ !this.lifecycleControlActive
9463
+ ) {
9464
+ await this.answerCallbackQueryBestEffort(callbackId, "Not authorized");
9465
+ return true;
9466
+ }
9467
+ if (route.expiresAt <= this.runtime.now()) {
9468
+ this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
9469
+ await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
9470
+ return true;
9471
+ }
9472
+ if (!this.#adoptionIntents.hasPendingTopic(route.topicId, route.chatId)) {
9473
+ this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
9474
+ await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
9475
+ return true;
9476
+ }
9477
+ if (
9478
+ this.dispatchState.seenUpdateIds.has(updateId) ||
9479
+ this.#adoptionIntents.hasNonExpiredTopic(route.topicId) ||
9480
+ this.#adoptionStartingTopics.has(route.topicId)
9481
+ ) {
9482
+ this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
9483
+ await this.answerCallbackQueryBestEffort(callbackId, "A session is already starting in this topic.");
9484
+ return true;
9485
+ }
9486
+ if (route.action.kind === "select") {
9487
+ const valid = validateLifecycleTarget("session_create", route.action.target);
9488
+ if (!valid.ok) {
9489
+ this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
9490
+ await this.answerCallbackQueryBestEffort(callbackId, "That folder is no longer valid.");
9491
+ return true;
9492
+ }
9493
+ }
9494
+ // Consume the whole picker synchronously before the first successful-path await.
9495
+ this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
9496
+ if (!(await this.pairedChatIsPrivate())) {
9497
+ await this.answerCallbackQueryBestEffort(callbackId, "Not authorized");
9498
+ return true;
9499
+ }
9500
+ await this.rememberSeenUpdateId(updateId);
9501
+ await this.answerCallbackQueryBestEffort(callbackId);
9502
+ if (route.action.kind === "recent") {
9503
+ if (!(await this.#renderRecentFolderChoices(route.topicId, route.intendedSessionId))) {
9504
+ await this.botApi
9505
+ .call("sendMessage", {
9506
+ chat_id: this.opts.chatId,
9507
+ message_thread_id: route.topicId,
9508
+ text: "Recent work folders could not be loaded. Open a new topic and try again, or use /session_create path <dir>.",
9509
+ })
9510
+ .catch(() => undefined);
9511
+ }
9512
+ return true;
9513
+ }
9514
+ if (route.action.kind === "direct") {
9515
+ this.#pendingAdoptionPathInputs.set(route.topicId, {
9516
+ chatId: route.chatId,
9517
+ intendedSessionId: route.intendedSessionId,
9518
+ expiresAt: route.expiresAt,
9519
+ });
9520
+ await this.botApi
9521
+ .call("sendMessage", {
9522
+ chat_id: this.opts.chatId,
9523
+ message_thread_id: route.topicId,
9524
+ text: "Send the folder path in your next message. Examples: ~ or ~/projects/my-app",
9525
+ })
9526
+ .catch(() => undefined);
9527
+ return true;
9528
+ }
9529
+ await this.#submitAdoptionFrame(
9530
+ route.topicId,
9531
+ route.action.target,
9532
+ route.chatId,
9533
+ updateId,
9534
+ route.intendedSessionId,
9535
+ );
9536
+ return true;
9537
+ }
9538
+
9539
+ /** Handle the next text message after the user explicitly chooses direct path entry. */
9540
+ async #handleAdoptionPathInput(update: unknown): Promise<boolean> {
9541
+ const parsed = update as { update_id?: unknown; message?: Record<string, unknown> };
9542
+ const message = parsed.message;
9543
+ const threadId = message?.message_thread_id;
9544
+ if (typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0) return false;
9545
+ this.#sweepExpiredAdoptionPickerAliases();
9546
+ const pending = this.#pendingAdoptionPathInputs.get(threadId);
9547
+ if (!pending) return false;
9548
+
9549
+ const text = typeof message?.text === "string" ? message.text.trim() : undefined;
9550
+ const chat = message?.chat as { id?: unknown; type?: unknown } | undefined;
9551
+ const from = message?.from as { id?: unknown; is_bot?: unknown } | undefined;
9552
+ const updateId = parsed.update_id;
9553
+ const commandCtx = {
9554
+ chatType: typeof chat?.type === "string" ? chat.type : undefined,
9555
+ botUsername: this.botUsername,
9556
+ };
9557
+ if (isLifecycleCommandText(text, commandCtx)) return false;
9558
+ const configuredUserId = Number(this.opts.chatId);
9559
+ if (
9560
+ !Number.isSafeInteger(configuredUserId) ||
9561
+ String(chat?.id) !== pending.chatId ||
9562
+ pending.chatId !== String(this.opts.chatId) ||
9563
+ from?.id !== configuredUserId ||
9564
+ from?.is_bot !== false ||
9565
+ typeof updateId !== "number" ||
9566
+ !Number.isSafeInteger(updateId) ||
9567
+ updateId < 0
9568
+ )
9569
+ return true;
9570
+ if (!this.lifecycleControlActive || this.dispatchState.seenUpdateIds.has(updateId)) return true;
9571
+ if (!(await this.pairedChatIsPrivate())) return true;
9572
+ if (
9573
+ !this.#adoptionIntents.hasPendingTopic(threadId, pending.chatId) ||
9574
+ this.#adoptionIntents.hasNonExpiredTopic(threadId) ||
9575
+ this.#adoptionStartingTopics.has(threadId)
9576
+ ) {
9577
+ this.#pendingAdoptionPathInputs.delete(threadId);
9578
+ return true;
9579
+ }
9580
+
9581
+ const normalized = text ? normalizeLifecyclePath(text) : undefined;
9582
+ const target = normalized ? ({ kind: "existing_path", path: normalized } as const) : undefined;
9583
+ if (!target || !validateLifecycleTarget("session_create", target).ok) {
9584
+ await this.rememberSeenUpdateId(updateId);
9585
+ await this.botApi
9586
+ .call("sendMessage", {
9587
+ chat_id: this.opts.chatId,
9588
+ message_thread_id: threadId,
9589
+ text: "That path is invalid. Send a folder path such as ~ or ~/projects/my-app.",
9590
+ })
9591
+ .catch(() => undefined);
9592
+ return true;
9593
+ }
9594
+
9595
+ this.#pendingAdoptionPathInputs.delete(threadId);
9596
+ await this.rememberSeenUpdateId(updateId);
9597
+ await this.#submitAdoptionFrame(threadId, target, pending.chatId, updateId, pending.intendedSessionId);
9598
+ return true;
9599
+ }
9600
+ /**
9601
+ * Handle an exact `/session_create path <dir>` typed inside a pending
9602
+ * (user-created, not-yet-adopted) topic without requiring the direct-entry choice.
9603
+ */
9604
+ async #handleAdoptionDirectPath(update: unknown): Promise<boolean> {
9605
+ const parsedUpdate = update as { update_id?: unknown; message?: Record<string, unknown> };
9606
+ const message = parsedUpdate.message;
9607
+ const text = typeof message?.text === "string" ? message.text : undefined;
9608
+ const chat = message?.chat as { id?: unknown; type?: unknown } | undefined;
9609
+ const from = message?.from as { id?: unknown; is_bot?: unknown } | undefined;
9610
+ const commandCtx = {
9611
+ chatType: typeof chat?.type === "string" ? chat.type : undefined,
9612
+ botUsername: this.botUsername,
9613
+ };
9614
+ if (!isLifecycleCommandText(text, commandCtx)) return false;
9615
+ const parsed = parseLifecycleCommand(text, commandCtx);
9616
+ if (parsed.kind !== "create") return false;
9617
+ const configuredUserId = Number(this.opts.chatId);
9618
+ const threadId = message?.message_thread_id;
9619
+ const updateId = parsedUpdate.update_id;
9620
+ if (typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0) return false;
9621
+ if (
9622
+ !Number.isSafeInteger(configuredUserId) ||
9623
+ String(chat?.id) !== String(this.opts.chatId) ||
9624
+ from?.id !== configuredUserId ||
9625
+ from?.is_bot !== false ||
9626
+ typeof updateId !== "number" ||
9627
+ !Number.isSafeInteger(updateId) ||
9628
+ updateId < 0
9629
+ )
9630
+ return true;
9631
+ if (!this.lifecycleControlActive) return false;
9632
+ if (this.dispatchState.seenUpdateIds.has(updateId)) return true;
9633
+ if (!(await this.pairedChatIsPrivate())) return true;
9634
+ if (this.topics.sessionForTopic(String(threadId))) return false;
9635
+ if (!this.#adoptionIntents.hasPendingTopic(threadId, String(this.opts.chatId))) {
9636
+ await this.rememberSeenUpdateId(updateId);
9637
+ await this.botApi
9638
+ .call("sendMessage", {
9639
+ chat_id: this.opts.chatId,
9640
+ message_thread_id: threadId,
9641
+ text: "This topic was not opened for a new GJC session, or its folder picker expired. Open a new topic and try again.",
9642
+ })
9643
+ .catch(error => {
9644
+ logger.warn(
9645
+ `notifications: failed to report stale topic adoption: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
9646
+ );
9647
+ });
9648
+ return true;
9649
+ }
9650
+ if (parsed.target.kind !== "existing_path") {
9651
+ await this.rememberSeenUpdateId(updateId);
9652
+ await this.botApi
9653
+ .call("sendMessage", {
9654
+ chat_id: this.opts.chatId,
9655
+ message_thread_id: threadId,
9656
+ text: "This topic can start a session only with /session_create path <dir>.",
9657
+ })
9658
+ .catch(error => {
9659
+ logger.warn(
9660
+ `notifications: failed to report unsupported topic-adoption target: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
9661
+ );
9662
+ });
9663
+ return true;
9664
+ }
9665
+ const valid = validateLifecycleTarget("session_create", parsed.target);
9666
+ if (!valid.ok) return false;
9667
+ await this.rememberSeenUpdateId(updateId);
9668
+ await this.#submitAdoptionFrame(threadId, parsed.target, String(this.opts.chatId), updateId);
9669
+ return true;
9670
+ }
9671
+
9672
+ /**
9673
+ * Build an authenticated lifecycle frame, durably persist the adoption intent
9674
+ * BEFORE submit, then submit. Definite no-spawn failures remove the intent;
9675
+ * readiness_timeout / terminal_uncertain retain it because a late spawn may
9676
+ * still adopt the original topic. Never deletes the user topic.
9677
+ */
9678
+ async #submitAdoptionFrame(
9679
+ topicId: number,
9680
+ target: TelegramAdoptionTarget,
9681
+ chatId: string,
9682
+ updateId: number,
9683
+ preallocatedSessionId?: string,
9684
+ ): Promise<void> {
9685
+ if (this.#adoptionStartingTopics.has(topicId) || this.#adoptionIntents.hasNonExpiredTopic(topicId)) {
9686
+ await this.botApi
9687
+ .call("sendMessage", {
9688
+ chat_id: this.opts.chatId,
9689
+ message_thread_id: topicId,
9690
+ text: "A session is already starting in this topic.",
9691
+ })
9692
+ .catch(() => undefined);
9693
+ return;
9694
+ }
9695
+ this.#adoptionStartingTopics.add(topicId);
9696
+ try {
9697
+ const frame = this.buildLifecycleFrame({ kind: "create", target }, updateId, preallocatedSessionId);
9698
+ if (frame.type !== "session_create") throw new Error("topic adoption requires a create frame");
9699
+ const intendedSessionId = frame.intendedSessionId;
9700
+ const intent = buildAdoptionIntent({
9701
+ intendedSessionId,
9702
+ topicId,
9703
+ chatId,
9704
+ target,
9705
+ now: this.runtime.now(),
9706
+ ttlMs: this.#adoptionIntentTtlMs,
9707
+ });
9708
+ try {
9709
+ await this.#adoptionIntents.put(intent);
9710
+ } catch (error) {
9711
+ logger.warn(`notifications: failed to persist topic-adoption intent: ${sanitizeDiagnostic(String(error))}`);
9712
+ try {
9713
+ await this.botApi.call("sendMessage", {
9714
+ chat_id: this.opts.chatId,
9715
+ message_thread_id: topicId,
9716
+ text: "Could not start a session because its durable topic binding could not be saved.",
9717
+ });
9718
+ } catch (noticeError) {
9719
+ logger.warn(
9720
+ `notifications: failed to report topic-adoption persistence failure: ${sanitizeDiagnostic(String(noticeError))}`,
9721
+ );
9722
+ }
9723
+ return;
9724
+ }
9725
+ const normalizedPath = target.kind === "existing_path" ? normalizeLifecyclePath(target.path) : undefined;
9726
+ let targetExists = false;
9727
+ if (normalizedPath) {
9728
+ try {
9729
+ const stat = await this.fsImpl.stat?.(normalizedPath);
9730
+ targetExists = stat?.isDirectory?.() === true;
9731
+ } catch {
9732
+ targetExists = false;
9733
+ }
9734
+ }
9735
+ if (!targetExists || !validateLifecycleTarget("session_create", target).ok) {
9736
+ await this.#adoptionIntents.remove(intendedSessionId);
9737
+ await this.botApi
9738
+ .call("sendMessage", {
9739
+ chat_id: this.opts.chatId,
9740
+ message_thread_id: topicId,
9741
+ text: "That folder is no longer available.",
9742
+ })
9743
+ .catch(() => undefined);
9744
+ return;
9745
+ }
9746
+ const response = await this.submitLifecycleFrame(frame);
9747
+ await this.botApi
9748
+ .call("sendMessage", {
9749
+ chat_id: this.opts.chatId,
9750
+ message_thread_id: topicId,
9751
+ text: this.formatLifecycleResponse(response, "session_create"),
9752
+ })
9753
+ .catch(error => {
9754
+ logger.warn(
9755
+ `notifications: failed to report topic-adoption lifecycle result: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
9756
+ );
9757
+ });
9758
+ if (response.status === "ok") return;
9759
+ if (response.type === "session_lifecycle_error" && response.reason === "spawn_failed") {
9760
+ try {
9761
+ await this.#adoptionIntents.remove(intendedSessionId);
9762
+ } catch (error) {
9763
+ logger.warn(
9764
+ `notifications: failed to remove rejected topic-adoption intent: ${sanitizeDiagnostic(String(error))}`,
9765
+ );
9766
+ }
9767
+ }
9768
+ // readiness_timeout / terminal_uncertain: spawn may still be in flight; retain intent.
9769
+ } finally {
9770
+ this.#adoptionStartingTopics.delete(topicId);
9771
+ }
8906
9772
  }
8907
9773
 
8908
9774
  /** Consume Telegram forum-topic rename service messages before text routing. */
@@ -8967,6 +9833,8 @@ export class TelegramNotificationDaemon {
8967
9833
  }
8968
9834
 
8969
9835
  private async processTelegramUpdate(update: unknown): Promise<TelegramUpdateOutcome> {
9836
+ const createdOutcome = await this.handleForumTopicCreatedUpdate(update);
9837
+ if (createdOutcome !== "not-topic") return createdOutcome;
8970
9838
  const topicOutcome = await this.handleForumTopicEdited(update);
8971
9839
  if (topicOutcome !== "not-topic") return topicOutcome;
8972
9840
  try {
@@ -8978,7 +9846,19 @@ export class TelegramNotificationDaemon {
8978
9846
  }
8979
9847
 
8980
9848
  async handleTelegramUpdate(update: unknown): Promise<void> {
9849
+ // A user-created forum topic (`forum_topic_created`) is consumed first: it
9850
+ // authenticates the chat/user, shows the folder-source picker, and emits the
9851
+ // "already starting" dedup notice for a topic with a live adoption intent.
9852
+ // This mirrors the poll-loop ordering in processTelegramUpdate so direct
9853
+ // (test/orchestration) callers observe identical created-topic handling.
9854
+ if ((await this.handleForumTopicCreatedUpdate(update)) !== "not-topic") return;
8981
9855
  if ((await this.handleForumTopicEdited(update)) !== "not-topic") return;
9856
+ // A raw path is accepted only after the explicit direct-entry choice. The exact
9857
+ // `/session_create path <dir>` form remains available in any pending topic.
9858
+ // Both must precede the general lifecycle handler, which would otherwise create
9859
+ // a separate daemon-owned topic.
9860
+ if (await this.#handleAdoptionPathInput(update)) return;
9861
+ if (await this.#handleAdoptionDirectPath(update)) return;
8982
9862
  // Session-lifecycle command (/session_*): handled ONLY from the paired chat,
8983
9863
  // gated before any arg parsing or side effect, and routed through the control
8984
9864
  // endpoint. Must run before threaded-injection so commands are not treated as
@@ -9038,6 +9918,9 @@ export class TelegramNotificationDaemon {
9038
9918
  ...threadField,
9039
9919
  text: body,
9040
9920
  parse_mode: TELEGRAM_PARSE_MODE,
9921
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
9922
+ ? { disable_notification: true }
9923
+ : {}),
9041
9924
  });
9042
9925
  } catch {
9043
9926
  // Best-effort confirmation; never block on the notice.
@@ -9234,6 +10117,9 @@ export class TelegramNotificationDaemon {
9234
10117
  chat_id: this.opts.chatId,
9235
10118
  message_thread_id: Number(inbound.threadId),
9236
10119
  text: "Session control unavailable: this local GJC session is disconnected.",
10120
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
10121
+ ? { disable_notification: true }
10122
+ : {}),
9237
10123
  });
9238
10124
  } catch {
9239
10125
  logger.warn("notifications: unavailable-control notice delivery failed");
@@ -9408,6 +10294,9 @@ export class TelegramNotificationDaemon {
9408
10294
  message_thread_id: Number(inbound.threadId),
9409
10295
  text: body,
9410
10296
  parse_mode: TELEGRAM_PARSE_MODE,
10297
+ ...(telegramDisableNotification(this.opts.sound, "finalized") === true
10298
+ ? { disable_notification: true }
10299
+ : {}),
9411
10300
  });
9412
10301
  } catch {
9413
10302
  // Best-effort control feedback; never convert to user input.
@@ -9489,6 +10378,7 @@ export class TelegramNotificationDaemon {
9489
10378
  }
9490
10379
  }
9491
10380
  const callbackId = (update as { callback_query?: { id?: unknown } }).callback_query?.id;
10381
+ if (await this.#handleAdoptionPickerCallback(update, callbackId)) return;
9492
10382
  if (await this.#handleModelChoiceCallback(update, callbackId)) return;
9493
10383
  const decision = routeInboundUpdate(update, {
9494
10384
  aliasTable: this.aliasTable,
@@ -9601,6 +10491,8 @@ export class TelegramNotificationDaemon {
9601
10491
  await this.registerBotCommands();
9602
10492
  await this.loadAliases();
9603
10493
  await this.loadTopics();
10494
+ await this.loadAdoptionIntents();
10495
+ this.startAdoptionSweepTimer();
9604
10496
  await this.loadSeenUpdateIds();
9605
10497
  await this.replyStore.load();
9606
10498
  await this.runScan();
@@ -9676,6 +10568,7 @@ export class TelegramNotificationDaemon {
9676
10568
  this.stopFlushTimer();
9677
10569
  this.stopScanTimer();
9678
10570
  this.stopTypingTimer();
10571
+ this.stopAdoptionSweepTimer();
9679
10572
  this.stopLifecycleControl();
9680
10573
  await this.cleanupAllAttachmentDirs();
9681
10574
  await this.persistAliases();