@a1hvdy/cc-openclaw 0.27.2 → 0.27.6

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 (45) hide show
  1. package/dist/src/channels/telegram-mirror/card-renderer.d.ts +8 -22
  2. package/dist/src/channels/telegram-mirror/card-renderer.js +227 -22
  3. package/dist/src/channels/telegram-mirror/commands.d.ts +16 -0
  4. package/dist/src/channels/telegram-mirror/commands.js +34 -0
  5. package/dist/src/channels/telegram-mirror/inbound-handler.js +1 -1
  6. package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +12 -1
  7. package/dist/src/channels/telegram-mirror/turn-bridge.js +41 -2
  8. package/dist/src/constants.d.ts +27 -0
  9. package/dist/src/constants.js +28 -0
  10. package/dist/src/engines/persistent-session.d.ts +5 -0
  11. package/dist/src/engines/persistent-session.js +27 -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 +23 -16
  16. package/dist/src/lib/html-render.js +127 -1
  17. package/dist/src/lib/markdown-to-mdv2.js +2 -1
  18. package/dist/src/lib/telegram-bot-api.d.ts +22 -6
  19. package/dist/src/lib/telegram-bot-api.js +94 -14
  20. package/dist/src/openai-compat/non-streaming-handler.js +18 -1
  21. package/dist/src/openai-compat/openai-compat.js +61 -2
  22. package/dist/src/openai-compat/request-coalescer.d.ts +77 -0
  23. package/dist/src/openai-compat/request-coalescer.js +157 -0
  24. package/dist/src/openai-compat/streaming-handler.d.ts +9 -1
  25. package/dist/src/openai-compat/streaming-handler.js +40 -5
  26. package/dist/src/session/persisted-sessions.d.ts +11 -0
  27. package/dist/src/session/persisted-sessions.js +17 -0
  28. package/dist/src/session/session-manager.js +22 -6
  29. package/dist/src/session/watchdogs.d.ts +3 -0
  30. package/dist/src/session/watchdogs.js +6 -0
  31. package/dist/src/session-bootstrap/cwd-patch.js +1 -2
  32. package/dist/src/types.d.ts +11 -0
  33. package/package.json +1 -1
  34. package/dist/src/config/drift-detector.d.ts +0 -28
  35. package/dist/src/config/drift-detector.js +0 -74
  36. package/dist/src/lib/stale-pid-files.d.ts +0 -17
  37. package/dist/src/lib/stale-pid-files.js +0 -39
  38. package/dist/src/persistence/snapshot.d.ts +0 -18
  39. package/dist/src/persistence/snapshot.js +0 -31
  40. package/dist/src/persistence/wal.d.ts +0 -17
  41. package/dist/src/persistence/wal.js +0 -31
  42. package/dist/src/types/index.d.ts +0 -15
  43. package/dist/src/types/index.js +0 -15
  44. package/dist/src/types/session.d.ts +0 -48
  45. package/dist/src/types/session.js +0 -19
@@ -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 */
@@ -10,7 +10,7 @@ import * as path from 'node:path';
10
10
  import * as os from 'node:os';
11
11
  import { randomUUID } from 'node:crypto';
12
12
  import { resolveEngineAndModel } from '../models.js';
13
- import { OPENAI_COMPAT_DEFAULT_MODEL, OPENAI_COMPAT_AUTO_COMPACT_THRESHOLD, } from '../constants.js';
13
+ import { OPENAI_COMPAT_DEFAULT_MODEL, OPENAI_COMPAT_AUTO_COMPACT_THRESHOLD, RESUME_FRESHNESS_MS, } from '../constants.js';
14
14
  import { isToolsPerMessageModeEnabled, isToolStreamMode } from './mode-flags.js';
15
15
  import { resolveSessionKey, sessionNameFromKey } from './session-key-resolver.js';
16
16
  import { buildSessionSystemPrompt, buildToolPromptBlock } from './prompts.js';
@@ -32,6 +32,8 @@ import { TTS_RULE } from './tts-rule.js';
32
32
  import { extractUserMessage, } from './message-extractor.js';
33
33
  import { handleNonStreaming } from './non-streaming-handler.js';
34
34
  import { handleStreaming } from './streaming-handler.js';
35
+ import { getDedupWindowMs, computeSignature, findInFlight, registerLeader, awaitLeader, replayCoalesced, } from './request-coalescer.js';
36
+ import { resolveTurnTimeoutMs } from '../lib/env-overrides.js';
35
37
  // Re-export for backward compat — Cluster B extracted these to dedicated
36
38
  // modules; keep the original import surface stable for any external caller.
37
39
  // See src/openai-compat/{mode-flags,session-key-resolver,prompts,tool-calls-parser,tool-results-serializer}.ts.
@@ -272,6 +274,17 @@ export async function handleChatCompletion(manager, body, headers, res) {
272
274
  // Note: noSessionPersistence (--no-session-persistence) is NOT set
273
275
  // because some CLI forks don't support this flag.
274
276
  skipPersistence: true,
277
+ // v0.27.4 (M4/M6): opt this session into freshness-windowed --resume so a
278
+ // gateway restart or stalled-session watchdog SIGTERM doesn't wipe the
279
+ // conversation. Persists the claudeSessionId (despite skipPersistence) and
280
+ // the next turn for this chat resumes it iff it was active within the
281
+ // window — older sessions still start fresh (anti-stale). Env override
282
+ // CC_OPENCLAW_RESUME_FRESHNESS_MS; default RESUME_FRESHNESS_MS (30 min).
283
+ resumeFreshnessMs: (() => {
284
+ const v = process.env.CC_OPENCLAW_RESUME_FRESHNESS_MS;
285
+ const n = v !== undefined ? parseInt(v, 10) : NaN;
286
+ return Number.isFinite(n) && n > 0 ? n : RESUME_FRESHNESS_MS;
287
+ })(),
275
288
  // v0.7.4 EMERGENCY RESTORE: re-enable --include-partial-messages for
276
289
  // openai-compat sessions. v0.6.0 made this opt-in (default OFF) for
277
290
  // a 10-100× JSON overhead drop, but the engine never grew the
@@ -405,13 +418,48 @@ export async function handleChatCompletion(manager, body, headers, res) {
405
418
  userMessage = `${toolBlock}\n\n${userMessage}`;
406
419
  }
407
420
  const completionId = `chatcmpl-${randomUUID().replace(/-/g, '').slice(0, 29)}`;
421
+ // ── v0.27.5 single-flight request coalescing (streaming path) ─────────────
422
+ // When OpenClaw retries a request whose stream it perceived as dead (the
423
+ // 2026-05-22 OOM/exit-137 incident), the retry carries a byte-identical body.
424
+ // Without this guard, session-manager's per-session send-chain SERIALIZES the
425
+ // retry into a SECOND full model run → a duplicate Telegram message. Here the
426
+ // first such request is the leader (runs once); a duplicate within the dedup
427
+ // window is a follower that replays the leader's result. FAIL-OPEN: any error,
428
+ // an empty/failed leader, or a leader that exceeds the turn timeout all fall
429
+ // THROUGH to a normal dispatch — this can never drop a real reply.
430
+ const dedupWindowMs = getDedupWindowMs();
431
+ let coalesceLeader;
432
+ if (isStreaming && dedupWindowMs > 0) {
433
+ try {
434
+ const sig = computeSignature(sessionName, sendInput);
435
+ const existing = findInFlight(sig, dedupWindowMs);
436
+ if (existing) {
437
+ const leaderResult = await awaitLeader(existing, resolveTurnTimeoutMs());
438
+ if (leaderResult && leaderResult.text.length > 0) {
439
+ replayCoalesced(res, completionId, resolvedModel, leaderResult);
440
+ emitTrajectory('response_complete', { engine, model: resolvedModel, latencyMs: Date.now() - _t0, ok: true, coalesced: true }, sessionName);
441
+ return; // duplicate served from the leader — no second model run
442
+ }
443
+ // leader failed / produced no text / timed out → fail-open below
444
+ }
445
+ else {
446
+ coalesceLeader = registerLeader(sig);
447
+ }
448
+ }
449
+ catch {
450
+ coalesceLeader = undefined; // any coalescer fault → behave as before
451
+ }
452
+ }
408
453
  // Pillar B v0.4.1: bracket dispatch with try/finally so response_complete
409
454
  // fires for both success and failure (the latter relabelled). Latency is
410
455
  // measured from request_in's _t0 above.
411
456
  let _ok = true;
412
457
  try {
413
458
  if (isStreaming) {
414
- await handleStreaming(manager, sessionName, resolvedModel, sendInput, completionId, res, hasTools, extracted.slashCommand);
459
+ await handleStreaming(manager, sessionName, resolvedModel, sendInput, completionId, res, hasTools, extracted.slashCommand,
460
+ // v0.27.5: capture this leader's result so a coalesced follower can
461
+ // replay it. handleStreaming calls this only on a successful turn.
462
+ coalesceLeader ? (r) => coalesceLeader.resolve(r) : undefined);
415
463
  }
416
464
  else {
417
465
  await handleNonStreaming(manager, sessionName, resolvedModel, sendInput, completionId, res, hasTools, extracted.slashCommand);
@@ -422,6 +470,17 @@ export async function handleChatCompletion(manager, body, headers, res) {
422
470
  throw err;
423
471
  }
424
472
  finally {
473
+ // v0.27.5: ALWAYS settle the leader so followers never hang. After a
474
+ // successful capture this is a no-op (resolve is idempotent); on error or
475
+ // an empty turn it resolves null → followers fail-open to a fresh run.
476
+ if (coalesceLeader) {
477
+ try {
478
+ coalesceLeader.resolve(null);
479
+ }
480
+ catch {
481
+ /* already settled */
482
+ }
483
+ }
425
484
  let tokensIn;
426
485
  let tokensOut;
427
486
  try {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Single-flight request coalescer for the openai-compat streaming path (v0.27.5).
3
+ *
4
+ * THE PROBLEM (2026-05-22 incident)
5
+ * ─────────────────────────────────
6
+ * When a turn is OOM-killed mid-stream (exit 137) — or simply runs long enough
7
+ * that OpenClaw's HTTP client perceives the SSE stream as dead — OpenClaw fires
8
+ * a RETRY with a byte-identical request body. session-manager.sendMessage()
9
+ * *serializes* concurrent same-session sends via a per-session promise chain
10
+ * (session-manager.ts:437-453) rather than coalescing them, so the retry runs
11
+ * the FULL turn a second time and delivers a second, identical Telegram message
12
+ * (the "two identical messages" the user reported).
13
+ *
14
+ * THE FIX
15
+ * ───────
16
+ * Classic single-flight (a.k.a. request coalescing): the FIRST request for a
17
+ * given signature becomes the "leader" and runs the model once; any duplicate
18
+ * arriving within DEDUP_WINDOW_MS becomes a "follower" that AWAITS the leader's
19
+ * result and replays it — no second subprocess, no divergent second generation.
20
+ * In the common retry-after-perceived-death case, OpenClaw has already abandoned
21
+ * the leader's connection, so only the follower delivers → exactly one message.
22
+ *
23
+ * SAFETY: FAIL-OPEN BY CONSTRUCTION
24
+ * ─────────────────────────────────
25
+ * The caller wraps every coalescer interaction in try/catch and, on ANY error
26
+ * (or a leader that produced empty text, or a leader that exceeds the turn
27
+ * timeout), falls THROUGH to a normal dispatch. The worst case this can produce
28
+ * is the prior behavior (a possible duplicate) — it can NEVER drop a real reply.
29
+ * That property is the whole point: the user's deepest pain is missing messages,
30
+ * so the duplicate defense must not be able to cause a miss.
31
+ */
32
+ import type * as http from 'node:http';
33
+ /** The leader's captured turn output, replayed verbatim to followers. */
34
+ export interface CoalescedResult {
35
+ text: string;
36
+ finishReason: 'stop' | 'tool_calls';
37
+ usage?: {
38
+ prompt_tokens: number;
39
+ completion_tokens: number;
40
+ total_tokens: number;
41
+ };
42
+ }
43
+ interface InFlightEntry {
44
+ startedAt: number;
45
+ /** Resolves when the leader's turn completes. `null` ⇒ leader failed or
46
+ * produced no replayable text; followers must fail-open to a fresh run. */
47
+ resultPromise: Promise<CoalescedResult | null>;
48
+ }
49
+ /** Resolve the dedup window. CC_OPENCLAW_DEDUP_WINDOW_MS=0 disables coalescing
50
+ * entirely (the caller then never enters the leader/follower branches). */
51
+ export declare function getDedupWindowMs(): number;
52
+ /** Stable signature for "the same turn". Session-scoped so two chats sending
53
+ * identical text never collide. SHA-256 of sessionName + NUL + serialized
54
+ * input keeps the key bounded regardless of prompt size. */
55
+ export declare function computeSignature(sessionName: string, input: unknown): string;
56
+ /** Return a live (within-window) in-flight entry for `sig`, or undefined.
57
+ * Prunes a stale entry as a side effect so the map self-heals. */
58
+ export declare function findInFlight(sig: string, windowMs: number): InFlightEntry | undefined;
59
+ /** Register the current request as the leader for `sig`. Returns a `resolve`
60
+ * the caller MUST invoke in a finally block with the captured result (or
61
+ * `null` on failure) so followers never hang. The entry is retained for the
62
+ * window after resolution, then evicted. */
63
+ export declare function registerLeader(sig: string): {
64
+ resolve: (r: CoalescedResult | null) => void;
65
+ };
66
+ /** Await a leader's result with a hard cap so a wedged leader can't hang the
67
+ * follower forever. On timeout returns `null` ⇒ caller fails open. */
68
+ export declare function awaitLeader(entry: InFlightEntry, timeoutMs: number): Promise<CoalescedResult | null>;
69
+ /** Replay a leader's captured result to a follower's response as a complete,
70
+ * well-formed SSE stream (role chunk → content chunk → final chunk → [DONE]).
71
+ * Mirrors the shape handleStreaming emits so OpenClaw sees an ordinary, valid
72
+ * completion. Best-effort writes: a disconnected follower socket is harmless. */
73
+ export declare function replayCoalesced(res: http.ServerResponse, completionId: string, model: string, result: CoalescedResult): void;
74
+ /** Test-only helpers. */
75
+ export declare function _resetForTest(): void;
76
+ export declare function _size(): number;
77
+ export {};
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Single-flight request coalescer for the openai-compat streaming path (v0.27.5).
3
+ *
4
+ * THE PROBLEM (2026-05-22 incident)
5
+ * ─────────────────────────────────
6
+ * When a turn is OOM-killed mid-stream (exit 137) — or simply runs long enough
7
+ * that OpenClaw's HTTP client perceives the SSE stream as dead — OpenClaw fires
8
+ * a RETRY with a byte-identical request body. session-manager.sendMessage()
9
+ * *serializes* concurrent same-session sends via a per-session promise chain
10
+ * (session-manager.ts:437-453) rather than coalescing them, so the retry runs
11
+ * the FULL turn a second time and delivers a second, identical Telegram message
12
+ * (the "two identical messages" the user reported).
13
+ *
14
+ * THE FIX
15
+ * ───────
16
+ * Classic single-flight (a.k.a. request coalescing): the FIRST request for a
17
+ * given signature becomes the "leader" and runs the model once; any duplicate
18
+ * arriving within DEDUP_WINDOW_MS becomes a "follower" that AWAITS the leader's
19
+ * result and replays it — no second subprocess, no divergent second generation.
20
+ * In the common retry-after-perceived-death case, OpenClaw has already abandoned
21
+ * the leader's connection, so only the follower delivers → exactly one message.
22
+ *
23
+ * SAFETY: FAIL-OPEN BY CONSTRUCTION
24
+ * ─────────────────────────────────
25
+ * The caller wraps every coalescer interaction in try/catch and, on ANY error
26
+ * (or a leader that produced empty text, or a leader that exceeds the turn
27
+ * timeout), falls THROUGH to a normal dispatch. The worst case this can produce
28
+ * is the prior behavior (a possible duplicate) — it can NEVER drop a real reply.
29
+ * That property is the whole point: the user's deepest pain is missing messages,
30
+ * so the duplicate defense must not be able to cause a miss.
31
+ */
32
+ import { createHash } from 'node:crypto';
33
+ import { DEDUP_WINDOW_MS } from '../constants.js';
34
+ import { formatCompletionChunk } from './response-formatter.js';
35
+ /** Module-scoped registry. Keyed by signature. Entries linger for the window
36
+ * after completion so a late retry (arriving just after the leader finished)
37
+ * still coalesces against the already-resolved result. */
38
+ const inFlight = new Map();
39
+ /** Resolve the dedup window. CC_OPENCLAW_DEDUP_WINDOW_MS=0 disables coalescing
40
+ * entirely (the caller then never enters the leader/follower branches). */
41
+ export function getDedupWindowMs() {
42
+ const raw = process.env.CC_OPENCLAW_DEDUP_WINDOW_MS;
43
+ const n = raw !== undefined ? parseInt(raw, 10) : NaN;
44
+ if (Number.isFinite(n) && n >= 0)
45
+ return n;
46
+ return DEDUP_WINDOW_MS;
47
+ }
48
+ /** Stable signature for "the same turn". Session-scoped so two chats sending
49
+ * identical text never collide. SHA-256 of sessionName + NUL + serialized
50
+ * input keeps the key bounded regardless of prompt size. */
51
+ export function computeSignature(sessionName, input) {
52
+ const raw = typeof input === 'string' ? input : JSON.stringify(input);
53
+ return createHash('sha256').update(sessionName).update('\0').update(raw).digest('hex');
54
+ }
55
+ /** Return a live (within-window) in-flight entry for `sig`, or undefined.
56
+ * Prunes a stale entry as a side effect so the map self-heals. */
57
+ export function findInFlight(sig, windowMs) {
58
+ const entry = inFlight.get(sig);
59
+ if (!entry)
60
+ return undefined;
61
+ // >= (not >): a window of 0 means "no coalescing window" → every entry is
62
+ // already stale and must be pruned (matches CC_OPENCLAW_DEDUP_WINDOW_MS=0
63
+ // disabling coalescing). With `>`, a same-millisecond lookup (elapsed 0)
64
+ // wrongly treated a 0-window entry as live. Real windows are unaffected
65
+ // (elapsed 0 >= 45000 is still false → live).
66
+ if (Date.now() - entry.startedAt >= windowMs) {
67
+ inFlight.delete(sig);
68
+ return undefined;
69
+ }
70
+ return entry;
71
+ }
72
+ /** Register the current request as the leader for `sig`. Returns a `resolve`
73
+ * the caller MUST invoke in a finally block with the captured result (or
74
+ * `null` on failure) so followers never hang. The entry is retained for the
75
+ * window after resolution, then evicted. */
76
+ export function registerLeader(sig) {
77
+ let resolveFn;
78
+ const resultPromise = new Promise((res) => {
79
+ resolveFn = res;
80
+ });
81
+ const entry = { startedAt: Date.now(), resultPromise };
82
+ inFlight.set(sig, entry);
83
+ // Idempotent: the caller resolves from BOTH the success-path capture callback
84
+ // and a finally-block backstop (which passes null). Only the first wins; the
85
+ // backstop is a no-op after a successful capture.
86
+ let settled = false;
87
+ return {
88
+ resolve: (r) => {
89
+ if (settled)
90
+ return;
91
+ settled = true;
92
+ resolveFn(r);
93
+ const t = setTimeout(() => {
94
+ if (inFlight.get(sig) === entry)
95
+ inFlight.delete(sig);
96
+ }, getDedupWindowMs());
97
+ // Don't let the eviction timer keep the process alive.
98
+ t.unref?.();
99
+ },
100
+ };
101
+ }
102
+ /** Await a leader's result with a hard cap so a wedged leader can't hang the
103
+ * follower forever. On timeout returns `null` ⇒ caller fails open. */
104
+ export async function awaitLeader(entry, timeoutMs) {
105
+ let timer;
106
+ const timeout = new Promise((res) => {
107
+ timer = setTimeout(() => res(null), timeoutMs);
108
+ timer.unref?.();
109
+ });
110
+ try {
111
+ return await Promise.race([entry.resultPromise, timeout]);
112
+ }
113
+ finally {
114
+ if (timer)
115
+ clearTimeout(timer);
116
+ }
117
+ }
118
+ /** Replay a leader's captured result to a follower's response as a complete,
119
+ * well-formed SSE stream (role chunk → content chunk → final chunk → [DONE]).
120
+ * Mirrors the shape handleStreaming emits so OpenClaw sees an ordinary, valid
121
+ * completion. Best-effort writes: a disconnected follower socket is harmless. */
122
+ export function replayCoalesced(res, completionId, model, result) {
123
+ res.writeHead(200, {
124
+ 'Content-Type': 'text/event-stream',
125
+ 'Cache-Control': 'no-cache',
126
+ Connection: 'keep-alive',
127
+ 'X-Accel-Buffering': 'no',
128
+ });
129
+ const write = (data) => {
130
+ try {
131
+ res.write(`data: ${data}\n\n`);
132
+ }
133
+ catch {
134
+ /* follower disconnected — nothing to deliver, safe to ignore */
135
+ }
136
+ };
137
+ write(JSON.stringify(formatCompletionChunk(completionId, model, { role: 'assistant' }, null)));
138
+ write(JSON.stringify(formatCompletionChunk(completionId, model, { content: result.text }, null)));
139
+ const finalChunk = formatCompletionChunk(completionId, model, {}, result.finishReason);
140
+ if (result.usage)
141
+ finalChunk.usage = result.usage;
142
+ write(JSON.stringify(finalChunk));
143
+ write('[DONE]');
144
+ try {
145
+ res.end();
146
+ }
147
+ catch {
148
+ /* already closed */
149
+ }
150
+ }
151
+ /** Test-only helpers. */
152
+ export function _resetForTest() {
153
+ inFlight.clear();
154
+ }
155
+ export function _size() {
156
+ return inFlight.size;
157
+ }
@@ -41,4 +41,12 @@ import type { UserMessageBlock } from './message-extractor.js';
41
41
  export declare function handleStreaming(manager: SessionManagerLike, sessionName: string, model: string, userMessage: string | UserMessageBlock[], completionId: string, res: http.ServerResponse, hasTools: boolean, slashCommand?: {
42
42
  cmd: string;
43
43
  mode?: string;
44
- }): Promise<void>;
44
+ }, onFinalText?: (result: {
45
+ text: string;
46
+ finishReason: 'stop' | 'tool_calls';
47
+ usage?: {
48
+ prompt_tokens: number;
49
+ completion_tokens: number;
50
+ total_tokens: number;
51
+ };
52
+ }) => void): Promise<void>;
@@ -43,7 +43,7 @@ import { emit as emitTrajectory, emitTurnTrace } from '../lib/trajectory.js';
43
43
  import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
44
44
  import { getSurfaceThinkingEnabled, getTtsAutoMode } from '../lib/config.js';
45
45
  import { applyVoiceRecovery, detectVoiceIntent, hasTtsMarkers, _logVoiceDebug } from './voice-recovery.js';
46
- import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, finalizeActiveCards as mirrorFinalizeActiveCards, failActiveCards as mirrorFailActiveCards, classifyFailure, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
46
+ import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, pushThinking as mirrorPushThinking, finalizeActiveCards as mirrorFinalizeActiveCards, failActiveCards as mirrorFailActiveCards, classifyFailure, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
47
47
  import { cardStateDebug as mirrorCardStateDebug } from '../channels/telegram-mirror/card-state.js';
48
48
  import { writePerfEvent } from '../observability/perf-telemetry.js';
49
49
  /** Coerce a userMessage (string | UserMessageBlock[]) to a flat string
@@ -69,7 +69,12 @@ userMessage, completionId, res, hasTools,
69
69
  // v0.19.1 M3: slash command captured by extractUserMessage, threaded to
70
70
  // the patched sendMessage so the live-card pill renders the original
71
71
  // /<slash> even when maybeInlineSkill replaced the message body.
72
- slashCommand) {
72
+ slashCommand,
73
+ // v0.27.5: leader-result capture for the single-flight request coalescer.
74
+ // Invoked exactly once on a SUCCESSFUL turn (never on the error path) with
75
+ // the final assistant text + finish reason + usage, so a coalesced follower
76
+ // can replay this turn's output instead of running the model a second time.
77
+ onFinalText) {
73
78
  // v0.26.1 observability: confirm the wired handler runs AND how many mirror
74
79
  // cards THIS module instance sees. If cards=0 here while the inbound handler
75
80
  // logged a registered card, the cardState singleton split across instances
@@ -125,7 +130,14 @@ slashCommand) {
125
130
  };
126
131
  // Initial chunk with role
127
132
  writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { role: 'assistant' }, null)));
128
- // SSE keepalive heartbeat
133
+ // SSE keepalive heartbeat. v0.27.5: 30s → 15s. A long quiet phase (Claude
134
+ // CLI thinking, a slow Bash/tool step) with no SSE write can make OpenClaw's
135
+ // HTTP client perceive the stream as dead and fire a RETRY — which the
136
+ // session-manager send-chain serializes into a SECOND full turn → duplicate
137
+ // Telegram message (2026-05-22 incident). A tighter heartbeat keeps the
138
+ // connection demonstrably alive between content events, cutting spurious
139
+ // retries at the source. (The request-coalescer is the second line of
140
+ // defense for the retries that still slip through.)
129
141
  const heartbeatTimer = setInterval(() => {
130
142
  if (!clientDisconnected) {
131
143
  try {
@@ -135,7 +147,7 @@ slashCommand) {
135
147
  clientDisconnected = true;
136
148
  }
137
149
  }
138
- }, 30_000);
150
+ }, 15_000);
139
151
  // Phase 2 R1+R2: in tool-stream mode, bridge session-manager's pre-parsed
140
152
  // tool_use events directly to OpenAI tool_calls SSE deltas. Skips the
141
153
  // legacy "buffer text + regex-parse <tool_calls> XML" path entirely.
@@ -327,6 +339,12 @@ slashCommand) {
327
339
  if (!text)
328
340
  return;
329
341
  thinkingBuffer += text;
342
+ // v0.27.4 M1 — surface thinking on the live Telegram card too (gap
343
+ // #1). Pass the cumulative buffer; pushThinking overwrites
344
+ // turn.thinkingText so the 💭 block grows in place. Same surfacing
345
+ // gate as the SSE reasoning emit below (this callback only exists
346
+ // when surfaceThinking is on).
347
+ mirrorPushThinking(thinkingBuffer);
330
348
  const chunk = {
331
349
  id: completionId,
332
350
  object: 'chat.completion.chunk',
@@ -528,6 +546,18 @@ slashCommand) {
528
546
  tool_calls: toolCallsEmitted,
529
547
  bytes_out: accumulatedText.length,
530
548
  });
549
+ // v0.27.5: hand the captured turn output to the coalescer (success only).
550
+ // A coalesced follower replays exactly this — so the model runs once even
551
+ // when OpenClaw retries. Guarded: a capture-callback throw must never break
552
+ // the SSE response that just succeeded.
553
+ if (onFinalText) {
554
+ try {
555
+ onFinalText({ text: accumulatedText, finishReason: traceFinishReason, usage });
556
+ }
557
+ catch {
558
+ /* capture is best-effort; the real reply already streamed */
559
+ }
560
+ }
531
561
  writeSSE('[DONE]');
532
562
  }
533
563
  catch (err) {
@@ -597,7 +627,12 @@ slashCommand) {
597
627
  // turn. reply_dispatch (gateway) fires too early to own this; the handler
598
628
  // does. Best-effort: a finalize failure must not break the SSE response.
599
629
  try {
600
- await mirrorFinalizeActiveCards();
630
+ // v0.27.6 — report-drop fix (Killer #2): when the gateway socket died
631
+ // mid-turn (clientDisconnected), the gateway delivers nothing separately,
632
+ // so pass the accumulated text and the finalized card KEEPS it as the
633
+ // sole delivery channel. Happy path (connected) passes undefined → card
634
+ // wiped → gateway delivers the reply (no duplicate).
635
+ await mirrorFinalizeActiveCards(clientDisconnected ? accumulatedText : undefined);
601
636
  }
602
637
  catch {
603
638
  /* finalize is cosmetic; never propagate */
@@ -21,6 +21,17 @@ export interface PersistedSession {
21
21
  lastResumed: string;
22
22
  lastActivity: number;
23
23
  }
24
+ /**
25
+ * v0.27.4 (M4/M6) — resume-freshness gate. A persisted Claude session is
26
+ * eligible for --resume only if its last activity is within `freshnessMs`.
27
+ * This restores cross-restart / post-watchdog-kill conversation continuity for
28
+ * openai-compat sessions WITHOUT reintroducing the stale-resume hazard that
29
+ * motivated skipPersistence: a session idle longer than the window starts
30
+ * fresh. Returns false for a missing entry, a non-numeric lastActivity, or a
31
+ * non-positive/non-finite window (resume disabled). Pure + side-effect-free so
32
+ * the decision is unit-testable independent of the disk layer.
33
+ */
34
+ export declare function isPersistedSessionFresh(persisted: Pick<PersistedSession, 'lastActivity'> | undefined, now: number, freshnessMs: number): boolean;
24
35
  export declare function loadPersistedSessions(): Map<string, PersistedSession>;
25
36
  export declare function savePersistedSessions(sessions: Map<string, PersistedSession>, logger?: Logger): void;
26
37
  export declare function savePersistedSessionsAsync(sessions: Map<string, PersistedSession>, logger?: Logger): void;
@@ -14,6 +14,23 @@ import { createConsoleLogger } from '../logger.js';
14
14
  import { PERSIST_DISK_TTL_MS } from '../constants.js';
15
15
  export const PERSIST_DIR = path.join(os.homedir(), '.openclaw');
16
16
  export const PERSIST_FILE = path.join(PERSIST_DIR, 'claude-sessions.json');
17
+ /**
18
+ * v0.27.4 (M4/M6) — resume-freshness gate. A persisted Claude session is
19
+ * eligible for --resume only if its last activity is within `freshnessMs`.
20
+ * This restores cross-restart / post-watchdog-kill conversation continuity for
21
+ * openai-compat sessions WITHOUT reintroducing the stale-resume hazard that
22
+ * motivated skipPersistence: a session idle longer than the window starts
23
+ * fresh. Returns false for a missing entry, a non-numeric lastActivity, or a
24
+ * non-positive/non-finite window (resume disabled). Pure + side-effect-free so
25
+ * the decision is unit-testable independent of the disk layer.
26
+ */
27
+ export function isPersistedSessionFresh(persisted, now, freshnessMs) {
28
+ if (!persisted || typeof persisted.lastActivity !== 'number')
29
+ return false;
30
+ if (!Number.isFinite(freshnessMs) || freshnessMs <= 0)
31
+ return false;
32
+ return now - persisted.lastActivity <= freshnessMs;
33
+ }
17
34
  export function loadPersistedSessions() {
18
35
  try {
19
36
  if (!fs.existsSync(PERSIST_FILE))
@@ -33,7 +33,7 @@ function getPluginVersion() {
33
33
  // ─── Persistence ─────────────────────────────────────────────────────────────
34
34
  // Extracted to `./persisted-sessions.ts` 2026-05-13 — coherent persistence
35
35
  // layer (load + sync atomic-write + async-write + types + constants).
36
- import { loadPersistedSessions, savePersistedSessions, savePersistedSessionsAsync, } from './persisted-sessions.js';
36
+ import { loadPersistedSessions, savePersistedSessions, savePersistedSessionsAsync, isPersistedSessionFresh, } from './persisted-sessions.js';
37
37
  // Debounce helper — coalesces rapid writes into one
38
38
  // `makeDebounced` extracted to `../lib/debounce.ts` 2026-05-13 —
39
39
  // pure-function hot-path decomposition.
@@ -158,10 +158,21 @@ export class SessionManager {
158
158
  }
159
159
  this._recordSpawn();
160
160
  // Auto-resume: if we have a persisted claudeSessionId for this name, inject it.
161
- // Skip when config.skipPersistence is set (e.g. openai-compat bridge sessions
162
- // that must NOT resume stale CLI state from a previous server run).
161
+ // Normal (non-skipPersistence) sessions resume unconditionally as before.
162
+ // skipPersistence sessions normally must NOT resume stale CLI state from a
163
+ // previous server run — EXCEPT v0.27.4 (M4/M6): when they opt into
164
+ // freshness-windowed resume (config.resumeFreshnessMs, set by the
165
+ // openai-compat bridge), resume the prior session iff it's still fresh, so
166
+ // Savvy keeps context across a gateway restart / watchdog-kill while a
167
+ // long-idle session still starts fresh.
163
168
  const skipPersist = !!config.skipPersistence;
164
- const persisted = skipPersist ? undefined : this.persistedSessions.get(name);
169
+ let persisted = skipPersist ? undefined : this.persistedSessions.get(name);
170
+ if (skipPersist && typeof config.resumeFreshnessMs === 'number') {
171
+ const candidate = this.persistedSessions.get(name);
172
+ if (isPersistedSessionFresh(candidate, Date.now(), config.resumeFreshnessMs)) {
173
+ persisted = candidate;
174
+ }
175
+ }
165
176
  // Unified: only use resumeSessionId (claudeResumeId is an internal alias, not exposed)
166
177
  const resumeId = config.resumeSessionId ?? persisted?.claudeSessionId;
167
178
  const fullConfig = {
@@ -349,10 +360,15 @@ export class SessionManager {
349
360
  }
350
361
  const result = await managed.session.send(message, sendOpts);
351
362
  // Update session ID if available (skip disk persist for ephemeral
352
- // sessions that were started with skipPersistence)
363
+ // sessions that were started with skipPersistence) — EXCEPT v0.27.4
364
+ // (M4/M6): a session that opted into freshness-windowed resume must be
365
+ // persisted (even though skipPersistence is true) so its claudeSessionId
366
+ // is on disk for the next turn / a post-restart resume.
353
367
  if (managed.session.sessionId) {
354
368
  managed.claudeSessionId = managed.session.sessionId;
355
- if (this.persistedSessions.has(name)) {
369
+ const optedIntoFreshResume = typeof managed.config.resumeFreshnessMs === 'number' &&
370
+ managed.config.resumeFreshnessMs > 0;
371
+ if (this.persistedSessions.has(name) || optedIntoFreshResume) {
356
372
  this._persistSession(name, managed);
357
373
  }
358
374
  }
@@ -27,6 +27,9 @@ export interface WatchdogManagedSession {
27
27
  getStats(): {
28
28
  lastActivity?: string | null | undefined;
29
29
  lastProgressAt?: string | null | undefined;
30
+ /** v0.27.6 — count of in-flight tool calls; > 0 means a tool is running
31
+ * (alive), so the stalled check is skipped no matter how quiet it is. */
32
+ inFlightTools?: number | undefined;
30
33
  };
31
34
  stop(): void;
32
35
  };
@@ -46,6 +46,12 @@ export function watchStalledSessions(opts) {
46
46
  if (!managed.session.isBusy)
47
47
  continue;
48
48
  const stats = managed.session.getStats();
49
+ // v0.27.6 (Killer #1) — a session with a tool in flight is ALIVE, not
50
+ // stalled, no matter how long the tool runs quiet (a 40-min build/test emits
51
+ // no stream events while it works). Skip it entirely; the age/threshold
52
+ // check below only governs a genuine silent wedge with NO tool running.
53
+ if ((stats.inFlightTools ?? 0) > 0)
54
+ continue;
49
55
  // v0.27.x — prefer the PROGRESS timestamp (real output: text/tool/result),
50
56
  // which excludes `system/api_retry` pings. Keying off lastActivity let a
51
57
  // retry-storm reset the clock forever so the watchdog never fired. Fall back
@@ -84,8 +84,6 @@ const PATHS = {
84
84
  const DEPS_STUB_PATH = join(PATHS.openclawDist, 'commands-status-deps.runtime.js');
85
85
  const STATUS_STUB_PATH = join(PATHS.openclawRoot, 'status.runtime.js');
86
86
  const AUTO_REPLY_STATUS_PATH = join(PATHS.autoReplyDir, 'commands-status.runtime.js');
87
- const SAVVY_REGISTRY_PATH = join(HOME, '.openclaw/savvy-resume-registry.json');
88
- const CLAUDE_SESSIONS_PATH = join(HOME, '.openclaw/claude-sessions.json');
89
87
  const CACHE_PARITY_REGISTRY_PATH = join(HOME, '.openclaw/openclaw-cache-parity-registry.json');
90
88
  // Patch identity symbols (module-scoped, stable across re-imports within a process)
91
89
  const PATCH_MARKER = Symbol.for('claude-local-enhancer:patched');
@@ -166,6 +164,7 @@ function _setToolDumpCacheEntry(key, val) {
166
164
  let _lastToolDumpHash = null;
167
165
  // ── sessionId capture state ───────────────────────────────────────────────
168
166
  let _lastCapturedJson = '';
167
+ // ── Resume registry helpers ───────────────────────────────────────────────
169
168
  // `restoreClaudeSessionsFromBackup` + `writeBackupRegistry` extracted to
170
169
  // `./resume-registry.ts` 2026-05-13. The wrapper preserves the caller-less
171
170
  // call signature locally by closing over the module `logger`.
@@ -137,6 +137,13 @@ export interface SessionConfig {
137
137
  sessionName?: string;
138
138
  claudeResumeId?: string;
139
139
  resumeSessionId?: string;
140
+ /** v0.27.4 (M4/M6) — opt a skipPersistence session into freshness-windowed
141
+ * --resume across restart / watchdog-kill. When set (ms), the SessionManager
142
+ * persists this session's claudeSessionId and, on the next start for the same
143
+ * name, resumes it iff its last activity is within this window. Used by the
144
+ * openai-compat bridge so Savvy keeps context across a gateway restart while
145
+ * still starting fresh after a long idle gap. */
146
+ resumeFreshnessMs?: number;
140
147
  forkSession?: boolean;
141
148
  addDir?: string[];
142
149
  effort?: EffortLevel;
@@ -190,6 +197,10 @@ export interface SessionStats {
190
197
  * events like `system/api_retry`. The stalled-session watchdog keys off this
191
198
  * so an API retry-storm (no output) is fast-failed instead of looking busy. */
192
199
  lastProgressAt: string | null;
200
+ /** v0.27.6 — tool calls currently in flight (dispatched without a matching
201
+ * result yet). Optional: only the Claude persistent-session engine populates
202
+ * it; the stalled-session watchdog treats > 0 as "alive, don't kill". */
203
+ inFlightTools?: number;
193
204
  /**
194
205
  * Approximate context window utilization (0-100).
195
206
  * Estimated as (tokensIn + tokensOut) / 200,000 * 100.