mixdog 0.9.1 → 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 (214) hide show
  1. package/package.json +8 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. package/src/workflows/solo/workflow.json +0 -7
@@ -0,0 +1,283 @@
1
+ // Telegram MarkdownV2 conversion — a faithful JS port of hermes-agent's
2
+ // gateway/platforms/telegram.py (functions: _MDV2_ESCAPE_RE, _escape_mdv2,
3
+ // _strip_mdv2, _TABLE_SEPARATOR_RE, _is_table_row, _split_markdown_table_row,
4
+ // _render_table_block_for_telegram, _wrap_markdown_tables, and the 12-step
5
+ // format_message pipeline). The Python behaviour is battle-tested; this port
6
+ // keeps the same step order and the same table/header/blockquote decisions so
7
+ // output matches the reference.
8
+ //
9
+ // Exported:
10
+ // toMarkdownV2(text) — the main converter (port of format_message)
11
+ // stripMdV2(text) — plain-text fallback (port of _strip_mdv2)
12
+ // escapeMdV2(text) — raw escape helper (port of _escape_mdv2)
13
+ // isParseEntitiesError(err) — detect a MarkdownV2 400 parse failure
14
+
15
+ // Matches every character MarkdownV2 requires to be backslash-escaped outside a
16
+ // code span / fenced block. Port of _MDV2_ESCAPE_RE:
17
+ // Python: r'([_*\[\]()~`>#\+\-=|{}.!\\])'
18
+ const MDV2_ESCAPE_RE = /([_*[\]()~`>#+\-=|{}.!\\])/g;
19
+
20
+ /** Port of _escape_mdv2: backslash-escape all MarkdownV2 specials. */
21
+ export function escapeMdV2(text) {
22
+ return String(text).replace(MDV2_ESCAPE_RE, "\\$1");
23
+ }
24
+
25
+ /**
26
+ * Port of _strip_mdv2: remove MarkdownV2 escape backslashes AND the entity
27
+ * markers format_message introduced, producing clean plain text for the
28
+ * parse-error fallback.
29
+ */
30
+ export function stripMdV2(text) {
31
+ let cleaned = String(text);
32
+ // Remove escape backslashes before special characters.
33
+ cleaned = cleaned.replace(/\\([_*[\]()~`>#+\-=|{}.!\\])/g, "$1");
34
+ // Remove bold markers (*text* → text).
35
+ cleaned = cleaned.replace(/\*([^*]+)\*/g, "$1");
36
+ // Remove italic markers (_text_ → text). Word-boundary guards keep
37
+ // snake_case identifiers intact (mirrors the Python lookbehind/lookahead).
38
+ cleaned = cleaned.replace(/(^|\W)_([^_]+)_(?!\w)/g, "$1$2");
39
+ // Remove strikethrough markers (~text~ → text).
40
+ cleaned = cleaned.replace(/~([^~]+)~/g, "$1");
41
+ // Remove spoiler markers (||text|| → text).
42
+ cleaned = cleaned.replace(/\|\|([^|]+)\|\|/g, "$1");
43
+ return cleaned;
44
+ }
45
+
46
+ // ── Markdown table → Telegram-friendly row groups ──────────────────────────
47
+ // Port of _TABLE_SEPARATOR_RE / _is_table_row / _split_markdown_table_row /
48
+ // _render_table_block_for_telegram / _wrap_markdown_tables.
49
+
50
+ // Python: r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$'
51
+ const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$/;
52
+
53
+ function isTableRow(line) {
54
+ const stripped = line.trim();
55
+ return !!stripped && stripped.includes("|");
56
+ }
57
+
58
+ function splitMarkdownTableRow(line) {
59
+ let stripped = line.trim();
60
+ if (stripped.startsWith("|")) stripped = stripped.slice(1);
61
+ if (stripped.endsWith("|")) stripped = stripped.slice(0, -1);
62
+ return stripped.split("|").map((cell) => cell.trim());
63
+ }
64
+
65
+ function renderTableBlockForTelegram(tableBlock) {
66
+ if (tableBlock.length < 3) return tableBlock.join("\n");
67
+
68
+ const headers = splitMarkdownTableRow(tableBlock[0]);
69
+ if (headers.length < 2) return tableBlock.join("\n");
70
+
71
+ // Row-label column present when data rows carry one more cell than headers.
72
+ const firstDataRow = tableBlock.length > 2 ? splitMarkdownTableRow(tableBlock[2]) : [];
73
+ const hasRowLabelCol = firstDataRow.length === headers.length + 1;
74
+
75
+ const renderedGroups = [];
76
+ for (let idx = 0; idx < tableBlock.length - 2; idx++) {
77
+ const row = tableBlock[idx + 2];
78
+ const cells = splitMarkdownTableRow(row);
79
+ let heading;
80
+ let dataCells;
81
+ if (hasRowLabelCol) {
82
+ heading = cells.length && cells[0] ? cells[0] : `Row ${idx + 1}`;
83
+ dataCells = cells.slice(1);
84
+ } else {
85
+ heading = cells.find((c) => c) ?? `Row ${idx + 1}`;
86
+ dataCells = cells.slice();
87
+ }
88
+ // Pad / trim dataCells to headers length.
89
+ if (dataCells.length < headers.length) {
90
+ dataCells = dataCells.concat(Array(headers.length - dataCells.length).fill(""));
91
+ } else if (dataCells.length > headers.length) {
92
+ dataCells = dataCells.slice(0, headers.length);
93
+ }
94
+ const bullets = [];
95
+ for (let c = 0; c < headers.length; c++) {
96
+ const value = dataCells[c];
97
+ // Skip a bullet that just duplicates the heading (no row-label column).
98
+ if (!hasRowLabelCol && value === heading) continue;
99
+ bullets.push(`• ${headers[c]}: ${value}`);
100
+ }
101
+ // The heading is emitted as `**heading**` markdown so step 5 (bold) of the
102
+ // main pipeline converts it — matches the Python ordering intentionally.
103
+ const groupLines = [`**${heading}**`, ...bullets];
104
+ renderedGroups.push(groupLines.join("\n"));
105
+ }
106
+ return renderedGroups.join("\n\n");
107
+ }
108
+
109
+ function wrapMarkdownTables(text) {
110
+ if (!text.includes("|") || !text.includes("-")) return text;
111
+ const lines = text.split("\n");
112
+ const out = [];
113
+ let inFence = false;
114
+ let i = 0;
115
+ while (i < lines.length) {
116
+ const line = lines[i];
117
+ const stripped = line.replace(/^\s+/, "");
118
+ // Track existing fenced code blocks — never touch content inside.
119
+ if (stripped.startsWith("```")) {
120
+ inFence = !inFence;
121
+ out.push(line);
122
+ i++;
123
+ continue;
124
+ }
125
+ if (inFence) {
126
+ out.push(line);
127
+ i++;
128
+ continue;
129
+ }
130
+ // Header row (contains '|') immediately followed by a delimiter row.
131
+ if (line.includes("|") && i + 1 < lines.length && TABLE_SEPARATOR_RE.test(lines[i + 1])) {
132
+ const tableBlock = [line, lines[i + 1]];
133
+ let j = i + 2;
134
+ while (j < lines.length && isTableRow(lines[j])) {
135
+ tableBlock.push(lines[j]);
136
+ j++;
137
+ }
138
+ out.push(renderTableBlockForTelegram(tableBlock));
139
+ i = j;
140
+ continue;
141
+ }
142
+ out.push(line);
143
+ i++;
144
+ }
145
+ return out.join("\n");
146
+ }
147
+
148
+ // ── Main converter — port of format_message (12-step placeholder pipeline) ──
149
+
150
+ /**
151
+ * Convert standard/assistant markdown into Telegram MarkdownV2.
152
+ *
153
+ * Protected regions (fenced code, inline code, links) are stashed behind
154
+ * `\x00PH{n}\x00` placeholders BEFORE escaping so their interiors survive; the
155
+ * remaining text is converted to MarkdownV2 entities then fully escaped, and
156
+ * placeholders are restored in reverse insertion order.
157
+ */
158
+ export function toMarkdownV2(content) {
159
+ if (!content) return content;
160
+
161
+ const placeholders = new Map();
162
+ let counter = 0;
163
+ const ph = (value) => {
164
+ const key = `\x00PH${counter}\x00`;
165
+ counter += 1;
166
+ placeholders.set(key, value);
167
+ return key;
168
+ };
169
+
170
+ let text = String(content);
171
+
172
+ // 0) Rewrite GFM pipe tables into Telegram-friendly row groups first.
173
+ text = wrapMarkdownTables(text);
174
+
175
+ // 1) Protect fenced code blocks (``` ... ```). Inside pre/code only \ and `
176
+ // must be escaped per the MarkdownV2 spec.
177
+ text = text.replace(/(```(?:[^\n]*\n)?[\s\S]*?```)/g, (raw) => {
178
+ // Split off the opening ``` (+ optional language line) and closing ```.
179
+ const openEnd = raw.slice(3).includes("\n") ? raw.indexOf("\n") + 1 : 3;
180
+ const opening = raw.slice(0, openEnd);
181
+ const bodyAndClose = raw.slice(openEnd);
182
+ let body = bodyAndClose.slice(0, -3);
183
+ body = body.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
184
+ return ph(opening + body + "```");
185
+ });
186
+
187
+ // 2) Protect inline code (`...`). Escape \ inside per spec.
188
+ text = text.replace(/(`[^`]+`)/g, (m) => ph(m.replace(/\\/g, "\\\\")));
189
+
190
+ // 3) Convert markdown links — escape display text; inside the URL only ')'
191
+ // and '\' need escaping.
192
+ text = text.replace(/\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g, (_m, display, url) => {
193
+ const d = escapeMdV2(display);
194
+ const u = url.replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
195
+ return ph(`[${d}](${u})`);
196
+ });
197
+
198
+ // 4) Headers (## Title) → bold *Title* (MarkdownV2 has no headers).
199
+ text = text.replace(/^#{1,6}\s+(.+)$/gm, (_m, inner) => {
200
+ const stripped = inner.trim().replace(/\*\*(.+?)\*\*/g, "$1");
201
+ return ph(`*${escapeMdV2(stripped)}*`);
202
+ });
203
+
204
+ // 5) Bold: **text** → *text*.
205
+ text = text.replace(/\*\*(.+?)\*\*/g, (_m, inner) => ph(`*${escapeMdV2(inner)}*`));
206
+
207
+ // 6) Italic: *text* (single asterisk) → _text_. [^*\n]+ avoids matching
208
+ // across newlines (which would corrupt * bullet lists / multi-line text).
209
+ text = text.replace(/\*([^*\n]+)\*/g, (_m, inner) => ph(`_${escapeMdV2(inner)}_`));
210
+
211
+ // 7) Strikethrough: ~~text~~ → ~text~.
212
+ text = text.replace(/~~(.+?)~~/g, (_m, inner) => ph(`~${escapeMdV2(inner)}~`));
213
+
214
+ // 8) Spoiler: ||text|| → ||text|| (protect from | escaping).
215
+ text = text.replace(/\|\|(.+?)\|\|/g, (_m, inner) => ph(`||${escapeMdV2(inner)}||`));
216
+
217
+ // 9) Blockquotes: line-leading >, >>, >>>, **>, **>> etc. Preserve the
218
+ // prefix from escaping; an expandable quote (**> … ||) keeps a trailing ||.
219
+ text = text.replace(/^((?:\*\*)?>{1,3}) (.+)$/gm, (_m, prefix, quoteContent) => {
220
+ if (prefix.startsWith("**") && quoteContent.endsWith("||")) {
221
+ return ph(`${prefix} ${escapeMdV2(quoteContent.slice(0, -2))}||`);
222
+ }
223
+ return ph(`${prefix} ${escapeMdV2(quoteContent)}`);
224
+ });
225
+
226
+ // 10) Escape all remaining special characters in the plain text.
227
+ text = escapeMdV2(text);
228
+
229
+ // 11) Restore placeholders in REVERSE insertion order so a placeholder
230
+ // nested inside another resolves correctly.
231
+ const keys = [...placeholders.keys()].reverse();
232
+ for (const key of keys) {
233
+ text = text.split(key).join(placeholders.get(key));
234
+ }
235
+
236
+ // 12) Safety net: escape bare ( ) { } that slipped past placeholder
237
+ // processing, WITHOUT touching content inside code spans/blocks.
238
+ const codeSplit = text.split(/(```[\s\S]*?```|`[^`]+`)/g);
239
+ const safeParts = [];
240
+ for (let idx = 0; idx < codeSplit.length; idx++) {
241
+ const seg = codeSplit[idx];
242
+ if (idx % 2 === 1) {
243
+ // Inside a code span/block — leave untouched.
244
+ safeParts.push(seg);
245
+ continue;
246
+ }
247
+ safeParts.push(seg.replace(/[(){}]/g, (ch, s) => {
248
+ // Already escaped.
249
+ if (s > 0 && seg[s - 1] === "\\") return ch;
250
+ // '(' that opens a MarkdownV2 link [text](url).
251
+ if (ch === "(" && s > 0 && seg[s - 1] === "]") return ch;
252
+ // ')' that closes a link URL.
253
+ if (ch === ")") {
254
+ const before = seg.slice(0, s);
255
+ if (before.includes("](http") || before.includes("](")) {
256
+ let depth = 0;
257
+ for (let j = s - 1; j >= Math.max(s - 2000, 0); j--) {
258
+ if (seg[j] === "(") {
259
+ depth -= 1;
260
+ if (depth < 0) {
261
+ if (j > 0 && seg[j - 1] === "]") return ch;
262
+ break;
263
+ }
264
+ } else if (seg[j] === ")") {
265
+ depth += 1;
266
+ }
267
+ }
268
+ }
269
+ }
270
+ return "\\" + ch;
271
+ }));
272
+ }
273
+ text = safeParts.join("");
274
+
275
+ return text;
276
+ }
277
+
278
+ /** True when a Bot API error looks like a MarkdownV2 parse failure. */
279
+ export function isParseEntitiesError(err) {
280
+ const status = err?.status;
281
+ const msg = err instanceof Error ? err.message : String(err ?? "");
282
+ return status === 400 && /can't parse entities|entities/i.test(msg);
283
+ }
@@ -42,7 +42,7 @@ function isMemoryFile(filePath) {
42
42
  function isHidden(name) {
43
43
  if (HIDDEN_TOOLS.has(name)) return true;
44
44
  if (formatToolSurface(name, {}).label === "Memory") return false;
45
- if (name.includes("plugin_mixdog") && !name.endsWith("recall_memory") || name === "reply" || name === "react" || name === "edit_message" || name === "fetch" || name === "download_attachment") return true;
45
+ if (name === "reply" || name === "react" || name === "edit_message" || name === "fetch" || name === "download_attachment") return true;
46
46
  return false;
47
47
  }
48
48
  /**
@@ -141,6 +141,25 @@ function candidateAffinity(candidate) {
141
141
  }
142
142
  const TRANSCRIPT_MTIME_DECISIVE_MS = 30_000;
143
143
  function compareTranscriptCandidates(left, right) {
144
+ // Identity beats recency. A live "parent-chain" candidate is the session
145
+ // that actually forked THIS owner worker (process.ppid walk) — it is the
146
+ // same session that receives injected input. When another co-located
147
+ // session (same cwd) writes its transcript more recently, the mtime rule
148
+ // below would otherwise hand the output-forwarder the wrong session's
149
+ // transcript: input lands in our window while output tails the sibling.
150
+ // Anchoring on the parent-chain session keeps forward output pinned to the
151
+ // owning session and lets it "steal back" the binding from a busier
152
+ // neighbour. Only decisive when exactly one side is the live self session;
153
+ // standalone remote (no parent session) falls through to the heuristics.
154
+ const leftSelf = Boolean(left.active && left.parentChain);
155
+ const rightSelf = Boolean(right.active && right.parentChain);
156
+ if (leftSelf !== rightSelf) return rightSelf ? 1 : -1;
157
+ // A live same-cwd session is a stronger ownership signal than an older
158
+ // transcript's mtime. The previous ordering let a stale-but-recent transcript
159
+ // keep winning after a remote runtime restart, so the output forwarder stayed
160
+ // bound to the old JSONL while inbound was delivered to the live session.
161
+ const affinityDiff = candidateAffinity(right) - candidateAffinity(left);
162
+ if (affinityDiff !== 0 && (left.active || right.active)) return affinityDiff;
144
163
  const leftMtime = Number(left.transcriptMtime) || 0;
145
164
  const rightMtime = Number(right.transcriptMtime) || 0;
146
165
  if (leftMtime > 0 && rightMtime > 0) {
@@ -148,7 +167,6 @@ function compareTranscriptCandidates(left, right) {
148
167
  if (mtimeDelta >= TRANSCRIPT_MTIME_DECISIVE_MS) return 1;
149
168
  if (-mtimeDelta >= TRANSCRIPT_MTIME_DECISIVE_MS) return -1;
150
169
  }
151
- const affinityDiff = candidateAffinity(right) - candidateAffinity(left);
152
170
  if (affinityDiff !== 0) return affinityDiff;
153
171
  if (Number(right.exists) !== Number(left.exists)) return Number(right.exists) - Number(left.exists);
154
172
  if (right.transcriptMtime !== left.transcriptMtime) return right.transcriptMtime - left.transcriptMtime;
@@ -7,6 +7,7 @@ import { getWebhookAuthtoken } from "../../shared/config.mjs";
7
7
  import { appendFileSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, existsSync, renameSync, watch as fsWatch } from "fs";
8
8
  import { appendFile } from "fs/promises";
9
9
  import { randomUUID } from "crypto";
10
+ import { readMarkdownDocument } from "../../shared/markdown-frontmatter.mjs";
10
11
  const WEBHOOKS_DIR = join(DATA_DIR, "webhooks");
11
12
  const WEBHOOK_LOG = join(DATA_DIR, "webhook.log");
12
13
  let webhookLogBuffer = [];
@@ -101,15 +102,29 @@ function verifySignature(secret, rawBody, signatureValue, parser) {
101
102
  }
102
103
 
103
104
  // ── Endpoint config loader ─────────────────────────────────────────────
104
- // Reads DATA_DIR/webhooks/<name>/config.json (written by setup-server.mjs
105
- // via POST /webhooks). Cached in-memory, invalidated by fs.watch on the
106
- // webhooks directory. Returns { secret, parser, channel, mode, role }
107
- // where mode {"delegate","interactive"} and role names a user-workflow
108
- // entry (e.g. "reviewer") when mode=delegate.
105
+ // Reads DATA_DIR/webhooks/<name>/WEBHOOK.md (written by setup-server.mjs
106
+ // via POST /webhooks). The frontmatter IS the config object; the markdown
107
+ // body is the instructions/prompt. Cached in-memory, invalidated by
108
+ // fs.watch on the webhooks directory. Returns the frontmatter object
109
+ // { secret, parser, channel, model, role, enabled } where routing is by
110
+ // `channel` presence and `role` names a user-workflow entry when set.
109
111
  const _endpointCache = new Map();
110
112
  let _endpointWatcher = null;
111
113
  function _endpointConfigPath(name) {
112
- return join(WEBHOOKS_DIR, name, "config.json");
114
+ return join(WEBHOOKS_DIR, name, "WEBHOOK.md");
115
+ }
116
+ // Per-endpoint HMAC secret is stored in a side file (WEBHOOKS_DIR/<name>/secret),
117
+ // not in WEBHOOK.md frontmatter — frontmatter is a lossy `key: value` format
118
+ // (unquote strips surrounding quotes) and would corrupt user secrets on the
119
+ // save->rewrite round-trip. Read fresh on each verify (no cache): the signing
120
+ // path is not hot and a stale secret would silently reject valid deliveries.
121
+ function _readEndpointSecret(name) {
122
+ try {
123
+ const s = readFileSync(join(WEBHOOKS_DIR, name, "secret"), "utf8").trim();
124
+ return s || null;
125
+ } catch {
126
+ return null;
127
+ }
113
128
  }
114
129
  function _ensureEndpointWatcher() {
115
130
  if (_endpointWatcher) return;
@@ -117,7 +132,7 @@ function _ensureEndpointWatcher() {
117
132
  if (!existsSync(WEBHOOKS_DIR)) return;
118
133
  _endpointWatcher = fsWatch(WEBHOOKS_DIR, { recursive: true }, (_event, filename) => {
119
134
  if (!filename) { _endpointCache.clear(); return; }
120
- // filename is like "<endpoint>/config.json" or "<endpoint>"
135
+ // filename is like "<endpoint>/WEBHOOK.md" or "<endpoint>"
121
136
  const parts = String(filename).split(/[\\/]/);
122
137
  const endpointName = parts[0];
123
138
  if (endpointName) _endpointCache.delete(endpointName);
@@ -138,7 +153,7 @@ function loadEndpointConfig(name) {
138
153
  if (!name) return null;
139
154
  // A cached entry is only authoritative while the fs.watch handle is
140
155
  // armed — otherwise a later mkdir+write of WEBHOOKS_DIR/<name>/
141
- // config.json has no way to invalidate the cache and a stale `null`
156
+ // WEBHOOK.md has no way to invalidate the cache and a stale `null`
142
157
  // (e.g. captured before WEBHOOKS_DIR existed) would pin forever.
143
158
  if (_endpointCache.has(name) && _endpointWatcher) return _endpointCache.get(name);
144
159
  _ensureEndpointWatcher();
@@ -151,7 +166,14 @@ function loadEndpointConfig(name) {
151
166
  return null;
152
167
  }
153
168
  try {
154
- const cfg = JSON.parse(readFileSync(p, "utf8"));
169
+ // Frontmatter is the config object; `enabled` arrives as a string and
170
+ // is cast so `endpoint?.enabled === false` gates match a written
171
+ // `enabled: false`.
172
+ const { frontmatter } = readMarkdownDocument(readFileSync(p, "utf8"));
173
+ const cfg = { ...frontmatter };
174
+ if (Object.prototype.hasOwnProperty.call(cfg, "enabled")) {
175
+ cfg.enabled = cfg.enabled !== "false" && cfg.enabled !== false;
176
+ }
155
177
  _endpointCache.set(name, cfg);
156
178
  return cfg;
157
179
  } catch {
@@ -631,7 +653,7 @@ class WebhookServer {
631
653
  }
632
654
  const _registeredPre = !!(
633
655
  _endpointPreCheck
634
- || existsSync(join(WEBHOOKS_DIR, name, "instructions.md"))
656
+ || existsSync(join(WEBHOOKS_DIR, name, "WEBHOOK.md"))
635
657
  );
636
658
  if (!_registeredPre) {
637
659
  logWebhook(`rejected: unknown endpoint ${name}`);
@@ -707,7 +729,7 @@ class WebhookServer {
707
729
  // routing is reachable only through a registered endpoint.
708
730
  const _registered = !!(
709
731
  endpoint
710
- || existsSync(join(WEBHOOKS_DIR, name, "instructions.md"))
732
+ || existsSync(join(WEBHOOKS_DIR, name, "WEBHOOK.md"))
711
733
  );
712
734
  if (!_registered) {
713
735
  logWebhook(`rejected: unknown endpoint ${name}`);
@@ -810,7 +832,11 @@ class WebhookServer {
810
832
  // Returns true when the request may proceed; otherwise writes the
811
833
  // appropriate 401/403 response and returns false.
812
834
  _verifySignatureGate(name, endpoint, body, headers, res) {
813
- const secret = endpoint?.secret || this.config.secret;
835
+ // Per-endpoint secret lives in the side file WEBHOOKS_DIR/<name>/secret
836
+ // (plaintext, one line) — NOT in WEBHOOK.md frontmatter, so a user
837
+ // secret containing quotes/colons/newlines round-trips losslessly and
838
+ // setWebhookEnabled (which only rewrites WEBHOOK.md) cannot corrupt it.
839
+ const secret = _readEndpointSecret(name) || this.config.secret;
814
840
  const parser = endpoint?.parser || this.config.endpoints?.[name]?.parser;
815
841
  if (secret) {
816
842
  const signature = extractSignature(headers, parser);
@@ -836,18 +862,18 @@ class WebhookServer {
836
862
  res.end(JSON.stringify({ error: "webhook secret required for signed parser" }));
837
863
  return false;
838
864
  }
839
- // instructions.md folder endpoint with no resolved signature
865
+ // WEBHOOK.md folder endpoint with no resolved signature
840
866
  // mode. handleWebhook's instructions.md branch enqueues the body as
841
867
  // an interactive prompt (or dispatches a delegate) — both are
842
868
  // privileged. With no per-endpoint secret/parser AND no global
843
869
  // secret/parser, there is no signature mode to fall back on, so
844
870
  // accepting the request would inject attacker-controlled input.
845
- // Fail closed. (Endpoints that DO carry a config.json with a
871
+ // Fail closed. (Endpoints that DO carry a WEBHOOK.md with a
846
872
  // secret/parser are handled by the branches above.)
847
- if (!secret && !parser && existsSync(join(WEBHOOKS_DIR, name, "instructions.md"))) {
848
- logWebhook(`${name}: rejected (instructions.md endpoint requires a webhook secret)`);
873
+ if (!secret && !parser && existsSync(join(WEBHOOKS_DIR, name, "WEBHOOK.md"))) {
874
+ logWebhook(`${name}: rejected (WEBHOOK.md endpoint requires a webhook secret)`);
849
875
  res.writeHead(401, { "Content-Type": "application/json" });
850
- res.end(JSON.stringify({ error: "webhook secret required for instructions.md endpoint" }));
876
+ res.end(JSON.stringify({ error: "webhook secret required for WEBHOOK.md endpoint" }));
851
877
  return false;
852
878
  }
853
879
  if (!this.noSecretWarned) {
@@ -1139,21 +1165,24 @@ class WebhookServer {
1139
1165
  }
1140
1166
  // ── Webhook handler ───────────────────────────────────────────────
1141
1167
  _readFolderHandler(folderPath) {
1142
- const configPath = join(folderPath, "config.json");
1168
+ const mdPath = join(folderPath, "WEBHOOK.md");
1143
1169
  // Routing by channel presence (no `mode` field): an endpoint WITH a
1144
1170
  // channel dispatches to the hidden webhook-handler role and reports to
1145
1171
  // that channel; an endpoint WITHOUT a channel injects into the current
1146
1172
  // (Lead) session. `channel` starts NULL so its absence is detectable;
1147
1173
  // `role` defaults to the mandatory webhook-handler for the direct path.
1148
- // The signature gate (below) fails closed on any instructions.md
1174
+ // The signature gate (below) fails closed on any WEBHOOK.md
1149
1175
  // endpoint lacking a secret, so dropping `mode` does not weaken auth.
1150
- const handler = { channel: null, role: "webhook-handler", model: null };
1151
- if (existsSync(configPath)) {
1176
+ // `instructions` carries the markdown body (the prompt) so callers read
1177
+ // one file instead of a config.json + instructions.md pair.
1178
+ const handler = { channel: null, role: "webhook-handler", model: null, instructions: "" };
1179
+ if (existsSync(mdPath)) {
1152
1180
  try {
1153
- const cfg = JSON.parse(readFileSync(configPath, "utf8"));
1154
- if (cfg.channel) handler.channel = cfg.channel;
1155
- if (typeof cfg.role === "string" && cfg.role) handler.role = cfg.role;
1156
- if (typeof cfg.model === "string" && cfg.model) handler.model = cfg.model;
1181
+ const { frontmatter, body } = readMarkdownDocument(readFileSync(mdPath, "utf8"));
1182
+ if (frontmatter.channel) handler.channel = frontmatter.channel;
1183
+ if (typeof frontmatter.role === "string" && frontmatter.role) handler.role = frontmatter.role;
1184
+ if (typeof frontmatter.model === "string" && frontmatter.model) handler.model = frontmatter.model;
1185
+ handler.instructions = String(body || "").trim();
1157
1186
  } catch {
1158
1187
  }
1159
1188
  }
@@ -1222,21 +1251,20 @@ ${payload}
1222
1251
  }
1223
1252
  handleWebhook(name, body, headers, res, deliveryId) {
1224
1253
  const folderPath = join(WEBHOOKS_DIR, name);
1225
- const instructionsPath = join(folderPath, "instructions.md");
1226
- if (existsSync(instructionsPath)) {
1254
+ const mdPath = join(folderPath, "WEBHOOK.md");
1255
+ if (existsSync(mdPath)) {
1227
1256
  try {
1228
- const instructions = readFileSync(instructionsPath, "utf8").trim();
1229
- const { channel, role, model } = this._readFolderHandler(folderPath);
1257
+ const { channel, role, model, instructions } = this._readFolderHandler(folderPath);
1230
1258
  const payloadContent = this._buildFencedPayload(body, headers);
1231
1259
  if (channel) {
1232
1260
  if (!role) {
1233
- appendDelivery(name, { id: deliveryId, status: "failed", error: "delegate mode requires role in config.json" });
1261
+ appendDelivery(name, { id: deliveryId, status: "failed", error: "delegate mode requires role in WEBHOOK.md" });
1234
1262
  logWebhook(`${name}: delegate mode requires role - rejected`);
1235
1263
  res.writeHead(400, { "Content-Type": "application/json" });
1236
1264
  res.end(JSON.stringify({ status: "rejected", error: "delegate mode requires role" }));
1237
1265
  return;
1238
1266
  } else if (!model) {
1239
- appendDelivery(name, { id: deliveryId, status: "failed", error: "delegate mode requires model in config.json" });
1267
+ appendDelivery(name, { id: deliveryId, status: "failed", error: "delegate mode requires model in WEBHOOK.md" });
1240
1268
  logWebhook(`${name}: delegate mode requires model - rejected`);
1241
1269
  res.writeHead(400, { "Content-Type": "application/json" });
1242
1270
  res.end(JSON.stringify({ status: "rejected", error: "delegate mode requires model" }));
@@ -167,7 +167,7 @@ export const TOOL_DEFS = [
167
167
  properties: {
168
168
  command: {
169
169
  type: "string",
170
- enum: ["reload-plugins", "clear"],
170
+ enum: ["clear"],
171
171
  description: "Slash command."
172
172
  }
173
173
  },