@a1hvdy/cc-openclaw 0.27.4 → 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 (32) 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/turn-bridge.d.ts +1 -1
  4. package/dist/src/channels/telegram-mirror/turn-bridge.js +10 -2
  5. package/dist/src/constants.d.ts +17 -0
  6. package/dist/src/constants.js +18 -0
  7. package/dist/src/engines/persistent-session.d.ts +5 -0
  8. package/dist/src/engines/persistent-session.js +27 -0
  9. package/dist/src/lib/html-render.d.ts +15 -0
  10. package/dist/src/lib/html-render.js +36 -0
  11. package/dist/src/openai-compat/non-streaming-handler.js +18 -1
  12. package/dist/src/openai-compat/openai-compat.js +49 -1
  13. package/dist/src/openai-compat/request-coalescer.d.ts +77 -0
  14. package/dist/src/openai-compat/request-coalescer.js +157 -0
  15. package/dist/src/openai-compat/streaming-handler.d.ts +9 -1
  16. package/dist/src/openai-compat/streaming-handler.js +33 -4
  17. package/dist/src/session/watchdogs.d.ts +3 -0
  18. package/dist/src/session/watchdogs.js +6 -0
  19. package/dist/src/types.d.ts +4 -0
  20. package/package.json +1 -1
  21. package/dist/src/config/drift-detector.d.ts +0 -28
  22. package/dist/src/config/drift-detector.js +0 -74
  23. package/dist/src/lib/stale-pid-files.d.ts +0 -17
  24. package/dist/src/lib/stale-pid-files.js +0 -39
  25. package/dist/src/persistence/snapshot.d.ts +0 -18
  26. package/dist/src/persistence/snapshot.js +0 -31
  27. package/dist/src/persistence/wal.d.ts +0 -17
  28. package/dist/src/persistence/wal.js +0 -31
  29. package/dist/src/types/index.d.ts +0 -15
  30. package/dist/src/types/index.js +0 -15
  31. package/dist/src/types/session.d.ts +0 -48
  32. 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)
@@ -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 */
@@ -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.
@@ -416,13 +418,48 @@ export async function handleChatCompletion(manager, body, headers, res) {
416
418
  userMessage = `${toolBlock}\n\n${userMessage}`;
417
419
  }
418
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
+ }
419
453
  // Pillar B v0.4.1: bracket dispatch with try/finally so response_complete
420
454
  // fires for both success and failure (the latter relabelled). Latency is
421
455
  // measured from request_in's _t0 above.
422
456
  let _ok = true;
423
457
  try {
424
458
  if (isStreaming) {
425
- 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);
426
463
  }
427
464
  else {
428
465
  await handleNonStreaming(manager, sessionName, resolvedModel, sendInput, completionId, res, hasTools, extracted.slashCommand);
@@ -433,6 +470,17 @@ export async function handleChatCompletion(manager, body, headers, res) {
433
470
  throw err;
434
471
  }
435
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
+ }
436
484
  let tokensIn;
437
485
  let tokensOut;
438
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>;
@@ -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.
@@ -534,6 +546,18 @@ slashCommand) {
534
546
  tool_calls: toolCallsEmitted,
535
547
  bytes_out: accumulatedText.length,
536
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
+ }
537
561
  writeSSE('[DONE]');
538
562
  }
539
563
  catch (err) {
@@ -603,7 +627,12 @@ slashCommand) {
603
627
  // turn. reply_dispatch (gateway) fires too early to own this; the handler
604
628
  // does. Best-effort: a finalize failure must not break the SSE response.
605
629
  try {
606
- 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);
607
636
  }
608
637
  catch {
609
638
  /* finalize is cosmetic; never propagate */
@@ -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
@@ -197,6 +197,10 @@ export interface SessionStats {
197
197
  * events like `system/api_retry`. The stalled-session watchdog keys off this
198
198
  * so an API retry-storm (no output) is fast-failed instead of looking busy. */
199
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;
200
204
  /**
201
205
  * Approximate context window utilization (0-100).
202
206
  * Estimated as (tokensIn + tokensOut) / 200,000 * 100.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a1hvdy/cc-openclaw",
3
- "version": "0.27.4",
3
+ "version": "0.27.6",
4
4
  "description": "A1xAI's Anthropic CLI bridge plugin for OpenClaw",
5
5
  "author": "@a1cy",
6
6
  "license": "MIT",
@@ -1,28 +0,0 @@
1
- /**
2
- * drift-detector — snapshots the parsed Config to disk on a healthy boot;
3
- * compares on subsequent boots and returns a delta list.
4
- *
5
- * Primary purpose: detect keys pruned by `openclaw doctor --fix`
6
- * (OF5 — feedback_cc_openclaw_provider_timeout.md).
7
- *
8
- * Snapshot path: ~/.openclaw/workspace/memory/cc-openclaw-config-snapshot.json
9
- * The file is written atomically (write-then-rename is not available in pure
10
- * Node ESM without extra deps; we write directly since the worst case is a
11
- * corrupted snapshot — non-fatal; falls back to empty baseline).
12
- */
13
- import type { Config } from './schema.js';
14
- export interface ConfigDelta {
15
- path: string;
16
- previous: unknown;
17
- current: unknown;
18
- }
19
- /**
20
- * Compare current config against the on-disk snapshot.
21
- * Returns an array of changed paths (empty = no drift).
22
- */
23
- export declare function detectDrift(current: Config): ConfigDelta[];
24
- /**
25
- * Persist current config as the baseline snapshot for future drift detection.
26
- * Called at the end of a successful boot (after phase 7 / ready state).
27
- */
28
- export declare function saveSnapshot(current: Config): void;
@@ -1,74 +0,0 @@
1
- /**
2
- * drift-detector — snapshots the parsed Config to disk on a healthy boot;
3
- * compares on subsequent boots and returns a delta list.
4
- *
5
- * Primary purpose: detect keys pruned by `openclaw doctor --fix`
6
- * (OF5 — feedback_cc_openclaw_provider_timeout.md).
7
- *
8
- * Snapshot path: ~/.openclaw/workspace/memory/cc-openclaw-config-snapshot.json
9
- * The file is written atomically (write-then-rename is not available in pure
10
- * Node ESM without extra deps; we write directly since the worst case is a
11
- * corrupted snapshot — non-fatal; falls back to empty baseline).
12
- */
13
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
14
- import { homedir } from 'os';
15
- import { join, dirname } from 'path';
16
- const SNAPSHOT_PATH = join(homedir(), '.openclaw', 'workspace', 'memory', 'cc-openclaw-config-snapshot.json');
17
- // ── Internal helpers ──────────────────────────────────────────────────────────
18
- function flattenConfig(obj, prefix = '') {
19
- if (obj === null || typeof obj !== 'object') {
20
- return { [prefix]: obj };
21
- }
22
- const result = {};
23
- for (const [k, v] of Object.entries(obj)) {
24
- const key = prefix ? `${prefix}.${k}` : k;
25
- if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
26
- Object.assign(result, flattenConfig(v, key));
27
- }
28
- else {
29
- result[key] = v;
30
- }
31
- }
32
- return result;
33
- }
34
- function readSnapshot() {
35
- if (!existsSync(SNAPSHOT_PATH))
36
- return {};
37
- try {
38
- const raw = readFileSync(SNAPSHOT_PATH, 'utf8');
39
- return JSON.parse(raw);
40
- }
41
- catch {
42
- return {};
43
- }
44
- }
45
- // ── Public API ────────────────────────────────────────────────────────────────
46
- /**
47
- * Compare current config against the on-disk snapshot.
48
- * Returns an array of changed paths (empty = no drift).
49
- */
50
- export function detectDrift(current) {
51
- const previous = readSnapshot();
52
- const flat = flattenConfig(current);
53
- const deltas = [];
54
- for (const [path, value] of Object.entries(flat)) {
55
- if (path in previous && JSON.stringify(previous[path]) !== JSON.stringify(value)) {
56
- deltas.push({ path, previous: previous[path], current: value });
57
- }
58
- }
59
- return deltas;
60
- }
61
- /**
62
- * Persist current config as the baseline snapshot for future drift detection.
63
- * Called at the end of a successful boot (after phase 7 / ready state).
64
- */
65
- export function saveSnapshot(current) {
66
- try {
67
- mkdirSync(dirname(SNAPSHOT_PATH), { recursive: true });
68
- const flat = flattenConfig(current);
69
- writeFileSync(SNAPSHOT_PATH, JSON.stringify(flat, null, 2), 'utf8');
70
- }
71
- catch {
72
- // Non-fatal: drift detection degrades to "no baseline" on next boot.
73
- }
74
- }
@@ -1,17 +0,0 @@
1
- /**
2
- * Stale PID-keyed file cleanup — for status/sentinel files named `<pid>.json`
3
- * in a tee directory, removes entries whose PID is no longer alive.
4
- *
5
- * Extracted from `live-card.ts` 2026-05-14. Pure-function utility with a
6
- * dir-parameter shape so future callers (other PID-keyed sentinel patterns)
7
- * can reuse without duplicating the kill(pid, 0) liveness probe.
8
- *
9
- * The original file-extension was `.json`; expose it as a parameter so this
10
- * is forward-compatible with `.sock`, `.lock`, etc.
11
- */
12
- /**
13
- * Remove `<pid><ext>` files in `dir` whose PID no longer exists.
14
- * Silent on missing directory. Per-file errors are swallowed — this is
15
- * best-effort cleanup, not a correctness guarantee.
16
- */
17
- export declare function cleanStalePidFiles(dir: string, ext?: string): void;
@@ -1,39 +0,0 @@
1
- /**
2
- * Stale PID-keyed file cleanup — for status/sentinel files named `<pid>.json`
3
- * in a tee directory, removes entries whose PID is no longer alive.
4
- *
5
- * Extracted from `live-card.ts` 2026-05-14. Pure-function utility with a
6
- * dir-parameter shape so future callers (other PID-keyed sentinel patterns)
7
- * can reuse without duplicating the kill(pid, 0) liveness probe.
8
- *
9
- * The original file-extension was `.json`; expose it as a parameter so this
10
- * is forward-compatible with `.sock`, `.lock`, etc.
11
- */
12
- import { readdirSync, unlinkSync } from 'node:fs';
13
- /**
14
- * Remove `<pid><ext>` files in `dir` whose PID no longer exists.
15
- * Silent on missing directory. Per-file errors are swallowed — this is
16
- * best-effort cleanup, not a correctness guarantee.
17
- */
18
- export function cleanStalePidFiles(dir, ext = '.json') {
19
- try {
20
- const files = readdirSync(dir);
21
- for (const file of files) {
22
- if (!file.endsWith(ext))
23
- continue;
24
- const pid = parseInt(file.replace(ext, ''), 10);
25
- if (isNaN(pid))
26
- continue;
27
- try {
28
- process.kill(pid, 0);
29
- }
30
- catch {
31
- try {
32
- unlinkSync(`${dir}/${file}`);
33
- }
34
- catch { /* ignore */ }
35
- }
36
- }
37
- }
38
- catch { /* directory doesn't exist — nothing to clean */ }
39
- }
@@ -1,18 +0,0 @@
1
- /**
2
- * SnapshotWriter — periodic full-state snapshot of the SessionRegistry.
3
- *
4
- * Snapshot file: ~/.openclaw/workspace/memory/cc-openclaw-session-snapshot.json
5
- * Written every N minutes from phase 7 (schedule-jobs).
6
- * Phase E wires SessionRegistry.snapshot() → SnapshotWriter.write().
7
- * Stub body in Phase D.
8
- */
9
- import type { SessionState } from './session-registry.js';
10
- export declare class SnapshotWriter {
11
- private readonly path;
12
- constructor(path?: string);
13
- /**
14
- * Write the full session state array as a JSON file.
15
- * Phase E: write to a temp file then rename for atomicity.
16
- */
17
- write(states: SessionState[]): Promise<void>;
18
- }
@@ -1,31 +0,0 @@
1
- /**
2
- * SnapshotWriter — periodic full-state snapshot of the SessionRegistry.
3
- *
4
- * Snapshot file: ~/.openclaw/workspace/memory/cc-openclaw-session-snapshot.json
5
- * Written every N minutes from phase 7 (schedule-jobs).
6
- * Phase E wires SessionRegistry.snapshot() → SnapshotWriter.write().
7
- * Stub body in Phase D.
8
- */
9
- import { mkdirSync, writeFileSync } from 'fs';
10
- import { homedir } from 'os';
11
- import { join, dirname } from 'path';
12
- const SNAPSHOT_PATH = join(homedir(), '.openclaw', 'workspace', 'memory', 'cc-openclaw-session-snapshot.json');
13
- export class SnapshotWriter {
14
- path;
15
- constructor(path = SNAPSHOT_PATH) {
16
- this.path = path;
17
- }
18
- /**
19
- * Write the full session state array as a JSON file.
20
- * Phase E: write to a temp file then rename for atomicity.
21
- */
22
- async write(states) {
23
- try {
24
- mkdirSync(dirname(this.path), { recursive: true });
25
- writeFileSync(this.path, JSON.stringify(states, null, 2), 'utf8');
26
- }
27
- catch {
28
- // Non-fatal stub; Phase E promotes to logged error.
29
- }
30
- }
31
- }
@@ -1,17 +0,0 @@
1
- /**
2
- * WalWriter — append-only JSONL writer for the session registry WAL.
3
- *
4
- * WAL file location: ~/.openclaw/workspace/memory/cc-openclaw-session-wal.jsonl
5
- * Phase E wires this into SessionRegistry.set() / delete().
6
- * Stub body in Phase D — compiles and exports the class with the correct interface.
7
- */
8
- export declare class WalWriter {
9
- private readonly path;
10
- constructor(path?: string);
11
- /**
12
- * Append a JSON-serializable record as a single JSONL line.
13
- * Creates parent directories if absent. Non-atomic in Phase D stub;
14
- * Phase E adds write-then-rename for crash safety.
15
- */
16
- append(record: Record<string, unknown>): void;
17
- }
@@ -1,31 +0,0 @@
1
- /**
2
- * WalWriter — append-only JSONL writer for the session registry WAL.
3
- *
4
- * WAL file location: ~/.openclaw/workspace/memory/cc-openclaw-session-wal.jsonl
5
- * Phase E wires this into SessionRegistry.set() / delete().
6
- * Stub body in Phase D — compiles and exports the class with the correct interface.
7
- */
8
- import { mkdirSync, appendFileSync } from 'fs';
9
- import { homedir } from 'os';
10
- import { join, dirname } from 'path';
11
- const WAL_PATH = join(homedir(), '.openclaw', 'workspace', 'memory', 'cc-openclaw-session-wal.jsonl');
12
- export class WalWriter {
13
- path;
14
- constructor(path = WAL_PATH) {
15
- this.path = path;
16
- }
17
- /**
18
- * Append a JSON-serializable record as a single JSONL line.
19
- * Creates parent directories if absent. Non-atomic in Phase D stub;
20
- * Phase E adds write-then-rename for crash safety.
21
- */
22
- append(record) {
23
- try {
24
- mkdirSync(dirname(this.path), { recursive: true });
25
- appendFileSync(this.path, JSON.stringify(record) + '\n', 'utf8');
26
- }
27
- catch {
28
- // Non-fatal in Phase D; Phase E promotes to logged error.
29
- }
30
- }
31
- }
@@ -1,15 +0,0 @@
1
- /**
2
- * Cluster A — Type-seams barrel.
3
- *
4
- * Single import surface for all typed boundaries introduced in Cluster A.
5
- * Subsequent clusters import from `cc-openclaw/types` (this barrel)
6
- * rather than reaching into individual modules.
7
- *
8
- * This file is intentionally pure re-exports — no logic, no side effects.
9
- */
10
- export * from './runtime-config.js';
11
- export * from './route.js';
12
- export * from './sse.js';
13
- export * from './session.js';
14
- export * from './upstream.js';
15
- export * from './tool-bridge.js';
@@ -1,15 +0,0 @@
1
- /**
2
- * Cluster A — Type-seams barrel.
3
- *
4
- * Single import surface for all typed boundaries introduced in Cluster A.
5
- * Subsequent clusters import from `cc-openclaw/types` (this barrel)
6
- * rather than reaching into individual modules.
7
- *
8
- * This file is intentionally pure re-exports — no logic, no side effects.
9
- */
10
- export * from './runtime-config.js';
11
- export * from './route.js';
12
- export * from './sse.js';
13
- export * from './session.js';
14
- export * from './upstream.js';
15
- export * from './tool-bridge.js';
@@ -1,48 +0,0 @@
1
- /**
2
- * Cluster A — Session boundary types.
3
- *
4
- * Re-exports the session vocabulary already established in the root
5
- * `src/types.ts` module. New aliases (`SessionStartConfig`, `SessionMeta`)
6
- * preview Cluster B's `SessionService` boundary without yet introducing
7
- * a new shape — they're typed wrappers over today's `SessionConfig` /
8
- * `ActiveSession`.
9
- *
10
- * `SessionName` is the branded type that Cluster D's `SessionRegistry`
11
- * will use to enforce the `'openai-'` prefix routing discriminant
12
- * (cwd-patch.ts:489 gates CWD redirect, tools restoration, CLAUDE.md
13
- * injection on this prefix).
14
- *
15
- * NOT consumed yet — Cluster A first commit establishes the vocabulary.
16
- */
17
- export type { SessionConfig as SessionStartConfig, SessionStats, ActiveSession, SessionInfo, SessionSendOptions, StreamCallbacks, StreamEvent, SendResult, SendOptions, TurnResult, CostBreakdown, ISession, EngineType, PermissionMode, EffortLevel, PluginConfig, HookConfig, CustomEngineConfig, } from '../types.js';
18
- /**
19
- * Branded session-name type. Prevents raw string from being passed where
20
- * the `'openai-'` prefix is load-bearing. Use the constructors below to
21
- * mint or strip the prefix — never construct directly.
22
- */
23
- export type SessionName = string & {
24
- readonly __brand: 'SessionName';
25
- };
26
- export declare const sessionNameFromKey: (key: string) => SessionName;
27
- export declare const isOpenAiBridgeName: (name: SessionName) => boolean;
28
- export declare const stripOpenAiPrefix: (name: SessionName) => string;
29
- /**
30
- * Cluster B/D will fold this into `SessionRegistry.SessionEntry`. For
31
- * Cluster A it's a typed preview — the shape Cluster D will persist,
32
- * declared here so other clusters can compile against the contract.
33
- */
34
- export interface SessionMeta {
35
- readonly name: SessionName;
36
- readonly claudeSessionId: string | null;
37
- readonly pid: number | null;
38
- readonly cachedSysPromptHash: string;
39
- readonly skipPersistence: boolean;
40
- readonly createdAt: number;
41
- readonly updatedAt: number;
42
- }
43
- /**
44
- * Input shape Cluster B's `SessionService.send()` accepts.
45
- * Today's call sites pass either a string or an array of content blocks;
46
- * this interface preserves both without yet committing to a tighter shape.
47
- */
48
- export type SendInput = string | ReadonlyArray<unknown>;
@@ -1,19 +0,0 @@
1
- /**
2
- * Cluster A — Session boundary types.
3
- *
4
- * Re-exports the session vocabulary already established in the root
5
- * `src/types.ts` module. New aliases (`SessionStartConfig`, `SessionMeta`)
6
- * preview Cluster B's `SessionService` boundary without yet introducing
7
- * a new shape — they're typed wrappers over today's `SessionConfig` /
8
- * `ActiveSession`.
9
- *
10
- * `SessionName` is the branded type that Cluster D's `SessionRegistry`
11
- * will use to enforce the `'openai-'` prefix routing discriminant
12
- * (cwd-patch.ts:489 gates CWD redirect, tools restoration, CLAUDE.md
13
- * injection on this prefix).
14
- *
15
- * NOT consumed yet — Cluster A first commit establishes the vocabulary.
16
- */
17
- export const sessionNameFromKey = (key) => `openai-${key}`;
18
- export const isOpenAiBridgeName = (name) => name.startsWith('openai-');
19
- export const stripOpenAiPrefix = (name) => name.replace(/^openai-/, '');