@sayknow-cli/coding-agent 0.3.8 → 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 (57) hide show
  1. package/dist/types/config/settings-schema.d.ts +14 -0
  2. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  3. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  4. package/dist/types/modes/interactive-mode.d.ts +2 -0
  5. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  6. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  7. package/dist/types/modes/types.d.ts +2 -0
  8. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  9. package/dist/types/notifications/config-commands.d.ts +9 -0
  10. package/dist/types/notifications/config.d.ts +16 -0
  11. package/dist/types/notifications/index.d.ts +2 -0
  12. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  13. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  14. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  15. package/dist/types/notifications/rich-draft.d.ts +68 -0
  16. package/dist/types/notifications/rich-render.d.ts +90 -0
  17. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  18. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  19. package/dist/types/notifications/threaded-render.d.ts +8 -0
  20. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  21. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  22. package/package.json +7 -7
  23. package/src/cli/notify-cli.ts +11 -9
  24. package/src/cli/update-cli.ts +63 -24
  25. package/src/config/keybindings.ts +1 -1
  26. package/src/config/settings-schema.ts +8 -0
  27. package/src/internal-urls/docs-index.generated.ts +5 -5
  28. package/src/lsp/client.ts +1 -0
  29. package/src/modes/components/footer.ts +7 -2
  30. package/src/modes/components/thinking-selector.ts +10 -8
  31. package/src/modes/controllers/event-controller.ts +11 -2
  32. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  33. package/src/modes/controllers/input-controller.ts +12 -17
  34. package/src/modes/controllers/selector-controller.ts +41 -0
  35. package/src/modes/interactive-mode.ts +5 -0
  36. package/src/modes/rpc/rpc-mode.ts +3 -9
  37. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  38. package/src/modes/types.ts +2 -0
  39. package/src/modes/utils/injected-user-submission.ts +94 -0
  40. package/src/notifications/config-commands.ts +20 -0
  41. package/src/notifications/config.ts +27 -0
  42. package/src/notifications/index.ts +28 -5
  43. package/src/notifications/lifecycle-commands.ts +37 -16
  44. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  45. package/src/notifications/reply-sent-store.ts +134 -0
  46. package/src/notifications/rich-draft.ts +107 -0
  47. package/src/notifications/rich-render.ts +142 -0
  48. package/src/notifications/telegram-daemon.ts +346 -89
  49. package/src/notifications/telegram-reference.ts +17 -0
  50. package/src/notifications/threaded-render.ts +28 -1
  51. package/src/prompts/tools/search-tool-bm25.md +5 -0
  52. package/src/sdk.ts +36 -8
  53. package/src/session/agent-session.ts +6 -4
  54. package/src/skc-runtime/tmux-common.ts +1 -0
  55. package/src/skc-runtime/ultragoal-runtime.ts +10 -1
  56. package/src/slash-commands/builtin-registry.ts +78 -1
  57. 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 } 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,
@@ -843,6 +847,10 @@ export interface TelegramDaemonOptions {
843
847
  * default applies (e.g. lifecycle control disabled), no control server starts.
844
848
  */
845
849
  createLifecycleControlServer?: LifecycleControlServerFactory | null;
850
+ /** Rich text promotion (enabled by default; see rich-render.ts). */
851
+ rich?: { enabled: boolean };
852
+ /** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
853
+ richDraft?: { enabled: boolean };
846
854
  }
847
855
 
848
856
  interface SessionSocket {
@@ -883,6 +891,10 @@ export class TelegramNotificationDaemon {
883
891
  private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
884
892
  private readonly poller: TelegramUpdatePoller;
885
893
  private readonly dispatchState = new TelegramEventDispatchState();
894
+ /** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
895
+ private readonly replyStore: ReplySentStore;
896
+ /** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
897
+ private readonly draftStream = new DraftStreamState();
886
898
  /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
887
899
  private readonly topicOwnerByIdentity = new Map<string, string>();
888
900
  /** Non-identity frames held until identity creates the correct thread. */
@@ -1120,7 +1132,7 @@ export class TelegramNotificationDaemon {
1120
1132
  /** Build an authenticated lifecycle frame from a parsed command + identity. */
1121
1133
  private buildLifecycleFrame(
1122
1134
  parsed:
1123
- | { kind: "create"; target: SessionCreateTarget }
1135
+ | { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
1124
1136
  | { kind: "close"; target: SessionCloseTarget }
1125
1137
  | { kind: "resume"; target: SessionResumeTarget },
1126
1138
  updateId: number,
@@ -1138,6 +1150,7 @@ export class TelegramNotificationDaemon {
1138
1150
  chatId,
1139
1151
  token,
1140
1152
  target: parsed.target,
1153
+ modelPreset: parsed.modelPreset,
1141
1154
  };
1142
1155
  }
1143
1156
  if (parsed.kind === "close") {
@@ -1242,6 +1255,7 @@ export class TelegramNotificationDaemon {
1242
1255
 
1243
1256
  constructor(private readonly opts: TelegramDaemonOptions) {
1244
1257
  this.fsImpl = opts.fs ?? nodeFs;
1258
+ this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
1245
1259
  this.aliasTable = createAliasTable();
1246
1260
  this.botApi =
1247
1261
  opts.botApi ??
@@ -1798,8 +1812,23 @@ export class TelegramNotificationDaemon {
1798
1812
  return { images, fileNotes };
1799
1813
  }
1800
1814
 
1815
+ /**
1816
+ * Serialize all pool flushes. Every caller (`submitThreadedFrame`, the flat
1817
+ * fallback, the drain timer's `void this.flushPool()`, topic teardown) goes
1818
+ * through one promise chain, so two flushes never interleave — a live send can
1819
+ * never be in-flight while a finalized flush reads `liveMessages` and decides
1820
+ * to post a fresh (duplicate) final. Errors are swallowed so one failed flush
1821
+ * never poisons the queue (each flush is already best-effort internally).
1822
+ */
1823
+ private flushChain: Promise<void> = Promise.resolve();
1824
+ private flushPool(): Promise<void> {
1825
+ const next = this.flushChain.then(() => this.flushPoolInner());
1826
+ this.flushChain = next.catch(() => {});
1827
+ return next;
1828
+ }
1829
+
1801
1830
  /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
1802
- private async flushPool(): Promise<void> {
1831
+ private async flushPoolInner(): Promise<void> {
1803
1832
  const batch = this.pool.drain();
1804
1833
  // Within a batch a finalized frame supersedes any still-queued live frame for
1805
1834
  // the same streamed message (finalized outranks live), so drop the stale live
@@ -1811,6 +1840,17 @@ export class TelegramNotificationDaemon {
1811
1840
  finalizedKeys.add(`${item.sessionId}:${item.coalesceKey}`);
1812
1841
  }
1813
1842
  }
1843
+ // Cross-batch protection: also purge any live frame still QUEUED for a
1844
+ // message whose finalized frame is in this batch, so a stale live edit can
1845
+ // never be delivered on a later drain after the authoritative final.
1846
+ if (finalizedKeys.size > 0) {
1847
+ this.pool.removeWhere(
1848
+ it =>
1849
+ it.lane === "live" &&
1850
+ it.coalesceKey !== undefined &&
1851
+ finalizedKeys.has(`${it.sessionId}:${it.coalesceKey}`),
1852
+ );
1853
+ }
1814
1854
  for (const item of batch) {
1815
1855
  const { send, topicId } = item.payload;
1816
1856
  if (topicId && !(await this.pairedChatIsPrivate())) continue;
@@ -1820,6 +1860,33 @@ export class TelegramNotificationDaemon {
1820
1860
  const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
1821
1861
  if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
1822
1862
  try {
1863
+ // Draft streaming (opt-in, off by default): stream a live turn frame as a
1864
+ // best-effort rich-draft preview, debounced to >=1.5s per session through
1865
+ // this same rate-limited drain; a finalized frame ends the turn's draft
1866
+ // window. Entirely inert when richDraft is off (the enabled gate /
1867
+ // shouldStreamDraft fail closed), so off-state HTML request bodies stay
1868
+ // byte-identical.
1869
+ if (this.opts.richDraft?.enabled === true && this.opts.rich?.enabled !== false) {
1870
+ if (send.lane === "finalized" && send.method === "sendMessage") {
1871
+ this.draftStream.reset(item.sessionId);
1872
+ } else if (
1873
+ shouldStreamDraft({
1874
+ enabled: this.opts.richDraft.enabled,
1875
+ send,
1876
+ })
1877
+ ) {
1878
+ const draftId = this.draftStream.tryClaim(item.sessionId, this.opts.now?.() ?? Date.now());
1879
+ if (draftId !== undefined) {
1880
+ await deliverDraft(
1881
+ this.botApi,
1882
+ { chat_id: this.opts.chatId, ...threadField },
1883
+ draftId,
1884
+ send.richDraftMarkdown!,
1885
+ logger,
1886
+ );
1887
+ }
1888
+ }
1889
+ }
1823
1890
  if (send.method === "sendPhoto" && send.photoBase64) {
1824
1891
  // Real photo upload (the default botApi multiparts base64 -> file).
1825
1892
  await this.botApi.call("sendPhoto", {
@@ -1841,53 +1908,139 @@ export class TelegramNotificationDaemon {
1841
1908
  parse_mode: TELEGRAM_PARSE_MODE,
1842
1909
  });
1843
1910
  } 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,
1911
+ // Rich pre-branch: promote stable non-editable finalized text to a fresh
1912
+ // sendRichMessage when enabled. Off/miss falls through to the unchanged
1913
+ // upstream edit/send path, so off behavior is byte-identical.
1914
+ if (
1915
+ shouldPromoteRich({
1916
+ enabled: this.opts.rich?.enabled === false ? false : true,
1917
+ send,
1918
+ })
1919
+ ) {
1920
+ const sendHtmlFallback = async () => {
1921
+ // Fairness: this frame consumed exactly one token, so send only the
1922
+ // first HTML chunk now and requeue any continuations as their own
1923
+ // non-editable, HTML-only pool items (rich markers stripped) — same
1924
+ // per-token discipline as the non-rich split path.
1925
+ const chunks = splitTelegramHtml(send.text!);
1926
+ await this.botApi.call("sendMessage", {
1927
+ chat_id: this.opts.chatId,
1928
+ ...threadField,
1929
+ text: chunks[0]!,
1930
+ parse_mode: TELEGRAM_PARSE_MODE,
1931
+ });
1932
+ for (let i = 1; i < chunks.length; i++) {
1933
+ this.pool.submit({
1934
+ sessionId: item.sessionId,
1935
+ lane: item.lane,
1936
+ payload: {
1937
+ send: {
1938
+ ...send,
1939
+ method: "sendMessage",
1940
+ text: chunks[i]!,
1941
+ editable: false,
1942
+ coalesceKey: undefined,
1943
+ photoBase64: undefined,
1944
+ documentBase64: undefined,
1945
+ richMarkdown: undefined,
1946
+ richDraftMarkdown: undefined,
1947
+ richClass: undefined,
1948
+ },
1949
+ topicId,
1885
1950
  },
1886
- topicId,
1887
- },
1951
+ });
1952
+ }
1953
+ };
1954
+ const richMessageId = await deliverRichWithFallback(
1955
+ this.botApi,
1956
+ { chat_id: this.opts.chatId, ...threadField },
1957
+ send,
1958
+ sendHtmlFallback,
1959
+ logger,
1960
+ );
1961
+ // Index the sent rich message so an inbound reply to it can restore
1962
+ // the original markdown as context (Telegram does not echo it back).
1963
+ if (richMessageId !== undefined) {
1964
+ await this.replyStore.record({
1965
+ chatId: this.opts.chatId,
1966
+ messageId: richMessageId,
1967
+ text: send.richMarkdown!,
1888
1968
  });
1889
1969
  }
1890
- const firstMessageId = res?.result?.message_id;
1970
+ } else {
1971
+ const chunks = splitTelegramHtml(send.text);
1972
+ const existingId = editKey ? this.liveMessages.get(editKey) : undefined;
1973
+ let firstMessageId: number | undefined;
1974
+ if (editKey && existingId !== undefined) {
1975
+ // Edit the existing streamed message in place with the first chunk
1976
+ // so a finalized turn never leaves a stale live preview. A LOCAL
1977
+ // try/catch keeps a failed edit from aborting the continuation
1978
+ // requeue below; "message is not modified" is a success (the message
1979
+ // already shows this text); a missing/deleted backing message (or a
1980
+ // transport error) resends so the first chunk is never lost.
1981
+ let edited = false;
1982
+ try {
1983
+ const res = (await this.botApi.call("editMessageText", {
1984
+ chat_id: this.opts.chatId,
1985
+ message_id: existingId,
1986
+ text: chunks[0],
1987
+ parse_mode: TELEGRAM_PARSE_MODE,
1988
+ })) as { ok?: boolean; description?: string } | null;
1989
+ edited = res?.ok !== false || /not modified/i.test(String(res?.description ?? ""));
1990
+ } catch {
1991
+ edited = false;
1992
+ }
1993
+ if (edited) {
1994
+ firstMessageId = existingId;
1995
+ } else {
1996
+ const res = (await this.botApi.call("sendMessage", {
1997
+ chat_id: this.opts.chatId,
1998
+ ...threadField,
1999
+ text: chunks[0]!,
2000
+ parse_mode: TELEGRAM_PARSE_MODE,
2001
+ })) as { result?: { message_id?: number } };
2002
+ firstMessageId = res?.result?.message_id;
2003
+ }
2004
+ } else {
2005
+ // No streamed message to edit: a single granted slot maps to a
2006
+ // single Telegram send.
2007
+ const res = (await this.botApi.call("sendMessage", {
2008
+ chat_id: this.opts.chatId,
2009
+ ...threadField,
2010
+ text: chunks[0]!,
2011
+ parse_mode: TELEGRAM_PARSE_MODE,
2012
+ })) as { result?: { message_id?: number } };
2013
+ firstMessageId = res?.result?.message_id;
2014
+ }
2015
+ // Continuation chunks are FINALIZED-lane only. A live preview is a
2016
+ // single edit-safe chunk (its authoritative full text arrives with the
2017
+ // finalized frame), so a split live frame never fans out into stale,
2018
+ // non-coalesced continuation messages. Finalized continuations are
2019
+ // fresh, non-editable, HTML-only sends (rich markers stripped) so they
2020
+ // can never be re-promoted to a duplicate sendRichMessage.
2021
+ if (item.lane !== "live") {
2022
+ for (let i = 1; i < chunks.length; i++) {
2023
+ this.pool.submit({
2024
+ sessionId: item.sessionId,
2025
+ lane: item.lane,
2026
+ payload: {
2027
+ send: {
2028
+ ...send,
2029
+ method: "sendMessage",
2030
+ text: chunks[i]!,
2031
+ editable: false,
2032
+ coalesceKey: undefined,
2033
+ photoBase64: undefined,
2034
+ documentBase64: undefined,
2035
+ richMarkdown: undefined,
2036
+ richDraftMarkdown: undefined,
2037
+ richClass: undefined,
2038
+ },
2039
+ topicId,
2040
+ },
2041
+ });
2042
+ }
2043
+ }
1891
2044
  if (editKey && ckey !== undefined && firstMessageId !== undefined) {
1892
2045
  this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
1893
2046
  }
@@ -1959,7 +2112,7 @@ export class TelegramNotificationDaemon {
1959
2112
  try {
1960
2113
  await this.botApi.call("sendMessage", {
1961
2114
  chat_id: this.opts.chatId,
1962
- text: "turn on threaded mode from botfather miniapp to receive skc notification!",
2115
+ text: "Flat Telegram private chat supports outbound notifications and inline ask buttons only. Enable Threaded Mode in @BotFather > Bot Settings > Threads Settings for free-text replies and session commands.",
1963
2116
  parse_mode: TELEGRAM_PARSE_MODE,
1964
2117
  });
1965
2118
  } catch {
@@ -2108,24 +2261,58 @@ export class TelegramNotificationDaemon {
2108
2261
  });
2109
2262
  const options = Array.isArray(msg.options) ? msg.options : [];
2110
2263
  // Daemon keyboards use alias callback data with compact one-based tap targets;
2111
- // full option text is rendered in the message body by buildActionMessage.
2264
+ // full option text is rendered in the message body by buildActionMessage/buildActionMarkdown.
2112
2265
  const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
2113
2266
  this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
2114
2267
  );
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 } };
2268
+ // HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
2269
+ // returns the last chunk's message_id (the reply-routable message).
2270
+ const sendHtmlChunks = async (): Promise<number | undefined> => {
2271
+ const chunks = splitTelegramHtml(rendered.text);
2272
+ let result: { result?: { message_id?: number } } = {};
2273
+ for (let i = 0; i < chunks.length; i++) {
2274
+ result = (await this.botApi.call("sendMessage", {
2275
+ chat_id: this.opts.chatId,
2276
+ ...threadField,
2277
+ text: chunks[i]!,
2278
+ parse_mode: TELEGRAM_PARSE_MODE,
2279
+ ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
2280
+ })) as { result?: { message_id?: number } };
2281
+ }
2282
+ return result.result?.message_id;
2283
+ };
2284
+ const kind = msg.kind === "idle" ? "idle" : "ask";
2285
+ if (this.opts.rich?.enabled !== false) {
2286
+ // Rich (default on): promote to sendRichMessage with a top-level
2287
+ // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
2288
+
2289
+ const outcome = await deliverRichActionWithFallback(
2290
+ this.botApi,
2291
+ { chat_id: this.opts.chatId, ...threadField },
2292
+ {
2293
+ markdown: buildActionMarkdown({
2294
+ kind,
2295
+ question: msg.question,
2296
+ options: msg.options,
2297
+ summary: msg.summary,
2298
+ }),
2299
+ replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
2300
+ requireMessageId: kind === "ask",
2301
+ },
2302
+ sendHtmlChunks,
2303
+ logger,
2304
+ );
2305
+ // Only asks are reply-routable; idle pings register no route.
2306
+ if (kind === "ask" && outcome.messageId !== undefined)
2307
+ this.messageRoutes.set(String(outcome.messageId), { sessionId: session.sessionId, actionId: msg.id });
2308
+ } else {
2309
+ // Off: byte-identical to the pre-rich HTML path.
2310
+ const messageId = await sendHtmlChunks();
2311
+ // Only asks are reply-routable; idle pings register no route (parity
2312
+ // with the rich branch and correct even in the byte-identical off path).
2313
+ if (kind === "ask" && messageId !== undefined)
2314
+ this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
2125
2315
  }
2126
- const messageId = result.result?.message_id;
2127
- if (messageId !== undefined)
2128
- this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
2129
2316
  await this.persistAliases();
2130
2317
  } else if (msg.type === "action_resolved" && msg.id) {
2131
2318
  session.pending.delete(msg.id);
@@ -2181,6 +2368,65 @@ export class TelegramNotificationDaemon {
2181
2368
  }
2182
2369
  }
2183
2370
  }
2371
+ // Rich-message toggle (/rich on|off): daemon-local delivery policy, NOT a
2372
+ // session config forward. Handled at paired-chat pre-routing, before threaded
2373
+ // injection and independent of any session WebSocket, so it works even when
2374
+ // no session is connected and never becomes an ask answer.
2375
+ {
2376
+ const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
2377
+ const chat = m?.chat as { id?: unknown } | undefined;
2378
+ const cmdText = typeof m?.text === "string" ? m.text : undefined;
2379
+ const rawFirst = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
2380
+ // Fail-closed: intercept ANY "/rich" or "/rich@<anything>" form (Telegram
2381
+ // appends @botname in groups; the bot username may be unknown if getMe
2382
+ // failed) so a rich command is never leaked into threaded injection / an
2383
+ // ask answer. Argument validity is decided by parseRichToggleCommand below.
2384
+ const isRichCommand = rawFirst?.split("@")[0] === "/rich";
2385
+ if (m !== undefined && String(chat?.id) === String(this.opts.chatId) && isRichCommand) {
2386
+ // Fail-closed: /rich mutates global config, so honor it ONLY in a PRIVATE
2387
+ // paired chat — the same contract as session delivery and lifecycle
2388
+ // commands. A group/supergroup chatId (legacy or hand-edited) must never
2389
+ // let an arbitrary chat member toggle the owner's notification config.
2390
+ if (!(await this.pairedChatIsPrivate())) return;
2391
+ const updateId = (update as { update_id?: number }).update_id;
2392
+ // Dedupe redelivered updates so a toggle+confirmation runs at most once.
2393
+ if (typeof updateId === "number") {
2394
+ if (this.dispatchState.seenUpdateIds.has(updateId)) return;
2395
+ await this.rememberSeenUpdateId(updateId);
2396
+ }
2397
+ const threadField =
2398
+ typeof m.message_thread_id === "number" ? { message_thread_id: m.message_thread_id as number } : {};
2399
+ const reply = async (body: string): Promise<void> => {
2400
+ try {
2401
+ await this.botApi.call("sendMessage", {
2402
+ chat_id: this.opts.chatId,
2403
+ ...threadField,
2404
+ text: body,
2405
+ parse_mode: TELEGRAM_PARSE_MODE,
2406
+ });
2407
+ } catch {
2408
+ // Best-effort confirmation; never block on the notice.
2409
+ }
2410
+ };
2411
+ const desired = parseRichToggleCommand(cmdText ?? "");
2412
+ if (desired === undefined) {
2413
+ await reply("Usage: /rich on|off");
2414
+ return;
2415
+ }
2416
+ try {
2417
+ await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
2418
+ } catch (err) {
2419
+ logger.warn(
2420
+ `notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
2421
+ );
2422
+ await reply("Rich messages: unchanged (settings write failed)");
2423
+ return;
2424
+ }
2425
+ this.opts.rich = { enabled: desired };
2426
+ await reply(desired ? "Rich messages: on" : "Rich messages: off");
2427
+ return;
2428
+ }
2429
+ }
2184
2430
  // Threaded injection: a free-text message in a known topic (not a button
2185
2431
  // tap and not a reply to a specific ask message) injects a user turn or an
2186
2432
  // in-thread config command. Fail-closed: paired chat + known topic +
@@ -2211,7 +2457,17 @@ export class TelegramNotificationDaemon {
2211
2457
  const images = attachmentResult?.images ?? [];
2212
2458
  const fileNotes = attachmentResult?.fileNotes ?? [];
2213
2459
  const hasMedia = images.length > 0 || fileNotes.length > 0;
2214
- const injectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
2460
+ const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
2461
+ // A reply to a rich message we sent (not an ask route) loses its original
2462
+ // text: Telegram does not echo it in reply_to_message. Restore it from the
2463
+ // reply index as a labeled context prefix; a miss leaves the turn unchanged.
2464
+ const repliedOriginal =
2465
+ typeof replyTo === "number"
2466
+ ? this.replyStore.lookup({ chatId: this.opts.chatId, messageId: replyTo })
2467
+ : undefined;
2468
+ const injectedText = repliedOriginal
2469
+ ? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
2470
+ : baseInjectedText;
2215
2471
  const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
2216
2472
  // A plain (non-config) message while an ask is pending for this session
2217
2473
  // answers that ask as free-input — instead of starting a new user turn.
@@ -2291,7 +2547,8 @@ export class TelegramNotificationDaemon {
2291
2547
  { command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
2292
2548
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
2293
2549
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
2294
- { command: "session_create", description: "Create a SKC session: path, worktree, or dir" },
2550
+ { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
2551
+ { command: "session_create", description: "Create a SKC session: path, worktree, or dir [--mpreset]" },
2295
2552
  { command: "session_recent", description: "List recent SKC sessions" },
2296
2553
  { command: "session_close", description: "Close a SKC-managed session" },
2297
2554
  { command: "session_resume", description: "Resume or reattach a session" },
@@ -2321,6 +2578,7 @@ export class TelegramNotificationDaemon {
2321
2578
  await this.loadAliases();
2322
2579
  await this.loadTopics();
2323
2580
  await this.loadSeenUpdateIds();
2581
+ await this.replyStore.load();
2324
2582
  await this.runScan();
2325
2583
  // Owner-only: start the session-lifecycle control server now that
2326
2584
  // ownership is confirmed (singleton-safe). Best-effort; degrades.
@@ -2341,31 +2599,30 @@ export class TelegramNotificationDaemon {
2341
2599
  await this.runScan();
2342
2600
  if (await this.controlStopRequested()) break;
2343
2601
  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
- }
2602
+ if (this.sessions.size > 0) {
2603
+ idleSince = this.runtime.now();
2604
+ } else if (idleElapsed) {
2605
+ // Zero sessions past the idle window: exit so the owner does not run
2606
+ // forever. An active session resets the idle window above.
2607
+ break;
2608
+ }
2609
+ // Poll getUpdates whenever the daemon owns the token — even with zero
2610
+ // sessions and no lifecycle control — so daemon-local commands (/rich,
2611
+ // /session_*) are always received until idle-exit.
2612
+ const activePoll = this.runtime.createAbortController();
2613
+ try {
2614
+ await this.pollOnce(activePoll.signal);
2615
+ this.loopBackoff.reset();
2616
+ } catch (e) {
2617
+ // A transient getUpdates/network failure must not kill the daemon.
2618
+ // Back off (bounded, below the heartbeat TTL) and keep renewing
2619
+ // ownership at the loop top.
2620
+ const backoffMs = this.loopBackoff.next();
2621
+ logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
2622
+ await this.runtime.sleep(backoffMs);
2623
+ continue;
2624
+ } finally {
2625
+ this.runtime.clearAbortController(activePoll);
2369
2626
  }
2370
2627
  if (await this.controlStopRequested()) break;
2371
2628
  await this.runtime.sleep(10);
@@ -143,6 +143,23 @@ export function buildActionMessage(action: {
143
143
  return { text: body, inline_keyboard };
144
144
  }
145
145
 
146
+ /** Render an `action_needed` body as raw markdown (rich-message source; the HTML fallback stays on buildActionMessage). */
147
+ export function buildActionMarkdown(action: {
148
+ kind: "ask" | "idle";
149
+ question?: string;
150
+ options?: string[];
151
+ summary?: string;
152
+ }): string {
153
+ if (action.kind === "idle") {
154
+ return action.summary ? `🟢 Agent idle\n${action.summary}` : "🟢 Agent idle";
155
+ }
156
+ const heading = `❓ **${action.question ?? "Question"}**`;
157
+ const options = action.options ?? [];
158
+ if (options.length === 0) return `${heading}\n\n(reply with text)`;
159
+ const list = options.map((label, i) => `${i + 1}. ${label.replace(/^\s*\d+[.)]\s+/, "")}`).join("\n");
160
+ return `${heading}\n\n${list}`;
161
+ }
162
+
146
163
  /** Send Telegram HTML text chunks sequentially so long messages preserve order. */
147
164
  export async function sendTelegramHtmlChunks(
148
165
  send: TelegramSend,
@@ -38,6 +38,13 @@ export interface ThreadedSend {
38
38
  * message. Set for streamed turn frames so live + finalized share one message.
39
39
  */
40
40
  editable?: boolean;
41
+ /** Rich message class metadata. Only finalized final-answer sends are ever
42
+ * rich-promoted; the daemon gate (`shouldPromoteRich`) requires exactly this. */
43
+ richClass?: "final";
44
+ /** Rich final-answer markdown (raw). Delivery marker derived ONLY from a frame's `finalAnswer` bit; never inferred from `phase`. */
45
+ richMarkdown?: string;
46
+ /** Live-turn raw markdown for opt-in draft streaming (set ONLY on non-finalized turn frames; never triggers rich-final promotion, which requires `lane === "finalized"`). */
47
+ richDraftMarkdown?: string;
41
48
  }
42
49
 
43
50
  interface ThreadedFrame {
@@ -58,6 +65,7 @@ interface ThreadedFrame {
58
65
  cwd?: unknown;
59
66
  // turn_stream
60
67
  phase?: unknown;
68
+ finalAnswer?: boolean;
61
69
  text?: unknown;
62
70
  messageRef?: unknown;
63
71
  // image_attachment / file_attachment
@@ -75,6 +83,10 @@ function str(v: unknown): string | undefined {
75
83
  return typeof v === "string" && v.length > 0 ? v : undefined;
76
84
  }
77
85
 
86
+ function isDotOnlyText(value: string): boolean {
87
+ return /^[.\s]+$/.test(value);
88
+ }
89
+
78
90
  /** Format the one-time identity header as pinned bullets. */
79
91
  export function formatIdentityHeader(frame: {
80
92
  repo?: unknown;
@@ -139,6 +151,7 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
139
151
  case "turn_stream": {
140
152
  const raw = str(frame.text);
141
153
  if (!raw) return undefined;
154
+ if (frame.phase === "finalized" && isDotOnlyText(raw)) return undefined;
142
155
  const text = markdownToTelegramHtml(raw);
143
156
  const finalized = frame.phase === "finalized";
144
157
  // A per-turn ref ties the streamed live edits and the finalized text to
@@ -156,6 +169,16 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
156
169
  text,
157
170
  coalesceKey,
158
171
  editable: coalesceKey !== undefined,
172
+ // Rich-final markers are set ONLY on a non-editable finalized final
173
+ // (no messageRef / coalesceKey). A streamed (editable) final owns a live
174
+ // message edited in place, so it is never rich-promoted and carries no
175
+ // rich marker to leak into split continuations.
176
+ richClass: frame.finalAnswer === true && coalesceKey === undefined ? "final" : undefined,
177
+ richMarkdown: frame.finalAnswer === true && coalesceKey === undefined ? raw : undefined,
178
+ // Live-only draft marker: carries the RAW markdown on non-finalized turn
179
+ // frames so the opt-in draft gate has the source. It never arms rich-final
180
+ // promotion (shouldPromoteRich requires lane === "finalized").
181
+ richDraftMarkdown: finalized ? undefined : raw,
159
182
  };
160
183
  }
161
184
  case "image_attachment": {
@@ -188,7 +211,11 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
188
211
  const redact = typeof frame.redact === "boolean" ? `redact ${frame.redact ? "on" : "off"}` : undefined;
189
212
  const parts = [verbosity ? `verbosity ${verbosity}` : undefined, redact].filter(Boolean);
190
213
  return parts.length
191
- ? { method: "sendMessage", lane: "idle", text: finalizeTelegramHtml(`⚙ ${escapeHtml(parts.join(", "))}`) }
214
+ ? {
215
+ method: "sendMessage",
216
+ lane: "idle",
217
+ text: finalizeTelegramHtml(`⚙ ${escapeHtml(parts.join(", "))}`),
218
+ }
192
219
  : undefined;
193
220
  }
194
221
  default: