@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.
- package/dist/src/channels/telegram-mirror/card-renderer.d.ts +7 -0
- package/dist/src/channels/telegram-mirror/card-renderer.js +61 -8
- package/dist/src/channels/telegram-mirror/commands.d.ts +13 -0
- package/dist/src/channels/telegram-mirror/commands.js +26 -0
- package/dist/src/channels/telegram-mirror/index.js +44 -1
- package/dist/src/channels/telegram-mirror/sync-commands.d.ts +26 -0
- package/dist/src/channels/telegram-mirror/sync-commands.js +18 -1
- package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +1 -1
- package/dist/src/channels/telegram-mirror/turn-bridge.js +10 -2
- package/dist/src/constants.d.ts +17 -0
- package/dist/src/constants.js +18 -0
- package/dist/src/engines/persistent-session.d.ts +5 -0
- package/dist/src/engines/persistent-session.js +27 -0
- package/dist/src/lib/html-render.d.ts +15 -0
- package/dist/src/lib/html-render.js +36 -0
- package/dist/src/openai-compat/non-streaming-handler.js +18 -1
- package/dist/src/openai-compat/openai-compat.js +49 -1
- package/dist/src/openai-compat/request-coalescer.d.ts +77 -0
- package/dist/src/openai-compat/request-coalescer.js +157 -0
- package/dist/src/openai-compat/streaming-handler.d.ts +9 -1
- package/dist/src/openai-compat/streaming-handler.js +33 -4
- package/dist/src/session/watchdogs.d.ts +3 -0
- package/dist/src/session/watchdogs.js +6 -0
- package/dist/src/types.d.ts +4 -0
- package/package.json +1 -1
- package/dist/src/config/drift-detector.d.ts +0 -28
- package/dist/src/config/drift-detector.js +0 -74
- package/dist/src/lib/stale-pid-files.d.ts +0 -17
- package/dist/src/lib/stale-pid-files.js +0 -39
- package/dist/src/persistence/snapshot.d.ts +0 -18
- package/dist/src/persistence/snapshot.js +0 -31
- package/dist/src/persistence/wal.d.ts +0 -17
- package/dist/src/persistence/wal.js +0 -31
- package/dist/src/types/index.d.ts +0 -15
- package/dist/src/types/index.js +0 -15
- package/dist/src/types/session.d.ts +0 -48
- 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).
|
|
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
|
-
|
|
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 <slug></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
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/constants.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/constants.js
CHANGED
|
@@ -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(/"/g, '"')
|
|
46
46
|
.replace(/&/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
|
-
|
|
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 */
|