@a1hvdy/cc-openclaw 0.27.4 → 0.27.7

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 (37) hide show
  1. package/dist/src/channels/telegram-mirror/card-renderer.d.ts +7 -0
  2. package/dist/src/channels/telegram-mirror/card-renderer.js +61 -8
  3. package/dist/src/channels/telegram-mirror/commands.d.ts +13 -0
  4. package/dist/src/channels/telegram-mirror/commands.js +26 -0
  5. package/dist/src/channels/telegram-mirror/index.js +44 -1
  6. package/dist/src/channels/telegram-mirror/sync-commands.d.ts +26 -0
  7. package/dist/src/channels/telegram-mirror/sync-commands.js +18 -1
  8. package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +1 -1
  9. package/dist/src/channels/telegram-mirror/turn-bridge.js +10 -2
  10. package/dist/src/constants.d.ts +17 -0
  11. package/dist/src/constants.js +18 -0
  12. package/dist/src/engines/persistent-session.d.ts +5 -0
  13. package/dist/src/engines/persistent-session.js +27 -0
  14. package/dist/src/lib/html-render.d.ts +15 -0
  15. package/dist/src/lib/html-render.js +36 -0
  16. package/dist/src/openai-compat/non-streaming-handler.js +18 -1
  17. package/dist/src/openai-compat/openai-compat.js +49 -1
  18. package/dist/src/openai-compat/request-coalescer.d.ts +77 -0
  19. package/dist/src/openai-compat/request-coalescer.js +157 -0
  20. package/dist/src/openai-compat/streaming-handler.d.ts +9 -1
  21. package/dist/src/openai-compat/streaming-handler.js +33 -4
  22. package/dist/src/session/watchdogs.d.ts +3 -0
  23. package/dist/src/session/watchdogs.js +6 -0
  24. package/dist/src/types.d.ts +4 -0
  25. package/package.json +1 -1
  26. package/dist/src/config/drift-detector.d.ts +0 -28
  27. package/dist/src/config/drift-detector.js +0 -74
  28. package/dist/src/lib/stale-pid-files.d.ts +0 -17
  29. package/dist/src/lib/stale-pid-files.js +0 -39
  30. package/dist/src/persistence/snapshot.d.ts +0 -18
  31. package/dist/src/persistence/snapshot.js +0 -31
  32. package/dist/src/persistence/wal.d.ts +0 -17
  33. package/dist/src/persistence/wal.js +0 -31
  34. package/dist/src/types/index.d.ts +0 -15
  35. package/dist/src/types/index.js +0 -15
  36. package/dist/src/types/session.d.ts +0 -48
  37. package/dist/src/types/session.js +0 -19
@@ -16,6 +16,13 @@
16
16
  import type { Turn, ToolCallRecord } from './state-machine.js';
17
17
  import type { CardMeta } from './card-state.js';
18
18
  export declare function toolDiffBlock(tc: ToolCallRecord): string;
19
+ /**
20
+ * v0.27.5 — pick a syntax-highlight language for a tool's RESULT block so the
21
+ * card colors output the way the terminal does. Bash/shell → 'bash'; file tools
22
+ * → inferred from the `file_path` extension via EXT_LANG. Unknown → undefined
23
+ * (renders classless, no regression — never guess a language we can't justify).
24
+ */
25
+ export declare function langForTool(tc: ToolCallRecord): string | undefined;
19
26
  /**
20
27
  * Format one tool-call line for inclusion in the rendered card body.
21
28
  * "✓ Bash · ls -la"
@@ -14,7 +14,7 @@
14
14
  * Tests live at tests/channels/telegram-mirror/m2-render-pipeline.test.ts.
15
15
  */
16
16
  import { renderStatusLine, renderMeters, renderTodos } from './status-line.js';
17
- import { escapeHtml, code, pre, markdownToHtml } from '../../lib/html-render.js';
17
+ import { escapeHtml, code, pre, markdownToHtml, closeDanglingTags } from '../../lib/html-render.js';
18
18
  // v0.27.0 M1 — the card renders as Telegram HTML (parse_mode: 'HTML'), not
19
19
  // MarkdownV2. HTML only needs `& < >` escaped (no per-char backslash litter)
20
20
  // and supports <pre><code class="language-…"> + <blockquote> for true
@@ -182,6 +182,48 @@ export function toolDiffBlock(tc) {
182
182
  }
183
183
  return truncateDiff(lines);
184
184
  }
185
+ /**
186
+ * v0.27.5 — extension → Telegram syntax-highlight language. Telegram's only
187
+ * source of color is `<pre><code class="language-X">`; this maps a file
188
+ * extension to the `X` it recognizes. Unlisted extensions fall through to
189
+ * `undefined` (classless block, exactly as today).
190
+ */
191
+ const EXT_LANG = {
192
+ ts: 'typescript',
193
+ tsx: 'typescript',
194
+ js: 'javascript',
195
+ jsx: 'javascript',
196
+ py: 'python',
197
+ sh: 'bash',
198
+ json: 'json',
199
+ md: 'markdown',
200
+ yml: 'yaml',
201
+ yaml: 'yaml',
202
+ sql: 'sql',
203
+ go: 'go',
204
+ rs: 'rust',
205
+ css: 'css',
206
+ html: 'html',
207
+ toml: 'toml',
208
+ };
209
+ /**
210
+ * v0.27.5 — pick a syntax-highlight language for a tool's RESULT block so the
211
+ * card colors output the way the terminal does. Bash/shell → 'bash'; file tools
212
+ * → inferred from the `file_path` extension via EXT_LANG. Unknown → undefined
213
+ * (renders classless, no regression — never guess a language we can't justify).
214
+ */
215
+ export function langForTool(tc) {
216
+ const n = tc.name.toLowerCase();
217
+ if (n === 'bash' || n === 'shell')
218
+ return 'bash';
219
+ const input = tc.input;
220
+ if (input && typeof input === 'object' && typeof input.file_path === 'string') {
221
+ const m = /\.([a-z0-9]+)$/i.exec(input.file_path);
222
+ if (m)
223
+ return EXT_LANG[m[1].toLowerCase()];
224
+ }
225
+ return undefined;
226
+ }
185
227
  /**
186
228
  * Format one tool-call line for inclusion in the rendered card body.
187
229
  * "✓ Bash · ls -la"
@@ -240,11 +282,14 @@ export function renderThinkingBlock(thinkingText) {
240
282
  // v0.27.0 M1 — Telegram HTML <blockquote> renders the reasoning as a single
241
283
  // indented quote (terminal-style), distinct from the assistant text. Content
242
284
  // is HTML-escaped; newlines are preserved inside the blockquote.
285
+ // v0.27.5 M3 — `expandable` collapses long reasoning to a tap-to-expand quote,
286
+ // keeping the card compact. stripHtml's `blockquote\b[^>]*>` already tolerates
287
+ // the attribute, so the plain-text fallback is unaffected.
243
288
  const body = trimmed
244
289
  .split('\n')
245
290
  .map((l) => escapeHtml(l))
246
291
  .join('\n');
247
- return `💭 Thinking\n<blockquote>${body}</blockquote>`;
292
+ return `💭 Thinking\n<blockquote expandable>${body}</blockquote>`;
248
293
  }
249
294
  /**
250
295
  * Render the full turn into a Telegram-safe message body. The mirror keeps
@@ -339,9 +384,9 @@ export function renderTurn(turn, meta) {
339
384
  const diffText = toolDiffBlock(tc);
340
385
  const resultText = toolResultText(tc);
341
386
  const block = diffText
342
- ? pre(diffText)
387
+ ? pre(diffText, 'diff') // v0.27.5 M1 — Telegram colors -/+ lines red/green
343
388
  : resultText
344
- ? pre(truncateResult(resultText))
389
+ ? pre(truncateResult(resultText), langForTool(tc)) // v0.27.5 M2 — lang-highlight output
345
390
  : '';
346
391
  const withBlock = block ? `${line}\n${block}` : line;
347
392
  // Prefer line+block; if that bursts the cap, fall back to the line alone
@@ -410,13 +455,21 @@ export function renderTurn(turn, meta) {
410
455
  }
411
456
  // Guaranteed safety net: even if the budgeted assembly above has a logic gap,
412
457
  // 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.
458
+ // rejected outright).
416
459
  const body = lines.join('\n');
417
460
  if (body.length <= 4096)
418
461
  return body;
419
- return body.slice(0, 4095) + '…';
462
+ // v0.27.5 M4 — a blunt slice can split an HTML tag, which makes Telegram reject
463
+ // the edit and forces editTg's plain-text fallback (the path that surfaces
464
+ // literal `<pre>`/`**`/`- ` markup — the exact "raw markdown" leak we're
465
+ // closing). Instead: truncate at the last whole line ≤ 4090, close any tag the
466
+ // slice left dangling (LIFO), then append the ellipsis. The result is always
467
+ // well-formed HTML, so editTg never has to fall back. The 3900 budget means
468
+ // this rarely fires, but it must be safe when it does.
469
+ const hard = body.slice(0, 4090);
470
+ const nl = hard.lastIndexOf('\n');
471
+ const sliced = nl > 0 ? hard.slice(0, nl) : hard;
472
+ return closeDanglingTags(sliced) + '\n…';
420
473
  }
421
474
  /**
422
475
  * Decide whether the current turn snapshot should be sent (first render)
@@ -103,6 +103,19 @@ export declare function handleStatus(ctx: CommandContext): CommandResult;
103
103
  export declare function handleCompact(ctx: CommandContext): CommandResult;
104
104
  export declare function handleCost(ctx: CommandContext): CommandResult;
105
105
  export declare function handleRewind(ctx: CommandContext): CommandResult;
106
+ /**
107
+ * v0.27.7 — /clear claims the command so it stops leaking to the LLM as plain
108
+ * text (the bug: before this, /clear wasn't in COMMAND_HANDLERS, so the inbound
109
+ * router forwarded it verbatim to Claude instead of handling it).
110
+ *
111
+ * It does NOT fake an in-place context reset. The running session's lifecycle +
112
+ * resume linkage live in the command-router (activeSessions, meta.claudeSessionId)
113
+ * and the engine — not this bookkeeping registry — so a pure mirror handler has
114
+ * no honest lever to wipe context (the same D-6 constraint behind /compact and
115
+ * /rewind being CLI-only). So /clear points to the paths that actually work: a
116
+ * fresh session via /new <slug>, or an in-place wipe via /clear in the CLI.
117
+ */
118
+ export declare function handleClear(ctx: CommandContext): CommandResult;
106
119
  /**
107
120
  * Open a compose session for the chat. M7 — drafts append until /send.
108
121
  * Returns a force_reply prompt so Telegram nudges the user into reply mode.
@@ -231,6 +231,30 @@ export function handleRewind(ctx) {
231
231
  ],
232
232
  };
233
233
  }
234
+ // ── /clear ───────────────────────────────────────────────────────────────
235
+ /**
236
+ * v0.27.7 — /clear claims the command so it stops leaking to the LLM as plain
237
+ * text (the bug: before this, /clear wasn't in COMMAND_HANDLERS, so the inbound
238
+ * router forwarded it verbatim to Claude instead of handling it).
239
+ *
240
+ * It does NOT fake an in-place context reset. The running session's lifecycle +
241
+ * resume linkage live in the command-router (activeSessions, meta.claudeSessionId)
242
+ * and the engine — not this bookkeeping registry — so a pure mirror handler has
243
+ * no honest lever to wipe context (the same D-6 constraint behind /compact and
244
+ * /rewind being CLI-only). So /clear points to the paths that actually work: a
245
+ * fresh session via /new <slug>, or an in-place wipe via /clear in the CLI.
246
+ */
247
+ export function handleClear(ctx) {
248
+ return {
249
+ actions: [
250
+ {
251
+ type: 'sendMessage',
252
+ chat_id: ctx.chatId,
253
+ text: "⚠️ /clear can't reset a running session from Telegram (no session-control primitive — same as /compact and /rewind). For a fresh start use <b>/new &lt;slug&gt;</b>; for an in-place context wipe run <b>/clear</b> in the Claude Code CLI.",
254
+ },
255
+ ],
256
+ };
257
+ }
234
258
  // ── /compose ─────────────────────────────────────────────────────────────
235
259
  /**
236
260
  * Open a compose session for the chat. M7 — drafts append until /send.
@@ -372,6 +396,7 @@ export const ALL_COMMANDS = [
372
396
  { command: 'cost', description: 'Show Max 20x usage + weekly burn' },
373
397
  { command: 'compact', description: 'Compact context (CLI-only — see note)' },
374
398
  { command: 'rewind', description: 'Rewind a session (CLI-only — see note)' },
399
+ { command: 'clear', description: 'Fresh start — /new <slug> (in-place reset is CLI-only)' },
375
400
  { command: 'compose', description: 'Start a multi-message draft — finish with /send' },
376
401
  { command: 'send', description: 'Send the composed draft to Claude' },
377
402
  { command: 'cancel', description: 'Cancel the active compose draft' },
@@ -398,6 +423,7 @@ export const COMMAND_HANDLERS = {
398
423
  compact: handleCompact,
399
424
  cost: handleCost,
400
425
  rewind: handleRewind,
426
+ clear: handleClear,
401
427
  compose: handleCompose,
402
428
  send: handleSend,
403
429
  cancel: handleCancel,
@@ -17,8 +17,10 @@
17
17
  * Risks monitored: R-7 (parallel maintenance burden — re-evaluate at soak start).
18
18
  */
19
19
  import { defaultRegisterGuard } from '../../lib/register-guard.js';
20
- import { initBotTokenFromConfig } from '../../lib/telegram-bot-api.js';
20
+ import { initBotTokenFromConfig, telegramApi } from '../../lib/telegram-bot-api.js';
21
21
  import { registerInboundHandler } from './inbound-handler.js';
22
+ import { syncMyCommands } from './sync-commands.js';
23
+ import { TOP_7_COMMANDS } from './commands.js';
22
24
  /**
23
25
  * Mirror channel register — idempotent via defaultRegisterGuard.
24
26
  *
@@ -82,6 +84,47 @@ export function register(api) {
82
84
  ? fullApi.enqueueNextTurnInjection.bind(api)
83
85
  : undefined,
84
86
  });
87
+ // v0.27.7 — wire the command-menu sync. It was DEAD CODE (defined in
88
+ // sync-commands.ts but never called from boot), so cc-openclaw's native
89
+ // commands never reached Telegram's command menu — only OpenClaw's
90
+ // nativeSkills auto-sync did, which is why /stop /new /status were
91
+ // invisible in the menu. We MERGE (getMyCommands → union) so the sync
92
+ // AUGMENTS the menu instead of clobbering the skill commands, and DELAY
93
+ // it so it lands after OpenClaw's own boot sync (tunable via
94
+ // CC_OPENCLAW_CMD_SYNC_DELAY_MS; default 8s). Gated out of the test
95
+ // runner — register() fires repeatedly in unit tests and must not make
96
+ // real Telegram network calls (eager-work-at-register safety, mirrors
97
+ // OPENCLAW_PLUGIN_TEST_MODE rationale).
98
+ const isTestRunner = process.env.VITEST !== undefined ||
99
+ process.env.NODE_ENV === 'test' ||
100
+ process.env.OPENCLAW_PLUGIN_TEST_MODE === '1';
101
+ if (!isTestRunner) {
102
+ const cmdSyncDelayMs = Number(process.env.CC_OPENCLAW_CMD_SYNC_DELAY_MS) || 8000;
103
+ const timer = setTimeout(() => {
104
+ void syncMyCommands({
105
+ setMyCommands: (payload) => telegramApi('setMyCommands', { commands: payload.commands }),
106
+ getMyCommands: async () => {
107
+ const res = await telegramApi('getMyCommands', {});
108
+ const result = res.result;
109
+ return { commands: result ?? [] };
110
+ },
111
+ nativeCommands: [
112
+ ...TOP_7_COMMANDS,
113
+ {
114
+ command: 'clear',
115
+ description: 'Fresh start — /new <slug> (in-place reset is CLI-only)',
116
+ },
117
+ ],
118
+ logger: {
119
+ info: (msg) => process.stderr.write(`${msg}\n`),
120
+ warn: (msg) => process.stderr.write(`${msg}\n`),
121
+ },
122
+ }).catch((err) => process.stderr.write(`[cc-openclaw/telegram-mirror] command-menu sync failed: ${err instanceof Error ? err.message : String(err)}\n`));
123
+ }, cmdSyncDelayMs);
124
+ // Don't keep the event loop alive solely for this timer.
125
+ if (typeof timer.unref === 'function')
126
+ timer.unref();
127
+ }
85
128
  process.stderr.write('[cc-openclaw/telegram-mirror] guard body completed (v0.25.2).\n');
86
129
  }
87
130
  catch (err) {
@@ -39,6 +39,32 @@ export interface SyncOptions {
39
39
  * Tests pass the G-3 mock; production wires a fetch-based caller.
40
40
  */
41
41
  setMyCommands: (payload: SetMyCommandsPayload) => Promise<unknown>;
42
+ /**
43
+ * v0.27.7 — optional getMyCommands dispatcher. When supplied, the CURRENTLY
44
+ * registered commands (e.g. OpenClaw's nativeSkills auto-synced /search,
45
+ * /plan, …) are fetched and MERGED with the native list so the sync augments
46
+ * the Telegram menu instead of replacing it. setMyCommands is a full-replace
47
+ * API, so without this merge a native-only sync would wipe the skill
48
+ * commands (and vice-versa — which is the bug that left native commands
49
+ * invisible: the dead sync never ran, and OpenClaw's skill-sync owned the
50
+ * menu alone). Omit it for the legacy native-only replace.
51
+ */
52
+ getMyCommands?: () => Promise<{
53
+ commands?: ReadonlyArray<{
54
+ command: string;
55
+ description: string;
56
+ }>;
57
+ }>;
58
+ /**
59
+ * v0.27.7 — native command set to advertise. Defaults to TOP_7_COMMANDS so
60
+ * the legacy callers (and the M5 boot-only test) keep the exact top-7 list.
61
+ * The boot wiring passes top-7 + /clear so the menu reflects the full native
62
+ * surface.
63
+ */
64
+ nativeCommands?: ReadonlyArray<{
65
+ command: string;
66
+ description: string;
67
+ }>;
42
68
  logger?: SyncLogger;
43
69
  }
44
70
  export interface SyncResult {
@@ -28,7 +28,24 @@ export async function syncMyCommands(opts) {
28
28
  return { alreadySynced: true, commandCount: 0 };
29
29
  }
30
30
  synced = true;
31
- const payload = { commands: TOP_7_COMMANDS.slice() };
31
+ const native = opts.nativeCommands ?? TOP_7_COMMANDS;
32
+ let commands = native.map((c) => ({ ...c }));
33
+ // v0.27.7 — merge in existing (skill) commands so the sync augments rather
34
+ // than replaces the menu. Native wins on a name collision. If the fetch
35
+ // fails, fall back to native-only (never wipe the menu on a transient error).
36
+ if (opts.getMyCommands) {
37
+ try {
38
+ const existing = await opts.getMyCommands();
39
+ const nativeNames = new Set(native.map((c) => c.command));
40
+ const preserved = (existing?.commands ?? []).filter((c) => !nativeNames.has(c.command));
41
+ commands = [...commands, ...preserved.map((c) => ({ ...c }))];
42
+ }
43
+ catch (err) {
44
+ const msg = err instanceof Error ? err.message : String(err);
45
+ opts.logger?.warn(`[cc-openclaw/telegram-mirror] getMyCommands failed — syncing native-only: ${msg}`);
46
+ }
47
+ }
48
+ const payload = { commands };
32
49
  try {
33
50
  await opts.setMyCommands(payload);
34
51
  opts.logger?.info(`[cc-openclaw/telegram-mirror] setMyCommands synced — ${payload.commands.length} commands.`);
@@ -90,7 +90,7 @@ export declare function pushThinking(text: string): void;
90
90
  * into the card during the turn, then calls this on turn end. Single-tenant:
91
91
  * finalizes ALL active cards (one at a time in practice).
92
92
  */
93
- export declare function finalizeActiveCards(): Promise<void>;
93
+ export declare function finalizeActiveCards(deliveredText?: string): Promise<void>;
94
94
  /**
95
95
  * Map a caught error to a short, user-readable card reason (v0.27.0 M4). Keeps
96
96
  * the "❌ <reason>" header concise and classifies the common cc-openclaw-side
@@ -272,7 +272,7 @@ export function pushThinking(text) {
272
272
  * into the card during the turn, then calls this on turn end. Single-tenant:
273
273
  * finalizes ALL active cards (one at a time in practice).
274
274
  */
275
- export async function finalizeActiveCards() {
275
+ export async function finalizeActiveCards(deliveredText) {
276
276
  const chats = activeChatIds();
277
277
  if (chats.length === 0)
278
278
  return;
@@ -291,7 +291,15 @@ export async function finalizeActiveCards() {
291
291
  // The plugin can't suppress the gateway reply (no suppressUserDelivery
292
292
  // knob), so the card yields the final text and finalizes to a clean
293
293
  // status/tools/✓ Done activity view. renderTurn itself is unchanged.
294
- turn.assistantText = '';
294
+ //
295
+ // v0.27.6 disconnect exception (Killer #2 report-drop) — when the gateway
296
+ // socket died mid-turn the gateway delivers NOTHING separately, so the
297
+ // dedup assumption breaks and wiping the text yields total silence
298
+ // ("✓ Done" then nothing). The caller passes the accumulated text in that
299
+ // case (deliveredText); the finalized card then KEEPS the full report as
300
+ // the sole delivery channel. On the happy path deliveredText is undefined
301
+ // → '' → behavior unchanged (gateway delivers, no duplicate).
302
+ turn.assistantText = deliveredText ?? '';
295
303
  try {
296
304
  await editTg(chatId, card.messageId, renderTurn(turn, card.meta));
297
305
  }
@@ -186,3 +186,20 @@ export declare const CC_AUTO_COMPACT_THRESHOLD = 70;
186
186
  * recreate) instead of compacting. Used when /compact itself would leave
187
187
  * the session too close to the model's window cap to do useful work. */
188
188
  export declare const CC_HARD_RESET_THRESHOLD = 90;
189
+ /** Single-flight window for the openai-compat streaming path. When two
190
+ * requests with a byte-identical (sessionName + input) signature arrive
191
+ * within this window, the second is treated as a duplicate (an OpenClaw
192
+ * retry of a stream it perceived as dead) and JOINS the in-flight turn
193
+ * instead of spawning a SECOND full model run. The leader's result is
194
+ * replayed to the follower; the model executes exactly once.
195
+ *
196
+ * Born from the 2026-05-22 incident: an OOM SIGKILL (exit 137) made
197
+ * OpenClaw retry, and session-manager's per-session send-chain SERIALIZES
198
+ * (rather than coalesces) the retry — so the turn ran twice and delivered
199
+ * two identical Telegram messages. This guards the duplicate at its source.
200
+ *
201
+ * Set CC_OPENCLAW_DEDUP_WINDOW_MS=0 to disable (pure fail-open to the prior
202
+ * serialize-and-rerun behavior). Default 45s comfortably exceeds a typical
203
+ * OpenClaw read-timeout-then-retry gap without risking a real, distinct
204
+ * follow-up message colliding with a just-finished identical one. */
205
+ export declare const DEDUP_WINDOW_MS = 45000;
@@ -195,3 +195,21 @@ export const CC_AUTO_COMPACT_THRESHOLD = 70;
195
195
  * recreate) instead of compacting. Used when /compact itself would leave
196
196
  * the session too close to the model's window cap to do useful work. */
197
197
  export const CC_HARD_RESET_THRESHOLD = 90;
198
+ // ─── request coalescing / duplicate-turn defense (v0.27.5) ──────────────────
199
+ /** Single-flight window for the openai-compat streaming path. When two
200
+ * requests with a byte-identical (sessionName + input) signature arrive
201
+ * within this window, the second is treated as a duplicate (an OpenClaw
202
+ * retry of a stream it perceived as dead) and JOINS the in-flight turn
203
+ * instead of spawning a SECOND full model run. The leader's result is
204
+ * replayed to the follower; the model executes exactly once.
205
+ *
206
+ * Born from the 2026-05-22 incident: an OOM SIGKILL (exit 137) made
207
+ * OpenClaw retry, and session-manager's per-session send-chain SERIALIZES
208
+ * (rather than coalesces) the retry — so the turn ran twice and delivered
209
+ * two identical Telegram messages. This guards the duplicate at its source.
210
+ *
211
+ * Set CC_OPENCLAW_DEDUP_WINDOW_MS=0 to disable (pure fail-open to the prior
212
+ * serialize-and-rerun behavior). Default 45s comfortably exceeds a typical
213
+ * OpenClaw read-timeout-then-retry gap without risking a real, distinct
214
+ * follow-up message colliding with a just-finished identical one. */
215
+ export const DEDUP_WINDOW_MS = 45_000;
@@ -27,6 +27,11 @@ interface InternalStats {
27
27
  lastActivity: string | null;
28
28
  /** v0.27.x — last PROGRESS event ts (excludes api_retry); watchdog keys off it. */
29
29
  lastProgressAt: string | null;
30
+ /** v0.27.6 — count of tool calls currently in flight (dispatched onToolUse
31
+ * without a matching onToolResult yet). The stalled-session watchdog treats
32
+ * inFlightTools > 0 as "alive" so a long quiet Bash/build/test step is never
33
+ * killed mid-run. Reset to 0 at turn boundaries (start / complete / close). */
34
+ inFlightTools: number;
30
35
  history: Array<{
31
36
  time: string;
32
37
  type: string;
@@ -59,6 +59,7 @@ export class PersistentClaudeSession extends EventEmitter {
59
59
  startTime: null,
60
60
  lastActivity: null,
61
61
  lastProgressAt: null,
62
+ inFlightTools: 0,
62
63
  history: [],
63
64
  retries: 0,
64
65
  lastRetryError: undefined,
@@ -291,6 +292,9 @@ export class PersistentClaudeSession extends EventEmitter {
291
292
  });
292
293
  this.proc.on('close', (code) => {
293
294
  this._isReady = false;
295
+ // v0.27.6 — liveness watchdog (Killer #1): subprocess gone, nothing can be
296
+ // in flight; clear so a stale count never outlives the process.
297
+ this.stats.inFlightTools = 0;
294
298
  this.emit(SESSION_EVENT.CLOSE, code);
295
299
  });
296
300
  this.proc.on('error', (err) => {
@@ -385,6 +389,10 @@ export class PersistentClaudeSession extends EventEmitter {
385
389
  const block = inner.content_block;
386
390
  if (block?.type === 'tool_use') {
387
391
  this.stats.toolCalls++;
392
+ // v0.27.6 — liveness watchdog (Killer #1): a tool is now in flight.
393
+ // Decremented on its tool_result; reset to 0 at turn boundaries so a
394
+ // missed result can't pin the count open forever.
395
+ this.stats.inFlightTools++;
388
396
  // v0.26.1: carry the tool_use block id so the Telegram mirror can
389
397
  // match the later tool_result event and flip the glyph … → ✓/✗.
390
398
  const toolEvent = { tool: { name: block.name, input: {}, id: block.id } };
@@ -470,6 +478,10 @@ export class PersistentClaudeSession extends EventEmitter {
470
478
  };
471
479
  if (b.is_error)
472
480
  this.stats.toolErrors++;
481
+ // v0.27.6 — liveness watchdog (Killer #1): this tool's result
482
+ // arrived, so it's no longer in flight. Clamp at 0 (a duplicate or
483
+ // unmatched result must never push the count negative).
484
+ this.stats.inFlightTools = Math.max(0, this.stats.inFlightTools - 1);
473
485
  try {
474
486
  this._streamCallbacks?.onToolResult?.(resultEvent);
475
487
  }
@@ -511,6 +523,8 @@ export class PersistentClaudeSession extends EventEmitter {
511
523
  }
512
524
  else if (block.type === 'tool_use') {
513
525
  this.stats.toolCalls++;
526
+ // v0.27.6 — liveness watchdog (Killer #1): tool in flight (see above).
527
+ this.stats.inFlightTools++;
514
528
  const toolEvent = {
515
529
  tool: {
516
530
  name: block.name,
@@ -559,6 +573,8 @@ export class PersistentClaudeSession extends EventEmitter {
559
573
  break;
560
574
  case 'tool_use':
561
575
  this.stats.toolCalls++;
576
+ // v0.27.6 — liveness watchdog (Killer #1): tool in flight (see above).
577
+ this.stats.inFlightTools++;
562
578
  try {
563
579
  this._streamCallbacks?.onToolUse?.(event);
564
580
  }
@@ -568,6 +584,8 @@ export class PersistentClaudeSession extends EventEmitter {
568
584
  this.emit(SESSION_EVENT.TOOL_USE, event);
569
585
  break;
570
586
  case 'tool_result':
587
+ // v0.27.6 — liveness watchdog (Killer #1): tool result arrived (see above).
588
+ this.stats.inFlightTools = Math.max(0, this.stats.inFlightTools - 1);
571
589
  try {
572
590
  this._streamCallbacks?.onToolResult?.(event);
573
591
  }
@@ -601,6 +619,10 @@ export class PersistentClaudeSession extends EventEmitter {
601
619
  }
602
620
  this.emit(SESSION_EVENT.RESULT, event);
603
621
  this.emit(SESSION_EVENT.TURN_COMPLETE, event);
622
+ // v0.27.6 — liveness watchdog (Killer #1): turn is over; clear any
623
+ // in-flight tool count so a missed tool_result can't pin the session
624
+ // "alive" across turns and permanently disable the stalled backstop.
625
+ this.stats.inFlightTools = 0;
604
626
  this._fireHook('onTurnComplete', {
605
627
  text: event.result,
606
628
  usage,
@@ -668,6 +690,10 @@ export class PersistentClaudeSession extends EventEmitter {
668
690
  this._streamCallbacks = options.callbacks;
669
691
  if (options.waitForComplete) {
670
692
  this._isBusy = true;
693
+ // v0.27.6 — liveness watchdog (Killer #1): each turn starts with zero
694
+ // tools in flight; guarantees no count leaks across turns regardless of
695
+ // how the prior turn ended (result / error / partial).
696
+ this.stats.inFlightTools = 0;
671
697
  try {
672
698
  return await this._waitForTurnComplete(options.timeout || resolveTurnTimeoutMs());
673
699
  }
@@ -781,6 +807,7 @@ export class PersistentClaudeSession extends EventEmitter {
781
807
  startTime: this.stats.startTime,
782
808
  lastActivity: this.stats.lastActivity,
783
809
  lastProgressAt: this.stats.lastProgressAt,
810
+ inFlightTools: this.stats.inFlightTools,
784
811
  // v0.6.0: contextPercent now reflects ACTUAL per-turn context occupancy
785
812
  // (input + cache-read tokens from the last `result` event), not lifetime
786
813
  // cumulative tokens. Pre-fix it saturated at 100% by turn 3 of any
@@ -26,6 +26,21 @@ export declare function code(s: string): string;
26
26
  * stays legible. Not a general sanitizer — scoped to the tags this module emits.
27
27
  */
28
28
  export declare function stripHtml(input: string | null | undefined): string;
29
+ /**
30
+ * v0.27.5 M4 — close any unclosed Telegram-HTML tags this module emits, in LIFO
31
+ * order. A hard truncation of a rendered card (renderTurn's 4096-char backstop)
32
+ * can slice through an open `<pre>`/`<b>`/… ; left dangling, Telegram rejects the
33
+ * edit and editTg falls back to plain text — dumping literal `<pre>`/`**`/`- `
34
+ * markup into the chat. Closing the danglers keeps the truncated HTML well-formed
35
+ * so the edit is accepted.
36
+ *
37
+ * Conservative by design: only the tags this module emits (pre, code, blockquote,
38
+ * b, i, s, a) are tracked, balanced input passes through byte-identical, and
39
+ * stray/mismatched closers are tolerated (pop nearest match, never go negative).
40
+ * Attribute values here never contain a literal `>` (escapeHtml runs on class /
41
+ * href), so `[^>]*` safely consumes the whole opening tag.
42
+ */
43
+ export declare function closeDanglingTags(html: string): string;
29
44
  /**
30
45
  * Fenced code block: `<pre><code class="language-LANG">escaped</code></pre>`.
31
46
  * Omits the class when no language is given. Body is HTML-escaped (the only
@@ -45,6 +45,42 @@ export function stripHtml(input) {
45
45
  .replace(/&quot;/g, '"')
46
46
  .replace(/&amp;/g, '&');
47
47
  }
48
+ /**
49
+ * v0.27.5 M4 — close any unclosed Telegram-HTML tags this module emits, in LIFO
50
+ * order. A hard truncation of a rendered card (renderTurn's 4096-char backstop)
51
+ * can slice through an open `<pre>`/`<b>`/… ; left dangling, Telegram rejects the
52
+ * edit and editTg falls back to plain text — dumping literal `<pre>`/`**`/`- `
53
+ * markup into the chat. Closing the danglers keeps the truncated HTML well-formed
54
+ * so the edit is accepted.
55
+ *
56
+ * Conservative by design: only the tags this module emits (pre, code, blockquote,
57
+ * b, i, s, a) are tracked, balanced input passes through byte-identical, and
58
+ * stray/mismatched closers are tolerated (pop nearest match, never go negative).
59
+ * Attribute values here never contain a literal `>` (escapeHtml runs on class /
60
+ * href), so `[^>]*` safely consumes the whole opening tag.
61
+ */
62
+ export function closeDanglingTags(html) {
63
+ const stack = [];
64
+ const re = /<(\/?)(pre|code|blockquote|b|i|s|a)\b[^>]*>/gi;
65
+ let m;
66
+ while ((m = re.exec(html)) !== null) {
67
+ const tag = m[2].toLowerCase();
68
+ if (m[1] === '/') {
69
+ const idx = stack.lastIndexOf(tag);
70
+ if (idx !== -1)
71
+ stack.splice(idx, 1);
72
+ }
73
+ else {
74
+ stack.push(tag);
75
+ }
76
+ }
77
+ if (stack.length === 0)
78
+ return html;
79
+ let out = html;
80
+ for (let i = stack.length - 1; i >= 0; i--)
81
+ out += `</${stack[i]}>`;
82
+ return out;
83
+ }
48
84
  /**
49
85
  * Fenced code block: `<pre><code class="language-LANG">escaped</code></pre>`.
50
86
  * Omits the class when no language is given. Body is HTML-escaped (the only
@@ -61,6 +61,17 @@ slashCommand) {
61
61
  // v0.15.0 Slice 1: hoist userText so the catch-path probe emit can reference
62
62
  // it (originally declared inside try at line ~133, post-recovery-pipeline).
63
63
  const probeUserText = userMessageToText(userMessage);
64
+ // v0.27.6 — report-drop fix (Killer #2), mirror of the streaming handler.
65
+ // The non-streaming path had NO disconnect tracking, so we add it: if the
66
+ // gateway socket dies mid-turn the buffered JSON reply reaches no one, and
67
+ // finalize() would otherwise wipe the card → total silence. Track the close,
68
+ // hoist deliveredText to function scope (same pattern as probeUserText above),
69
+ // and the finally block keeps the text on the card only when disconnected.
70
+ let clientDisconnected = false;
71
+ res.on('close', () => {
72
+ clientDisconnected = true;
73
+ });
74
+ let deliveredText = '';
64
75
  try {
65
76
  reportStatus('thinking', 'Processing request...');
66
77
  // v0.7.1: accumulate thinking-block content when surfaceThinking is on.
@@ -163,6 +174,9 @@ slashCommand) {
163
174
  // card. reply_dispatch in inbound-handler will fire shortly after and
164
175
  // re-render the card with state='done', picking up this text inline.
165
176
  mirrorPushAssistantText(outputText);
177
+ // v0.27.6 — remember the text the card is showing so the finally block can
178
+ // re-apply it if the socket died (see clientDisconnected note above).
179
+ deliveredText = outputText;
166
180
  // Parse tool_calls from response text when caller provided tools
167
181
  let traceToolCount = 0;
168
182
  let traceFinishReason = 'stop';
@@ -256,7 +270,10 @@ slashCommand) {
256
270
  // v0.26.1 — finalize the Telegram mirror card at the true end of the model
257
271
  // turn (see handleStreaming counterpart). Best-effort.
258
272
  try {
259
- await mirrorFinalizeActiveCards();
273
+ // v0.27.6 — report-drop fix (Killer #2): keep the report on the card when
274
+ // the socket died (mirror of the streaming handler). Happy path passes
275
+ // undefined → card wiped → gateway delivers (no duplicate).
276
+ await mirrorFinalizeActiveCards(clientDisconnected ? deliveredText : undefined);
260
277
  }
261
278
  catch {
262
279
  /* finalize is cosmetic; never propagate */