@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.
- package/dist/src/channels/telegram-mirror/card-renderer.d.ts +8 -22
- package/dist/src/channels/telegram-mirror/card-renderer.js +227 -22
- package/dist/src/channels/telegram-mirror/commands.d.ts +16 -0
- package/dist/src/channels/telegram-mirror/commands.js +34 -0
- package/dist/src/channels/telegram-mirror/inbound-handler.js +1 -1
- package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +12 -1
- package/dist/src/channels/telegram-mirror/turn-bridge.js +41 -2
- package/dist/src/constants.d.ts +27 -0
- package/dist/src/constants.js +28 -0
- package/dist/src/engines/persistent-session.d.ts +5 -0
- package/dist/src/engines/persistent-session.js +27 -0
- package/dist/src/lib/error-formatter.d.ts +14 -2
- package/dist/src/lib/error-formatter.js +23 -11
- package/dist/src/lib/error-renderer.js +3 -1
- package/dist/src/lib/html-render.d.ts +23 -16
- package/dist/src/lib/html-render.js +127 -1
- package/dist/src/lib/markdown-to-mdv2.js +2 -1
- package/dist/src/lib/telegram-bot-api.d.ts +22 -6
- package/dist/src/lib/telegram-bot-api.js +94 -14
- package/dist/src/openai-compat/non-streaming-handler.js +18 -1
- package/dist/src/openai-compat/openai-compat.js +61 -2
- package/dist/src/openai-compat/request-coalescer.d.ts +77 -0
- package/dist/src/openai-compat/request-coalescer.js +157 -0
- package/dist/src/openai-compat/streaming-handler.d.ts +9 -1
- package/dist/src/openai-compat/streaming-handler.js +40 -5
- package/dist/src/session/persisted-sessions.d.ts +11 -0
- package/dist/src/session/persisted-sessions.js +17 -0
- package/dist/src/session/session-manager.js +22 -6
- package/dist/src/session/watchdogs.d.ts +3 -0
- package/dist/src/session/watchdogs.js +6 -0
- package/dist/src/session-bootstrap/cwd-patch.js +1 -2
- package/dist/src/types.d.ts +11 -0
- package/package.json +1 -1
- package/dist/src/config/drift-detector.d.ts +0 -28
- package/dist/src/config/drift-detector.js +0 -74
- package/dist/src/lib/stale-pid-files.d.ts +0 -17
- package/dist/src/lib/stale-pid-files.js +0 -39
- package/dist/src/persistence/snapshot.d.ts +0 -18
- package/dist/src/persistence/snapshot.js +0 -31
- package/dist/src/persistence/wal.d.ts +0 -17
- package/dist/src/persistence/wal.js +0 -31
- package/dist/src/types/index.d.ts +0 -15
- package/dist/src/types/index.js +0 -15
- package/dist/src/types/session.d.ts +0 -48
- package/dist/src/types/session.js +0 -19
|
@@ -61,6 +61,17 @@ slashCommand) {
|
|
|
61
61
|
// v0.15.0 Slice 1: hoist userText so the catch-path probe emit can reference
|
|
62
62
|
// it (originally declared inside try at line ~133, post-recovery-pipeline).
|
|
63
63
|
const probeUserText = userMessageToText(userMessage);
|
|
64
|
+
// v0.27.6 — report-drop fix (Killer #2), mirror of the streaming handler.
|
|
65
|
+
// The non-streaming path had NO disconnect tracking, so we add it: if the
|
|
66
|
+
// gateway socket dies mid-turn the buffered JSON reply reaches no one, and
|
|
67
|
+
// finalize() would otherwise wipe the card → total silence. Track the close,
|
|
68
|
+
// hoist deliveredText to function scope (same pattern as probeUserText above),
|
|
69
|
+
// and the finally block keeps the text on the card only when disconnected.
|
|
70
|
+
let clientDisconnected = false;
|
|
71
|
+
res.on('close', () => {
|
|
72
|
+
clientDisconnected = true;
|
|
73
|
+
});
|
|
74
|
+
let deliveredText = '';
|
|
64
75
|
try {
|
|
65
76
|
reportStatus('thinking', 'Processing request...');
|
|
66
77
|
// v0.7.1: accumulate thinking-block content when surfaceThinking is on.
|
|
@@ -163,6 +174,9 @@ slashCommand) {
|
|
|
163
174
|
// card. reply_dispatch in inbound-handler will fire shortly after and
|
|
164
175
|
// re-render the card with state='done', picking up this text inline.
|
|
165
176
|
mirrorPushAssistantText(outputText);
|
|
177
|
+
// v0.27.6 — remember the text the card is showing so the finally block can
|
|
178
|
+
// re-apply it if the socket died (see clientDisconnected note above).
|
|
179
|
+
deliveredText = outputText;
|
|
166
180
|
// Parse tool_calls from response text when caller provided tools
|
|
167
181
|
let traceToolCount = 0;
|
|
168
182
|
let traceFinishReason = 'stop';
|
|
@@ -256,7 +270,10 @@ slashCommand) {
|
|
|
256
270
|
// v0.26.1 — finalize the Telegram mirror card at the true end of the model
|
|
257
271
|
// turn (see handleStreaming counterpart). Best-effort.
|
|
258
272
|
try {
|
|
259
|
-
|
|
273
|
+
// v0.27.6 — report-drop fix (Killer #2): keep the report on the card when
|
|
274
|
+
// the socket died (mirror of the streaming handler). Happy path passes
|
|
275
|
+
// undefined → card wiped → gateway delivers (no duplicate).
|
|
276
|
+
await mirrorFinalizeActiveCards(clientDisconnected ? deliveredText : undefined);
|
|
260
277
|
}
|
|
261
278
|
catch {
|
|
262
279
|
/* finalize is cosmetic; never propagate */
|
|
@@ -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
|
-
}
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
//
|
|
162
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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.
|