@a1hvdy/cc-openclaw 0.27.1 → 0.27.4

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 (30) hide show
  1. package/dist/src/channels/telegram-mirror/askuser.js +2 -0
  2. package/dist/src/channels/telegram-mirror/card-renderer.d.ts +1 -22
  3. package/dist/src/channels/telegram-mirror/card-renderer.js +172 -20
  4. package/dist/src/channels/telegram-mirror/commands.d.ts +16 -0
  5. package/dist/src/channels/telegram-mirror/commands.js +53 -12
  6. package/dist/src/channels/telegram-mirror/inbound-handler.d.ts +18 -0
  7. package/dist/src/channels/telegram-mirror/inbound-handler.js +21 -8
  8. package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +11 -0
  9. package/dist/src/channels/telegram-mirror/turn-bridge.js +31 -0
  10. package/dist/src/constants.d.ts +10 -0
  11. package/dist/src/constants.js +10 -0
  12. package/dist/src/lib/error-formatter.d.ts +14 -2
  13. package/dist/src/lib/error-formatter.js +23 -11
  14. package/dist/src/lib/error-renderer.js +3 -1
  15. package/dist/src/lib/html-render.d.ts +8 -16
  16. package/dist/src/lib/html-render.js +91 -1
  17. package/dist/src/lib/markdown-to-mdv2.js +2 -1
  18. package/dist/src/lib/probes.d.ts +50 -0
  19. package/dist/src/lib/probes.js +96 -0
  20. package/dist/src/lib/telegram-bot-api.d.ts +52 -6
  21. package/dist/src/lib/telegram-bot-api.js +180 -13
  22. package/dist/src/openai-compat/message-extractor.js +4 -0
  23. package/dist/src/openai-compat/openai-compat.js +12 -1
  24. package/dist/src/openai-compat/streaming-handler.js +7 -1
  25. package/dist/src/session/persisted-sessions.d.ts +11 -0
  26. package/dist/src/session/persisted-sessions.js +17 -0
  27. package/dist/src/session/session-manager.js +22 -6
  28. package/dist/src/session-bootstrap/cwd-patch.js +1 -2
  29. package/dist/src/types.d.ts +7 -0
  30. package/package.json +1 -1
@@ -23,6 +23,7 @@
23
23
  import { CallbackMap } from './callback-mapping.js';
24
24
  import { sendTg, editTg, telegramApi } from '../../lib/telegram-bot-api.js';
25
25
  import { escapeHtml } from '../../lib/html-render.js';
26
+ import { probeInjectionEnqueued } from '../../lib/probes.js';
26
27
  /** Namespace prefix for callback_data so api.registerInteractiveHandler routes
27
28
  * taps here. Matched at the first ':' by the gateway (must be [A-Za-z0-9._-]+). */
28
29
  export const ASKUSER_NS = 'ccmirror';
@@ -165,6 +166,7 @@ function injectAnswer(api, ctx, text) {
165
166
  return;
166
167
  }
167
168
  try {
169
+ probeInjectionEnqueued(sessionKey, 'askuser'); // P0-A (observe-only, gated)
168
170
  api.enqueueNextTurnInjection({
169
171
  sessionKey,
170
172
  text: `[User answered the AskUserQuestion]: ${text}`,
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import type { Turn, ToolCallRecord } from './state-machine.js';
17
17
  import type { CardMeta } from './card-state.js';
18
+ export declare function toolDiffBlock(tc: ToolCallRecord): string;
18
19
  /**
19
20
  * Format one tool-call line for inclusion in the rendered card body.
20
21
  * "✓ Bash · ls -la"
@@ -36,28 +37,6 @@ export declare function renderToolLine(tc: ToolCallRecord): string;
36
37
  * without depending on MarkdownV2 escapes. Empty thinking returns ''.
37
38
  */
38
39
  export declare function renderThinkingBlock(thinkingText: string): string;
39
- /**
40
- * Render the full turn into a Telegram-safe message body. The mirror keeps
41
- * a single message per turn that gets edited in place — this function is
42
- * idempotent for a given turn snapshot and produces the next body string.
43
- *
44
- * Body shape (lines separated by \n):
45
- *
46
- * ▶ Working ← or "✓ Done" when state==='done'
47
- * ✓ Bash · ls -la ← one line per tool call, in arrival order
48
- * … Read · src/x.ts
49
- * <empty line>
50
- * 💭 Thinking ← M6, only when thinkingText non-empty
51
- * > reasoning line 1
52
- * > reasoning line 2
53
- * <empty line>
54
- * <assistant text> ← present only when non-empty;
55
- * ★ Insight ─ blocks preserved INLINE
56
- * at their emission position (M6).
57
- *
58
- * Lines with no content (e.g. empty tool list, empty assistant text) are
59
- * omitted entirely so the rendered body never has trailing whitespace.
60
- */
61
40
  export declare function renderTurn(turn: Turn, meta?: CardMeta): string;
62
41
  /**
63
42
  * A render action — abstraction the dispatch layer (added in a later
@@ -101,8 +101,13 @@ function toolResultText(tc) {
101
101
  * readable (Telegram's 4096-char message cap) while still surfacing tool output
102
102
  * the way the CLI shows it inline.
103
103
  */
104
- const RESULT_MAX_LINES = 3;
105
- const RESULT_MAX_CHARS = 200;
104
+ // v0.27.4 M3 — CLI-parity gap #4: the prior 3-line/200-char cap was far stingier
105
+ // than the terminal (which shows full, scrollable output). Raised to 10 lines/500
106
+ // chars — a ~2.5× visibility gain that stays card-safe because renderTurn's
107
+ // per-entry budget loop renders newest-first and collapses older tools to a
108
+ // "… +N earlier" marker, so a single fat result can't starve the others.
109
+ const RESULT_MAX_LINES = 10;
110
+ const RESULT_MAX_CHARS = 500;
106
111
  function truncateResult(s) {
107
112
  const lines = s.split('\n');
108
113
  let cut = lines.length > RESULT_MAX_LINES;
@@ -113,6 +118,70 @@ function truncateResult(s) {
113
118
  }
114
119
  return cut ? out.replace(/\s+$/, '') + '\n…' : out;
115
120
  }
121
+ /**
122
+ * v0.27.4 M2 — CLI-parity gap #3: render a compact file diff for Edit / Write /
123
+ * MultiEdit from the tool INPUT (the result payload is only "File updated", so
124
+ * the meaningful change is in old_string/new_string / content / edits[]). Mirrors
125
+ * the terminal's inline diff: `- removed` / `+ added` lines, capped for the card.
126
+ * Returns RAW text (caller wraps in a <pre> block, which HTML-escapes it). '' for
127
+ * non-edit tools or empty input (no-fake-data — never invents a diff).
128
+ */
129
+ const DIFF_MAX_LINES = 8;
130
+ const DIFF_MAX_CHARS = 320;
131
+ function truncateDiff(lines) {
132
+ let cut = lines.length > DIFF_MAX_LINES;
133
+ let out = lines.slice(0, DIFF_MAX_LINES).join('\n');
134
+ if (out.length > DIFF_MAX_CHARS) {
135
+ out = out.slice(0, DIFF_MAX_CHARS);
136
+ cut = true;
137
+ }
138
+ return cut ? out.replace(/\s+$/, '') + '\n…' : out;
139
+ }
140
+ export function toolDiffBlock(tc) {
141
+ const n = tc.name.toLowerCase();
142
+ const input = tc.input;
143
+ if (!input || typeof input !== 'object')
144
+ return '';
145
+ const lines = [];
146
+ if (n === 'edit') {
147
+ const oldS = typeof input.old_string === 'string' ? input.old_string : '';
148
+ const newS = typeof input.new_string === 'string' ? input.new_string : '';
149
+ if (!oldS && !newS)
150
+ return '';
151
+ for (const l of oldS.split('\n'))
152
+ lines.push(`- ${l}`);
153
+ for (const l of newS.split('\n'))
154
+ lines.push(`+ ${l}`);
155
+ }
156
+ else if (n === 'multiedit') {
157
+ const edits = Array.isArray(input.edits) ? input.edits : [];
158
+ if (edits.length === 0)
159
+ return '';
160
+ lines.push(`~ ${edits.length} edit${edits.length === 1 ? '' : 's'}`);
161
+ const first = edits[0];
162
+ if (first) {
163
+ const oldS = typeof first.old_string === 'string' ? first.old_string : '';
164
+ const newS = typeof first.new_string === 'string' ? first.new_string : '';
165
+ for (const l of oldS.split('\n'))
166
+ lines.push(`- ${l}`);
167
+ for (const l of newS.split('\n'))
168
+ lines.push(`+ ${l}`);
169
+ }
170
+ }
171
+ else if (n === 'write') {
172
+ const content = typeof input.content === 'string' ? input.content : '';
173
+ if (!content)
174
+ return '';
175
+ const contentLines = content.split('\n');
176
+ lines.push(`+ new file (${contentLines.length} line${contentLines.length === 1 ? '' : 's'})`);
177
+ for (const l of contentLines)
178
+ lines.push(`+ ${l}`);
179
+ }
180
+ else {
181
+ return '';
182
+ }
183
+ return truncateDiff(lines);
184
+ }
116
185
  /**
117
186
  * Format one tool-call line for inclusion in the rendered card body.
118
187
  * "✓ Bash · ls -la"
@@ -199,8 +268,27 @@ export function renderThinkingBlock(thinkingText) {
199
268
  * Lines with no content (e.g. empty tool list, empty assistant text) are
200
269
  * omitted entirely so the rendered body never has trailing whitespace.
201
270
  */
271
+ /**
272
+ * Telegram hard-caps a single message at 4096 chars; an edit that exceeds it is
273
+ * rejected outright (and editTg's fallback can't help — same oversized text).
274
+ * v0.27.3: budget the rendered body. v0.27.0 M2 began emitting a
275
+ * <pre><code> result block under every tool line, which on a heavy multi-tool
276
+ * turn (e.g. a 15-Bash version check) silently blew past 4096 → every edit
277
+ * failed → the card froze as a bare "✓ Done" with no activity. The fix renders a
278
+ * GUARANTEED spine (status + header + tool LINES) first, then adds the richer,
279
+ * droppable blocks (result previews, todos, thinking, assistant text) only while
280
+ * budget remains. The card therefore always shows the tool activity.
281
+ */
282
+ const TG_BODY_BUDGET = 3900; // margin under the 4096 hard cap (entities + safety)
202
283
  export function renderTurn(turn, meta) {
203
284
  const lines = [];
285
+ // Running length tracker (string.length + the join newline). O(1) per push;
286
+ // avoids re-joining the array on every budget check.
287
+ let used = 0;
288
+ const push = (s) => {
289
+ lines.push(s);
290
+ used += s.length + 1;
291
+ };
204
292
  // v0.26.2 M1 — CC-CLI-style status line at the top (model · CC ver · ⏱ · 🔧 ·
205
293
  // bypass). Gated on `meta` presence so legacy no-meta callers (and the initial
206
294
  // pre-model inbound card) render exactly as before — no regression. Once the
@@ -210,17 +298,17 @@ export function renderTurn(turn, meta) {
210
298
  // v0.27.0 M1 — bold the status line (the card header) via HTML <b>. Content
211
299
  // HTML-escaped first ( & < > only — model/version dots & brackets are safe).
212
300
  if (status)
213
- lines.push(`<b>${escapeHtml(status)}</b>`);
301
+ push(`<b>${escapeHtml(status)}</b>`);
214
302
  // v0.26.2 M2 — meter row (context % + quota % + reset). Same no-fake-data
215
303
  // gating: omitted unless real values are present. HTML-escaped for safety.
216
304
  const meters = renderMeters(meta);
217
305
  if (meters)
218
- lines.push(escapeHtml(meters));
306
+ push(escapeHtml(meters));
219
307
  // v0.26.4 styling — divider between the status/telemetry block and the
220
308
  // activity block (only when a status block was actually rendered). The
221
309
  // heavy-bar glyph is not HTML-significant, so it needs no escaping.
222
310
  if (lines.length > 0)
223
- lines.push('━━━━━━━━━━━━');
311
+ push('━━━━━━━━━━━━');
224
312
  }
225
313
  // Turn-status header. v0.27.0 M4 — a failed turn shows "❌ <reason>" so it
226
314
  // never dies as an eternal "…"; reason is HTML-escaped (it's an error message).
@@ -229,42 +317,106 @@ export function renderTurn(turn, meta) {
229
317
  : turn.state === 'done'
230
318
  ? '✓ Done'
231
319
  : '▶ Working';
232
- lines.push(header);
320
+ push(header);
233
321
  if (turn.toolCalls.length > 0) {
234
- for (const tc of turn.toolCalls) {
235
- lines.push(renderToolLine(tc));
236
- // v0.27.0 M2 surface the tool's RESULT as a truncated <pre><code> block
237
- // under its line (CLI-style inline output), when there's real text.
322
+ // Build tool ENTRIES (tool line + its optional <pre><code> result block)
323
+ // newest-first, keeping what fits under budget; the older overflow collapses
324
+ // to a "… +N earlier tools" marker. Budgeting the line AND its result block
325
+ // TOGETHER is the fix for the v0.27.0 regression: the prior code budgeted
326
+ // lines alone, then result blocks ate the remaining room and the leftover
327
+ // lines pushed unguarded — bursting Telegram's 4096 cap. Reserve TAIL_RESERVE
328
+ // so the assistant text / todos / thinking below always have somewhere to go.
329
+ const TAIL_RESERVE = 700;
330
+ const cap = TG_BODY_BUDGET - TAIL_RESERVE;
331
+ const entries = []; // oldest→newest display order
332
+ let shown = 0;
333
+ let acc = 0;
334
+ for (let i = turn.toolCalls.length - 1; i >= 0; i--) {
335
+ const tc = turn.toolCalls[i];
336
+ const line = renderToolLine(tc);
337
+ // v0.27.4 M2 — for Edit/Write/MultiEdit show a diff (from input) instead of
338
+ // the "File updated" result; other tools keep the result preview.
339
+ const diffText = toolDiffBlock(tc);
238
340
  const resultText = toolResultText(tc);
239
- if (resultText)
240
- lines.push(pre(truncateResult(resultText)));
341
+ const block = diffText
342
+ ? pre(diffText)
343
+ : resultText
344
+ ? pre(truncateResult(resultText))
345
+ : '';
346
+ const withBlock = block ? `${line}\n${block}` : line;
347
+ // Prefer line+block; if that bursts the cap, fall back to the line alone
348
+ // (the result preview is the droppable part, the activity line is not).
349
+ if (used + acc + withBlock.length + 1 <= cap) {
350
+ entries.unshift(withBlock);
351
+ acc += withBlock.length + 1;
352
+ shown++;
353
+ }
354
+ else if (used + acc + line.length + 1 <= cap) {
355
+ entries.unshift(line);
356
+ acc += line.length + 1;
357
+ shown++;
358
+ }
359
+ else {
360
+ break; // no room for even the bare line → stop; rest become "+N earlier"
361
+ }
241
362
  }
363
+ const hidden = turn.toolCalls.length - shown;
364
+ if (hidden > 0)
365
+ push(`… +${hidden} earlier tool${hidden === 1 ? '' : 's'}`);
366
+ for (const e of entries)
367
+ push(e);
242
368
  }
243
369
  // v0.26.2 M4 — todo checklist (real data from the agent's TodoWrite calls).
244
370
  // Escaped as a block (glyphs are safe; content + the "+N more" plus sign are
245
371
  // not, so the whole block goes through the escaper).
246
372
  const todoBlock = renderTodos(meta);
247
373
  if (todoBlock) {
248
- lines.push('');
249
- lines.push(escapeHtml(todoBlock));
374
+ const block = escapeHtml(todoBlock);
375
+ if (used + block.length + 2 <= TG_BODY_BUDGET) {
376
+ push('');
377
+ push(block);
378
+ }
250
379
  }
251
380
  // Defensive default — Turn.thinkingText is required by the interface, but
252
381
  // some test helpers and pre-M6 fixtures construct Turn-shaped objects
253
382
  // without setting it. Treat absent / undefined as empty.
254
383
  const thinking = renderThinkingBlock(turn.thinkingText ?? '');
255
- if (thinking.length > 0) {
256
- lines.push('');
257
- lines.push(thinking);
384
+ if (thinking.length > 0 && used + thinking.length + 2 <= TG_BODY_BUDGET) {
385
+ push('');
386
+ push(thinking);
258
387
  }
259
388
  const text = turn.assistantText.trim();
260
389
  if (text.length > 0) {
261
- lines.push('');
262
390
  // v0.27.0 M1 — render the model's markdown as Telegram HTML with structure
263
391
  // preserved (**bold**→<b>, `code`→<code>, ```bash fences→<pre><code
264
392
  // class="language-bash">). HTML-escapes all remaining content (& < >).
265
- lines.push(markdownToHtml(text));
393
+ // Budget-aware: if the full text won't fit, render a truncated head so the
394
+ // card still ends with readable context rather than dropping it entirely.
395
+ const room = TG_BODY_BUDGET - used - 2;
396
+ if (room > 0) {
397
+ const full = markdownToHtml(text);
398
+ if (full.length <= room) {
399
+ push('');
400
+ push(full);
401
+ }
402
+ else if (room > 240) {
403
+ // Truncate the SOURCE before markdown→HTML so we never split an HTML tag
404
+ // mid-emit (which would break Telegram's parser). Leave slack for the "…".
405
+ const headSrc = text.slice(0, room - 40).replace(/\s+\S*$/, '');
406
+ push('');
407
+ push(markdownToHtml(headSrc) + '\n…');
408
+ }
409
+ }
266
410
  }
267
- return lines.join('\n');
411
+ // Guaranteed safety net: even if the budgeted assembly above has a logic gap,
412
+ // a card MUST NOT exceed Telegram's 4096-char hard cap (an over-cap edit is
413
+ // rejected outright). A blunt slice here can split an HTML tag, but editTg's
414
+ // hardened plain-text fallback strips tags on rejection, so this degrades
415
+ // gracefully rather than freezing the card.
416
+ const body = lines.join('\n');
417
+ if (body.length <= 4096)
418
+ return body;
419
+ return body.slice(0, 4095) + '…';
268
420
  }
269
421
  /**
270
422
  * Decide whether the current turn snapshot should be sent (first render)
@@ -126,6 +126,22 @@ export declare function handleCancel(ctx: CommandContext): CommandResult;
126
126
  * resolver call recordLaptopCheck().
127
127
  */
128
128
  export declare function handleSoakCheck(ctx: CommandContext): CommandResult;
129
+ /**
130
+ * Full user-facing command catalogue, used by /help. The TOP_7 are the
131
+ * phone-tier set synced to Telegram's command menu; the workflow extras
132
+ * (compose/send/cancel) + help round out what the mirror actually claims.
133
+ */
134
+ export declare const ALL_COMMANDS: ReadonlyArray<{
135
+ command: string;
136
+ description: string;
137
+ }>;
138
+ /**
139
+ * /help — list the mirror's commands. Discoverability close for gap #6: the 11
140
+ * commands were previously invisible. Notes the passthrough so the user knows
141
+ * any other slash (custom skills like /pregoal) or plain text reaches Claude.
142
+ * HTML-safe content (only the intentional <b> tag); sendTg renders parse_mode:HTML.
143
+ */
144
+ export declare function handleHelp(ctx: CommandContext): CommandResult;
129
145
  export type CommandHandler = (ctx: CommandContext) => CommandResult;
130
146
  export declare const COMMAND_HANDLERS: Record<string, CommandHandler>;
131
147
  /**
@@ -98,17 +98,21 @@ export function handleNew(ctx) {
98
98
  };
99
99
  }
100
100
  const existing = getBySlug(slug);
101
- // Session-name comes from the engine; M4 stores a placeholder that M5+ overwrites.
101
+ // Session-name comes from the engine when a real turn fronts this slug; until
102
+ // then we store a placeholder. The registry IS the real state that /sessions
103
+ // and /status read — so the confirmation reflects the actual registry count
104
+ // (planning D-5/D-2), not a false "engine wire-up lands in M5" promise.
102
105
  const sessionName = existing?.sessionName ?? `pending-${slug}-${Date.now()}`;
103
106
  register(slug, sessionName);
107
+ const total = list().length;
104
108
  return {
105
109
  actions: [
106
110
  {
107
111
  type: 'sendMessage',
108
112
  chat_id: ctx.chatId,
109
113
  text: existing
110
- ? `Session "${slug}" already registered fronted.`
111
- : `Session "${slug}" registered. Engine wire-up lands in M5.`,
114
+ ? `Session "${slug}" already registered (${total} total). Open it from /sessions.`
115
+ : `Session "${slug}" registered (${total} total). Open it from /sessions.`,
112
116
  },
113
117
  ],
114
118
  };
@@ -128,13 +132,14 @@ export function handleStop(ctx) {
128
132
  };
129
133
  }
130
134
  const removed = unregister(slug);
135
+ const remaining = list().length;
131
136
  return {
132
137
  actions: [
133
138
  {
134
139
  type: 'sendMessage',
135
140
  chat_id: ctx.chatId,
136
141
  text: removed
137
- ? `Session "${slug}" stopped.`
142
+ ? `Session "${slug}" stopped (${remaining} remaining).`
138
143
  : `No registered session named "${slug}".`,
139
144
  },
140
145
  ],
@@ -166,16 +171,17 @@ export function handleStatus(ctx) {
166
171
  }
167
172
  // ── /compact ─────────────────────────────────────────────────────────────
168
173
  export function handleCompact(ctx) {
169
- // M4: surface the intent. Actual context-compaction wiring runs through
170
- // the cc-handler module (existing /cc compact path)bridging the
171
- // mirror to that handler lands in M5 alongside the rest of the engine
172
- // integration. Without the bridge, the user sees a clear "queued" state.
174
+ // D-6 (planning): honest stub. The Telegram bridge has no session-control
175
+ // primitive to trigger context compaction on the running session the only
176
+ // plugin levers are enqueueNextTurnInjection (text, next-turn only) and
177
+ // registerInteractiveHandler. So /compact is CLI-only until/unless OpenClaw
178
+ // exposes a control primitive. Claiming "queued" would be a lie (it never runs).
173
179
  return {
174
180
  actions: [
175
181
  {
176
182
  type: 'sendMessage',
177
183
  chat_id: ctx.chatId,
178
- text: '⏳ Compact queuedengine wire-up lands in M5.',
184
+ text: "⚠️ /compact is CLI-only the Telegram bridge can't trigger context compaction (no session-control primitive). Run it from Claude Code directly.",
179
185
  },
180
186
  ],
181
187
  };
@@ -212,14 +218,15 @@ export function handleCost(ctx) {
212
218
  }
213
219
  // ── /rewind ──────────────────────────────────────────────────────────────
214
220
  export function handleRewind(ctx) {
215
- // M4: queuedactual rewind walks the cc-handler resume path (deferred
216
- // to M5 with the rest of engine bridging).
221
+ // D-6 (planning): honest stub same rationale as /compact. Rewinding a
222
+ // running session needs a session-control primitive the plugin can't reach;
223
+ // it's CLI-only. "Queued" would never actually run, so we say so plainly.
217
224
  return {
218
225
  actions: [
219
226
  {
220
227
  type: 'sendMessage',
221
228
  chat_id: ctx.chatId,
222
- text: '⏪ Rewind queuedengine wire-up lands in M5.',
229
+ text: "⚠️ /rewind is CLI-only the Telegram bridge can't rewind a session (no session-control primitive). Run it from Claude Code directly.",
223
230
  },
224
231
  ],
225
232
  };
@@ -351,6 +358,38 @@ export function handleSoakCheck(ctx) {
351
358
  ],
352
359
  };
353
360
  }
361
+ // ── /help (v0.27.4 M7 — CLI-parity gap #6) ───────────────────────────────
362
+ /**
363
+ * Full user-facing command catalogue, used by /help. The TOP_7 are the
364
+ * phone-tier set synced to Telegram's command menu; the workflow extras
365
+ * (compose/send/cancel) + help round out what the mirror actually claims.
366
+ */
367
+ export const ALL_COMMANDS = [
368
+ { command: 'sessions', description: 'List + switch fronted Claude session' },
369
+ { command: 'new', description: 'Register a new session slug — /new <slug>' },
370
+ { command: 'stop', description: 'Stop a registered session — /stop <slug>' },
371
+ { command: 'status', description: 'Show active sessions and their state' },
372
+ { command: 'cost', description: 'Show Max 20x usage + weekly burn' },
373
+ { command: 'compact', description: 'Compact context (CLI-only — see note)' },
374
+ { command: 'rewind', description: 'Rewind a session (CLI-only — see note)' },
375
+ { command: 'compose', description: 'Start a multi-message draft — finish with /send' },
376
+ { command: 'send', description: 'Send the composed draft to Claude' },
377
+ { command: 'cancel', description: 'Cancel the active compose draft' },
378
+ { command: 'help', description: 'Show this command list' },
379
+ ];
380
+ /**
381
+ * /help — list the mirror's commands. Discoverability close for gap #6: the 11
382
+ * commands were previously invisible. Notes the passthrough so the user knows
383
+ * any other slash (custom skills like /pregoal) or plain text reaches Claude.
384
+ * HTML-safe content (only the intentional <b> tag); sendTg renders parse_mode:HTML.
385
+ */
386
+ export function handleHelp(ctx) {
387
+ const lines = ['<b>cc-openclaw Telegram commands</b>', ''];
388
+ for (const c of ALL_COMMANDS)
389
+ lines.push(`/${c.command} — ${c.description}`);
390
+ lines.push('', 'Any other slash command or message is sent straight to Claude.');
391
+ return { actions: [{ type: 'sendMessage', chat_id: ctx.chatId, text: lines.join('\n') }] };
392
+ }
354
393
  export const COMMAND_HANDLERS = {
355
394
  sessions: handleSessions,
356
395
  new: handleNew,
@@ -363,6 +402,8 @@ export const COMMAND_HANDLERS = {
363
402
  send: handleSend,
364
403
  cancel: handleCancel,
365
404
  'soak-check': handleSoakCheck,
405
+ help: handleHelp,
406
+ commands: handleHelp,
366
407
  };
367
408
  /**
368
409
  * Telegram bot commands list — declarative source for M5's setMyCommands sync.
@@ -22,9 +22,14 @@
22
22
  * Single shared CallbackMap + ComposeBuffer per process so callback
23
23
  * resolution and compose state survive across handler invocations.
24
24
  */
25
+ import { type TelegramAction } from './commands.js';
25
26
  import { CallbackMap } from './callback-mapping.js';
26
27
  import { ComposeBuffer } from './compose-buffer.js';
27
28
  import { type InteractiveCtx, type InjectApi } from './askuser.js';
29
+ interface InboundLogger {
30
+ info: (msg: string) => void;
31
+ warn: (msg: string) => void;
32
+ }
28
33
  export interface InboundHandlerApi {
29
34
  on(event: string, handler: (...args: unknown[]) => unknown | Promise<unknown>): void;
30
35
  logger?: {
@@ -50,6 +55,19 @@ export interface HandlerState {
50
55
  composeBuffer: ComposeBuffer;
51
56
  }
52
57
  export declare function createHandlerState(): HandlerState;
58
+ /**
59
+ * Forward a single TelegramAction to the actual Telegram API. Returns
60
+ * the API response (or {ok:false} on failure). Pure I/O — no state
61
+ * mutation. Exported for unit testing (planning M-B/B2).
62
+ *
63
+ * planning M-B/B2 (D-3): the `sendDocument` branch is now wired to the
64
+ * multipart `sendDocumentTg` helper (was a no-op warn). NOTE: the PRODUCER of
65
+ * sendDocument actions — ExitPlanMode detection → buildPlanAttachment — is
66
+ * milestone M-B/B3, deferred pending probe P0-B. Until B3 lands this branch is
67
+ * dormant forwarding infrastructure, not yet a user-reachable feature.
68
+ */
69
+ export declare function forwardAction(action: TelegramAction, threadId: number | undefined, logger: InboundLogger): Promise<void>;
53
70
  /** Test-only — reset module-level dispatch + card state. */
54
71
  export declare function _resetSubscriptionForTests(): void;
55
72
  export declare function registerInboundHandler(api: InboundHandlerApi, state?: HandlerState): HandlerState;
73
+ export {};
@@ -23,13 +23,14 @@
23
23
  * resolution and compose state survive across handler invocations.
24
24
  */
25
25
  import { dispatchCommand, parseSlash, COMMAND_HANDLERS } from './commands.js';
26
- import { sendTg, editTg } from '../../lib/telegram-bot-api.js';
26
+ import { sendTg, editTg, sendDocumentTg } from '../../lib/telegram-bot-api.js';
27
27
  import { CallbackMap } from './callback-mapping.js';
28
28
  import { ComposeBuffer } from './compose-buffer.js';
29
29
  import { TurnStateMachine } from './state-machine.js';
30
30
  import { renderTurn } from './card-renderer.js';
31
31
  import { cardState as _cardState, cardStateDebug } from './card-state.js';
32
32
  import { handleTap, handleTapData, isAskUserCallback, rememberSessionKey, ASKUSER_NS, } from './askuser.js';
33
+ import { probeInboundShape, probeToolUse } from '../../lib/probes.js';
33
34
  const PLUGIN_TAG = '[cc-openclaw/telegram-mirror/inbound]';
34
35
  // v0.26.3 M5 — register the AskUserQuestion interactive tap handler exactly
35
36
  // once per process (registerInboundHandler runs on every register() call).
@@ -76,12 +77,15 @@ const MIRROR_COMMANDS = new Set(Object.keys(COMMAND_HANDLERS));
76
77
  /**
77
78
  * Forward a single TelegramAction to the actual Telegram API. Returns
78
79
  * the API response (or {ok:false} on failure). Pure I/O — no state
79
- * mutation.
80
+ * mutation. Exported for unit testing (planning M-B/B2).
80
81
  *
81
- * editMessageText and sendDocument variants land in v0.25.2 when the
82
- * render pipeline and plan-attachment dispatch wire up.
82
+ * planning M-B/B2 (D-3): the `sendDocument` branch is now wired to the
83
+ * multipart `sendDocumentTg` helper (was a no-op warn). NOTE: the PRODUCER of
84
+ * sendDocument actions — ExitPlanMode detection → buildPlanAttachment — is
85
+ * milestone M-B/B3, deferred pending probe P0-B. Until B3 lands this branch is
86
+ * dormant forwarding infrastructure, not yet a user-reachable feature.
83
87
  */
84
- async function forwardAction(action, threadId, logger) {
88
+ export async function forwardAction(action, threadId, logger) {
85
89
  try {
86
90
  if (action.type === 'sendMessage') {
87
91
  await sendTg(String(action.chat_id), action.text, threadId !== undefined ? String(threadId) : undefined, action.reply_markup);
@@ -91,8 +95,15 @@ async function forwardAction(action, threadId, logger) {
91
95
  await editTg(String(action.chat_id), action.message_id, action.text, action.reply_markup);
92
96
  return;
93
97
  }
94
- // sendDocument: v0.25.2 plan-mode attachment wire-up.
95
- logger.warn(`${PLUGIN_TAG} action type "${action.type}" not yet forwarded (deferred to v0.25.2)`);
98
+ if (action.type === 'sendDocument') {
99
+ await sendDocumentTg(String(action.chat_id), action.filename, action.content, {
100
+ caption: action.caption,
101
+ replyMarkup: action.reply_markup,
102
+ threadId,
103
+ });
104
+ return;
105
+ }
106
+ logger.warn(`${PLUGIN_TAG} action type "${action.type}" not forwarded (no handler)`);
96
107
  }
97
108
  catch (err) {
98
109
  logger.warn(`${PLUGIN_TAG} forwardAction failed: ${err.message}`);
@@ -205,6 +216,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
205
216
  // Per-event-id dedup at dispatch layer (purpose=slash).
206
217
  if (_seenOrMark('slash', _eventId(event)))
207
218
  return undefined;
219
+ probeInboundShape(event); // P0-C inbound surface (observe-only, gated)
208
220
  // Extract text from the canonical (2026.5.x) `event.content` field;
209
221
  // fall back to legacy nested paths if a future gateway version reverts.
210
222
  const text = (typeof event.content === 'string' ? event.content : undefined) ??
@@ -380,7 +392,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
380
392
  if (parsed && MIRROR_COMMANDS.has(parsed.cmd))
381
393
  return undefined;
382
394
  const fromKey = chatIdFromSessionKey(event.sessionKey);
383
- let chatId = fromKey.chatId || (event.senderId !== undefined ? String(event.senderId) : '');
395
+ const chatId = fromKey.chatId || (event.senderId !== undefined ? String(event.senderId) : '');
384
396
  if (!chatId)
385
397
  return undefined;
386
398
  const threadId = fromKey.threadId !== undefined ? fromKey.threadId : event.raw?.message?.message_thread_id;
@@ -413,6 +425,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
413
425
  const event = args[0];
414
426
  dumpShapeOnce('before_tool_call', event);
415
427
  const ev = event;
428
+ probeToolUse(ev); // P0-B ExitPlanMode detection (observe-only, gated)
416
429
  const evId = _eventId({ sessionKey: (ev?.sessionKey ?? ev?.ctx?.['SessionKey']), timestamp: ev?.timestamp, content: `tool_use:${String((ev?.id ?? ev?.tool_use_id ?? ''))}` });
417
430
  if (_seenOrMark('tool_use', evId))
418
431
  return undefined;
@@ -66,6 +66,17 @@ export declare function pushToolResult(toolUseId: string | undefined, output: un
66
66
  * don't hammer Telegram with edits.
67
67
  */
68
68
  export declare function pushAssistantText(text: string): void;
69
+ /**
70
+ * Record extended-thinking text on every active card (v0.27.4 M1 — CLI-parity
71
+ * gap #1). The streaming handler calls this from its `onThinking` callback as
72
+ * reasoning deltas arrive, passing the CUMULATIVE buffer (not the delta) — so
73
+ * the live card grows a 💭 block exactly the way the terminal streams thinking
74
+ * before the answer. Overwrites turn.thinkingText (same pattern as
75
+ * pushAssistantText), debounced repaint. Only fires while surfaceThinking is on
76
+ * (the handler gates the callback), so card-thinking matches the CLI's
77
+ * thinking-visibility setting rather than leaking reasoning unconditionally.
78
+ */
79
+ export declare function pushThinking(text: string): void;
69
80
  /**
70
81
  * Finalize every active card at the END of a model turn. Flips each card's
71
82
  * turn to 'done' (so renderTurn shows "✓ Done"), repaints once, and removes
@@ -113,6 +113,7 @@ export function readQuotaMeta() {
113
113
  // low-frequency, so log every call (including the cards=0 failure case, logged
114
114
  // BEFORE the early return). pushAssistantText is per-chunk, so throttle it.
115
115
  let _lastTextLogAt = 0;
116
+ let _lastThinkLogAt = 0;
116
117
  function logPush(kind, cards, extra) {
117
118
  process.stderr.write(`[cc-openclaw/turn-bridge] ${kind} pid=${process.pid} ${cardStateDebug()}${extra}\n`);
118
119
  }
@@ -228,6 +229,36 @@ export function pushAssistantText(text) {
228
229
  void repaint(chatId, /* force */ false);
229
230
  }
230
231
  }
232
+ /**
233
+ * Record extended-thinking text on every active card (v0.27.4 M1 — CLI-parity
234
+ * gap #1). The streaming handler calls this from its `onThinking` callback as
235
+ * reasoning deltas arrive, passing the CUMULATIVE buffer (not the delta) — so
236
+ * the live card grows a 💭 block exactly the way the terminal streams thinking
237
+ * before the answer. Overwrites turn.thinkingText (same pattern as
238
+ * pushAssistantText), debounced repaint. Only fires while surfaceThinking is on
239
+ * (the handler gates the callback), so card-thinking matches the CLI's
240
+ * thinking-visibility setting rather than leaking reasoning unconditionally.
241
+ */
242
+ export function pushThinking(text) {
243
+ const chats = activeChatIds();
244
+ const now = Date.now();
245
+ if (chats.length === 0 || now - _lastThinkLogAt > 1500) {
246
+ _lastThinkLogAt = now;
247
+ logPush('pushThinking', chats.length, ` len=${text.length}`);
248
+ }
249
+ if (chats.length === 0)
250
+ return;
251
+ for (const chatId of chats) {
252
+ const card = cardState.get(chatId);
253
+ if (!card)
254
+ continue;
255
+ const turn = card.sm.getTurn(chatId);
256
+ if (!turn || turn.state !== 'working')
257
+ continue;
258
+ turn.thinkingText = text;
259
+ void repaint(chatId, /* force */ false);
260
+ }
261
+ }
231
262
  /**
232
263
  * Finalize every active card at the END of a model turn. Flips each card's
233
264
  * turn to 'done' (so renderTurn shows "✓ Done"), repaints once, and removes