@sayknow-cli/coding-agent 0.3.8 → 0.3.10

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 (93) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +11 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +15 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  13. package/dist/types/modes/components/thinking-selector.d.ts +7 -2
  14. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  15. package/dist/types/modes/interactive-mode.d.ts +2 -0
  16. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  17. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  18. package/dist/types/modes/types.d.ts +3 -0
  19. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  20. package/dist/types/notifications/config-commands.d.ts +38 -0
  21. package/dist/types/notifications/config.d.ts +16 -0
  22. package/dist/types/notifications/index.d.ts +14 -1
  23. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  24. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  25. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  26. package/dist/types/notifications/rich-draft.d.ts +68 -0
  27. package/dist/types/notifications/rich-render.d.ts +90 -0
  28. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  29. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  30. package/dist/types/notifications/threaded-render.d.ts +10 -0
  31. package/dist/types/notifications/topic-registry.d.ts +4 -5
  32. package/dist/types/runtime-credential-selector.d.ts +7 -0
  33. package/dist/types/sdk.d.ts +7 -1
  34. package/dist/types/session/agent-session.d.ts +6 -3
  35. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  36. package/dist/types/tools/index.d.ts +1 -0
  37. package/dist/types/tools/skill-discovery.d.ts +40 -0
  38. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  39. package/package.json +7 -7
  40. package/src/cli/args.ts +8 -0
  41. package/src/cli/notify-cli.ts +38 -10
  42. package/src/cli.ts +5 -0
  43. package/src/commands/launch.ts +5 -0
  44. package/src/commands/notify.ts +2 -2
  45. package/src/config/keybindings.ts +1 -1
  46. package/src/config/model-profile-activation.ts +23 -7
  47. package/src/config/model-registry.ts +107 -11
  48. package/src/config/model-resolver.ts +5 -16
  49. package/src/config/settings-schema.ts +11 -2
  50. package/src/config/settings.ts +24 -1
  51. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  52. package/src/internal-urls/docs-index.generated.ts +4 -4
  53. package/src/lsp/client.ts +1 -0
  54. package/src/main.ts +26 -3
  55. package/src/modes/components/footer.ts +7 -2
  56. package/src/modes/components/settings-selector.ts +3 -3
  57. package/src/modes/components/thinking-selector.ts +83 -20
  58. package/src/modes/controllers/event-controller.ts +11 -2
  59. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  60. package/src/modes/controllers/input-controller.ts +62 -26
  61. package/src/modes/controllers/selector-controller.ts +43 -0
  62. package/src/modes/interactive-mode.ts +5 -0
  63. package/src/modes/rpc/rpc-mode.ts +3 -9
  64. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  65. package/src/modes/types.ts +3 -0
  66. package/src/modes/utils/injected-user-submission.ts +94 -0
  67. package/src/modes/utils/ui-helpers.ts +48 -15
  68. package/src/notifications/config-commands.ts +90 -0
  69. package/src/notifications/config.ts +27 -0
  70. package/src/notifications/index.ts +184 -14
  71. package/src/notifications/lifecycle-commands.ts +37 -16
  72. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  73. package/src/notifications/reply-sent-store.ts +134 -0
  74. package/src/notifications/rich-draft.ts +107 -0
  75. package/src/notifications/rich-render.ts +143 -0
  76. package/src/notifications/telegram-daemon.ts +431 -91
  77. package/src/notifications/telegram-reference.ts +17 -0
  78. package/src/notifications/threaded-render.ts +42 -1
  79. package/src/notifications/topic-registry.ts +9 -8
  80. package/src/prompts/tools/search-tool-bm25.md +5 -0
  81. package/src/prompts/tools/skill-discovery.md +13 -0
  82. package/src/runtime-credential-selector.ts +35 -0
  83. package/src/sdk.ts +104 -13
  84. package/src/session/agent-session.ts +48 -22
  85. package/src/skc-runtime/psmux-detect.ts +17 -2
  86. package/src/skc-runtime/tmux-common.ts +1 -0
  87. package/src/skc-runtime/ultragoal-runtime.ts +10 -1
  88. package/src/slash-commands/builtin-registry.ts +78 -1
  89. package/src/system-prompt.ts +4 -6
  90. package/src/tools/index.ts +4 -0
  91. package/src/tools/skill-discovery.ts +73 -0
  92. package/src/tools/skill.ts +15 -2
  93. package/src/utils/pasted-image-path.ts +170 -0
@@ -9,7 +9,7 @@ import type { Settings } from "../config/settings";
9
9
  import type { DaemonRuntimeInfo } from "../daemon/control-types";
10
10
  import { resolveSkcRuntimeSpawnInfo } from "../daemon/runtime";
11
11
  import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
12
- import { parseInThreadConfigCommand } from "./config-commands";
12
+ import { parseInThreadConfigCommand, parseRichToggleCommand, parseTelegramControlCommand } from "./config-commands";
13
13
  import { daemonPaths } from "./daemon-paths";
14
14
  import {
15
15
  buildCompactChoiceGrid,
@@ -44,8 +44,12 @@ import {
44
44
  import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
45
45
  import { RateLimitPool } from "./rate-limit-pool";
46
46
  import { listRecentSessions } from "./recent-activity";
47
+ import { ReplySentStore } from "./reply-sent-store";
48
+ import { DraftStreamState, deliverDraft, shouldStreamDraft } from "./rich-draft";
49
+ import { deliverRichActionWithFallback, deliverRichWithFallback, shouldPromoteRich } from "./rich-render";
47
50
  import {
48
51
  type AliasTable,
52
+ buildActionMarkdown,
49
53
  buildActionMessage,
50
54
  type CallbackRoute,
51
55
  createAliasTable,
@@ -107,6 +111,21 @@ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
107
111
  export const NOTIFICATION_PROTOCOL_VERSION = 2;
108
112
 
109
113
  const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
114
+
115
+ /**
116
+ * Durably persist a `/rich` toggle. A real {@link Settings} exposes
117
+ * `flushOrThrow()`, which rejects on a failed config.yml write (its `set()` is a
118
+ * fire-and-forget whose background save swallows errors). The lightweight daemon
119
+ * settings has no `flushOrThrow` — its `set()` already wrote durably and throws
120
+ * on failure — so its plain `flush()` no-op drain is sufficient.
121
+ */
122
+ async function flushRichToggleSettings(settings: Settings): Promise<void> {
123
+ if (typeof settings.flushOrThrow === "function") {
124
+ await settings.flushOrThrow();
125
+ return;
126
+ }
127
+ await settings.flush();
128
+ }
110
129
  const RATE_LIMIT_FLUSH_INTERVAL_MS = 1_000;
111
130
  // How often the daemon rescans for newly-started sessions. This MUST run
112
131
  // independently of the Telegram getUpdates long-poll (up to 25s): otherwise a
@@ -843,6 +862,10 @@ export interface TelegramDaemonOptions {
843
862
  * default applies (e.g. lifecycle control disabled), no control server starts.
844
863
  */
845
864
  createLifecycleControlServer?: LifecycleControlServerFactory | null;
865
+ /** Rich text promotion (enabled by default; see rich-render.ts). */
866
+ rich?: { enabled: boolean };
867
+ /** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
868
+ richDraft?: { enabled: boolean };
846
869
  }
847
870
 
848
871
  interface SessionSocket {
@@ -883,6 +906,10 @@ export class TelegramNotificationDaemon {
883
906
  private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
884
907
  private readonly poller: TelegramUpdatePoller;
885
908
  private readonly dispatchState = new TelegramEventDispatchState();
909
+ /** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
910
+ private readonly replyStore: ReplySentStore;
911
+ /** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
912
+ private readonly draftStream = new DraftStreamState();
886
913
  /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
887
914
  private readonly topicOwnerByIdentity = new Map<string, string>();
888
915
  /** Non-identity frames held until identity creates the correct thread. */
@@ -1120,7 +1147,7 @@ export class TelegramNotificationDaemon {
1120
1147
  /** Build an authenticated lifecycle frame from a parsed command + identity. */
1121
1148
  private buildLifecycleFrame(
1122
1149
  parsed:
1123
- | { kind: "create"; target: SessionCreateTarget }
1150
+ | { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
1124
1151
  | { kind: "close"; target: SessionCloseTarget }
1125
1152
  | { kind: "resume"; target: SessionResumeTarget },
1126
1153
  updateId: number,
@@ -1138,6 +1165,7 @@ export class TelegramNotificationDaemon {
1138
1165
  chatId,
1139
1166
  token,
1140
1167
  target: parsed.target,
1168
+ modelPreset: parsed.modelPreset,
1141
1169
  };
1142
1170
  }
1143
1171
  if (parsed.kind === "close") {
@@ -1242,6 +1270,7 @@ export class TelegramNotificationDaemon {
1242
1270
 
1243
1271
  constructor(private readonly opts: TelegramDaemonOptions) {
1244
1272
  this.fsImpl = opts.fs ?? nodeFs;
1273
+ this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
1245
1274
  this.aliasTable = createAliasTable();
1246
1275
  this.botApi =
1247
1276
  opts.botApi ??
@@ -1536,6 +1565,7 @@ export class TelegramNotificationDaemon {
1536
1565
  "image_attachment",
1537
1566
  "file_attachment",
1538
1567
  "config_update",
1568
+ "control_command_result",
1539
1569
  ]);
1540
1570
 
1541
1571
  private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
@@ -1798,8 +1828,23 @@ export class TelegramNotificationDaemon {
1798
1828
  return { images, fileNotes };
1799
1829
  }
1800
1830
 
1831
+ /**
1832
+ * Serialize all pool flushes. Every caller (`submitThreadedFrame`, the flat
1833
+ * fallback, the drain timer's `void this.flushPool()`, topic teardown) goes
1834
+ * through one promise chain, so two flushes never interleave — a live send can
1835
+ * never be in-flight while a finalized flush reads `liveMessages` and decides
1836
+ * to post a fresh (duplicate) final. Errors are swallowed so one failed flush
1837
+ * never poisons the queue (each flush is already best-effort internally).
1838
+ */
1839
+ private flushChain: Promise<void> = Promise.resolve();
1840
+ private flushPool(): Promise<void> {
1841
+ const next = this.flushChain.then(() => this.flushPoolInner());
1842
+ this.flushChain = next.catch(() => {});
1843
+ return next;
1844
+ }
1845
+
1801
1846
  /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
1802
- private async flushPool(): Promise<void> {
1847
+ private async flushPoolInner(): Promise<void> {
1803
1848
  const batch = this.pool.drain();
1804
1849
  // Within a batch a finalized frame supersedes any still-queued live frame for
1805
1850
  // the same streamed message (finalized outranks live), so drop the stale live
@@ -1811,6 +1856,17 @@ export class TelegramNotificationDaemon {
1811
1856
  finalizedKeys.add(`${item.sessionId}:${item.coalesceKey}`);
1812
1857
  }
1813
1858
  }
1859
+ // Cross-batch protection: also purge any live frame still QUEUED for a
1860
+ // message whose finalized frame is in this batch, so a stale live edit can
1861
+ // never be delivered on a later drain after the authoritative final.
1862
+ if (finalizedKeys.size > 0) {
1863
+ this.pool.removeWhere(
1864
+ it =>
1865
+ it.lane === "live" &&
1866
+ it.coalesceKey !== undefined &&
1867
+ finalizedKeys.has(`${it.sessionId}:${it.coalesceKey}`),
1868
+ );
1869
+ }
1814
1870
  for (const item of batch) {
1815
1871
  const { send, topicId } = item.payload;
1816
1872
  if (topicId && !(await this.pairedChatIsPrivate())) continue;
@@ -1820,6 +1876,33 @@ export class TelegramNotificationDaemon {
1820
1876
  const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
1821
1877
  if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
1822
1878
  try {
1879
+ // Draft streaming (opt-in, off by default): stream a live turn frame as a
1880
+ // best-effort rich-draft preview, debounced to >=1.5s per session through
1881
+ // this same rate-limited drain; a finalized frame ends the turn's draft
1882
+ // window. Entirely inert when richDraft is off (the enabled gate /
1883
+ // shouldStreamDraft fail closed), so off-state HTML request bodies stay
1884
+ // byte-identical.
1885
+ if (this.opts.richDraft?.enabled === true && this.opts.rich?.enabled !== false) {
1886
+ if (send.lane === "finalized" && send.method === "sendMessage") {
1887
+ this.draftStream.reset(item.sessionId);
1888
+ } else if (
1889
+ shouldStreamDraft({
1890
+ enabled: this.opts.richDraft.enabled,
1891
+ send,
1892
+ })
1893
+ ) {
1894
+ const draftId = this.draftStream.tryClaim(item.sessionId, this.opts.now?.() ?? Date.now());
1895
+ if (draftId !== undefined) {
1896
+ await deliverDraft(
1897
+ this.botApi,
1898
+ { chat_id: this.opts.chatId, ...threadField },
1899
+ draftId,
1900
+ send.richDraftMarkdown!,
1901
+ logger,
1902
+ );
1903
+ }
1904
+ }
1905
+ }
1823
1906
  if (send.method === "sendPhoto" && send.photoBase64) {
1824
1907
  // Real photo upload (the default botApi multiparts base64 -> file).
1825
1908
  await this.botApi.call("sendPhoto", {
@@ -1841,53 +1924,139 @@ export class TelegramNotificationDaemon {
1841
1924
  parse_mode: TELEGRAM_PARSE_MODE,
1842
1925
  });
1843
1926
  } else if (send.text) {
1844
- const chunks = splitTelegramHtml(send.text);
1845
- const existingId = editKey ? this.liveMessages.get(editKey) : undefined;
1846
- if (editKey && existingId !== undefined && chunks.length === 1) {
1847
- // In-place edit of the streamed message. An unchanged edit is
1848
- // rejected by Telegram ("message is not modified") and swallowed.
1849
- await this.botApi.call("editMessageText", {
1850
- chat_id: this.opts.chatId,
1851
- message_id: existingId,
1852
- text: chunks[0],
1853
- parse_mode: TELEGRAM_PARSE_MODE,
1854
- });
1855
- } else {
1856
- // A single granted slot MUST map to a single Telegram send. When the
1857
- // rendered text splits into multiple chunks (e.g. a long finalized
1858
- // turn raised via SKC_NOTIFICATIONS_TURN_MAX), deliver the first
1859
- // chunk on this token and re-submit the remaining chunks as their
1860
- // own pool items so each consumes a token on a later drain.
1861
- // Otherwise one frame would fan out into many sends against a single
1862
- // slot, bypassing the per-chat rate-limit / fairness invariant.
1863
- const res = (await this.botApi.call("sendMessage", {
1864
- chat_id: this.opts.chatId,
1865
- ...threadField,
1866
- text: chunks[0]!,
1867
- parse_mode: TELEGRAM_PARSE_MODE,
1868
- })) as { result?: { message_id?: number } };
1869
- for (let i = 1; i < chunks.length; i++) {
1870
- // Continuation chunks are fresh, non-editable text sends: no
1871
- // coalesce key (they neither replace nor are replaced by other
1872
- // frames) and no media payload.
1873
- this.pool.submit({
1874
- sessionId: item.sessionId,
1875
- lane: item.lane,
1876
- payload: {
1877
- send: {
1878
- ...send,
1879
- method: "sendMessage",
1880
- text: chunks[i]!,
1881
- editable: false,
1882
- coalesceKey: undefined,
1883
- photoBase64: undefined,
1884
- documentBase64: undefined,
1927
+ // Rich pre-branch: promote stable non-editable finalized text to a fresh
1928
+ // sendRichMessage when enabled. Off/miss falls through to the unchanged
1929
+ // upstream edit/send path, so off behavior is byte-identical.
1930
+ if (
1931
+ shouldPromoteRich({
1932
+ enabled: this.opts.rich?.enabled !== false,
1933
+ send,
1934
+ })
1935
+ ) {
1936
+ const sendHtmlFallback = async () => {
1937
+ // Fairness: this frame consumed exactly one token, so send only the
1938
+ // first HTML chunk now and requeue any continuations as their own
1939
+ // non-editable, HTML-only pool items (rich markers stripped) — same
1940
+ // per-token discipline as the non-rich split path.
1941
+ const chunks = splitTelegramHtml(send.text!);
1942
+ await this.botApi.call("sendMessage", {
1943
+ chat_id: this.opts.chatId,
1944
+ ...threadField,
1945
+ text: chunks[0]!,
1946
+ parse_mode: TELEGRAM_PARSE_MODE,
1947
+ });
1948
+ for (let i = 1; i < chunks.length; i++) {
1949
+ this.pool.submit({
1950
+ sessionId: item.sessionId,
1951
+ lane: item.lane,
1952
+ payload: {
1953
+ send: {
1954
+ ...send,
1955
+ method: "sendMessage",
1956
+ text: chunks[i]!,
1957
+ editable: false,
1958
+ coalesceKey: undefined,
1959
+ photoBase64: undefined,
1960
+ documentBase64: undefined,
1961
+ richMarkdown: undefined,
1962
+ richDraftMarkdown: undefined,
1963
+ richClass: undefined,
1964
+ },
1965
+ topicId,
1885
1966
  },
1886
- topicId,
1887
- },
1967
+ });
1968
+ }
1969
+ };
1970
+ const richMessageId = await deliverRichWithFallback(
1971
+ this.botApi,
1972
+ { chat_id: this.opts.chatId, ...threadField },
1973
+ send,
1974
+ sendHtmlFallback,
1975
+ logger,
1976
+ );
1977
+ // Index the sent rich message so an inbound reply to it can restore
1978
+ // the original markdown as context (Telegram does not echo it back).
1979
+ if (richMessageId !== undefined) {
1980
+ await this.replyStore.record({
1981
+ chatId: this.opts.chatId,
1982
+ messageId: richMessageId,
1983
+ text: send.richMarkdown!,
1888
1984
  });
1889
1985
  }
1890
- const firstMessageId = res?.result?.message_id;
1986
+ } else {
1987
+ const chunks = splitTelegramHtml(send.text);
1988
+ const existingId = editKey ? this.liveMessages.get(editKey) : undefined;
1989
+ let firstMessageId: number | undefined;
1990
+ if (editKey && existingId !== undefined) {
1991
+ // Edit the existing streamed message in place with the first chunk
1992
+ // so a finalized turn never leaves a stale live preview. A LOCAL
1993
+ // try/catch keeps a failed edit from aborting the continuation
1994
+ // requeue below; "message is not modified" is a success (the message
1995
+ // already shows this text); a missing/deleted backing message (or a
1996
+ // transport error) resends so the first chunk is never lost.
1997
+ let edited = false;
1998
+ try {
1999
+ const res = (await this.botApi.call("editMessageText", {
2000
+ chat_id: this.opts.chatId,
2001
+ message_id: existingId,
2002
+ text: chunks[0],
2003
+ parse_mode: TELEGRAM_PARSE_MODE,
2004
+ })) as { ok?: boolean; description?: string } | null;
2005
+ edited = res?.ok !== false || /not modified/i.test(String(res?.description ?? ""));
2006
+ } catch {
2007
+ edited = false;
2008
+ }
2009
+ if (edited) {
2010
+ firstMessageId = existingId;
2011
+ } else {
2012
+ const res = (await this.botApi.call("sendMessage", {
2013
+ chat_id: this.opts.chatId,
2014
+ ...threadField,
2015
+ text: chunks[0]!,
2016
+ parse_mode: TELEGRAM_PARSE_MODE,
2017
+ })) as { result?: { message_id?: number } };
2018
+ firstMessageId = res?.result?.message_id;
2019
+ }
2020
+ } else {
2021
+ // No streamed message to edit: a single granted slot maps to a
2022
+ // single Telegram send.
2023
+ const res = (await this.botApi.call("sendMessage", {
2024
+ chat_id: this.opts.chatId,
2025
+ ...threadField,
2026
+ text: chunks[0]!,
2027
+ parse_mode: TELEGRAM_PARSE_MODE,
2028
+ })) as { result?: { message_id?: number } };
2029
+ firstMessageId = res?.result?.message_id;
2030
+ }
2031
+ // Continuation chunks are FINALIZED-lane only. A live preview is a
2032
+ // single edit-safe chunk (its authoritative full text arrives with the
2033
+ // finalized frame), so a split live frame never fans out into stale,
2034
+ // non-coalesced continuation messages. Finalized continuations are
2035
+ // fresh, non-editable, HTML-only sends (rich markers stripped) so they
2036
+ // can never be re-promoted to a duplicate sendRichMessage.
2037
+ if (item.lane !== "live") {
2038
+ for (let i = 1; i < chunks.length; i++) {
2039
+ this.pool.submit({
2040
+ sessionId: item.sessionId,
2041
+ lane: item.lane,
2042
+ payload: {
2043
+ send: {
2044
+ ...send,
2045
+ method: "sendMessage",
2046
+ text: chunks[i]!,
2047
+ editable: false,
2048
+ coalesceKey: undefined,
2049
+ photoBase64: undefined,
2050
+ documentBase64: undefined,
2051
+ richMarkdown: undefined,
2052
+ richDraftMarkdown: undefined,
2053
+ richClass: undefined,
2054
+ },
2055
+ topicId,
2056
+ },
2057
+ });
2058
+ }
2059
+ }
1891
2060
  if (editKey && ckey !== undefined && firstMessageId !== undefined) {
1892
2061
  this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
1893
2062
  }
@@ -1959,7 +2128,7 @@ export class TelegramNotificationDaemon {
1959
2128
  try {
1960
2129
  await this.botApi.call("sendMessage", {
1961
2130
  chat_id: this.opts.chatId,
1962
- text: "turn on threaded mode from botfather miniapp to receive skc notification!",
2131
+ 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.",
1963
2132
  parse_mode: TELEGRAM_PARSE_MODE,
1964
2133
  });
1965
2134
  } catch {
@@ -2066,16 +2235,22 @@ export class TelegramNotificationDaemon {
2066
2235
  // Rename the topic if the title changed (e.g. the session title was
2067
2236
  // auto-generated after the topic was first created). This runs on
2068
2237
  // every identity frame, but does NOT re-send the bulleted message.
2238
+ // Only commit the new registry name after Telegram accepts the edit:
2239
+ // a transient editForumTopic failure must remain retryable on the
2240
+ // next identity re-assert instead of leaving the remote topic stuck
2241
+ // at the provisional "SKC <id>" name forever.
2069
2242
  const name = this.topicNameFor(session.sessionId, msg);
2070
- if (this.topics.applyName(session.sessionId, name)) {
2243
+ if (this.topics.needsRename(session.sessionId, name)) {
2071
2244
  try {
2072
2245
  await this.botApi.call("editForumTopic", {
2073
2246
  chat_id: this.opts.chatId,
2074
2247
  message_thread_id: Number(topicId),
2075
2248
  name,
2076
2249
  });
2250
+ this.topics.markNameApplied(session.sessionId, name);
2077
2251
  } catch {
2078
- // Best-effort rename; never block delivery.
2252
+ // Best-effort rename; never block delivery. Leave the old
2253
+ // registry name intact so a later identity frame retries.
2079
2254
  }
2080
2255
  }
2081
2256
  // Send the full bulleted identity header EXACTLY ONCE per topic.
@@ -2108,24 +2283,58 @@ export class TelegramNotificationDaemon {
2108
2283
  });
2109
2284
  const options = Array.isArray(msg.options) ? msg.options : [];
2110
2285
  // Daemon keyboards use alias callback data with compact one-based tap targets;
2111
- // full option text is rendered in the message body by buildActionMessage.
2286
+ // full option text is rendered in the message body by buildActionMessage/buildActionMarkdown.
2112
2287
  const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
2113
2288
  this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
2114
2289
  );
2115
- const chunks = splitTelegramHtml(rendered.text);
2116
- let result: { result?: { message_id?: number } } = {};
2117
- for (let i = 0; i < chunks.length; i++) {
2118
- result = (await this.botApi.call("sendMessage", {
2119
- chat_id: this.opts.chatId,
2120
- ...threadField,
2121
- text: chunks[i]!,
2122
- parse_mode: TELEGRAM_PARSE_MODE,
2123
- ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
2124
- })) as { result?: { message_id?: number } };
2290
+ // HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
2291
+ // returns the last chunk's message_id (the reply-routable message).
2292
+ const sendHtmlChunks = async (): Promise<number | undefined> => {
2293
+ const chunks = splitTelegramHtml(rendered.text);
2294
+ let result: { result?: { message_id?: number } } = {};
2295
+ for (let i = 0; i < chunks.length; i++) {
2296
+ result = (await this.botApi.call("sendMessage", {
2297
+ chat_id: this.opts.chatId,
2298
+ ...threadField,
2299
+ text: chunks[i]!,
2300
+ parse_mode: TELEGRAM_PARSE_MODE,
2301
+ ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
2302
+ })) as { result?: { message_id?: number } };
2303
+ }
2304
+ return result.result?.message_id;
2305
+ };
2306
+ const kind = msg.kind === "idle" ? "idle" : "ask";
2307
+ if (this.opts.rich?.enabled !== false) {
2308
+ // Rich (default on): promote to sendRichMessage with a top-level
2309
+ // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
2310
+
2311
+ const outcome = await deliverRichActionWithFallback(
2312
+ this.botApi,
2313
+ { chat_id: this.opts.chatId, ...threadField },
2314
+ {
2315
+ markdown: buildActionMarkdown({
2316
+ kind,
2317
+ question: msg.question,
2318
+ options: msg.options,
2319
+ summary: msg.summary,
2320
+ }),
2321
+ replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
2322
+ requireMessageId: kind === "ask",
2323
+ },
2324
+ sendHtmlChunks,
2325
+ logger,
2326
+ );
2327
+ // Only asks are reply-routable; idle pings register no route.
2328
+ if (kind === "ask" && outcome.messageId !== undefined)
2329
+ this.messageRoutes.set(String(outcome.messageId), { sessionId: session.sessionId, actionId: msg.id });
2330
+ } else {
2331
+ // Off: byte-identical to the pre-rich HTML path.
2332
+ const messageId = await sendHtmlChunks();
2333
+ // Only asks are reply-routable; idle pings register no route (parity
2334
+ // with the rich branch and correct even in the byte-identical off path).
2335
+ if (kind === "ask" && messageId !== undefined)
2336
+ this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
2125
2337
  }
2126
- const messageId = result.result?.message_id;
2127
- if (messageId !== undefined)
2128
- this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
2129
2338
  await this.persistAliases();
2130
2339
  } else if (msg.type === "action_resolved" && msg.id) {
2131
2340
  session.pending.delete(msg.id);
@@ -2181,6 +2390,74 @@ export class TelegramNotificationDaemon {
2181
2390
  }
2182
2391
  }
2183
2392
  }
2393
+ // Rich-message toggle (/rich on|off): daemon-local delivery policy, NOT a
2394
+ // session config forward. Handled at paired-chat pre-routing, before threaded
2395
+ // injection and independent of any session WebSocket, so it works even when
2396
+ // no session is connected and never becomes an ask answer.
2397
+ {
2398
+ const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
2399
+ const chat = m?.chat as { id?: unknown } | undefined;
2400
+ const cmdText = typeof m?.text === "string" ? m.text : undefined;
2401
+ const rawFirst = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
2402
+ // Fail-closed: intercept ANY "/rich" or "/rich@<anything>" form (Telegram
2403
+ // appends @botname in groups; the bot username may be unknown if getMe
2404
+ // failed) so a rich command is never leaked into threaded injection / an
2405
+ // ask answer. Argument validity is decided by parseRichToggleCommand below.
2406
+ const isRichCommand = rawFirst?.split("@")[0] === "/rich";
2407
+ if (m !== undefined && String(chat?.id) === String(this.opts.chatId) && isRichCommand) {
2408
+ // Fail-closed: /rich mutates global config, so honor it ONLY in a PRIVATE
2409
+ // paired chat — the same contract as session delivery and lifecycle
2410
+ // commands. A group/supergroup chatId (legacy or hand-edited) must never
2411
+ // let an arbitrary chat member toggle the owner's notification config.
2412
+ if (!(await this.pairedChatIsPrivate())) return;
2413
+ const updateId = (update as { update_id?: number }).update_id;
2414
+ // Dedupe redelivered updates so a toggle+confirmation runs at most once.
2415
+ if (typeof updateId === "number") {
2416
+ if (this.dispatchState.seenUpdateIds.has(updateId)) return;
2417
+ await this.rememberSeenUpdateId(updateId);
2418
+ }
2419
+ const threadField =
2420
+ typeof m.message_thread_id === "number" ? { message_thread_id: m.message_thread_id as number } : {};
2421
+ const reply = async (body: string): Promise<void> => {
2422
+ try {
2423
+ await this.botApi.call("sendMessage", {
2424
+ chat_id: this.opts.chatId,
2425
+ ...threadField,
2426
+ text: body,
2427
+ parse_mode: TELEGRAM_PARSE_MODE,
2428
+ });
2429
+ } catch {
2430
+ // Best-effort confirmation; never block on the notice.
2431
+ }
2432
+ };
2433
+ const desired = parseRichToggleCommand(cmdText ?? "");
2434
+ if (desired === undefined) {
2435
+ await reply("Usage: /rich on|off");
2436
+ return;
2437
+ }
2438
+ try {
2439
+ await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
2440
+ // Confirm success only after a DURABLE write. The real Settings.set is
2441
+ // a synchronous fire-and-forget whose queued save (Settings.#saveNow)
2442
+ // swallows write errors, and Settings.flush() inherits that — neither
2443
+ // rejects on a failed config.yml write. flushOrThrow() rethrows the
2444
+ // durable-write failure so it lands in the catch below (in-memory
2445
+ // isolated Settings short-circuit and never throw). The lightweight
2446
+ // daemon settings has no flushOrThrow: its set() already wrote durably
2447
+ // (and throws on failure), so its flush() is only a no-op drain.
2448
+ await flushRichToggleSettings(this.opts.settings);
2449
+ } catch (err) {
2450
+ logger.warn(
2451
+ `notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
2452
+ );
2453
+ await reply("Rich messages: unchanged (settings write failed)");
2454
+ return;
2455
+ }
2456
+ this.opts.rich = { enabled: desired };
2457
+ await reply(desired ? "Rich messages: on" : "Rich messages: off");
2458
+ return;
2459
+ }
2460
+ }
2184
2461
  // Threaded injection: a free-text message in a known topic (not a button
2185
2462
  // tap and not a reply to a specific ask message) injects a user turn or an
2186
2463
  // in-thread config command. Fail-closed: paired chat + known topic +
@@ -2211,7 +2488,56 @@ export class TelegramNotificationDaemon {
2211
2488
  const images = attachmentResult?.images ?? [];
2212
2489
  const fileNotes = attachmentResult?.fileNotes ?? [];
2213
2490
  const hasMedia = images.length > 0 || fileNotes.length > 0;
2214
- const injectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
2491
+ const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
2492
+ // A reply to a rich message we sent (not an ask route) loses its original
2493
+ // text: Telegram does not echo it in reply_to_message. Restore it from the
2494
+ // reply index as a labeled context prefix; a miss leaves the turn unchanged.
2495
+ const repliedOriginal =
2496
+ typeof replyTo === "number"
2497
+ ? this.replyStore.lookup({ chatId: this.opts.chatId, messageId: replyTo })
2498
+ : undefined;
2499
+ const injectedText = repliedOriginal
2500
+ ? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
2501
+ : baseInjectedText;
2502
+ const control = hasMedia
2503
+ ? { kind: "none" as const }
2504
+ : parseTelegramControlCommand(inbound.text, this.botUsername);
2505
+ if (control.kind !== "none") {
2506
+ await this.rememberSeenUpdateId(inbound.updateId);
2507
+ const sendControlNotice = async (body: string): Promise<void> => {
2508
+ try {
2509
+ await this.botApi.call("sendMessage", {
2510
+ chat_id: this.opts.chatId,
2511
+ message_thread_id: Number(inbound.threadId),
2512
+ text: body,
2513
+ parse_mode: TELEGRAM_PARSE_MODE,
2514
+ });
2515
+ } catch {
2516
+ // Best-effort control feedback; never convert to user input.
2517
+ }
2518
+ };
2519
+ if (control.kind === "ignored") return;
2520
+ if (control.kind === "invalid") {
2521
+ await sendControlNotice(control.usage);
2522
+ return;
2523
+ }
2524
+ if (session?.ws.readyState !== WebSocket.OPEN) {
2525
+ await sendControlNotice("Session control unavailable: session is disconnected.");
2526
+ return;
2527
+ }
2528
+ session.ws.send(
2529
+ JSON.stringify({
2530
+ type: "control_command",
2531
+ sessionId: inbound.sessionId,
2532
+ token: session.token,
2533
+ requestId: `tg:${inbound.updateId}`,
2534
+ updateId: inbound.updateId,
2535
+ threadId: inbound.threadId,
2536
+ command: control.command,
2537
+ }),
2538
+ );
2539
+ return;
2540
+ }
2215
2541
  const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
2216
2542
  // A plain (non-config) message while an ask is pending for this session
2217
2543
  // answers that ask as free-input — instead of starting a new user turn.
@@ -2228,6 +2554,15 @@ export class TelegramNotificationDaemon {
2228
2554
  }),
2229
2555
  );
2230
2556
  await this.rememberSeenUpdateId(inbound.updateId);
2557
+ await this.botApi
2558
+ .call("sendMessage", {
2559
+ chat_id: this.opts.chatId,
2560
+ message_thread_id: Number(inbound.threadId),
2561
+ text: "Received as an answer to the pending ask.",
2562
+ })
2563
+ .catch(error => {
2564
+ logger.warn(`telegram: failed to acknowledge pending ask reply: ${String(error)}`);
2565
+ });
2231
2566
  if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
2232
2567
  return;
2233
2568
  }
@@ -2291,7 +2626,12 @@ export class TelegramNotificationDaemon {
2291
2626
  { command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
2292
2627
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
2293
2628
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
2294
- { command: "session_create", description: "Create a SKC session: path, worktree, or dir" },
2629
+ { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
2630
+ { command: "reasoning", description: "Show or change reasoning effort in this session" },
2631
+ { command: "usage", description: "Show provider/local usage for this session" },
2632
+ { command: "context", description: "Show current context usage for this session" },
2633
+ { command: "compact", description: "Compact this session: /compact [instructions]" },
2634
+ { command: "session_create", description: "Create a SKC session: path, worktree, or dir [--mpreset]" },
2295
2635
  { command: "session_recent", description: "List recent SKC sessions" },
2296
2636
  { command: "session_close", description: "Close a SKC-managed session" },
2297
2637
  { command: "session_resume", description: "Resume or reattach a session" },
@@ -2321,6 +2661,7 @@ export class TelegramNotificationDaemon {
2321
2661
  await this.loadAliases();
2322
2662
  await this.loadTopics();
2323
2663
  await this.loadSeenUpdateIds();
2664
+ await this.replyStore.load();
2324
2665
  await this.runScan();
2325
2666
  // Owner-only: start the session-lifecycle control server now that
2326
2667
  // ownership is confirmed (singleton-safe). Best-effort; degrades.
@@ -2341,31 +2682,30 @@ export class TelegramNotificationDaemon {
2341
2682
  await this.runScan();
2342
2683
  if (await this.controlStopRequested()) break;
2343
2684
  const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
2344
- if (this.sessions.size === 0 && !this.lifecycleControlActive) {
2345
- // No sessions and no lifecycle control: idle-exit on timeout.
2346
- if (idleElapsed) break;
2347
- } else {
2348
- // Poll getUpdates when sessions exist OR lifecycle control is active
2349
- // (so phone /session_* commands are received even with zero sessions).
2350
- // With zero sessions, still idle-exit after the timeout so the owner
2351
- // does not run forever; an active session resets the idle window.
2352
- if (this.sessions.size > 0) idleSince = this.runtime.now();
2353
- else if (idleElapsed) break;
2354
- const activePoll = this.runtime.createAbortController();
2355
- try {
2356
- await this.pollOnce(activePoll.signal);
2357
- this.loopBackoff.reset();
2358
- } catch (e) {
2359
- // A transient getUpdates/network failure must not kill the
2360
- // daemon. Back off (bounded, below the heartbeat TTL) and keep
2361
- // renewing ownership at the loop top.
2362
- const backoffMs = this.loopBackoff.next();
2363
- logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
2364
- await this.runtime.sleep(backoffMs);
2365
- continue;
2366
- } finally {
2367
- this.runtime.clearAbortController(activePoll);
2368
- }
2685
+ if (this.sessions.size > 0) {
2686
+ idleSince = this.runtime.now();
2687
+ } else if (idleElapsed) {
2688
+ // Zero sessions past the idle window: exit so the owner does not run
2689
+ // forever. An active session resets the idle window above.
2690
+ break;
2691
+ }
2692
+ // Poll getUpdates whenever the daemon owns the token — even with zero
2693
+ // sessions and no lifecycle control — so daemon-local commands (/rich,
2694
+ // /session_*) are always received until idle-exit.
2695
+ const activePoll = this.runtime.createAbortController();
2696
+ try {
2697
+ await this.pollOnce(activePoll.signal);
2698
+ this.loopBackoff.reset();
2699
+ } catch (e) {
2700
+ // A transient getUpdates/network failure must not kill the daemon.
2701
+ // Back off (bounded, below the heartbeat TTL) and keep renewing
2702
+ // ownership at the loop top.
2703
+ const backoffMs = this.loopBackoff.next();
2704
+ logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
2705
+ await this.runtime.sleep(backoffMs);
2706
+ continue;
2707
+ } finally {
2708
+ this.runtime.clearAbortController(activePoll);
2369
2709
  }
2370
2710
  if (await this.controlStopRequested()) break;
2371
2711
  await this.runtime.sleep(10);