@sayknow-cli/coding-agent 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/dist/types/append-only-mode.d.ts +31 -0
  2. package/dist/types/capability/rule.d.ts +8 -0
  3. package/dist/types/cli/completion-cli.d.ts +41 -0
  4. package/dist/types/cli/config-cli.d.ts +1 -0
  5. package/dist/types/cli/fixture-report.d.ts +20 -0
  6. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  7. package/dist/types/cli/nofile-limit.d.ts +11 -0
  8. package/dist/types/cli/notify-cli.d.ts +7 -0
  9. package/dist/types/cli/update-cli.d.ts +22 -0
  10. package/dist/types/cli.d.ts +124 -1
  11. package/dist/types/commands/completion.d.ts +29 -0
  12. package/dist/types/commands/config.d.ts +3 -0
  13. package/dist/types/commands/deep-interview.d.ts +3 -0
  14. package/dist/types/config/file-lock.d.ts +7 -6
  15. package/dist/types/config/keybindings.d.ts +2 -2
  16. package/dist/types/config/model-profile-activation.d.ts +6 -0
  17. package/dist/types/config/model-profiles.d.ts +1 -0
  18. package/dist/types/config/settings-schema.d.ts +119 -1
  19. package/dist/types/config/settings.d.ts +8 -0
  20. package/dist/types/discovery/index.d.ts +1 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -3
  23. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  24. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  25. package/dist/types/goals/runtime.d.ts +1 -0
  26. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  27. package/dist/types/main.d.ts +4 -1
  28. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  29. package/dist/types/modes/components/model-selector.d.ts +4 -0
  30. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  31. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  32. package/dist/types/modes/components/status-line.d.ts +9 -0
  33. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  34. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  35. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  36. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  38. package/dist/types/modes/interactive-mode.d.ts +4 -0
  39. package/dist/types/modes/print-mode.d.ts +13 -1
  40. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  41. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  42. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  44. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  45. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  46. package/dist/types/modes/types.d.ts +3 -0
  47. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  48. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  49. package/dist/types/notifications/config-commands.d.ts +9 -0
  50. package/dist/types/notifications/config.d.ts +30 -1
  51. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  52. package/dist/types/notifications/index.d.ts +8 -3
  53. package/dist/types/notifications/lifecycle-commands.d.ts +12 -3
  54. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  55. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  56. package/dist/types/notifications/rich-draft.d.ts +68 -0
  57. package/dist/types/notifications/rich-render.d.ts +90 -0
  58. package/dist/types/notifications/telegram-daemon.d.ts +45 -3
  59. package/dist/types/notifications/telegram-reference.d.ts +7 -5
  60. package/dist/types/notifications/threaded-render.d.ts +14 -0
  61. package/dist/types/runtime-api-key.d.ts +7 -0
  62. package/dist/types/sdk.d.ts +3 -0
  63. package/dist/types/session/agent-session.d.ts +61 -6
  64. package/dist/types/session/agent-storage.d.ts +1 -0
  65. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  66. package/dist/types/session/messages.d.ts +4 -0
  67. package/dist/types/session/session-manager.d.ts +17 -1
  68. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  69. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  70. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  71. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  72. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  73. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  74. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  75. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  76. package/dist/types/skill-state/active-state.d.ts +18 -1
  77. package/dist/types/system-prompt.d.ts +18 -2
  78. package/dist/types/task/executor.d.ts +3 -0
  79. package/dist/types/task/fork-context-budget.d.ts +7 -0
  80. package/dist/types/task/token-log.d.ts +30 -0
  81. package/dist/types/task/token-log.test.d.ts +1 -0
  82. package/dist/types/tools/bisect.d.ts +81 -0
  83. package/dist/types/tools/computer.d.ts +2 -2
  84. package/dist/types/tools/index.d.ts +1 -0
  85. package/dist/types/tools/search.d.ts +2 -2
  86. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  87. package/dist/types/utils/file-mentions.d.ts +14 -0
  88. package/dist/types/utils/git.d.ts +32 -0
  89. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  90. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  91. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  92. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  93. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  94. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  95. package/package.json +8 -8
  96. package/src/append-only-mode.ts +58 -0
  97. package/src/autoresearch/prompt.md +1 -1
  98. package/src/autoresearch/tools/log-experiment.ts +1 -1
  99. package/src/capability/rule.ts +9 -0
  100. package/src/cli/completion-cli.ts +234 -0
  101. package/src/cli/config-cli.ts +61 -9
  102. package/src/cli/fast-help.ts +1 -0
  103. package/src/cli/fixture-report.test.ts +230 -0
  104. package/src/cli/fixture-report.ts +171 -0
  105. package/src/cli/nofile-limit.ts +48 -0
  106. package/src/cli/notify-cli.ts +103 -17
  107. package/src/cli/telegram-cli.ts +1 -1
  108. package/src/cli/update-cli.ts +148 -33
  109. package/src/cli.ts +29 -1
  110. package/src/commands/completion.ts +84 -0
  111. package/src/commands/config.ts +2 -0
  112. package/src/commands/contribution-prep.ts +1 -1
  113. package/src/commands/deep-interview.ts +2 -1
  114. package/src/config/file-lock.ts +73 -34
  115. package/src/config/keybindings.ts +3 -3
  116. package/src/config/model-profile-activation.ts +65 -0
  117. package/src/config/model-profiles.ts +31 -2
  118. package/src/config/settings-schema.ts +76 -1
  119. package/src/config/settings.ts +22 -0
  120. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  121. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  122. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  123. package/src/defaults/skc-defaults.ts +9 -0
  124. package/src/discovery/helpers.ts +10 -0
  125. package/src/discovery/index.ts +1 -0
  126. package/src/export/html/template.generated.ts +1 -1
  127. package/src/export/html/template.js +60 -2
  128. package/src/export/ttsr.ts +96 -18
  129. package/src/extensibility/extensions/runner.ts +183 -219
  130. package/src/extensibility/extensions/types.ts +9 -0
  131. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  132. package/src/goals/runtime.ts +10 -2
  133. package/src/hindsight/backend.ts +1 -3
  134. package/src/hooks/native-skill-hook.ts +124 -4
  135. package/src/internal-urls/docs-index.generated.ts +17 -8
  136. package/src/lsp/client.ts +1 -0
  137. package/src/main.ts +95 -43
  138. package/src/memories/index.ts +12 -1
  139. package/src/modes/components/assistant-message.ts +241 -82
  140. package/src/modes/components/custom-editor.ts +1 -0
  141. package/src/modes/components/footer.ts +7 -2
  142. package/src/modes/components/model-selector.ts +54 -11
  143. package/src/modes/components/queued-message-selector.ts +92 -0
  144. package/src/modes/components/settings-selector.ts +4 -0
  145. package/src/modes/components/status-line.ts +167 -54
  146. package/src/modes/components/thinking-selector.ts +10 -8
  147. package/src/modes/components/tool-execution.ts +44 -19
  148. package/src/modes/components/welcome.ts +181 -44
  149. package/src/modes/controllers/command-controller.ts +40 -4
  150. package/src/modes/controllers/event-controller.ts +77 -49
  151. package/src/modes/controllers/extension-ui-controller.ts +12 -11
  152. package/src/modes/controllers/input-controller.ts +192 -35
  153. package/src/modes/controllers/selector-controller.ts +168 -24
  154. package/src/modes/interactive-mode.ts +56 -16
  155. package/src/modes/print-mode.ts +43 -4
  156. package/src/modes/prompt-action-autocomplete.ts +21 -20
  157. package/src/modes/rpc/rpc-mode.ts +32 -12
  158. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  159. package/src/modes/rpc/rpc-types.ts +9 -0
  160. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  161. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  162. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  163. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  164. package/src/modes/types.ts +3 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  166. package/src/modes/utils/injected-user-submission.ts +94 -0
  167. package/src/modes/utils/ui-helpers.ts +20 -7
  168. package/src/notifications/config-commands.ts +20 -0
  169. package/src/notifications/config.ts +52 -5
  170. package/src/notifications/daemon-paths.ts +2 -0
  171. package/src/notifications/index.ts +144 -18
  172. package/src/notifications/lifecycle-commands.ts +74 -23
  173. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  174. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  175. package/src/notifications/reply-sent-store.ts +134 -0
  176. package/src/notifications/rich-draft.ts +107 -0
  177. package/src/notifications/rich-render.ts +142 -0
  178. package/src/notifications/telegram-daemon-control.ts +5 -3
  179. package/src/notifications/telegram-daemon.ts +606 -98
  180. package/src/notifications/telegram-reference.ts +17 -16
  181. package/src/notifications/threaded-render.ts +45 -2
  182. package/src/prompts/agents/architect.md +19 -10
  183. package/src/prompts/agents/critic.md +16 -18
  184. package/src/prompts/agents/executor.md +14 -11
  185. package/src/prompts/agents/planner.md +18 -13
  186. package/src/prompts/ci-green-request.md +3 -8
  187. package/src/prompts/goals/goal-continuation.md +0 -5
  188. package/src/prompts/goals/goal-mode-active.md +0 -5
  189. package/src/prompts/system/agent-creation-architect.md +2 -2
  190. package/src/prompts/system/plan-mode-active.md +1 -1
  191. package/src/prompts/system/plan-mode-subagent.md +3 -3
  192. package/src/prompts/system/project-prompt.md +0 -13
  193. package/src/prompts/system/subagent-system-prompt.md +3 -2
  194. package/src/prompts/system/system-prompt.md +29 -17
  195. package/src/prompts/system/volatile-project-context.md +10 -0
  196. package/src/prompts/system/web-search.md +4 -7
  197. package/src/prompts/tools/apply-patch.md +6 -0
  198. package/src/prompts/tools/ast-edit.md +1 -0
  199. package/src/prompts/tools/bash.md +1 -1
  200. package/src/prompts/tools/bisect.md +22 -0
  201. package/src/prompts/tools/browser.md +1 -1
  202. package/src/prompts/tools/computer.md +2 -2
  203. package/src/prompts/tools/cron.md +0 -2
  204. package/src/prompts/tools/eval.md +2 -2
  205. package/src/prompts/tools/find.md +1 -1
  206. package/src/prompts/tools/github.md +6 -6
  207. package/src/prompts/tools/goal.md +1 -3
  208. package/src/prompts/tools/image-gen.md +12 -5
  209. package/src/prompts/tools/irc.md +5 -4
  210. package/src/prompts/tools/job.md +2 -2
  211. package/src/prompts/tools/lsp.md +1 -1
  212. package/src/prompts/tools/monitor.md +2 -2
  213. package/src/prompts/tools/patch.md +2 -2
  214. package/src/prompts/tools/replace.md +2 -21
  215. package/src/prompts/tools/rewind.md +1 -1
  216. package/src/prompts/tools/search-tool-bm25.md +6 -11
  217. package/src/prompts/tools/search.md +1 -1
  218. package/src/prompts/tools/skill.md +3 -2
  219. package/src/prompts/tools/ssh.md +2 -1
  220. package/src/prompts/tools/subagent.md +2 -2
  221. package/src/prompts/tools/task.md +4 -5
  222. package/src/prompts/tools/todo-write.md +1 -1
  223. package/src/prompts/tools/vim.md +1 -0
  224. package/src/prompts/tools/web-search.md +2 -0
  225. package/src/prompts/tools/write.md +8 -2
  226. package/src/runtime-api-key.ts +16 -0
  227. package/src/sdk.ts +88 -36
  228. package/src/session/agent-session.ts +1013 -141
  229. package/src/session/agent-storage.ts +4 -0
  230. package/src/session/file-mention-pruning.ts +64 -0
  231. package/src/session/messages.ts +4 -0
  232. package/src/session/session-manager.ts +103 -4
  233. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  234. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  235. package/src/skc-runtime/deep-interview-state.ts +44 -2
  236. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  237. package/src/skc-runtime/state-runtime.ts +12 -2
  238. package/src/skc-runtime/state-writer.ts +15 -0
  239. package/src/skc-runtime/tmux-common.ts +1 -0
  240. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  241. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  242. package/src/skc-runtime/ultragoal-runtime.ts +1672 -159
  243. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  244. package/src/skc-runtime/workflow-manifest.ts +74 -1
  245. package/src/skill-state/active-state.ts +141 -5
  246. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  247. package/src/slash-commands/builtin-registry.ts +296 -72
  248. package/src/system-prompt.ts +57 -16
  249. package/src/task/executor.ts +132 -24
  250. package/src/task/fork-context-advisory.ts +2 -7
  251. package/src/task/fork-context-budget.ts +9 -0
  252. package/src/task/index.ts +13 -6
  253. package/src/task/output-manager.ts +20 -8
  254. package/src/task/receipt.ts +26 -7
  255. package/src/task/token-log.test.ts +188 -0
  256. package/src/task/token-log.ts +179 -0
  257. package/src/tools/bisect.ts +367 -0
  258. package/src/tools/computer.ts +246 -43
  259. package/src/tools/index.ts +3 -0
  260. package/src/tools/output-meta.ts +116 -4
  261. package/src/tools/search.ts +8 -2
  262. package/src/tools/skill.ts +15 -0
  263. package/src/tools/telegram-send.ts +6 -0
  264. package/src/tools/ultragoal-ask-guard.ts +39 -5
  265. package/src/utils/file-mentions.ts +39 -7
  266. package/src/utils/git.ts +73 -0
  267. package/src/utils/pasted-image-path.ts +170 -0
  268. package/src/web/scrapers/youtube.ts +24 -12
  269. package/src/workflow/workflow-intent-diff.ts +29 -6
  270. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  271. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  272. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -15,10 +15,23 @@ import * as os from "node:os";
15
15
  import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
16
16
 
17
17
  export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
18
+ function normalizeLifecycleCommandToken(
19
+ token: string,
20
+ ctx: { chatType?: string; botUsername?: string } = {},
21
+ ): string | undefined {
22
+ const at = token.indexOf("@");
23
+ const command = at === -1 ? token : token.slice(0, at);
24
+ if (!/^\/session_(create|close|resume|recent)\b/.test(command)) return undefined;
25
+ if (at === -1) return ctx.chatType === undefined || ctx.chatType === "private" ? command : undefined;
26
+
27
+ const botUsername = ctx.botUsername;
28
+ if (!botUsername || token.slice(at + 1).toLowerCase() !== botUsername.toLowerCase()) return undefined;
29
+ return command;
30
+ }
18
31
 
19
32
  /** A parsed, validated lifecycle command (transport identity added by caller). */
20
33
  export type ParsedLifecycleCommand =
21
- | { kind: "create"; target: SessionCreateTarget }
34
+ | { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
22
35
  | { kind: "close"; target: SessionCloseTarget }
23
36
  | { kind: "resume"; target: SessionResumeTarget }
24
37
  | { kind: "recent"; which: "create" | "resume" | "all" }
@@ -28,18 +41,49 @@ export type ParsedLifecycleCommand =
28
41
 
29
42
  const USAGE = [
30
43
  "Session commands:",
31
- "/session_create path <dir>",
32
- "/session_create worktree <repo> <branch>",
33
- "/session_create dir <newdir>",
44
+ "/session_create path <dir> [--mpreset <profile>]",
45
+ "/session_create worktree <repo> <branch> [--mpreset <profile>]",
46
+ "/session_create dir <newdir> [--mpreset <profile>]",
34
47
  "/session_close <sessionId>",
35
48
  "/session_resume <sessionId|prefix>",
36
49
  "/session_recent [create|resume]",
37
50
  ].join("\n");
38
51
 
39
- /** True when the text begins a /session_* command (cheap pre-gate). */
40
- export function isLifecycleCommandText(text: string | undefined): boolean {
52
+ /** True when the text begins with any /session_* token, regardless of addressability. */
53
+ export function isLifecycleCommandLikeText(text: string | undefined): boolean {
54
+ if (!text) return false;
55
+ const [token] = text.trim().split(/\s+/, 1);
56
+ if (!token) return false;
57
+ const at = token.indexOf("@");
58
+ const command = at === -1 ? token : token.slice(0, at);
59
+ return /^\/session_(create|close|resume|recent)\b/.test(command);
60
+ }
61
+
62
+ /** True when the text begins an addressable /session_* command (cheap pre-gate). */
63
+ export function isLifecycleCommandText(
64
+ text: string | undefined,
65
+ ctx: { chatType?: string; botUsername?: string } = {},
66
+ ): boolean {
41
67
  if (!text) return false;
42
- return /^\/session_(create|close|resume|recent)\b/.test(text.trim());
68
+ const [rawCommand] = text.trim().split(/\s+/, 1);
69
+ return normalizeLifecycleCommandToken(rawCommand ?? "", ctx) !== undefined;
70
+ }
71
+
72
+ /** Extract `--mpreset <name>` (or `--mpreset=<name>`) from args, returning remaining positional args. */
73
+ function extractModelPreset(args: string[]): { positional: string[]; modelPreset?: string } {
74
+ const positional: string[] = [];
75
+ let modelPreset: string | undefined;
76
+ for (let i = 0; i < args.length; i++) {
77
+ const arg = args[i]!;
78
+ if (arg === "--mpreset" && i + 1 < args.length) {
79
+ modelPreset = args[++i]!;
80
+ } else if (arg.startsWith("--mpreset=")) {
81
+ modelPreset = arg.slice("--mpreset=".length);
82
+ } else {
83
+ positional.push(arg);
84
+ }
85
+ }
86
+ return { positional, modelPreset };
43
87
  }
44
88
 
45
89
  /**
@@ -50,9 +94,14 @@ export function isLifecycleCommandText(text: string | undefined): boolean {
50
94
  * The caller MUST have already enforced paired-chat authorization; this function
51
95
  * performs grammar + target validation only.
52
96
  */
53
- export function parseLifecycleCommand(text: string | undefined): ParsedLifecycleCommand {
54
- if (!isLifecycleCommandText(text)) return { kind: "none" };
97
+ export function parseLifecycleCommand(
98
+ text: string | undefined,
99
+ ctx: { chatType?: string; botUsername?: string } = {},
100
+ ): ParsedLifecycleCommand {
55
101
  const raw = (text ?? "").trim();
102
+ const [rawCommand, ...args] = raw.split(/\s+/);
103
+ const command = normalizeLifecycleCommandToken(rawCommand ?? "", ctx);
104
+ if (command === undefined) return { kind: "none" };
56
105
 
57
106
  // MVP: reject any initial-prompt separator outright (no prompt handling yet).
58
107
  if (/\s--(\s|$)/.test(raw)) {
@@ -63,8 +112,6 @@ export function parseLifecycleCommand(text: string | undefined): ParsedLifecycle
63
112
  };
64
113
  }
65
114
 
66
- const [command, ...args] = raw.split(/\s+/);
67
-
68
115
  if (command === "/session_recent") {
69
116
  const which = args[0];
70
117
  if (which === undefined || which === "create" || which === "resume") {
@@ -91,29 +138,33 @@ export function parseLifecycleCommand(text: string | undefined): ParsedLifecycle
91
138
  return { kind: "resume", target: { sessionIdOrPrefix: idOrPrefix } };
92
139
  }
93
140
 
94
- // /session_create <kind> ...
95
- const kind = args[0];
141
+ // /session_create <kind> ... [--mpreset <profile>]
142
+ const { positional, modelPreset } = extractModelPreset(args);
143
+ if (modelPreset !== undefined && !isSafeIdentifier(modelPreset)) {
144
+ return { kind: "reject", reason: "invalid_target", message: `Invalid model preset name.\n\n${USAGE}` };
145
+ }
146
+ const kind = positional[0];
96
147
  if (kind === "path") {
97
- if (args.length !== 2) return { kind: "usage", message: USAGE };
98
- const p = normalizeLifecyclePath(args[1]!);
148
+ if (positional.length !== 2) return { kind: "usage", message: USAGE };
149
+ const p = normalizeLifecyclePath(positional[1]!);
99
150
  if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
100
- return { kind: "create", target: { kind: "existing_path", path: p } };
151
+ return { kind: "create", target: { kind: "existing_path", path: p }, modelPreset };
101
152
  }
102
153
  if (kind === "dir") {
103
- if (args.length !== 2) return { kind: "usage", message: USAGE };
104
- const p = normalizeLifecyclePath(args[1]!);
154
+ if (positional.length !== 2) return { kind: "usage", message: USAGE };
155
+ const p = normalizeLifecyclePath(positional[1]!);
105
156
  if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
106
- return { kind: "create", target: { kind: "plain_dir", path: p } };
157
+ return { kind: "create", target: { kind: "plain_dir", path: p }, modelPreset };
107
158
  }
108
159
  if (kind === "worktree") {
109
- if (args.length !== 3) return { kind: "usage", message: USAGE };
110
- const repo = normalizeLifecyclePath(args[1]!);
111
- const branch = args[2]!;
160
+ if (positional.length !== 3) return { kind: "usage", message: USAGE };
161
+ const repo = normalizeLifecyclePath(positional[1]!);
162
+ const branch = positional[2]!;
112
163
  if (!repo) return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
113
164
  if (!isSafeBranch(branch)) {
114
165
  return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
115
166
  }
116
- return { kind: "create", target: { kind: "worktree", repo, branch } };
167
+ return { kind: "create", target: { kind: "worktree", repo, branch }, modelPreset };
117
168
  }
118
169
  return { kind: "usage", message: USAGE };
119
170
  }
@@ -127,22 +127,27 @@ function tmuxSessionNameFor(sessionId: string): string {
127
127
  * The launched session id is carried via `SKC_SESSION_ID` in the child env (see
128
128
  * {@link daemonSpawnCreate}); the root `skc` launcher has no `--session-id`
129
129
  * flag, so it must never appear in argv. Only flags the launch parser actually
130
- * supports are emitted (`--worktree <branch>` for worktree targets). */
130
+ * supports are emitted (`--worktree <branch>` for worktree targets,
131
+ * `--mpreset <profile>` for model presets). */
131
132
  export function buildCreateArgv(
132
133
  frame: SessionCreateFrame,
133
134
  _ids: { intendedSessionId: string; startupPromptRef?: string },
134
135
  ): { cwd: string; args: string[] } {
136
+ const extraArgs: string[] = [];
137
+ if (frame.modelPreset) {
138
+ extraArgs.push("--mpreset", frame.modelPreset);
139
+ }
135
140
  if (frame.target.kind === "worktree") {
136
141
  const cwd = normalizeLifecyclePath(frame.target.repo);
137
142
  if (!cwd) throw new Error("invalid_lifecycle_repo_path");
138
143
  // Use the `--worktree=<branch>` form so the branch is a single argv token:
139
144
  // a flag-shaped branch (e.g. `-x`) can never be mis-parsed as a separate
140
145
  // launcher flag / detached-mode trigger.
141
- return { cwd, args: [`--worktree=${frame.target.branch}`] };
146
+ return { cwd, args: [`--worktree=${frame.target.branch}`, ...extraArgs] };
142
147
  }
143
148
  const cwd = normalizeLifecyclePath(frame.target.path);
144
149
  if (!cwd) throw new Error("invalid_lifecycle_path");
145
- return { cwd, args: [] };
150
+ return { cwd, args: extraArgs };
146
151
  }
147
152
 
148
153
  /** Real daemon-safe tmux launcher: detached `tmux new-session -d` + SKC tags. */
@@ -252,7 +252,17 @@ export async function handleLifecycleRequest(
252
252
  return { status: "error", reason: "rate_limited", message: "create rate limit exceeded" };
253
253
  }
254
254
 
255
- // 4. Preallocate ids + write in_progress (fsynced) BEFORE any spawn.
255
+ // 4. Close is intentionally force-only until a graceful close contract exists.
256
+ if (frame.type === "session_close" && frame.force !== true) {
257
+ await deps.audit({ ...baseAudit, event: "rejected", reason: "invalid_target" });
258
+ return {
259
+ status: "error",
260
+ reason: "invalid_target",
261
+ message: "session_close requires force=true; graceful close is not supported",
262
+ };
263
+ }
264
+
265
+ // 5. Preallocate ids + write in_progress (fsynced) BEFORE any spawn.
256
266
  const lifecycleRequestId = frame.type === "session_create" ? frame.lifecycleRequestId : deps.newLifecycleRequestId();
257
267
  const intendedSessionId =
258
268
  frame.type === "session_create" ? frame.intendedSessionId || deps.newSessionId() : deps.newSessionId();
@@ -344,15 +354,18 @@ export async function handleLifecycleRequest(
344
354
  await deps.audit({ ...baseAudit, event: "success", sessionId: resumed.sessionId });
345
355
  return { status: "ok", entry, mode: resumed.mode };
346
356
  } catch (err) {
347
- // A side effect may have occurred; do not auto-respawn. Mark terminal
348
- // uncertain so a retry reconciles instead of duplicating.
357
+ // A side effect may have occurred; do not repeat it automatically. Mark
358
+ // terminal-uncertain so a retry reconciles instead of duplicating work.
359
+ let reason: LifecycleErrorReason = "terminal_uncertain";
360
+ if (frame.type === "session_create") reason = "spawn_failed";
361
+ if (frame.type === "session_close") reason = "close_refused";
349
362
  Object.assign(entry, {
350
363
  state: "terminal_uncertain",
351
364
  updatedAt: deps.now(),
352
- reason: "spawn_failed",
365
+ reason,
353
366
  });
354
367
  await deps.store.write(doc);
355
- await deps.audit({ ...baseAudit, event: "terminal_uncertain", reason: "spawn_failed" });
356
- return { status: "error", reason: "terminal_uncertain", message: `lifecycle effect failed: ${String(err)}` };
368
+ await deps.audit({ ...baseAudit, event: "terminal_uncertain", reason });
369
+ return { status: "error", reason: "terminal_uncertain", message: `${frame.type} effect failed: ${String(err)}` };
357
370
  }
358
371
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Index of rich messages the daemon sent, mapping `${chat_id}:${message_id}` to
3
+ * the original markdown. Telegram does not echo a `sendRichMessage` message's
4
+ * text in a reply's `reply_to_message`, so a user replying to a rich final
5
+ * answer would otherwise strand the agent without the quoted context. The daemon
6
+ * records the markdown on every promoted send and looks it up on inbound replies
7
+ * to restore that context.
8
+ *
9
+ * Fail-closed and off-path-neutral: nothing is recorded unless a send was
10
+ * actually promoted to rich, `record` is a no-op on any error (the in-memory map
11
+ * is only mutated after the atomic persist succeeds), and `lookup` returns
12
+ * undefined on any miss or error, so a bad index write/read never kills the
13
+ * daemon.
14
+ */
15
+
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { daemonPaths } from "./daemon-paths";
19
+
20
+ /** Max distinct (chat, message) entries retained; oldest-by-timestamp evicted past this. */
21
+ const MAX_ENTRIES = 1000;
22
+ /** Max stored characters of the original markdown per entry. */
23
+ const MAX_TEXT_LENGTH = 2000;
24
+ /** Persisted index filename under the notifications directory. */
25
+ const INDEX_FILENAME = "telegram-rich-sent-index.json";
26
+
27
+ /**
28
+ * Minimal async filesystem surface the store needs. Structurally a subset of the
29
+ * daemon's `TelegramDaemonFs`, so the daemon can pass its own `fs` straight
30
+ * through and tests can inject a fake.
31
+ */
32
+ export interface ReplySentStoreFs {
33
+ mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise<unknown>;
34
+ readFile(path: string, encoding: BufferEncoding): Promise<string>;
35
+ writeFile(path: string, data: string, opts?: fs.WriteFileOptions): Promise<void>;
36
+ rename(oldPath: string, newPath: string): Promise<void>;
37
+ chmod(path: string, mode: number): Promise<void>;
38
+ }
39
+
40
+ const nodeFs: ReplySentStoreFs = fs.promises as unknown as ReplySentStoreFs;
41
+
42
+ interface StoredEntry {
43
+ text: string;
44
+ ts: number;
45
+ }
46
+
47
+ interface PersistShape {
48
+ version: 1;
49
+ entries: Record<string, StoredEntry>;
50
+ }
51
+
52
+ /** Compose the stable per-message key. */
53
+ function keyFor(chatId: string | number, messageId: number): string {
54
+ return `${chatId}:${messageId}`;
55
+ }
56
+
57
+ /** Drop oldest-by-timestamp entries until at most {@link MAX_ENTRIES} remain. */
58
+ function evictToCap(entries: Map<string, StoredEntry>): void {
59
+ if (entries.size <= MAX_ENTRIES) return;
60
+ // Stable sort keeps insertion order among equal timestamps, so the oldest
61
+ // inserted entry is evicted first when timestamps tie.
62
+ const oldestFirst = [...entries.entries()].sort((a, b) => a[1].ts - b[1].ts);
63
+ const overflow = entries.size - MAX_ENTRIES;
64
+ for (let i = 0; i < overflow; i++) entries.delete(oldestFirst[i]![0]);
65
+ }
66
+
67
+ export class ReplySentStore {
68
+ readonly #dir: string;
69
+ readonly #file: string;
70
+ readonly #fsImpl: ReplySentStoreFs;
71
+ readonly #now: () => number;
72
+ #entries = new Map<string, StoredEntry>();
73
+
74
+ constructor(input: { agentDir: string; fs?: ReplySentStoreFs; now?: () => number }) {
75
+ this.#dir = daemonPaths(input.agentDir).dir;
76
+ this.#file = path.join(this.#dir, INDEX_FILENAME);
77
+ this.#fsImpl = input.fs ?? nodeFs;
78
+ this.#now = input.now ?? Date.now;
79
+ }
80
+
81
+ /** Restore the persisted index into memory. No-op on a missing or corrupt file. */
82
+ async load(): Promise<void> {
83
+ try {
84
+ const parsed = JSON.parse(await this.#fsImpl.readFile(this.#file, "utf8")) as Partial<PersistShape>;
85
+ const restored = new Map<string, StoredEntry>();
86
+ for (const [key, value] of Object.entries(parsed?.entries ?? {})) {
87
+ if (value && typeof value.text === "string" && typeof value.ts === "number") {
88
+ restored.set(key, { text: value.text, ts: value.ts });
89
+ }
90
+ }
91
+ evictToCap(restored);
92
+ this.#entries = restored;
93
+ } catch {
94
+ // Missing/corrupt index: keep the current in-memory map (empty on first load).
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Record the original markdown of a rich message the daemon just sent. The
100
+ * text is capped at {@link MAX_TEXT_LENGTH}; the index is capped at
101
+ * {@link MAX_ENTRIES} (oldest-by-timestamp evicted). No-op on any failure: the
102
+ * in-memory map is only replaced after the atomic persist succeeds.
103
+ */
104
+ async record(input: { chatId: string | number; messageId: number; text: string }): Promise<void> {
105
+ try {
106
+ const text = input.text.length > MAX_TEXT_LENGTH ? input.text.slice(0, MAX_TEXT_LENGTH) : input.text;
107
+ const next = new Map(this.#entries);
108
+ next.set(keyFor(input.chatId, input.messageId), { text, ts: this.#now() });
109
+ evictToCap(next);
110
+ await this.#persist(next);
111
+ this.#entries = next;
112
+ } catch {
113
+ // Best-effort: never let an index write kill the daemon.
114
+ }
115
+ }
116
+
117
+ /** Look up the original markdown for a message the daemon sent. undefined on miss or failure. */
118
+ lookup(input: { chatId: string | number; messageId: number }): string | undefined {
119
+ try {
120
+ return this.#entries.get(keyFor(input.chatId, input.messageId))?.text;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ async #persist(entries: Map<string, StoredEntry>): Promise<void> {
127
+ await this.#fsImpl.mkdir(this.#dir, { recursive: true, mode: 0o700 });
128
+ const payload: PersistShape = { version: 1, entries: Object.fromEntries(entries) };
129
+ const tmp = `${this.#file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
130
+ await this.#fsImpl.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
131
+ await this.#fsImpl.chmod(tmp, 0o600).catch(() => undefined);
132
+ await this.#fsImpl.rename(tmp, this.#file);
133
+ }
134
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Opt-in rich-draft streaming for a turn's in-progress preview.
3
+ *
4
+ * When the operator opts in (`richDraft.enabled`) the daemon streams LIVE turn
5
+ * markdown to the Bot API `sendRichMessageDraft` method as a debounced preview.
6
+ * Every failure is a harmless no-op (warn once, keep the daemon alive), and when
7
+ * off no draft call is ever made — so the off-state request bodies stay
8
+ * byte-identical.
9
+ */
10
+
11
+ import type { BotApi } from "./telegram-daemon";
12
+ import type { ThreadedSend } from "./threaded-render";
13
+
14
+ /** Minimum gap between two draft sends for one session (debounce floor). */
15
+ export const DRAFT_DEBOUNCE_MS = 1_500;
16
+
17
+ /**
18
+ * Wrap raw markdown + a monotonic draft id in the `sendRichMessageDraft` request
19
+ * payload shape: mirrors `buildRichMessage`'s proven `rich_message.markdown`
20
+ * content wrapper, with a top-level `draft_id` selecting the draft revision to
21
+ * update (a routing param, like `message_thread_id`).
22
+ */
23
+ export function buildRichDraft(draftId: number, raw: string): { draft_id: number; rich_message: { markdown: string } } {
24
+ return { draft_id: draftId, rich_message: { markdown: raw } };
25
+ }
26
+
27
+ /**
28
+ * Whether a granted send should stream a rich draft. Fail-closed: every clause
29
+ * must hold, otherwise no draft is sent. Mirrors `shouldPromoteRich` but targets
30
+ * the LIVE lane and the live-only `richDraftMarkdown` marker (set by
31
+ * `renderThreadedFrame` for non-finalized turn frames).
32
+ */
33
+ export function shouldStreamDraft(input: { enabled?: boolean; send: ThreadedSend }): boolean {
34
+ const { enabled, send } = input;
35
+ return (
36
+ enabled === true &&
37
+ send.method === "sendMessage" &&
38
+ send.lane === "live" &&
39
+ typeof send.richDraftMarkdown === "string" &&
40
+ send.richDraftMarkdown.trim().length > 0
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Per-session debounce + monotonic draft-id state for draft streaming. Skips a
46
+ * draft when less than `debounceMs` has elapsed since the session's last SENT
47
+ * draft (the rate-limit pool already coalesces live frames to the latest, so a
48
+ * skipped frame is naturally superseded by the next one). `reset` clears a
49
+ * session's window when its turn finalizes so the next turn starts fresh.
50
+ */
51
+ export class DraftStreamState {
52
+ readonly #debounceMs: number;
53
+ readonly #lastSentAt = new Map<string, number>();
54
+ /** Daemon-global monotonic draft id: unique across sessions so two sessions
55
+ * sharing one flat chat (no per-topic thread key) never collide on draft id. */
56
+ #nextDraftId = 0;
57
+
58
+ constructor(debounceMs: number = DRAFT_DEBOUNCE_MS) {
59
+ this.#debounceMs = debounceMs;
60
+ }
61
+
62
+ /**
63
+ * If enough time has elapsed since the session's last draft, record `now` as
64
+ * the new last-sent time and return the next monotonic draft id; otherwise
65
+ * return `undefined` (debounced — the caller skips this frame).
66
+ */
67
+ tryClaim(sessionId: string, now: number): number | undefined {
68
+ const last = this.#lastSentAt.get(sessionId);
69
+ if (last !== undefined && now - last < this.#debounceMs) return undefined;
70
+ this.#lastSentAt.set(sessionId, now);
71
+ const id = ++this.#nextDraftId;
72
+ return id;
73
+ }
74
+
75
+ /** Clear a session's debounce window (called when its turn finalizes). The
76
+ * global draft id keeps incrementing so ids are never reused across turns. */
77
+ reset(sessionId: string): void {
78
+ this.#lastSentAt.delete(sessionId);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Best-effort delivery of a rich draft. Never throws and has no HTML fallback: a
84
+ * draft is a purely additive preview, so on any failure (a thrown transport
85
+ * error or an `{ ok: false }` JSON response — the transport returns `res.json()`
86
+ * for JSON methods, so `ok:false` does not throw) it warns exactly once and
87
+ * returns; the unchanged live HTML send still carries the content.
88
+ */
89
+ export async function deliverDraft(
90
+ botApi: BotApi,
91
+ base: { chat_id: string | number; message_thread_id?: number },
92
+ draftId: number,
93
+ raw: string,
94
+ log?: { warn(msg: string): void },
95
+ ): Promise<void> {
96
+ let failure: string | undefined;
97
+ try {
98
+ const res = await botApi.call("sendRichMessageDraft", { ...base, ...buildRichDraft(draftId, raw) });
99
+ if (res !== null && typeof res === "object" && (res as { ok?: unknown }).ok === false) {
100
+ const description = (res as { description?: unknown }).description;
101
+ failure = typeof description === "string" && description.length > 0 ? description : "ok:false";
102
+ }
103
+ } catch (err) {
104
+ failure = err instanceof Error ? err.message : String(err);
105
+ }
106
+ if (failure !== undefined) log?.warn(`notifications: sendRichMessageDraft failed (${failure}); draft skipped`);
107
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Rich-message promotion for stable non-editable Telegram text sends.
3
+ *
4
+ * When enabled, the daemon promotes eligible finalized `sendMessage` payloads
5
+ * carrying raw markdown to the Bot API `sendRichMessage` method. On any miss or
6
+ * failure the daemon keeps the unchanged HTML `sendMessage` path, so the
7
+ * off-state request bodies are byte-identical.
8
+ */
9
+
10
+ import type { BotApi } from "./telegram-daemon";
11
+ import type { ThreadedSend } from "./threaded-render";
12
+
13
+ /**
14
+ * Telegram's hard per-message character ceiling (4096). Surfaced here purely as
15
+ * documentation and a marker for a future native rich-message splitter — it is
16
+ * intentionally NON-BEHAVIORAL and MUST stay that way: nothing in the rich path
17
+ * branches on this value.
18
+ *
19
+ * Overflow is already safe without it. The production final-answer text is capped
20
+ * at 3500 chars upstream (`summaryFromMessage(..., 3500)`), so a promoted
21
+ * `sendRichMessage` never approaches this ceiling; and if the Bot API ever rejects
22
+ * an oversized rich payload it returns `{ ok: false }`, which
23
+ * `deliverRichWithFallback` (below) turns into the chunked HTML `splitTelegramHtml`
24
+ * fallback (each chunk ≤ TELEGRAM_MESSAGE_LIMIT). This constant only marks where a
25
+ * future rich splitter would read its ceiling; wiring it into a branch would change
26
+ * byte-for-byte behavior and is out of scope.
27
+ */
28
+ export const RICH_MESSAGE_LIMIT = 4096;
29
+
30
+ /** Wrap raw markdown in the `sendRichMessage` request payload shape. */
31
+ export function buildRichMessage(
32
+ raw: string,
33
+ extras: { reply_markup?: unknown } = {},
34
+ ): { rich_message: { markdown: string }; reply_markup?: unknown } {
35
+ return { rich_message: { markdown: raw }, ...extras };
36
+ }
37
+
38
+ /**
39
+ * Whether a granted send should be promoted to `sendRichMessage`. Fail-closed
40
+ * and class-aware: every clause must hold, otherwise the daemon keeps the HTML path.
41
+ */
42
+ export function shouldPromoteRich(input: { enabled?: boolean; send: ThreadedSend }): boolean {
43
+ const { enabled, send } = input;
44
+ return (
45
+ enabled === true &&
46
+ send.method === "sendMessage" &&
47
+ send.lane === "finalized" &&
48
+ send.richClass === "final" &&
49
+ send.editable !== true &&
50
+ typeof send.richMarkdown === "string" &&
51
+ send.richMarkdown.trim().length > 0 &&
52
+ typeof send.text === "string" &&
53
+ send.text.length > 0
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Deliver the promoted rich message, falling back to `fallbackDeliver` (the
59
+ * unchanged HTML `sendMessage` loop) on any failure. A failure is either a
60
+ * thrown transport error or a `{ ok: false }` JSON response (the transport
61
+ * returns `res.json()` for JSON methods, so `ok:false` does not throw). On
62
+ * failure exactly one diagnostic is logged before the fallback runs; on success
63
+ * the fallback never runs.
64
+ *
65
+ * Returns the sent message's `message_id` on success (when the response carries
66
+ * one), otherwise `undefined` — including every failure/fallback path and a
67
+ * success whose response omits `result.message_id`. Callers that ignore the
68
+ * return value are unaffected.
69
+ */
70
+ export async function deliverRichWithFallback(
71
+ botApi: BotApi,
72
+ base: { chat_id: string | number; message_thread_id?: number },
73
+ send: ThreadedSend,
74
+ fallbackDeliver: () => Promise<void>,
75
+ log?: { warn(msg: string): void },
76
+ ): Promise<number | undefined> {
77
+ let failure: string | undefined;
78
+ let messageId: number | undefined;
79
+ try {
80
+ const res = await botApi.call("sendRichMessage", { ...base, ...buildRichMessage(send.richMarkdown!) });
81
+ if (res !== null && typeof res === "object" && (res as { ok?: unknown }).ok === false) {
82
+ const description = (res as { description?: unknown }).description;
83
+ failure = typeof description === "string" && description.length > 0 ? description : "ok:false";
84
+ } else {
85
+ const candidate = (res as { result?: { message_id?: unknown } } | null)?.result?.message_id;
86
+ if (typeof candidate === "number") messageId = candidate;
87
+ }
88
+ } catch (err) {
89
+ failure = err instanceof Error ? err.message : String(err);
90
+ }
91
+ if (failure === undefined) return messageId;
92
+ log?.warn(`notifications: sendRichMessage failed (${failure}); falling back to HTML`);
93
+ await fallbackDeliver();
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * Deliver an action-needed (ask/idle) message via `sendRichMessage`, falling
99
+ * back to the unchanged HTML chunk loop on any failure. Mirrors
100
+ * {@link deliverRichWithFallback} but takes an explicit markdown body plus an
101
+ * optional top-level `reply_markup` (probe-confirmed: `sendRichMessage` accepts
102
+ * `reply_markup` alongside `rich_message`), and surfaces a structured outcome so
103
+ * the daemon can route inbound replies to the resulting message id.
104
+ *
105
+ * On rich success: returns `{ messageId, usedRich: true, usedFallback: false }`
106
+ * where `messageId` is `res.result.message_id` when present. On a `{ ok:false }`
107
+ * response or a thrown transport error: warns exactly once, runs `htmlFallback`,
108
+ * and returns `{ messageId, usedRich: false, usedFallback: true }` where
109
+ * `messageId` is the fallback's return value (the last HTML chunk's id).
110
+ */
111
+ export async function deliverRichActionWithFallback(
112
+ botApi: BotApi,
113
+ base: { chat_id: string | number; message_thread_id?: number },
114
+ opts: { markdown: string; replyMarkup?: unknown; requireMessageId?: boolean },
115
+ htmlFallback: () => Promise<number | undefined>,
116
+ log?: { warn(msg: string): void },
117
+ ): Promise<{ messageId?: number; usedRich: boolean; usedFallback: boolean }> {
118
+ let failure: string | undefined;
119
+ let messageId: number | undefined;
120
+ try {
121
+ const res = await botApi.call("sendRichMessage", {
122
+ ...base,
123
+ ...buildRichMessage(opts.markdown, opts.replyMarkup === undefined ? {} : { reply_markup: opts.replyMarkup }),
124
+ });
125
+ if (res !== null && typeof res === "object" && (res as { ok?: unknown }).ok === false) {
126
+ const description = (res as { description?: unknown }).description;
127
+ failure = typeof description === "string" && description.length > 0 ? description : "ok:false";
128
+ } else {
129
+ const candidate = (res as { result?: { message_id?: unknown } } | null)?.result?.message_id;
130
+ if (typeof candidate === "number") messageId = candidate;
131
+ // Ask messages MUST be reply-routable: if a rich success carries no numeric
132
+ // message_id, fall back to HTML so a routable id is guaranteed.
133
+ else if (opts.requireMessageId) failure = "rich response missing message_id";
134
+ }
135
+ } catch (err) {
136
+ failure = err instanceof Error ? err.message : String(err);
137
+ }
138
+ if (failure === undefined) return { messageId, usedRich: true, usedFallback: false };
139
+ log?.warn(`notifications: sendRichMessage(action) failed (${failure}); falling back to HTML`);
140
+ const fallbackId = await htmlFallback();
141
+ return { messageId: fallbackId, usedRich: false, usedFallback: true };
142
+ }
@@ -20,7 +20,7 @@ import type {
20
20
  DaemonStatus,
21
21
  } from "../daemon/control-types";
22
22
  import { resolveSkcRuntimeSpawnInfo } from "../daemon/runtime";
23
- import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
23
+ import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
24
24
  import {
25
25
  daemonPaths,
26
26
  isFreshLiveOwner,
@@ -159,7 +159,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
159
159
  async status(): Promise<DaemonStatus> {
160
160
  const runtime = this.runtimeInfo();
161
161
  const cfg = getNotificationConfig(this.settings);
162
- const configured = isGloballyConfigured(cfg) && Boolean(cfg.botToken) && Boolean(cfg.chatId);
162
+ const configured = isTelegramConfigured(cfg);
163
163
  if (!configured) {
164
164
  return { kind: this.kind, configured: false, health: "not_configured", runtime };
165
165
  }
@@ -269,6 +269,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
269
269
  { settings: this.settings, roots, tokenFingerprint: fp, chatId },
270
270
  this.spawnDeps(),
271
271
  );
272
+ warnings.push(...spawned.warnings);
272
273
  const after = await this.status();
273
274
  return this.result(
274
275
  action,
@@ -351,6 +352,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
351
352
  { settings: this.settings, roots, tokenFingerprint: fp, chatId },
352
353
  this.spawnDeps(),
353
354
  );
355
+ warnings.push(...spawned.warnings);
354
356
  const after = await this.status();
355
357
  if (spawned.result === "attached") {
356
358
  // A live owner already exists; attaching to it is a valid running end-state.
@@ -360,7 +362,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
360
362
  }
361
363
  return this.result(
362
364
  action,
363
- spawned.result !== "disabled",
365
+ spawned.result !== "disabled" && spawned.result !== "blocked",
364
366
  `reloaded telegram daemon (${spawned.result})`,
365
367
  before,
366
368
  after,