@gajae-code/coding-agent 0.9.0 → 0.9.2

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 (98) hide show
  1. package/CHANGELOG.md +48 -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 +7 -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/gjc-runtime/team-runtime.d.ts +9 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  14. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  15. package/dist/types/modes/components/thinking-selector.d.ts +7 -2
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  19. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  20. package/dist/types/modes/types.d.ts +3 -0
  21. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  22. package/dist/types/notifications/config-commands.d.ts +38 -0
  23. package/dist/types/notifications/config.d.ts +16 -0
  24. package/dist/types/notifications/index.d.ts +14 -1
  25. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  26. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  27. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  28. package/dist/types/notifications/rich-draft.d.ts +68 -0
  29. package/dist/types/notifications/rich-render.d.ts +90 -0
  30. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  31. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  32. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  33. package/dist/types/notifications/threaded-render.d.ts +10 -0
  34. package/dist/types/notifications/topic-registry.d.ts +4 -5
  35. package/dist/types/runtime-credential-selector.d.ts +7 -0
  36. package/dist/types/sdk.d.ts +7 -1
  37. package/dist/types/session/agent-session.d.ts +6 -3
  38. package/dist/types/tools/index.d.ts +1 -0
  39. package/dist/types/tools/skill-discovery.d.ts +40 -0
  40. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  41. package/package.json +8 -11
  42. package/src/cli/args.ts +8 -0
  43. package/src/cli/notify-cli.ts +38 -10
  44. package/src/cli.ts +5 -0
  45. package/src/commands/launch.ts +5 -0
  46. package/src/commands/notify.ts +2 -2
  47. package/src/config/keybindings.ts +1 -1
  48. package/src/config/model-profile-activation.ts +23 -7
  49. package/src/config/model-registry.ts +21 -4
  50. package/src/config/model-resolver.ts +5 -16
  51. package/src/config/settings-schema.ts +11 -2
  52. package/src/config/settings.ts +24 -1
  53. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  54. package/src/gjc-runtime/launch-tmux.ts +78 -12
  55. package/src/gjc-runtime/psmux-detect.ts +17 -2
  56. package/src/gjc-runtime/team-runtime.ts +46 -7
  57. package/src/gjc-runtime/tmux-common.ts +1 -0
  58. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  59. package/src/internal-urls/docs-index.generated.ts +6 -6
  60. package/src/lsp/client.ts +1 -0
  61. package/src/main.ts +26 -3
  62. package/src/modes/components/footer.ts +7 -2
  63. package/src/modes/components/settings-selector.ts +3 -3
  64. package/src/modes/components/thinking-selector.ts +83 -20
  65. package/src/modes/controllers/event-controller.ts +11 -2
  66. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  67. package/src/modes/controllers/input-controller.ts +62 -26
  68. package/src/modes/controllers/selector-controller.ts +43 -0
  69. package/src/modes/interactive-mode.ts +5 -0
  70. package/src/modes/rpc/rpc-mode.ts +3 -9
  71. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  72. package/src/modes/types.ts +3 -0
  73. package/src/modes/utils/injected-user-submission.ts +94 -0
  74. package/src/modes/utils/ui-helpers.ts +48 -15
  75. package/src/notifications/config-commands.ts +90 -0
  76. package/src/notifications/config.ts +27 -0
  77. package/src/notifications/index.ts +184 -14
  78. package/src/notifications/lifecycle-commands.ts +37 -16
  79. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  80. package/src/notifications/reply-sent-store.ts +134 -0
  81. package/src/notifications/rich-draft.ts +107 -0
  82. package/src/notifications/rich-render.ts +143 -0
  83. package/src/notifications/telegram-daemon-cli.ts +60 -5
  84. package/src/notifications/telegram-daemon.ts +431 -91
  85. package/src/notifications/telegram-reference.ts +17 -0
  86. package/src/notifications/threaded-render.ts +42 -1
  87. package/src/notifications/topic-registry.ts +9 -8
  88. package/src/prompts/tools/search-tool-bm25.md +5 -0
  89. package/src/prompts/tools/skill-discovery.md +13 -0
  90. package/src/runtime-credential-selector.ts +35 -0
  91. package/src/sdk.ts +104 -13
  92. package/src/session/agent-session.ts +48 -22
  93. package/src/slash-commands/builtin-registry.ts +78 -1
  94. package/src/system-prompt.ts +4 -6
  95. package/src/tools/index.ts +4 -0
  96. package/src/tools/skill-discovery.ts +73 -0
  97. package/src/tools/skill.ts +15 -2
  98. package/src/utils/pasted-image-path.ts +170 -0
@@ -31,7 +31,7 @@ function normalizeLifecycleCommandToken(
31
31
 
32
32
  /** A parsed, validated lifecycle command (transport identity added by caller). */
33
33
  export type ParsedLifecycleCommand =
34
- | { kind: "create"; target: SessionCreateTarget }
34
+ | { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
35
35
  | { kind: "close"; target: SessionCloseTarget }
36
36
  | { kind: "resume"; target: SessionResumeTarget }
37
37
  | { kind: "recent"; which: "create" | "resume" | "all" }
@@ -41,9 +41,9 @@ export type ParsedLifecycleCommand =
41
41
 
42
42
  const USAGE = [
43
43
  "Session commands:",
44
- "/session_create path <dir>",
45
- "/session_create worktree <repo> <branch>",
46
- "/session_create dir <newdir>",
44
+ "/session_create path <dir> [--mpreset <profile>]",
45
+ "/session_create worktree <repo> <branch> [--mpreset <profile>]",
46
+ "/session_create dir <newdir> [--mpreset <profile>]",
47
47
  "/session_close <sessionId>",
48
48
  "/session_resume <sessionId|prefix>",
49
49
  "/session_recent [create|resume]",
@@ -69,6 +69,23 @@ export function isLifecycleCommandText(
69
69
  return normalizeLifecycleCommandToken(rawCommand ?? "", ctx) !== undefined;
70
70
  }
71
71
 
72
+ /** Extract `--mpreset <name>` (or `--mpreset=<name>`) from args, returning remaining positional args. */
73
+ function extractModelPreset(args: string[]): { positional: string[]; modelPreset?: string } {
74
+ const positional: string[] = [];
75
+ let modelPreset: string | undefined;
76
+ for (let i = 0; i < args.length; i++) {
77
+ const arg = args[i]!;
78
+ if (arg === "--mpreset" && i + 1 < args.length) {
79
+ modelPreset = args[++i]!;
80
+ } else if (arg.startsWith("--mpreset=")) {
81
+ modelPreset = arg.slice("--mpreset=".length);
82
+ } else {
83
+ positional.push(arg);
84
+ }
85
+ }
86
+ return { positional, modelPreset };
87
+ }
88
+
72
89
  /**
73
90
  * Parse a paired-chat message into a lifecycle command. Returns `none` for
74
91
  * non-lifecycle text, `usage`/`reject` for malformed input (no side effect), or
@@ -121,29 +138,33 @@ export function parseLifecycleCommand(
121
138
  return { kind: "resume", target: { sessionIdOrPrefix: idOrPrefix } };
122
139
  }
123
140
 
124
- // /session_create <kind> ...
125
- const kind = args[0];
141
+ // /session_create <kind> ... [--mpreset <profile>]
142
+ const { positional, modelPreset } = extractModelPreset(args);
143
+ if (modelPreset !== undefined && !isSafeIdentifier(modelPreset)) {
144
+ return { kind: "reject", reason: "invalid_target", message: `Invalid model preset name.\n\n${USAGE}` };
145
+ }
146
+ const kind = positional[0];
126
147
  if (kind === "path") {
127
- if (args.length !== 2) return { kind: "usage", message: USAGE };
128
- const p = normalizeLifecyclePath(args[1]!);
148
+ if (positional.length !== 2) return { kind: "usage", message: USAGE };
149
+ const p = normalizeLifecyclePath(positional[1]!);
129
150
  if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
130
- return { kind: "create", target: { kind: "existing_path", path: p } };
151
+ return { kind: "create", target: { kind: "existing_path", path: p }, modelPreset };
131
152
  }
132
153
  if (kind === "dir") {
133
- if (args.length !== 2) return { kind: "usage", message: USAGE };
134
- const p = normalizeLifecyclePath(args[1]!);
154
+ if (positional.length !== 2) return { kind: "usage", message: USAGE };
155
+ const p = normalizeLifecyclePath(positional[1]!);
135
156
  if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
136
- return { kind: "create", target: { kind: "plain_dir", path: p } };
157
+ return { kind: "create", target: { kind: "plain_dir", path: p }, modelPreset };
137
158
  }
138
159
  if (kind === "worktree") {
139
- if (args.length !== 3) return { kind: "usage", message: USAGE };
140
- const repo = normalizeLifecyclePath(args[1]!);
141
- const branch = args[2]!;
160
+ if (positional.length !== 3) return { kind: "usage", message: USAGE };
161
+ const repo = normalizeLifecyclePath(positional[1]!);
162
+ const branch = positional[2]!;
142
163
  if (!repo) return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
143
164
  if (!isSafeBranch(branch)) {
144
165
  return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
145
166
  }
146
- return { kind: "create", target: { kind: "worktree", repo, branch } };
167
+ return { kind: "create", target: { kind: "worktree", repo, branch }, modelPreset };
147
168
  }
148
169
  return { kind: "usage", message: USAGE };
149
170
  }
@@ -127,22 +127,27 @@ function tmuxSessionNameFor(sessionId: string): string {
127
127
  * The launched session id is carried via `GJC_SESSION_ID` in the child env (see
128
128
  * {@link daemonSpawnCreate}); the root `gjc` launcher has no `--session-id`
129
129
  * flag, so it must never appear in argv. Only flags the launch parser actually
130
- * supports are emitted (`--worktree <branch>` for worktree targets). */
130
+ * supports are emitted (`--worktree <branch>` for worktree targets,
131
+ * `--mpreset <profile>` for model presets). */
131
132
  export function buildCreateArgv(
132
133
  frame: SessionCreateFrame,
133
134
  _ids: { intendedSessionId: string; startupPromptRef?: string },
134
135
  ): { cwd: string; args: string[] } {
136
+ const extraArgs: string[] = [];
137
+ if (frame.modelPreset) {
138
+ extraArgs.push("--mpreset", frame.modelPreset);
139
+ }
135
140
  if (frame.target.kind === "worktree") {
136
141
  const cwd = normalizeLifecyclePath(frame.target.repo);
137
142
  if (!cwd) throw new Error("invalid_lifecycle_repo_path");
138
143
  // Use the `--worktree=<branch>` form so the branch is a single argv token:
139
144
  // a flag-shaped branch (e.g. `-x`) can never be mis-parsed as a separate
140
145
  // launcher flag / detached-mode trigger.
141
- return { cwd, args: [`--worktree=${frame.target.branch}`] };
146
+ return { cwd, args: [`--worktree=${frame.target.branch}`, ...extraArgs] };
142
147
  }
143
148
  const cwd = normalizeLifecyclePath(frame.target.path);
144
149
  if (!cwd) throw new Error("invalid_lifecycle_path");
145
- return { cwd, args: [] };
150
+ return { cwd, args: extraArgs };
146
151
  }
147
152
 
148
153
  /** Real daemon-safe tmux launcher: detached `tmux new-session -d` + GJC tags. */
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Index of rich messages the daemon sent, mapping `${chat_id}:${message_id}` to
3
+ * the original markdown. Telegram does not echo a `sendRichMessage` message's
4
+ * text in a reply's `reply_to_message`, so a user replying to a rich final
5
+ * answer would otherwise strand the agent without the quoted context. The daemon
6
+ * records the markdown on every promoted send and looks it up on inbound replies
7
+ * to restore that context.
8
+ *
9
+ * Fail-closed and off-path-neutral: nothing is recorded unless a send was
10
+ * actually promoted to rich, `record` is a no-op on any error (the in-memory map
11
+ * is only mutated after the atomic persist succeeds), and `lookup` returns
12
+ * undefined on any miss or error, so a bad index write/read never kills the
13
+ * daemon.
14
+ */
15
+
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { daemonPaths } from "./daemon-paths";
19
+
20
+ /** Max distinct (chat, message) entries retained; oldest-by-timestamp evicted past this. */
21
+ const MAX_ENTRIES = 1000;
22
+ /** Max stored characters of the original markdown per entry. */
23
+ const MAX_TEXT_LENGTH = 2000;
24
+ /** Persisted index filename under the notifications directory. */
25
+ const INDEX_FILENAME = "telegram-rich-sent-index.json";
26
+
27
+ /**
28
+ * Minimal async filesystem surface the store needs. Structurally a subset of the
29
+ * daemon's `TelegramDaemonFs`, so the daemon can pass its own `fs` straight
30
+ * through and tests can inject a fake.
31
+ */
32
+ export interface ReplySentStoreFs {
33
+ mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise<unknown>;
34
+ readFile(path: string, encoding: BufferEncoding): Promise<string>;
35
+ writeFile(path: string, data: string, opts?: fs.WriteFileOptions): Promise<void>;
36
+ rename(oldPath: string, newPath: string): Promise<void>;
37
+ chmod(path: string, mode: number): Promise<void>;
38
+ }
39
+
40
+ const nodeFs: ReplySentStoreFs = fs.promises as unknown as ReplySentStoreFs;
41
+
42
+ interface StoredEntry {
43
+ text: string;
44
+ ts: number;
45
+ }
46
+
47
+ interface PersistShape {
48
+ version: 1;
49
+ entries: Record<string, StoredEntry>;
50
+ }
51
+
52
+ /** Compose the stable per-message key. */
53
+ function keyFor(chatId: string | number, messageId: number): string {
54
+ return `${chatId}:${messageId}`;
55
+ }
56
+
57
+ /** Drop oldest-by-timestamp entries until at most {@link MAX_ENTRIES} remain. */
58
+ function evictToCap(entries: Map<string, StoredEntry>): void {
59
+ if (entries.size <= MAX_ENTRIES) return;
60
+ // Stable sort keeps insertion order among equal timestamps, so the oldest
61
+ // inserted entry is evicted first when timestamps tie.
62
+ const oldestFirst = [...entries.entries()].sort((a, b) => a[1].ts - b[1].ts);
63
+ const overflow = entries.size - MAX_ENTRIES;
64
+ for (let i = 0; i < overflow; i++) entries.delete(oldestFirst[i]![0]);
65
+ }
66
+
67
+ export class ReplySentStore {
68
+ readonly #dir: string;
69
+ readonly #file: string;
70
+ readonly #fsImpl: ReplySentStoreFs;
71
+ readonly #now: () => number;
72
+ #entries = new Map<string, StoredEntry>();
73
+
74
+ constructor(input: { agentDir: string; fs?: ReplySentStoreFs; now?: () => number }) {
75
+ this.#dir = daemonPaths(input.agentDir).dir;
76
+ this.#file = path.join(this.#dir, INDEX_FILENAME);
77
+ this.#fsImpl = input.fs ?? nodeFs;
78
+ this.#now = input.now ?? Date.now;
79
+ }
80
+
81
+ /** Restore the persisted index into memory. No-op on a missing or corrupt file. */
82
+ async load(): Promise<void> {
83
+ try {
84
+ const parsed = JSON.parse(await this.#fsImpl.readFile(this.#file, "utf8")) as Partial<PersistShape>;
85
+ const restored = new Map<string, StoredEntry>();
86
+ for (const [key, value] of Object.entries(parsed?.entries ?? {})) {
87
+ if (value && typeof value.text === "string" && typeof value.ts === "number") {
88
+ restored.set(key, { text: value.text, ts: value.ts });
89
+ }
90
+ }
91
+ evictToCap(restored);
92
+ this.#entries = restored;
93
+ } catch {
94
+ // Missing/corrupt index: keep the current in-memory map (empty on first load).
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Record the original markdown of a rich message the daemon just sent. The
100
+ * text is capped at {@link MAX_TEXT_LENGTH}; the index is capped at
101
+ * {@link MAX_ENTRIES} (oldest-by-timestamp evicted). No-op on any failure: the
102
+ * in-memory map is only replaced after the atomic persist succeeds.
103
+ */
104
+ async record(input: { chatId: string | number; messageId: number; text: string }): Promise<void> {
105
+ try {
106
+ const text = input.text.length > MAX_TEXT_LENGTH ? input.text.slice(0, MAX_TEXT_LENGTH) : input.text;
107
+ const next = new Map(this.#entries);
108
+ next.set(keyFor(input.chatId, input.messageId), { text, ts: this.#now() });
109
+ evictToCap(next);
110
+ await this.#persist(next);
111
+ this.#entries = next;
112
+ } catch {
113
+ // Best-effort: never let an index write kill the daemon.
114
+ }
115
+ }
116
+
117
+ /** Look up the original markdown for a message the daemon sent. undefined on miss or failure. */
118
+ lookup(input: { chatId: string | number; messageId: number }): string | undefined {
119
+ try {
120
+ return this.#entries.get(keyFor(input.chatId, input.messageId))?.text;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ async #persist(entries: Map<string, StoredEntry>): Promise<void> {
127
+ await this.#fsImpl.mkdir(this.#dir, { recursive: true, mode: 0o700 });
128
+ const payload: PersistShape = { version: 1, entries: Object.fromEntries(entries) };
129
+ const tmp = `${this.#file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
130
+ await this.#fsImpl.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
131
+ await this.#fsImpl.chmod(tmp, 0o600).catch(() => undefined);
132
+ await this.#fsImpl.rename(tmp, this.#file);
133
+ }
134
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Opt-in rich-draft streaming for a turn's in-progress preview.
3
+ *
4
+ * When the operator opts in (`richDraft.enabled`) the daemon streams LIVE turn
5
+ * markdown to the Bot API `sendRichMessageDraft` method as a debounced preview.
6
+ * Every failure is a harmless no-op (warn once, keep the daemon alive), and when
7
+ * off no draft call is ever made — so the off-state request bodies stay
8
+ * byte-identical.
9
+ */
10
+
11
+ import type { BotApi } from "./telegram-daemon";
12
+ import type { ThreadedSend } from "./threaded-render";
13
+
14
+ /** Minimum gap between two draft sends for one session (debounce floor). */
15
+ export const DRAFT_DEBOUNCE_MS = 1_500;
16
+
17
+ /**
18
+ * Wrap raw markdown + a monotonic draft id in the `sendRichMessageDraft` request
19
+ * payload shape: mirrors `buildRichMessage`'s proven `rich_message.markdown`
20
+ * content wrapper, with a top-level `draft_id` selecting the draft revision to
21
+ * update (a routing param, like `message_thread_id`).
22
+ */
23
+ export function buildRichDraft(draftId: number, raw: string): { draft_id: number; rich_message: { markdown: string } } {
24
+ return { draft_id: draftId, rich_message: { markdown: raw } };
25
+ }
26
+
27
+ /**
28
+ * Whether a granted send should stream a rich draft. Fail-closed: every clause
29
+ * must hold, otherwise no draft is sent. Mirrors `shouldPromoteRich` but targets
30
+ * the LIVE lane and the live-only `richDraftMarkdown` marker (set by
31
+ * `renderThreadedFrame` for non-finalized turn frames).
32
+ */
33
+ export function shouldStreamDraft(input: { enabled?: boolean; send: ThreadedSend }): boolean {
34
+ const { enabled, send } = input;
35
+ return (
36
+ enabled === true &&
37
+ send.method === "sendMessage" &&
38
+ send.lane === "live" &&
39
+ typeof send.richDraftMarkdown === "string" &&
40
+ send.richDraftMarkdown.trim().length > 0
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Per-session debounce + monotonic draft-id state for draft streaming. Skips a
46
+ * draft when less than `debounceMs` has elapsed since the session's last SENT
47
+ * draft (the rate-limit pool already coalesces live frames to the latest, so a
48
+ * skipped frame is naturally superseded by the next one). `reset` clears a
49
+ * session's window when its turn finalizes so the next turn starts fresh.
50
+ */
51
+ export class DraftStreamState {
52
+ readonly #debounceMs: number;
53
+ readonly #lastSentAt = new Map<string, number>();
54
+ /** Daemon-global monotonic draft id: unique across sessions so two sessions
55
+ * sharing one flat chat (no per-topic thread key) never collide on draft id. */
56
+ #nextDraftId = 0;
57
+
58
+ constructor(debounceMs: number = DRAFT_DEBOUNCE_MS) {
59
+ this.#debounceMs = debounceMs;
60
+ }
61
+
62
+ /**
63
+ * If enough time has elapsed since the session's last draft, record `now` as
64
+ * the new last-sent time and return the next monotonic draft id; otherwise
65
+ * return `undefined` (debounced — the caller skips this frame).
66
+ */
67
+ tryClaim(sessionId: string, now: number): number | undefined {
68
+ const last = this.#lastSentAt.get(sessionId);
69
+ if (last !== undefined && now - last < this.#debounceMs) return undefined;
70
+ this.#lastSentAt.set(sessionId, now);
71
+ const id = ++this.#nextDraftId;
72
+ return id;
73
+ }
74
+
75
+ /** Clear a session's debounce window (called when its turn finalizes). The
76
+ * global draft id keeps incrementing so ids are never reused across turns. */
77
+ reset(sessionId: string): void {
78
+ this.#lastSentAt.delete(sessionId);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Best-effort delivery of a rich draft. Never throws and has no HTML fallback: a
84
+ * draft is a purely additive preview, so on any failure (a thrown transport
85
+ * error or an `{ ok: false }` JSON response — the transport returns `res.json()`
86
+ * for JSON methods, so `ok:false` does not throw) it warns exactly once and
87
+ * returns; the unchanged live HTML send still carries the content.
88
+ */
89
+ export async function deliverDraft(
90
+ botApi: BotApi,
91
+ base: { chat_id: string | number; message_thread_id?: number },
92
+ draftId: number,
93
+ raw: string,
94
+ log?: { warn(msg: string): void },
95
+ ): Promise<void> {
96
+ let failure: string | undefined;
97
+ try {
98
+ const res = await botApi.call("sendRichMessageDraft", { ...base, ...buildRichDraft(draftId, raw) });
99
+ if (res !== null && typeof res === "object" && (res as { ok?: unknown }).ok === false) {
100
+ const description = (res as { description?: unknown }).description;
101
+ failure = typeof description === "string" && description.length > 0 ? description : "ok:false";
102
+ }
103
+ } catch (err) {
104
+ failure = err instanceof Error ? err.message : String(err);
105
+ }
106
+ if (failure !== undefined) log?.warn(`notifications: sendRichMessageDraft failed (${failure}); draft skipped`);
107
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Rich-message promotion for stable non-editable Telegram text sends.
3
+ *
4
+ * When enabled, the daemon promotes eligible finalized `sendMessage` payloads
5
+ * carrying raw markdown to the Bot API `sendRichMessage` method. On any miss or
6
+ * failure the daemon keeps the unchanged HTML `sendMessage` path, so the
7
+ * off-state request bodies are byte-identical.
8
+ */
9
+
10
+ import type { BotApi } from "./telegram-daemon";
11
+ import type { ThreadedSend } from "./threaded-render";
12
+
13
+ /**
14
+ * Telegram's hard per-message character ceiling (4096). Surfaced here purely as
15
+ * documentation and a marker for a future native rich-message splitter — it is
16
+ * intentionally NON-BEHAVIORAL and MUST stay that way: nothing in the rich path
17
+ * branches on this value.
18
+ *
19
+ * Overflow is already safe without it. The production final-answer text is capped
20
+ * at 3500 chars upstream (`summaryFromMessage(..., 3500)`), so a promoted
21
+ * `sendRichMessage` never approaches this ceiling; and if the Bot API ever rejects
22
+ * an oversized rich payload it returns `{ ok: false }`, which
23
+ * `deliverRichWithFallback` (below) turns into the chunked HTML `splitTelegramHtml`
24
+ * fallback (each chunk ≤ TELEGRAM_MESSAGE_LIMIT). This constant only marks where a
25
+ * future rich splitter would read its ceiling; wiring it into a branch would change
26
+ * byte-for-byte behavior and is out of scope.
27
+ */
28
+ export const RICH_MESSAGE_LIMIT = 4096;
29
+
30
+ /** Wrap raw markdown in the `sendRichMessage` request payload shape. */
31
+ export function buildRichMessage(
32
+ raw: string,
33
+ extras: { reply_markup?: unknown } = {},
34
+ ): { rich_message: { markdown: string }; reply_markup?: unknown } {
35
+ return { rich_message: { markdown: raw }, ...extras };
36
+ }
37
+
38
+ /**
39
+ * Whether a granted send should be promoted to `sendRichMessage`. Fail-closed
40
+ * and class-aware: every clause must hold, otherwise the daemon keeps the HTML path.
41
+ */
42
+ export function shouldPromoteRich(input: { enabled?: boolean; send: ThreadedSend }): boolean {
43
+ const { enabled, send } = input;
44
+ return (
45
+ enabled === true &&
46
+ send.method === "sendMessage" &&
47
+ send.lane === "finalized" &&
48
+ send.richClass === "final" &&
49
+ send.editable !== true &&
50
+ typeof send.richMarkdown === "string" &&
51
+ send.richMarkdown.trim().length > 0 &&
52
+ send.richMarkdown.length <= RICH_MESSAGE_LIMIT &&
53
+ typeof send.text === "string" &&
54
+ send.text.length > 0
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Deliver the promoted rich message, falling back to `fallbackDeliver` (the
60
+ * unchanged HTML `sendMessage` loop) on any failure. A failure is either a
61
+ * thrown transport error or a `{ ok: false }` JSON response (the transport
62
+ * returns `res.json()` for JSON methods, so `ok:false` does not throw). On
63
+ * failure exactly one diagnostic is logged before the fallback runs; on success
64
+ * the fallback never runs.
65
+ *
66
+ * Returns the sent message's `message_id` on success (when the response carries
67
+ * one), otherwise `undefined` — including every failure/fallback path and a
68
+ * success whose response omits `result.message_id`. Callers that ignore the
69
+ * return value are unaffected.
70
+ */
71
+ export async function deliverRichWithFallback(
72
+ botApi: BotApi,
73
+ base: { chat_id: string | number; message_thread_id?: number },
74
+ send: ThreadedSend,
75
+ fallbackDeliver: () => Promise<void>,
76
+ log?: { warn(msg: string): void },
77
+ ): Promise<number | undefined> {
78
+ let failure: string | undefined;
79
+ let messageId: number | undefined;
80
+ try {
81
+ const res = await botApi.call("sendRichMessage", { ...base, ...buildRichMessage(send.richMarkdown!) });
82
+ if (res !== null && typeof res === "object" && (res as { ok?: unknown }).ok === false) {
83
+ const description = (res as { description?: unknown }).description;
84
+ failure = typeof description === "string" && description.length > 0 ? description : "ok:false";
85
+ } else {
86
+ const candidate = (res as { result?: { message_id?: unknown } } | null)?.result?.message_id;
87
+ if (typeof candidate === "number") messageId = candidate;
88
+ }
89
+ } catch (err) {
90
+ failure = err instanceof Error ? err.message : String(err);
91
+ }
92
+ if (failure === undefined) return messageId;
93
+ log?.warn(`notifications: sendRichMessage failed (${failure}); falling back to HTML`);
94
+ await fallbackDeliver();
95
+ return undefined;
96
+ }
97
+
98
+ /**
99
+ * Deliver an action-needed (ask/idle) message via `sendRichMessage`, falling
100
+ * back to the unchanged HTML chunk loop on any failure. Mirrors
101
+ * {@link deliverRichWithFallback} but takes an explicit markdown body plus an
102
+ * optional top-level `reply_markup` (probe-confirmed: `sendRichMessage` accepts
103
+ * `reply_markup` alongside `rich_message`), and surfaces a structured outcome so
104
+ * the daemon can route inbound replies to the resulting message id.
105
+ *
106
+ * On rich success: returns `{ messageId, usedRich: true, usedFallback: false }`
107
+ * where `messageId` is `res.result.message_id` when present. On a `{ ok:false }`
108
+ * response or a thrown transport error: warns exactly once, runs `htmlFallback`,
109
+ * and returns `{ messageId, usedRich: false, usedFallback: true }` where
110
+ * `messageId` is the fallback's return value (the last HTML chunk's id).
111
+ */
112
+ export async function deliverRichActionWithFallback(
113
+ botApi: BotApi,
114
+ base: { chat_id: string | number; message_thread_id?: number },
115
+ opts: { markdown: string; replyMarkup?: unknown; requireMessageId?: boolean },
116
+ htmlFallback: () => Promise<number | undefined>,
117
+ log?: { warn(msg: string): void },
118
+ ): Promise<{ messageId?: number; usedRich: boolean; usedFallback: boolean }> {
119
+ let failure: string | undefined;
120
+ let messageId: number | undefined;
121
+ try {
122
+ const res = await botApi.call("sendRichMessage", {
123
+ ...base,
124
+ ...buildRichMessage(opts.markdown, opts.replyMarkup === undefined ? {} : { reply_markup: opts.replyMarkup }),
125
+ });
126
+ if (res !== null && typeof res === "object" && (res as { ok?: unknown }).ok === false) {
127
+ const description = (res as { description?: unknown }).description;
128
+ failure = typeof description === "string" && description.length > 0 ? description : "ok:false";
129
+ } else {
130
+ const candidate = (res as { result?: { message_id?: unknown } } | null)?.result?.message_id;
131
+ if (typeof candidate === "number") messageId = candidate;
132
+ // Ask messages MUST be reply-routable: if a rich success carries no numeric
133
+ // message_id, fall back to HTML so a routable id is guaranteed.
134
+ else if (opts.requireMessageId) failure = "rich response missing message_id";
135
+ }
136
+ } catch (err) {
137
+ failure = err instanceof Error ? err.message : String(err);
138
+ }
139
+ if (failure === undefined) return { messageId, usedRich: true, usedFallback: false };
140
+ log?.warn(`notifications: sendRichMessage(action) failed (${failure}); falling back to HTML`);
141
+ const fallbackId = await htmlFallback();
142
+ return { messageId: fallbackId, usedRich: false, usedFallback: true };
143
+ }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { YAML } from "bun";
4
+ import { withFileLock } from "../config/file-lock";
4
5
  import type { Settings } from "../config/settings";
5
6
  import { getNotificationConfig, isTelegramConfigured } from "./config";
6
7
  import { daemonPaths } from "./daemon-paths";
@@ -14,7 +15,9 @@ type TelegramDaemonRunner = {
14
15
  type TelegramDaemonConstructor = new (opts: TelegramDaemonOptions) => TelegramDaemonRunner;
15
16
 
16
17
  export interface RunDaemonInternalDeps {
17
- SettingsImpl?: { init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir">> };
18
+ SettingsImpl?: {
19
+ init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir" | "set" | "flush">>;
20
+ };
18
21
  DaemonImpl?: TelegramDaemonConstructor;
19
22
  processPid?: number;
20
23
  pidAlive?: (pid: number) => boolean;
@@ -34,6 +37,17 @@ function getByPath(obj: unknown, pathSegments: string[]): unknown {
34
37
  return current;
35
38
  }
36
39
 
40
+ function setByPath(obj: Record<string, unknown>, pathSegments: string[], value: unknown): void {
41
+ let current = obj;
42
+ for (let i = 0; i < pathSegments.length - 1; i++) {
43
+ const segment = pathSegments[i]!;
44
+ const next = current[segment];
45
+ if (!next || typeof next !== "object" || Array.isArray(next)) current[segment] = {};
46
+ current = current[segment] as Record<string, unknown>;
47
+ }
48
+ current[pathSegments[pathSegments.length - 1]!] = value;
49
+ }
50
+
37
51
  function asString(value: unknown): string | undefined {
38
52
  return typeof value === "string" && value.length > 0 ? value : undefined;
39
53
  }
@@ -49,7 +63,7 @@ function asIdleTimeoutMs(value: unknown): number {
49
63
  export function createLightweightDaemonSettings(input: {
50
64
  agentDir: string;
51
65
  rawConfig?: unknown;
52
- }): Pick<Settings, "get" | "getAgentDir"> {
66
+ }): Pick<Settings, "get" | "getAgentDir" | "set" | "flush"> {
53
67
  const rawConfig = input.rawConfig && typeof input.rawConfig === "object" ? input.rawConfig : {};
54
68
  return {
55
69
  get(pathName: string): unknown {
@@ -64,6 +78,10 @@ export function createLightweightDaemonSettings(input: {
64
78
  case "notifications.slack.botToken":
65
79
  case "notifications.slack.channelId":
66
80
  return asString(value);
81
+ case "notifications.telegram.rich.enabled":
82
+ return asBoolean(value, true);
83
+ case "notifications.telegram.richDraft.enabled":
84
+ return asBoolean(value, false);
67
85
  case "notifications.redact":
68
86
  return asBoolean(value, false);
69
87
  case "notifications.verbosity":
@@ -77,10 +95,45 @@ export function createLightweightDaemonSettings(input: {
77
95
  getAgentDir(): string {
78
96
  return input.agentDir;
79
97
  },
80
- } as Pick<Settings, "get" | "getAgentDir">;
98
+ async set(pathName: string, value: unknown): Promise<void> {
99
+ // Back onto config.yml directly (the full Settings class is not loaded in
100
+ // the spawned daemon process). Contend on the SAME per-file lock as
101
+ // Settings.#saveNow and re-read UNDER the lock, patching only this key, so
102
+ // a concurrent main-process save can never drop unrelated settings (no
103
+ // whole-file last-writer-wins). The write is atomic (tmp + rename) so a
104
+ // crash mid-write can never truncate config.yml, and any failure propagates
105
+ // so the `/rich` handler leaves runtime state unchanged. The in-memory view
106
+ // is updated only after the durable write succeeds.
107
+ const segments = pathName.split(".");
108
+ const configPath = path.join(input.agentDir, "config.yml");
109
+ await withFileLock(configPath, async () => {
110
+ let onDisk: Record<string, unknown> = {};
111
+ try {
112
+ const parsed = YAML.parse(await fs.promises.readFile(configPath, "utf8"));
113
+ if (parsed && typeof parsed === "object") onDisk = parsed as Record<string, unknown>;
114
+ } catch (error) {
115
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
116
+ }
117
+ setByPath(onDisk, segments, value);
118
+ await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
119
+ const tmpPath = `${configPath}.tmp.${process.pid}.${Date.now()}`;
120
+ await fs.promises.writeFile(tmpPath, YAML.stringify(onDisk), { mode: 0o600 });
121
+ await fs.promises.rename(tmpPath, configPath);
122
+ });
123
+ setByPath(rawConfig as Record<string, unknown>, segments, value);
124
+ },
125
+ async flush(): Promise<void> {
126
+ // The set() above is synchronously durable (it awaits the atomic tmp+rename
127
+ // write under the shared file lock), so there is never a pending save to
128
+ // flush. Present so the daemon can await flush() uniformly regardless of
129
+ // which Settings implementation is injected.
130
+ },
131
+ } as Pick<Settings, "get" | "getAgentDir" | "set" | "flush">;
81
132
  }
82
133
 
83
- export async function loadLightweightDaemonSettings(agentDir: string): Promise<Pick<Settings, "get" | "getAgentDir">> {
134
+ export async function loadLightweightDaemonSettings(
135
+ agentDir: string,
136
+ ): Promise<Pick<Settings, "get" | "getAgentDir" | "set" | "flush">> {
84
137
  const configPath = path.join(agentDir, "config.yml");
85
138
  let rawConfig: unknown = {};
86
139
  try {
@@ -94,7 +147,7 @@ export async function loadLightweightDaemonSettings(agentDir: string): Promise<P
94
147
  async function resolveDaemonSettings(
95
148
  agentDir: string,
96
149
  deps: RunDaemonInternalDeps,
97
- ): Promise<Pick<Settings, "get" | "getAgentDir">> {
150
+ ): Promise<Pick<Settings, "get" | "getAgentDir" | "set" | "flush">> {
98
151
  if (deps.SettingsImpl) return await deps.SettingsImpl.init({ agentDir });
99
152
  return await loadLightweightDaemonSettings(agentDir);
100
153
  }
@@ -156,6 +209,8 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
156
209
  botToken: cfg.botToken,
157
210
  chatId: cfg.chatId,
158
211
  idleTimeoutMs: cfg.idleTimeoutMs,
212
+ rich: cfg.rich,
213
+ richDraft: cfg.richDraft,
159
214
  pid: deps.processPid ?? process.pid,
160
215
  control: {
161
216
  shouldStop: async owner => {