@gajae-code/coding-agent 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/types/append-only-mode.d.ts +31 -0
  3. package/dist/types/capability/rule.d.ts +8 -0
  4. package/dist/types/cli/fixture-report.d.ts +20 -0
  5. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  6. package/dist/types/cli/nofile-limit.d.ts +11 -0
  7. package/dist/types/cli/update-cli.d.ts +22 -0
  8. package/dist/types/config/settings-schema.d.ts +119 -1
  9. package/dist/types/discovery/index.d.ts +1 -0
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/export/ttsr.d.ts +14 -3
  12. package/dist/types/gjc-runtime/deep-interview-state.d.ts +5 -2
  13. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +7 -0
  14. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  15. package/dist/types/gjc-runtime/team-runtime.d.ts +26 -0
  16. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  17. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +119 -13
  19. package/dist/types/goals/runtime.d.ts +1 -0
  20. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  22. package/dist/types/modes/components/status-line.d.ts +9 -0
  23. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  26. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  27. package/dist/types/modes/interactive-mode.d.ts +4 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  29. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  30. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  31. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  32. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  33. package/dist/types/modes/types.d.ts +3 -0
  34. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  35. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  36. package/dist/types/notifications/config-commands.d.ts +9 -0
  37. package/dist/types/notifications/config.d.ts +22 -0
  38. package/dist/types/notifications/index.d.ts +2 -0
  39. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  40. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  41. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  42. package/dist/types/notifications/rich-draft.d.ts +68 -0
  43. package/dist/types/notifications/rich-render.d.ts +90 -0
  44. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  45. package/dist/types/notifications/telegram-daemon.d.ts +30 -0
  46. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  47. package/dist/types/notifications/threaded-render.d.ts +14 -0
  48. package/dist/types/sdk.d.ts +3 -0
  49. package/dist/types/session/agent-session.d.ts +43 -1
  50. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  51. package/dist/types/session/messages.d.ts +4 -0
  52. package/dist/types/session/session-manager.d.ts +17 -1
  53. package/dist/types/skill-state/active-state.d.ts +18 -1
  54. package/dist/types/system-prompt.d.ts +18 -2
  55. package/dist/types/task/executor.d.ts +1 -0
  56. package/dist/types/task/fork-context-budget.d.ts +7 -0
  57. package/dist/types/task/token-log.d.ts +30 -0
  58. package/dist/types/task/token-log.test.d.ts +1 -0
  59. package/dist/types/tools/bisect.d.ts +81 -0
  60. package/dist/types/tools/index.d.ts +1 -0
  61. package/dist/types/tools/search.d.ts +2 -2
  62. package/dist/types/utils/file-mentions.d.ts +14 -0
  63. package/dist/types/utils/git.d.ts +32 -0
  64. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  65. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  66. package/package.json +8 -11
  67. package/src/append-only-mode.ts +58 -0
  68. package/src/autoresearch/prompt.md +1 -1
  69. package/src/autoresearch/tools/log-experiment.ts +1 -1
  70. package/src/capability/rule.ts +9 -0
  71. package/src/cli/config-cli.ts +27 -3
  72. package/src/cli/fixture-report.test.ts +230 -0
  73. package/src/cli/fixture-report.ts +171 -0
  74. package/src/cli/nofile-limit.ts +48 -0
  75. package/src/cli/notify-cli.ts +11 -9
  76. package/src/cli/update-cli.ts +85 -9
  77. package/src/cli.ts +26 -0
  78. package/src/commands/contribution-prep.ts +1 -1
  79. package/src/config/keybindings.ts +1 -1
  80. package/src/config/settings-schema.ts +76 -1
  81. package/src/defaults/gjc/skills/deep-interview/SKILL.md +12 -13
  82. package/src/defaults/gjc/skills/ralplan/SKILL.md +30 -93
  83. package/src/defaults/gjc/skills/team/SKILL.md +16 -13
  84. package/src/defaults/gjc/skills/ultragoal/SKILL.md +59 -4
  85. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  86. package/src/defaults/gjc-defaults.ts +9 -0
  87. package/src/discovery/helpers.ts +10 -0
  88. package/src/discovery/index.ts +1 -0
  89. package/src/export/html/template.generated.ts +1 -1
  90. package/src/export/html/template.js +60 -2
  91. package/src/export/ttsr.ts +96 -18
  92. package/src/extensibility/extensions/runner.ts +181 -219
  93. package/src/extensibility/gjc-plugins/runtime-adapters.ts +156 -44
  94. package/src/gjc-runtime/deep-interview-state.ts +31 -2
  95. package/src/gjc-runtime/launch-tmux.ts +6 -0
  96. package/src/gjc-runtime/session-state-sidecar.ts +208 -13
  97. package/src/gjc-runtime/state-writer.ts +15 -0
  98. package/src/gjc-runtime/team-runtime.ts +175 -19
  99. package/src/gjc-runtime/tmux-common.ts +1 -0
  100. package/src/gjc-runtime/ultragoal-guard.ts +55 -66
  101. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +315 -0
  102. package/src/gjc-runtime/ultragoal-runtime.ts +1602 -143
  103. package/src/gjc-runtime/workflow-manifest.generated.json +113 -2
  104. package/src/gjc-runtime/workflow-manifest.ts +74 -1
  105. package/src/goals/runtime.ts +10 -2
  106. package/src/hindsight/backend.ts +1 -3
  107. package/src/hooks/native-skill-hook.ts +95 -4
  108. package/src/internal-urls/docs-index.generated.ts +18 -9
  109. package/src/lsp/client.ts +1 -0
  110. package/src/main.ts +43 -2
  111. package/src/memories/index.ts +12 -1
  112. package/src/modes/components/assistant-message.ts +241 -82
  113. package/src/modes/components/custom-editor.ts +1 -0
  114. package/src/modes/components/footer.ts +7 -2
  115. package/src/modes/components/status-line.ts +167 -54
  116. package/src/modes/components/thinking-selector.ts +10 -8
  117. package/src/modes/components/tool-execution.ts +44 -19
  118. package/src/modes/controllers/command-controller.ts +39 -2
  119. package/src/modes/controllers/event-controller.ts +77 -49
  120. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  121. package/src/modes/controllers/input-controller.ts +13 -18
  122. package/src/modes/controllers/selector-controller.ts +41 -0
  123. package/src/modes/interactive-mode.ts +14 -1
  124. package/src/modes/rpc/rpc-mode.ts +32 -12
  125. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  126. package/src/modes/rpc/rpc-types.ts +9 -0
  127. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  128. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  129. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  130. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  131. package/src/modes/types.ts +3 -0
  132. package/src/modes/utils/injected-user-submission.ts +94 -0
  133. package/src/modes/utils/ui-helpers.ts +17 -4
  134. package/src/notifications/config-commands.ts +20 -0
  135. package/src/notifications/config.ts +43 -4
  136. package/src/notifications/index.ts +105 -9
  137. package/src/notifications/lifecycle-commands.ts +37 -16
  138. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  139. package/src/notifications/reply-sent-store.ts +134 -0
  140. package/src/notifications/rich-draft.ts +107 -0
  141. package/src/notifications/rich-render.ts +142 -0
  142. package/src/notifications/telegram-daemon-cli.ts +54 -7
  143. package/src/notifications/telegram-daemon-control.ts +2 -2
  144. package/src/notifications/telegram-daemon.ts +390 -56
  145. package/src/notifications/telegram-reference.ts +17 -0
  146. package/src/notifications/threaded-render.ts +45 -2
  147. package/src/prompts/agents/architect.md +19 -10
  148. package/src/prompts/agents/critic.md +16 -18
  149. package/src/prompts/agents/executor.md +14 -11
  150. package/src/prompts/agents/planner.md +18 -13
  151. package/src/prompts/ci-green-request.md +3 -8
  152. package/src/prompts/goals/goal-continuation.md +0 -5
  153. package/src/prompts/goals/goal-mode-active.md +0 -5
  154. package/src/prompts/system/agent-creation-architect.md +2 -2
  155. package/src/prompts/system/plan-mode-active.md +1 -1
  156. package/src/prompts/system/plan-mode-subagent.md +3 -3
  157. package/src/prompts/system/project-prompt.md +0 -13
  158. package/src/prompts/system/subagent-system-prompt.md +3 -2
  159. package/src/prompts/system/system-prompt.md +26 -19
  160. package/src/prompts/system/volatile-project-context.md +10 -0
  161. package/src/prompts/system/web-search.md +4 -7
  162. package/src/prompts/tools/apply-patch.md +6 -0
  163. package/src/prompts/tools/ast-edit.md +1 -0
  164. package/src/prompts/tools/bash.md +1 -1
  165. package/src/prompts/tools/bisect.md +22 -0
  166. package/src/prompts/tools/browser.md +1 -1
  167. package/src/prompts/tools/cron.md +0 -2
  168. package/src/prompts/tools/eval.md +2 -2
  169. package/src/prompts/tools/find.md +1 -1
  170. package/src/prompts/tools/github.md +6 -6
  171. package/src/prompts/tools/goal.md +1 -3
  172. package/src/prompts/tools/image-gen.md +12 -5
  173. package/src/prompts/tools/irc.md +5 -4
  174. package/src/prompts/tools/job.md +2 -2
  175. package/src/prompts/tools/lsp.md +1 -1
  176. package/src/prompts/tools/monitor.md +2 -2
  177. package/src/prompts/tools/patch.md +2 -2
  178. package/src/prompts/tools/replace.md +2 -21
  179. package/src/prompts/tools/rewind.md +1 -1
  180. package/src/prompts/tools/search-tool-bm25.md +6 -11
  181. package/src/prompts/tools/search.md +1 -1
  182. package/src/prompts/tools/skill.md +3 -2
  183. package/src/prompts/tools/ssh.md +2 -1
  184. package/src/prompts/tools/subagent.md +2 -2
  185. package/src/prompts/tools/task.md +4 -5
  186. package/src/prompts/tools/todo-write.md +1 -1
  187. package/src/prompts/tools/vim.md +1 -0
  188. package/src/prompts/tools/web-search.md +2 -0
  189. package/src/prompts/tools/write.md +8 -2
  190. package/src/sdk.ts +68 -30
  191. package/src/session/agent-session.ts +734 -107
  192. package/src/session/file-mention-pruning.ts +64 -0
  193. package/src/session/messages.ts +4 -0
  194. package/src/session/session-manager.ts +59 -2
  195. package/src/skill-state/active-state.ts +141 -5
  196. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  197. package/src/slash-commands/builtin-registry.ts +143 -1
  198. package/src/system-prompt.ts +57 -16
  199. package/src/task/executor.ts +125 -20
  200. package/src/task/fork-context-advisory.ts +2 -7
  201. package/src/task/fork-context-budget.ts +9 -0
  202. package/src/task/index.ts +4 -3
  203. package/src/task/output-manager.ts +4 -2
  204. package/src/task/token-log.test.ts +188 -0
  205. package/src/task/token-log.ts +179 -0
  206. package/src/tools/bisect.ts +367 -0
  207. package/src/tools/index.ts +3 -0
  208. package/src/tools/output-meta.ts +116 -4
  209. package/src/tools/search.ts +8 -2
  210. package/src/tools/skill.ts +15 -0
  211. package/src/utils/file-mentions.ts +39 -7
  212. package/src/utils/git.ts +73 -0
  213. package/src/utils/pasted-image-path.ts +170 -0
  214. package/src/web/scrapers/youtube.ts +24 -12
@@ -8,8 +8,8 @@ import { withFileLock } from "../config/file-lock";
8
8
  import type { Settings } from "../config/settings";
9
9
  import type { DaemonRuntimeInfo } from "../daemon/control-types";
10
10
  import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
11
- import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
12
- import { parseInThreadConfigCommand } from "./config-commands";
11
+ import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
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,
@@ -636,7 +640,7 @@ export async function ensureTelegramDaemonRunning(
636
640
  deps: TelegramDaemonDeps = {},
637
641
  ): Promise<EnsureDaemonResult> {
638
642
  const cfg = getNotificationConfig(input.settings);
639
- if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return "disabled";
643
+ if (!isTelegramConfigured(cfg)) return "disabled";
640
644
  const root = notificationRootForCwd(input.cwd);
641
645
  const fp = tokenFingerprint(cfg.botToken);
642
646
  const spawned = await spawnTelegramDaemonOwner(
@@ -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 {
@@ -869,6 +877,8 @@ interface PendingThreadedFrame {
869
877
  export class TelegramNotificationDaemon {
870
878
  readonly aliasTable: AliasTable;
871
879
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
880
+ /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
881
+ private readonly liveMessages = new Map<string, number>();
872
882
  readonly sessions = new Map<string, SessionSocket>();
873
883
  private readonly runtime: NotificationOperatorRuntime;
874
884
  private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
@@ -881,6 +891,10 @@ export class TelegramNotificationDaemon {
881
891
  private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
882
892
  private readonly poller: TelegramUpdatePoller;
883
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();
884
898
  /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
885
899
  private readonly topicOwnerByIdentity = new Map<string, string>();
886
900
  /** Non-identity frames held until identity creates the correct thread. */
@@ -1118,7 +1132,7 @@ export class TelegramNotificationDaemon {
1118
1132
  /** Build an authenticated lifecycle frame from a parsed command + identity. */
1119
1133
  private buildLifecycleFrame(
1120
1134
  parsed:
1121
- | { kind: "create"; target: SessionCreateTarget }
1135
+ | { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
1122
1136
  | { kind: "close"; target: SessionCloseTarget }
1123
1137
  | { kind: "resume"; target: SessionResumeTarget },
1124
1138
  updateId: number,
@@ -1136,6 +1150,7 @@ export class TelegramNotificationDaemon {
1136
1150
  chatId,
1137
1151
  token,
1138
1152
  target: parsed.target,
1153
+ modelPreset: parsed.modelPreset,
1139
1154
  };
1140
1155
  }
1141
1156
  if (parsed.kind === "close") {
@@ -1240,6 +1255,7 @@ export class TelegramNotificationDaemon {
1240
1255
 
1241
1256
  constructor(private readonly opts: TelegramDaemonOptions) {
1242
1257
  this.fsImpl = opts.fs ?? nodeFs;
1258
+ this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
1243
1259
  this.aliasTable = createAliasTable();
1244
1260
  this.botApi =
1245
1261
  opts.botApi ??
@@ -1666,6 +1682,9 @@ export class TelegramNotificationDaemon {
1666
1682
  })) as { ok?: boolean };
1667
1683
  if (res?.ok === false) return;
1668
1684
  this.topics.delete(sessionId);
1685
+ for (const k of [...this.liveMessages.keys()]) {
1686
+ if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
1687
+ }
1669
1688
  this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
1670
1689
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
1671
1690
  });
@@ -1793,14 +1812,81 @@ export class TelegramNotificationDaemon {
1793
1812
  return { images, fileNotes };
1794
1813
  }
1795
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
+
1796
1830
  /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
1797
- private async flushPool(): Promise<void> {
1798
- for (const item of this.pool.drain()) {
1831
+ private async flushPoolInner(): Promise<void> {
1832
+ const batch = this.pool.drain();
1833
+ // Within a batch a finalized frame supersedes any still-queued live frame for
1834
+ // the same streamed message (finalized outranks live), so drop the stale live
1835
+ // edit — otherwise the authoritative final text could be overwritten by an
1836
+ // older partial delivered right after it.
1837
+ const finalizedKeys = new Set<string>();
1838
+ for (const item of batch) {
1839
+ if (item.lane === "finalized" && item.coalesceKey !== undefined) {
1840
+ finalizedKeys.add(`${item.sessionId}:${item.coalesceKey}`);
1841
+ }
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
+ }
1854
+ for (const item of batch) {
1799
1855
  const { send, topicId } = item.payload;
1800
1856
  if (topicId && !(await this.pairedChatIsPrivate())) continue;
1801
1857
  // Threaded topic when available; otherwise deliver flat to the paired chat.
1802
1858
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
1859
+ const ckey = send.editable ? item.coalesceKey : undefined;
1860
+ const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
1861
+ if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
1803
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
+ }
1804
1890
  if (send.method === "sendPhoto" && send.photoBase64) {
1805
1891
  // Real photo upload (the default botApi multiparts base64 -> file).
1806
1892
  await this.botApi.call("sendPhoto", {
@@ -1822,21 +1908,165 @@ export class TelegramNotificationDaemon {
1822
1908
  parse_mode: TELEGRAM_PARSE_MODE,
1823
1909
  });
1824
1910
  } else if (send.text) {
1825
- for (const text of splitTelegramHtml(send.text)) {
1826
- await this.botApi.call("sendMessage", {
1827
- chat_id: this.opts.chatId,
1828
- ...threadField,
1829
- text,
1830
- parse_mode: TELEGRAM_PARSE_MODE,
1831
- });
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,
1950
+ },
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!,
1968
+ });
1969
+ }
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
+ }
2044
+ if (editKey && ckey !== undefined && firstMessageId !== undefined) {
2045
+ this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
2046
+ }
1832
2047
  }
1833
2048
  }
1834
2049
  } catch {
1835
- // Best-effort: a failed send must never stop the daemon.
2050
+ // Best-effort: a failed send/edit must never stop the daemon.
1836
2051
  }
1837
2052
  }
1838
2053
  }
1839
2054
 
2055
+ /**
2056
+ * Track the Telegram message id backing a streamed `(sessionId, coalesceKey)`
2057
+ * so later live/finalized frames edit it in place. Evicts this session's stale
2058
+ * same-category entries (e.g. prior turns) so the map stays bounded.
2059
+ */
2060
+ private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
2061
+ const mapKey = `${sessionId}:${coalesceKey}`;
2062
+ const category = coalesceKey.split(":")[0] ?? "";
2063
+ const prefix = `${sessionId}:${category}:`;
2064
+ for (const k of [...this.liveMessages.keys()]) {
2065
+ if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
2066
+ }
2067
+ this.liveMessages.set(mapKey, messageId);
2068
+ }
2069
+
1840
2070
  /**
1841
2071
  * Threaded Mode is unavailable (the bot owner has not enabled forum topics in
1842
2072
  * @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
@@ -1882,7 +2112,7 @@ export class TelegramNotificationDaemon {
1882
2112
  try {
1883
2113
  await this.botApi.call("sendMessage", {
1884
2114
  chat_id: this.opts.chatId,
1885
- text: "turn on threaded mode from botfather miniapp to receive gjc 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.",
1886
2116
  parse_mode: TELEGRAM_PARSE_MODE,
1887
2117
  });
1888
2118
  } catch {
@@ -2031,24 +2261,58 @@ export class TelegramNotificationDaemon {
2031
2261
  });
2032
2262
  const options = Array.isArray(msg.options) ? msg.options : [];
2033
2263
  // Daemon keyboards use alias callback data with compact one-based tap targets;
2034
- // full option text is rendered in the message body by buildActionMessage.
2264
+ // full option text is rendered in the message body by buildActionMessage/buildActionMarkdown.
2035
2265
  const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
2036
2266
  this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
2037
2267
  );
2038
- const chunks = splitTelegramHtml(rendered.text);
2039
- let result: { result?: { message_id?: number } } = {};
2040
- for (let i = 0; i < chunks.length; i++) {
2041
- result = (await this.botApi.call("sendMessage", {
2042
- chat_id: this.opts.chatId,
2043
- ...threadField,
2044
- text: chunks[i]!,
2045
- parse_mode: TELEGRAM_PARSE_MODE,
2046
- ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
2047
- })) 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 });
2048
2315
  }
2049
- const messageId = result.result?.message_id;
2050
- if (messageId !== undefined)
2051
- this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
2052
2316
  await this.persistAliases();
2053
2317
  } else if (msg.type === "action_resolved" && msg.id) {
2054
2318
  session.pending.delete(msg.id);
@@ -2104,6 +2368,65 @@ export class TelegramNotificationDaemon {
2104
2368
  }
2105
2369
  }
2106
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
+ }
2107
2430
  // Threaded injection: a free-text message in a known topic (not a button
2108
2431
  // tap and not a reply to a specific ask message) injects a user turn or an
2109
2432
  // in-thread config command. Fail-closed: paired chat + known topic +
@@ -2134,7 +2457,17 @@ export class TelegramNotificationDaemon {
2134
2457
  const images = attachmentResult?.images ?? [];
2135
2458
  const fileNotes = attachmentResult?.fileNotes ?? [];
2136
2459
  const hasMedia = images.length > 0 || fileNotes.length > 0;
2137
- 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;
2138
2471
  const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
2139
2472
  // A plain (non-config) message while an ask is pending for this session
2140
2473
  // answers that ask as free-input — instead of starting a new user turn.
@@ -2214,7 +2547,8 @@ export class TelegramNotificationDaemon {
2214
2547
  { command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
2215
2548
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
2216
2549
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
2217
- { command: "session_create", description: "Create a GJC session: path, worktree, or dir" },
2550
+ { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
2551
+ { command: "session_create", description: "Create a GJC session: path, worktree, or dir [--mpreset]" },
2218
2552
  { command: "session_recent", description: "List recent GJC sessions" },
2219
2553
  { command: "session_close", description: "Close a GJC-managed session" },
2220
2554
  { command: "session_resume", description: "Resume or reattach a session" },
@@ -2244,6 +2578,7 @@ export class TelegramNotificationDaemon {
2244
2578
  await this.loadAliases();
2245
2579
  await this.loadTopics();
2246
2580
  await this.loadSeenUpdateIds();
2581
+ await this.replyStore.load();
2247
2582
  await this.runScan();
2248
2583
  // Owner-only: start the session-lifecycle control server now that
2249
2584
  // ownership is confirmed (singleton-safe). Best-effort; degrades.
@@ -2264,31 +2599,30 @@ export class TelegramNotificationDaemon {
2264
2599
  await this.runScan();
2265
2600
  if (await this.controlStopRequested()) break;
2266
2601
  const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
2267
- if (this.sessions.size === 0 && !this.lifecycleControlActive) {
2268
- // No sessions and no lifecycle control: idle-exit on timeout.
2269
- if (idleElapsed) break;
2270
- } else {
2271
- // Poll getUpdates when sessions exist OR lifecycle control is active
2272
- // (so phone /session_* commands are received even with zero sessions).
2273
- // With zero sessions, still idle-exit after the timeout so the owner
2274
- // does not run forever; an active session resets the idle window.
2275
- if (this.sessions.size > 0) idleSince = this.runtime.now();
2276
- else if (idleElapsed) break;
2277
- const activePoll = this.runtime.createAbortController();
2278
- try {
2279
- await this.pollOnce(activePoll.signal);
2280
- this.loopBackoff.reset();
2281
- } catch (e) {
2282
- // A transient getUpdates/network failure must not kill the
2283
- // daemon. Back off (bounded, below the heartbeat TTL) and keep
2284
- // renewing ownership at the loop top.
2285
- const backoffMs = this.loopBackoff.next();
2286
- logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
2287
- await this.runtime.sleep(backoffMs);
2288
- continue;
2289
- } finally {
2290
- this.runtime.clearAbortController(activePoll);
2291
- }
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);
2292
2626
  }
2293
2627
  if (await this.controlStopRequested()) break;
2294
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,