@a1hvdy/cc-openclaw 0.9.2 → 0.11.0
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/card-renderer.d.ts +63 -0
- package/dist/src/channels/telegram/card-renderer.js +149 -0
- package/dist/src/channels/telegram/card-renderer.js.map +1 -0
- package/dist/src/channels/telegram/completion-summary.js +20 -1
- package/dist/src/channels/telegram/completion-summary.js.map +1 -1
- package/dist/src/channels/telegram/insight-formatter.d.ts +36 -0
- package/dist/src/channels/telegram/insight-formatter.js +36 -0
- package/dist/src/channels/telegram/insight-formatter.js.map +1 -0
- package/dist/src/channels/telegram/live-card.d.ts +7 -90
- package/dist/src/channels/telegram/live-card.js +22 -265
- package/dist/src/channels/telegram/live-card.js.map +1 -1
- package/dist/src/channels/telegram/logger.d.ts +10 -0
- package/dist/src/channels/telegram/logger.js +13 -0
- package/dist/src/channels/telegram/logger.js.map +1 -0
- package/dist/src/channels/telegram/throttle-controller.d.ts +54 -0
- package/dist/src/channels/telegram/throttle-controller.js +132 -0
- package/dist/src/channels/telegram/throttle-controller.js.map +1 -0
- package/dist/src/channels/telegram/tool-tracker.js +3 -14
- package/dist/src/channels/telegram/tool-tracker.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +24 -0
- package/dist/src/cli/doctor.js +194 -0
- package/dist/src/cli/doctor.js.map +1 -0
- package/dist/src/cli/index.d.ts +8 -0
- package/dist/src/cli/index.js +39 -0
- package/dist/src/cli/index.js.map +1 -0
- package/dist/src/command-router/cc-handler.js +35 -709
- package/dist/src/command-router/cc-handler.js.map +1 -1
- package/dist/src/command-router/launch-policy.d.ts +93 -0
- package/dist/src/command-router/launch-policy.js +323 -0
- package/dist/src/command-router/launch-policy.js.map +1 -0
- package/dist/src/command-router/resume-policy.d.ts +18 -0
- package/dist/src/command-router/resume-policy.js +236 -0
- package/dist/src/command-router/resume-policy.js.map +1 -0
- package/dist/src/command-router/turn-formatter.d.ts +19 -0
- package/dist/src/command-router/turn-formatter.js +144 -0
- package/dist/src/command-router/turn-formatter.js.map +1 -0
- package/dist/src/constants.d.ts +33 -2
- package/dist/src/constants.js +33 -2
- package/dist/src/constants.js.map +1 -1
- package/dist/src/lib/config.d.ts +3 -0
- package/dist/src/lib/config.js +46 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/trajectory.d.ts +1 -1
- package/dist/src/lib/trajectory.js.map +1 -1
- package/dist/src/openai-compat/bridges/allowlist.d.ts +10 -0
- package/dist/src/openai-compat/bridges/allowlist.js +17 -0
- package/dist/src/openai-compat/bridges/allowlist.js.map +1 -0
- package/dist/src/openai-compat/bridges/factory.d.ts +30 -0
- package/dist/src/openai-compat/bridges/factory.js +84 -0
- package/dist/src/openai-compat/bridges/factory.js.map +1 -0
- package/dist/src/openai-compat/bridges/media-bridge.d.ts +34 -0
- package/dist/src/openai-compat/bridges/media-bridge.js +20 -0
- package/dist/src/openai-compat/bridges/media-bridge.js.map +1 -0
- package/dist/src/openai-compat/bridges/openclaw-native-tools.d.ts +61 -0
- package/dist/src/openai-compat/bridges/openclaw-native-tools.js +171 -0
- package/dist/src/openai-compat/bridges/openclaw-native-tools.js.map +1 -0
- package/dist/src/openai-compat/bridges/openclaw-tool-registry.d.ts +26 -0
- package/dist/src/openai-compat/bridges/openclaw-tool-registry.js +38 -0
- package/dist/src/openai-compat/bridges/openclaw-tool-registry.js.map +1 -0
- package/dist/src/openai-compat/bridges/tts-media-bridge.d.ts +19 -0
- package/dist/src/openai-compat/bridges/tts-media-bridge.js +59 -0
- package/dist/src/openai-compat/bridges/tts-media-bridge.js.map +1 -0
- package/dist/src/openai-compat/non-streaming-handler.js +52 -3
- package/dist/src/openai-compat/non-streaming-handler.js.map +1 -1
- package/dist/src/openai-compat/openai-compat.js +64 -2
- package/dist/src/openai-compat/openai-compat.js.map +1 -1
- package/dist/src/openai-compat/streaming-handler.js +107 -1
- package/dist/src/openai-compat/streaming-handler.js.map +1 -1
- package/dist/src/openai-compat/voice-recovery.d.ts +56 -0
- package/dist/src/openai-compat/voice-recovery.js +231 -0
- package/dist/src/openai-compat/voice-recovery.js.map +1 -0
- package/dist/src/session/session-manager.d.ts +51 -0
- package/dist/src/session/session-manager.js +165 -1
- package/dist/src/session/session-manager.js.map +1 -1
- package/dist/src/types/tool-bridge.d.ts +2 -1
- package/dist/src/types/tool-bridge.js +1 -0
- package/dist/src/types/tool-bridge.js.map +1 -1
- package/package.json +1 -1
- package/dist/scripts/bench/ab-harness.d.ts +0 -58
- package/dist/scripts/bench/ab-harness.d.ts.map +0 -1
- package/dist/scripts/bench/ab-harness.js +0 -78
- package/dist/scripts/bench/ab-harness.js.map +0 -1
- package/dist/src/channels/adapter.d.ts.map +0 -1
- package/dist/src/channels/telegram/completion-summary.d.ts.map +0 -1
- package/dist/src/channels/telegram/error-renderer.d.ts.map +0 -1
- package/dist/src/channels/telegram/event-reducer.d.ts.map +0 -1
- package/dist/src/channels/telegram/index.d.ts.map +0 -1
- package/dist/src/channels/telegram/injector.d.ts.map +0 -1
- package/dist/src/channels/telegram/live-card.d.ts.map +0 -1
- package/dist/src/channels/telegram/state-machine.d.ts.map +0 -1
- package/dist/src/channels/telegram/tool-tracker.d.ts.map +0 -1
- package/dist/src/command-router/cc-handler.d.ts.map +0 -1
- package/dist/src/command-router/index.d.ts.map +0 -1
- package/dist/src/constants.d.ts.map +0 -1
- package/dist/src/council/consensus.d.ts.map +0 -1
- package/dist/src/council/council.d.ts.map +0 -1
- package/dist/src/council/index.d.ts.map +0 -1
- package/dist/src/engines/base-oneshot-session.d.ts.map +0 -1
- package/dist/src/engines/index.d.ts.map +0 -1
- package/dist/src/engines/persistent-codex-session.d.ts.map +0 -1
- package/dist/src/engines/persistent-cursor-session.d.ts.map +0 -1
- package/dist/src/engines/persistent-custom-session.d.ts.map +0 -1
- package/dist/src/engines/persistent-gemini-session.d.ts.map +0 -1
- package/dist/src/engines/persistent-session.d.ts.map +0 -1
- package/dist/src/health/handler.d.ts.map +0 -1
- package/dist/src/health/index.d.ts.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/lib/auto-recovery.d.ts.map +0 -1
- package/dist/src/lib/cache-parity.d.ts.map +0 -1
- package/dist/src/lib/circuit-breaker.d.ts.map +0 -1
- package/dist/src/lib/config.d.ts.map +0 -1
- package/dist/src/lib/debug-tap.d.ts.map +0 -1
- package/dist/src/lib/drift-detector.d.ts.map +0 -1
- package/dist/src/lib/error-formatter.d.ts.map +0 -1
- package/dist/src/lib/heartbeat-workaround.d.ts.map +0 -1
- package/dist/src/lib/index.d.ts.map +0 -1
- package/dist/src/lib/register-guard.d.ts.map +0 -1
- package/dist/src/lib/route-flag.d.ts +0 -49
- package/dist/src/lib/route-flag.d.ts.map +0 -1
- package/dist/src/lib/route-flag.js +0 -52
- package/dist/src/lib/route-flag.js.map +0 -1
- package/dist/src/lib/sysprompt-strip.d.ts.map +0 -1
- package/dist/src/lib/telemetry.d.ts.map +0 -1
- package/dist/src/lib/test-mode.d.ts.map +0 -1
- package/dist/src/lib/vendor-paths.d.ts.map +0 -1
- package/dist/src/logger.d.ts.map +0 -1
- package/dist/src/mcp/bridge.d.ts.map +0 -1
- package/dist/src/mcp/index.d.ts.map +0 -1
- package/dist/src/models.d.ts.map +0 -1
- package/dist/src/openai-compat/cli-stream-parser.d.ts.map +0 -1
- package/dist/src/openai-compat/index.d.ts.map +0 -1
- package/dist/src/openai-compat/openai-compat.d.ts.map +0 -1
- package/dist/src/openai-compat/skill-resolver.d.ts.map +0 -1
- package/dist/src/openai-compat/sse-translator.d.ts.map +0 -1
- package/dist/src/proxy/anthropic-adapter.d.ts.map +0 -1
- package/dist/src/proxy/handler.d.ts.map +0 -1
- package/dist/src/proxy/index.d.ts.map +0 -1
- package/dist/src/proxy/schema-cleaner.d.ts.map +0 -1
- package/dist/src/proxy/thought-cache.d.ts.map +0 -1
- package/dist/src/session/embedded-server.d.ts.map +0 -1
- package/dist/src/session/inbox-manager.d.ts.map +0 -1
- package/dist/src/session/index.d.ts.map +0 -1
- package/dist/src/session/session-manager.d.ts.map +0 -1
- package/dist/src/session-bootstrap/cwd-patch.d.ts.map +0 -1
- package/dist/src/session-bootstrap/index.d.ts.map +0 -1
- package/dist/src/session-bootstrap/sysprompt-strip.d.ts.map +0 -1
- package/dist/src/session-bootstrap/think-conflict-resolver.d.ts.map +0 -1
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/validation.d.ts.map +0 -1
- package/dist/tests/_helpers/subprocess-mock.d.ts +0 -35
- package/dist/tests/_helpers/subprocess-mock.d.ts.map +0 -1
- package/dist/tests/_helpers/subprocess-mock.js +0 -136
- package/dist/tests/_helpers/subprocess-mock.js.map +0 -1
- package/dist/tests/auto-recovery.test.d.ts +0 -2
- package/dist/tests/auto-recovery.test.d.ts.map +0 -1
- package/dist/tests/auto-recovery.test.js +0 -189
- package/dist/tests/auto-recovery.test.js.map +0 -1
- package/dist/tests/bench-harness.test.d.ts +0 -2
- package/dist/tests/bench-harness.test.d.ts.map +0 -1
- package/dist/tests/bench-harness.test.js +0 -21
- package/dist/tests/bench-harness.test.js.map +0 -1
- package/dist/tests/cache-parity.test.d.ts +0 -2
- package/dist/tests/cache-parity.test.d.ts.map +0 -1
- package/dist/tests/cache-parity.test.js +0 -401
- package/dist/tests/cache-parity.test.js.map +0 -1
- package/dist/tests/command-router.test.d.ts +0 -2
- package/dist/tests/command-router.test.d.ts.map +0 -1
- package/dist/tests/command-router.test.js +0 -60
- package/dist/tests/command-router.test.js.map +0 -1
- package/dist/tests/council.test.d.ts +0 -2
- package/dist/tests/council.test.d.ts.map +0 -1
- package/dist/tests/council.test.js +0 -20
- package/dist/tests/council.test.js.map +0 -1
- package/dist/tests/drift-detector.test.d.ts +0 -2
- package/dist/tests/drift-detector.test.d.ts.map +0 -1
- package/dist/tests/drift-detector.test.js +0 -268
- package/dist/tests/drift-detector.test.js.map +0 -1
- package/dist/tests/eager-bootstrap-gating.test.d.ts +0 -9
- package/dist/tests/eager-bootstrap-gating.test.d.ts.map +0 -1
- package/dist/tests/eager-bootstrap-gating.test.js +0 -97
- package/dist/tests/eager-bootstrap-gating.test.js.map +0 -1
- package/dist/tests/engines.test.d.ts +0 -2
- package/dist/tests/engines.test.d.ts.map +0 -1
- package/dist/tests/engines.test.js +0 -8
- package/dist/tests/engines.test.js.map +0 -1
- package/dist/tests/error-formatter.test.d.ts +0 -2
- package/dist/tests/error-formatter.test.d.ts.map +0 -1
- package/dist/tests/error-formatter.test.js +0 -220
- package/dist/tests/error-formatter.test.js.map +0 -1
- package/dist/tests/health.test.d.ts +0 -2
- package/dist/tests/health.test.d.ts.map +0 -1
- package/dist/tests/health.test.js +0 -110
- package/dist/tests/health.test.js.map +0 -1
- package/dist/tests/heartbeat-workaround.test.d.ts +0 -2
- package/dist/tests/heartbeat-workaround.test.d.ts.map +0 -1
- package/dist/tests/heartbeat-workaround.test.js +0 -90
- package/dist/tests/heartbeat-workaround.test.js.map +0 -1
- package/dist/tests/index.test.d.ts +0 -2
- package/dist/tests/index.test.d.ts.map +0 -1
- package/dist/tests/index.test.js +0 -7
- package/dist/tests/index.test.js.map +0 -1
- package/dist/tests/lib-sysprompt-strip.test.d.ts +0 -2
- package/dist/tests/lib-sysprompt-strip.test.d.ts.map +0 -1
- package/dist/tests/lib-sysprompt-strip.test.js +0 -145
- package/dist/tests/lib-sysprompt-strip.test.js.map +0 -1
- package/dist/tests/listener-activation.test.d.ts +0 -2
- package/dist/tests/listener-activation.test.d.ts.map +0 -1
- package/dist/tests/listener-activation.test.js +0 -87
- package/dist/tests/listener-activation.test.js.map +0 -1
- package/dist/tests/mcp-bridge.test.d.ts +0 -2
- package/dist/tests/mcp-bridge.test.d.ts.map +0 -1
- package/dist/tests/mcp-bridge.test.js +0 -137
- package/dist/tests/mcp-bridge.test.js.map +0 -1
- package/dist/tests/openai-compat.test.d.ts +0 -2
- package/dist/tests/openai-compat.test.d.ts.map +0 -1
- package/dist/tests/openai-compat.test.js +0 -8
- package/dist/tests/openai-compat.test.js.map +0 -1
- package/dist/tests/proxy-heartbeat-integration.test.d.ts +0 -15
- package/dist/tests/proxy-heartbeat-integration.test.d.ts.map +0 -1
- package/dist/tests/proxy-heartbeat-integration.test.js +0 -122
- package/dist/tests/proxy-heartbeat-integration.test.js.map +0 -1
- package/dist/tests/proxy.test.d.ts +0 -2
- package/dist/tests/proxy.test.d.ts.map +0 -1
- package/dist/tests/proxy.test.js +0 -8
- package/dist/tests/proxy.test.js.map +0 -1
- package/dist/tests/register-guard-stacking.test.d.ts +0 -2
- package/dist/tests/register-guard-stacking.test.d.ts.map +0 -1
- package/dist/tests/register-guard-stacking.test.js +0 -61
- package/dist/tests/register-guard-stacking.test.js.map +0 -1
- package/dist/tests/register-guard.test.d.ts +0 -2
- package/dist/tests/register-guard.test.d.ts.map +0 -1
- package/dist/tests/register-guard.test.js +0 -129
- package/dist/tests/register-guard.test.js.map +0 -1
- package/dist/tests/route-flag-rollback.test.d.ts +0 -2
- package/dist/tests/route-flag-rollback.test.d.ts.map +0 -1
- package/dist/tests/route-flag-rollback.test.js +0 -70
- package/dist/tests/route-flag-rollback.test.js.map +0 -1
- package/dist/tests/route-flag.test.d.ts +0 -2
- package/dist/tests/route-flag.test.d.ts.map +0 -1
- package/dist/tests/route-flag.test.js +0 -101
- package/dist/tests/route-flag.test.js.map +0 -1
- package/dist/tests/session-bootstrap.test.d.ts +0 -2
- package/dist/tests/session-bootstrap.test.d.ts.map +0 -1
- package/dist/tests/session-bootstrap.test.js +0 -183
- package/dist/tests/session-bootstrap.test.js.map +0 -1
- package/dist/tests/session.test.d.ts +0 -2
- package/dist/tests/session.test.d.ts.map +0 -1
- package/dist/tests/session.test.js +0 -17
- package/dist/tests/session.test.js.map +0 -1
- package/dist/tests/state-machine.test.d.ts +0 -2
- package/dist/tests/state-machine.test.d.ts.map +0 -1
- package/dist/tests/state-machine.test.js +0 -133
- package/dist/tests/state-machine.test.js.map +0 -1
- package/dist/tests/streaming/cli-stream-parser.test.d.ts +0 -2
- package/dist/tests/streaming/cli-stream-parser.test.d.ts.map +0 -1
- package/dist/tests/streaming/cli-stream-parser.test.js +0 -233
- package/dist/tests/streaming/cli-stream-parser.test.js.map +0 -1
- package/dist/tests/streaming/feature-flag.test.d.ts +0 -14
- package/dist/tests/streaming/feature-flag.test.d.ts.map +0 -1
- package/dist/tests/streaming/feature-flag.test.js +0 -163
- package/dist/tests/streaming/feature-flag.test.js.map +0 -1
- package/dist/tests/streaming/no-tools-prompt.test.d.ts +0 -17
- package/dist/tests/streaming/no-tools-prompt.test.d.ts.map +0 -1
- package/dist/tests/streaming/no-tools-prompt.test.js +0 -229
- package/dist/tests/streaming/no-tools-prompt.test.js.map +0 -1
- package/dist/tests/streaming/skill-plus-tools.test.d.ts +0 -14
- package/dist/tests/streaming/skill-plus-tools.test.d.ts.map +0 -1
- package/dist/tests/streaming/skill-plus-tools.test.js +0 -234
- package/dist/tests/streaming/skill-plus-tools.test.js.map +0 -1
- package/dist/tests/streaming/sse-translator.test.d.ts +0 -2
- package/dist/tests/streaming/sse-translator.test.d.ts.map +0 -1
- package/dist/tests/streaming/sse-translator.test.js +0 -227
- package/dist/tests/streaming/sse-translator.test.js.map +0 -1
- package/dist/tests/streaming/tool-result-roundtrip.test.d.ts +0 -11
- package/dist/tests/streaming/tool-result-roundtrip.test.d.ts.map +0 -1
- package/dist/tests/streaming/tool-result-roundtrip.test.js +0 -215
- package/dist/tests/streaming/tool-result-roundtrip.test.js.map +0 -1
- package/dist/tests/streaming/tool-use-translation.test.d.ts +0 -10
- package/dist/tests/streaming/tool-use-translation.test.d.ts.map +0 -1
- package/dist/tests/streaming/tool-use-translation.test.js +0 -251
- package/dist/tests/streaming/tool-use-translation.test.js.map +0 -1
- package/dist/tests/telegram-bridge.test.d.ts +0 -2
- package/dist/tests/telegram-bridge.test.d.ts.map +0 -1
- package/dist/tests/telegram-bridge.test.js +0 -17
- package/dist/tests/telegram-bridge.test.js.map +0 -1
- package/dist/tests/telegram-injector.test.d.ts +0 -2
- package/dist/tests/telegram-injector.test.d.ts.map +0 -1
- package/dist/tests/telegram-injector.test.js +0 -74
- package/dist/tests/telegram-injector.test.js.map +0 -1
- package/dist/tests/telemetry.test.d.ts +0 -2
- package/dist/tests/telemetry.test.d.ts.map +0 -1
- package/dist/tests/telemetry.test.js +0 -405
- package/dist/tests/telemetry.test.js.map +0 -1
- package/dist/tests/test-mode.test.d.ts +0 -2
- package/dist/tests/test-mode.test.d.ts.map +0 -1
- package/dist/tests/test-mode.test.js +0 -39
- package/dist/tests/test-mode.test.js.map +0 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* voice-recovery — server-side bridge that ensures voice delivery via
|
|
3
|
+
* `[[tts:text]]...[[/tts:text]]` markers even when Savvy doesn't emit them.
|
|
4
|
+
*
|
|
5
|
+
* v0.10.3 turns cc-openclaw from a passive text proxy into an active
|
|
6
|
+
* voice-aware bridge for three failure modes:
|
|
7
|
+
*
|
|
8
|
+
* 1. Savvy emits `<tool_calls>` XML for a native OpenClaw voice tool
|
|
9
|
+
* (e.g. `message.voice`) that cc-openclaw can't execute. The XML
|
|
10
|
+
* lands as raw text in the SSE stream. → `translateVoiceToolCalls`
|
|
11
|
+
* rewrites it as `[[tts:text]]` markers.
|
|
12
|
+
*
|
|
13
|
+
* 2. User explicitly asks for voice but Savvy produces markerless text
|
|
14
|
+
* (the dominant failure observed in the 2026-05-11 evening test).
|
|
15
|
+
* → `autoWrapMissingMarkers` wraps the first sentence/paragraph
|
|
16
|
+
* in markers so OpenClaw's `maybeApplyTtsToPayload` triggers.
|
|
17
|
+
*
|
|
18
|
+
* 3. Hint-loss between turns. → `detectVoiceIntent` gates recovery so
|
|
19
|
+
* it only fires on explicit voice requests, never on accidental
|
|
20
|
+
* voice-keyword matches in non-voice prompts.
|
|
21
|
+
*
|
|
22
|
+
* All functions are PURE — no side effects, no IO, no logging. Tests
|
|
23
|
+
* exercise them directly via `tests/voice-recovery.test.ts`.
|
|
24
|
+
*/
|
|
25
|
+
// ── Constants ─────────────────────────────────────────────────────────────
|
|
26
|
+
/** Word-boundary regex for explicit voice-delivery intent in user prompts.
|
|
27
|
+
* Kept TIGHT to avoid false positives: "voice" alone is too broad (could be
|
|
28
|
+
* asking about TTS config); "send a voice note" / "speak" / "in voice" are
|
|
29
|
+
* the intended triggers. */
|
|
30
|
+
const VOICE_INTENT_REGEX = /\b(voice\s*notes?|voice\s*messages?|send\s+(?:me\s+)?(?:a\s+)?voice|in\s+voice|speak\s+(?:it|the|me|to|aloud)|say\s+(?:it|the|me).*aloud|tell\s+me\s+(?:out\s+loud|aloud)|read\s+(?:it|the|this)\s+aloud)\b/i;
|
|
31
|
+
/** Markers OpenClaw's `maybeApplyTtsToPayload` looks for. */
|
|
32
|
+
const TTS_OPEN = '[[tts:text]]';
|
|
33
|
+
const TTS_CLOSE = '[[/tts:text]]';
|
|
34
|
+
/** Default max chars inside the spoken block. Matches the TTS_RULE budget. */
|
|
35
|
+
export const DEFAULT_MAX_SPOKEN_CHARS = 500;
|
|
36
|
+
// ── Diagnostic logging (v0.10.4) ──────────────────────────────────────────
|
|
37
|
+
/** v0.10.4 — env-gated debug logger for triangulating voice-recovery
|
|
38
|
+
* failure mode on Telegram vs. direct-probe paths. Single-line JSON for
|
|
39
|
+
* grep-ability via `pm2 logs openclaw-gateway | grep _voice_debug`.
|
|
40
|
+
* Disabled unless `CC_OPENCLAW_VOICE_DEBUG=1`. Safe to leave in code
|
|
41
|
+
* permanently — the gate is a single string equality check. */
|
|
42
|
+
export function _logVoiceDebug(label, fields) {
|
|
43
|
+
if (process.env.CC_OPENCLAW_VOICE_DEBUG !== '1')
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
const payload = { _voice_debug: label, ...fields, ts: new Date().toISOString() };
|
|
47
|
+
// eslint-disable-next-line no-console
|
|
48
|
+
console.error(JSON.stringify(payload));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Logging must never throw into the request path
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
55
|
+
/** Detect whether the user's prompt explicitly requests a voice note. */
|
|
56
|
+
export function detectVoiceIntent(userPrompt) {
|
|
57
|
+
if (!userPrompt)
|
|
58
|
+
return false;
|
|
59
|
+
return VOICE_INTENT_REGEX.test(userPrompt);
|
|
60
|
+
}
|
|
61
|
+
/** Check whether the reply text already contains a complete `[[tts:text]]`
|
|
62
|
+
* block (open + close pair). */
|
|
63
|
+
export function hasTtsMarkers(text) {
|
|
64
|
+
if (!text)
|
|
65
|
+
return false;
|
|
66
|
+
const openIdx = text.indexOf(TTS_OPEN);
|
|
67
|
+
if (openIdx === -1)
|
|
68
|
+
return false;
|
|
69
|
+
const closeIdx = text.indexOf(TTS_CLOSE, openIdx + TTS_OPEN.length);
|
|
70
|
+
return closeIdx !== -1;
|
|
71
|
+
}
|
|
72
|
+
/** Pattern matching `<tool_calls>` or `<tool_use>` or `<function_calls>` XML
|
|
73
|
+
* blocks whose `name` attribute looks like a voice/TTS delivery tool.
|
|
74
|
+
* Matches names: `voice`, `speak`, `tts`, `sendVoice`, `send_voice`,
|
|
75
|
+
* `message.voice`, `message_voice`, `messageVoice`, and `message.send` if
|
|
76
|
+
* it carries an `audioAsVoice` or `as_voice` argument. Permissive on
|
|
77
|
+
* purpose — Phase H of the ship plan analyzes the actual tool name from
|
|
78
|
+
* a sysprompt dump and may narrow this in v0.10.4. */
|
|
79
|
+
const VOICE_TOOL_NAME_REGEX = /(?:voice|speak|tts|send[\s_-]?voice|message[.\s_-]?voice|message[.\s_-]?send.*?(?:audioAsVoice|as[\s_-]?voice))/i;
|
|
80
|
+
/** Loose regex catching the common Anthropic-style tool-call XML envelopes. */
|
|
81
|
+
const TOOL_CALL_XML_BLOCK_REGEX = /<(tool_calls?|tool_use|function_calls?)\b[^>]*>([\s\S]*?)<\/\1>/gi;
|
|
82
|
+
/** Inside a tool-call XML block (or the full <tag name="..."> opening),
|
|
83
|
+
* find the `name` attribute or `<name>` field or JSON-style `"name":"..."`. */
|
|
84
|
+
const TOOL_NAME_REGEX = /name\s*=\s*"([^"]+)"|<name>\s*([^<]+?)\s*<\/name>|"name"\s*:\s*"([^"]+)"/i;
|
|
85
|
+
/** Inside a tool-call XML block, extract the spoken text argument. Tries
|
|
86
|
+
* several common shapes: `<text>X</text>`, `<input>X</input>`,
|
|
87
|
+
* `<content>X</content>`, `"text":"X"`, `"input":"X"`, `"content":"X"`.
|
|
88
|
+
* Returns the first non-empty match, trimmed. */
|
|
89
|
+
function extractSpokenText(xmlBody) {
|
|
90
|
+
const xmlTagPatterns = [/<text>([\s\S]*?)<\/text>/i, /<input>([\s\S]*?)<\/input>/i, /<content>([\s\S]*?)<\/content>/i, /<speech>([\s\S]*?)<\/speech>/i];
|
|
91
|
+
for (const re of xmlTagPatterns) {
|
|
92
|
+
const m = xmlBody.match(re);
|
|
93
|
+
if (m && m[1]) {
|
|
94
|
+
const trimmed = m[1].trim();
|
|
95
|
+
if (trimmed)
|
|
96
|
+
return trimmed;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const jsonPatterns = [/"text"\s*:\s*"((?:[^"\\]|\\.)*)"/i, /"input"\s*:\s*"((?:[^"\\]|\\.)*)"/i, /"content"\s*:\s*"((?:[^"\\]|\\.)*)"/i, /"speech"\s*:\s*"((?:[^"\\]|\\.)*)"/i];
|
|
100
|
+
for (const re of jsonPatterns) {
|
|
101
|
+
const m = xmlBody.match(re);
|
|
102
|
+
if (m && m[1]) {
|
|
103
|
+
const decoded = m[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
|
|
104
|
+
if (decoded)
|
|
105
|
+
return decoded;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/** If `text` contains a `<tool_calls>` XML block targeting a voice-delivery
|
|
111
|
+
* tool, extract the spoken-text argument. Returns the spoken text or null. */
|
|
112
|
+
export function extractTtsToolCallText(text) {
|
|
113
|
+
if (!text)
|
|
114
|
+
return null;
|
|
115
|
+
TOOL_CALL_XML_BLOCK_REGEX.lastIndex = 0;
|
|
116
|
+
let match;
|
|
117
|
+
while ((match = TOOL_CALL_XML_BLOCK_REGEX.exec(text)) !== null) {
|
|
118
|
+
const body = match[2] ?? '';
|
|
119
|
+
// Search the FULL match (including opening tag) so `name="..."` attributes
|
|
120
|
+
// on the outer <tool_use name="speak"> tag are caught. Also handles
|
|
121
|
+
// JSON-style `"name":"..."` payloads inside the body.
|
|
122
|
+
const nameMatch = match[0].match(TOOL_NAME_REGEX);
|
|
123
|
+
const toolName = (nameMatch?.[1] ?? nameMatch?.[2] ?? nameMatch?.[3] ?? '').trim();
|
|
124
|
+
if (toolName && VOICE_TOOL_NAME_REGEX.test(toolName)) {
|
|
125
|
+
const spoken = extractSpokenText(body);
|
|
126
|
+
if (spoken)
|
|
127
|
+
return spoken;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
/** Rewrite `text`: if a voice-tool XML block is present, remove it and
|
|
133
|
+
* insert a `[[tts:text]]<spoken>[[/tts:text]]` block in its place. Idempotent
|
|
134
|
+
* when no voice-tool XML is present. */
|
|
135
|
+
export function translateVoiceToolCalls(text) {
|
|
136
|
+
if (!text)
|
|
137
|
+
return text;
|
|
138
|
+
TOOL_CALL_XML_BLOCK_REGEX.lastIndex = 0;
|
|
139
|
+
// Walk the matches, build a replacement.
|
|
140
|
+
let result = '';
|
|
141
|
+
let lastEnd = 0;
|
|
142
|
+
let match;
|
|
143
|
+
TOOL_CALL_XML_BLOCK_REGEX.lastIndex = 0;
|
|
144
|
+
while ((match = TOOL_CALL_XML_BLOCK_REGEX.exec(text)) !== null) {
|
|
145
|
+
const body = match[2] ?? '';
|
|
146
|
+
// Search the FULL match (including opening tag) so `name="..."` attributes
|
|
147
|
+
// on the outer <tool_use name="speak"> tag are caught. Also handles
|
|
148
|
+
// JSON-style `"name":"..."` payloads inside the body.
|
|
149
|
+
const nameMatch = match[0].match(TOOL_NAME_REGEX);
|
|
150
|
+
const toolName = (nameMatch?.[1] ?? nameMatch?.[2] ?? nameMatch?.[3] ?? '').trim();
|
|
151
|
+
if (toolName && VOICE_TOOL_NAME_REGEX.test(toolName)) {
|
|
152
|
+
const spoken = extractSpokenText(body);
|
|
153
|
+
if (spoken) {
|
|
154
|
+
result += text.slice(lastEnd, match.index);
|
|
155
|
+
result += `${TTS_OPEN}${spoken}${TTS_CLOSE}`;
|
|
156
|
+
lastEnd = match.index + match[0].length;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
result += text.slice(lastEnd);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
/** Truncate at a sentence boundary if possible, otherwise hard-cut.
|
|
164
|
+
* Used to keep the spoken portion within the voice-budget. */
|
|
165
|
+
function truncateAtSentence(text, maxChars) {
|
|
166
|
+
if (text.length <= maxChars)
|
|
167
|
+
return text;
|
|
168
|
+
const slice = text.slice(0, maxChars);
|
|
169
|
+
// Try last sentence-ending punctuation
|
|
170
|
+
const lastSentence = Math.max(slice.lastIndexOf('. '), slice.lastIndexOf('! '), slice.lastIndexOf('? '));
|
|
171
|
+
if (lastSentence > maxChars * 0.5)
|
|
172
|
+
return slice.slice(0, lastSentence + 1);
|
|
173
|
+
// Otherwise hard-cut at last space
|
|
174
|
+
const lastSpace = slice.lastIndexOf(' ');
|
|
175
|
+
if (lastSpace > maxChars * 0.5)
|
|
176
|
+
return slice.slice(0, lastSpace) + '…';
|
|
177
|
+
return slice + '…';
|
|
178
|
+
}
|
|
179
|
+
/** If `text` is missing voice markers, find a reasonable spoken summary
|
|
180
|
+
* (first paragraph up to `maxSpokenChars`) and wrap it. Returns text with
|
|
181
|
+
* the marker block prepended; the original first paragraph is left in place
|
|
182
|
+
* as text expansion. Idempotent when markers already present. */
|
|
183
|
+
export function autoWrapMissingMarkers(text, maxSpokenChars = DEFAULT_MAX_SPOKEN_CHARS) {
|
|
184
|
+
if (!text)
|
|
185
|
+
return text;
|
|
186
|
+
if (hasTtsMarkers(text))
|
|
187
|
+
return text;
|
|
188
|
+
const trimmed = text.trim();
|
|
189
|
+
if (!trimmed)
|
|
190
|
+
return text;
|
|
191
|
+
// Pick the first paragraph (up to the first blank line). The ★ Insight
|
|
192
|
+
// block typically appears below — leave it alone.
|
|
193
|
+
const firstBlankLine = trimmed.search(/\n\s*\n/);
|
|
194
|
+
const firstPara = firstBlankLine === -1 ? trimmed : trimmed.slice(0, firstBlankLine).trim();
|
|
195
|
+
// Strip leading boilerplate Savvy sometimes adds ("Let me send the voice note now.")
|
|
196
|
+
const filtered = firstPara
|
|
197
|
+
.replace(/^(?:i (?:have|got) what i need\.?\s*)?let me send (?:the\s+)?voice (?:note|message)(?:\s+now)?\.?\s*/i, '')
|
|
198
|
+
.replace(/^(?:here(?:'s|\s+is)|on\s+it|sending\s+(?:a\s+)?voice(?:\s+note)?)[:.]?\s*/i, '')
|
|
199
|
+
.trim();
|
|
200
|
+
// Fall back to the original first paragraph if filtering removed everything
|
|
201
|
+
const spokenRaw = filtered || firstPara;
|
|
202
|
+
const spoken = truncateAtSentence(spokenRaw, maxSpokenChars);
|
|
203
|
+
if (!spoken)
|
|
204
|
+
return text;
|
|
205
|
+
// Prepend the marker block; keep the rest of the original text as text expansion.
|
|
206
|
+
// Use \n\n separator so the marker block is visually distinct.
|
|
207
|
+
return `${TTS_OPEN}${spoken}${TTS_CLOSE}\n\n${text}`;
|
|
208
|
+
}
|
|
209
|
+
/** Convenience helper: run the full recovery pipeline on a reply when the
|
|
210
|
+
* user has expressed voice intent. Returns the (possibly rewritten) text. */
|
|
211
|
+
export function applyVoiceRecovery(userPrompt, replyText, maxSpokenChars = DEFAULT_MAX_SPOKEN_CHARS) {
|
|
212
|
+
if (!detectVoiceIntent(userPrompt)) {
|
|
213
|
+
return { text: replyText, recovered: false, via: 'none' };
|
|
214
|
+
}
|
|
215
|
+
// First try translating any tool-call XML
|
|
216
|
+
const translated = translateVoiceToolCalls(replyText);
|
|
217
|
+
if (translated !== replyText && hasTtsMarkers(translated)) {
|
|
218
|
+
return { text: translated, recovered: true, via: 'tool-translate' };
|
|
219
|
+
}
|
|
220
|
+
// If markers already present, nothing to do
|
|
221
|
+
if (hasTtsMarkers(translated)) {
|
|
222
|
+
return { text: translated, recovered: false, via: 'none' };
|
|
223
|
+
}
|
|
224
|
+
// Auto-wrap the first paragraph
|
|
225
|
+
const wrapped = autoWrapMissingMarkers(translated, maxSpokenChars);
|
|
226
|
+
if (wrapped !== translated && hasTtsMarkers(wrapped)) {
|
|
227
|
+
return { text: wrapped, recovered: true, via: 'auto-wrap' };
|
|
228
|
+
}
|
|
229
|
+
return { text: translated, recovered: false, via: 'none' };
|
|
230
|
+
}
|
|
231
|
+
//# sourceMappingURL=voice-recovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-recovery.js","sourceRoot":"","sources":["../../../src/openai-compat/voice-recovery.ts"],"names":[],"mappings":"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"}
|
|
@@ -28,6 +28,8 @@ export declare class SessionManager {
|
|
|
28
28
|
private sessions;
|
|
29
29
|
private _pendingSessions;
|
|
30
30
|
private cleanupTimer;
|
|
31
|
+
private stalledWatchTimer;
|
|
32
|
+
private _recentSpawns;
|
|
31
33
|
private pluginConfig;
|
|
32
34
|
private persistedSessions;
|
|
33
35
|
private _debouncedSave;
|
|
@@ -207,5 +209,54 @@ export declare class SessionManager {
|
|
|
207
209
|
}): UltrareviewResult;
|
|
208
210
|
ultrareviewStatus(id: string): UltrareviewResult | undefined;
|
|
209
211
|
private _cleanupIdleSessions;
|
|
212
|
+
/**
|
|
213
|
+
* v0.10.0 — runtime stalled-session watchdog.
|
|
214
|
+
*
|
|
215
|
+
* Fires every STALLED_WATCH_INTERVAL_MS. For each session that is
|
|
216
|
+
* currently `isBusy === true` (mid-turn) AND whose underlying
|
|
217
|
+
* PersistentClaudeSession has not received any subprocess event for
|
|
218
|
+
* STALLED_SESSION_KILL_MS, the watchdog:
|
|
219
|
+
*
|
|
220
|
+
* 1. Logs the stall
|
|
221
|
+
* 2. Emits a `session_stalled_killed` trajectory event
|
|
222
|
+
* 3. Calls session.stop() (SIGTERM, then SIGKILL after STOP_SIGKILL_DELAY_MS)
|
|
223
|
+
* 4. Removes the entry from the in-memory `sessions` Map
|
|
224
|
+
*
|
|
225
|
+
* The in-flight `sendMessage()` promise will reject with the existing
|
|
226
|
+
* `TURN_TIMEOUT_MS` error or a session-stop error. The outer agent-runner
|
|
227
|
+
* then fast-fails to the cross-engine fallback (`openai-codex/gpt-5.4`)
|
|
228
|
+
* rather than waiting the full provider envelope (900s).
|
|
229
|
+
*
|
|
230
|
+
* Threshold is overridable via `CC_OPENCLAW_STALLED_KILL_MS` env var.
|
|
231
|
+
*
|
|
232
|
+
* Mirrors `gateway-pm2-wrapper.sh:53-60` boot-time orphan reaper.
|
|
233
|
+
*/
|
|
234
|
+
private _watchStalledSessions;
|
|
235
|
+
/**
|
|
236
|
+
* Resolve the stalled-session kill threshold. Env var
|
|
237
|
+
* `CC_OPENCLAW_STALLED_KILL_MS` overrides STALLED_SESSION_KILL_MS at
|
|
238
|
+
* runtime so the value can be tuned without rebuild.
|
|
239
|
+
*/
|
|
240
|
+
private _stalledThresholdMs;
|
|
241
|
+
/**
|
|
242
|
+
* v0.10.1 — record a fresh subprocess spawn for the runaway-loop
|
|
243
|
+
* watchdog. Trims entries older than RUNAWAY_LOOP_WINDOW_MS so the
|
|
244
|
+
* array length is bounded.
|
|
245
|
+
*/
|
|
246
|
+
private _recordSpawn;
|
|
247
|
+
/**
|
|
248
|
+
* v0.10.1 — true when the spawn rate over RUNAWAY_LOOP_WINDOW_MS
|
|
249
|
+
* exceeds the configured threshold (env-overridable). Called by
|
|
250
|
+
* `_doStartSession` BEFORE recording the new spawn — i.e. the check
|
|
251
|
+
* fires when the (N+1)-th spawn attempt would push the count over.
|
|
252
|
+
*/
|
|
253
|
+
private _isRunawayLoop;
|
|
254
|
+
/**
|
|
255
|
+
* v0.10.1 — resolve the runaway-loop spawn threshold. Env var
|
|
256
|
+
* `CC_OPENCLAW_LOOP_MAX_SUBPROCS` overrides RUNAWAY_LOOP_MAX_SUBPROCS
|
|
257
|
+
* for runtime tuning without rebuild. Clamped to [2, 20] to prevent
|
|
258
|
+
* accidental disable (0/1) or unbounded raise.
|
|
259
|
+
*/
|
|
260
|
+
private _runawayThreshold;
|
|
210
261
|
}
|
|
211
262
|
export {};
|
|
@@ -111,13 +111,19 @@ import { PersistentCustomSession } from '../engines/persistent-custom-session.js
|
|
|
111
111
|
import { overrideModelPricing, } from '../types.js';
|
|
112
112
|
import { resolveAlias, isClaudeModel } from '../models.js';
|
|
113
113
|
import { Council } from '../council/council.js';
|
|
114
|
-
import { PERSIST_DISK_TTL_MS, DEBOUNCED_SAVE_MS, CLEANUP_INTERVAL_MS, TURN_TIMEOUT_MS, GREP_HISTORY_FETCH, TEAM_LIST_TIMEOUT_MS, TEAM_SEND_TIMEOUT_MS, RESULT_TTL_MS, ULTRAPLAN_TIMEOUT_MS, ULTRAREVIEW_POLL_INTERVAL_MS, STOP_SIGKILL_DELAY_MS, SESSION_EVENT, DEFAULT_HISTORY_LIMIT, } from '../constants.js';
|
|
114
|
+
import { PERSIST_DISK_TTL_MS, DEBOUNCED_SAVE_MS, CLEANUP_INTERVAL_MS, TURN_TIMEOUT_MS, GREP_HISTORY_FETCH, TEAM_LIST_TIMEOUT_MS, TEAM_SEND_TIMEOUT_MS, RESULT_TTL_MS, ULTRAPLAN_TIMEOUT_MS, ULTRAREVIEW_POLL_INTERVAL_MS, STOP_SIGKILL_DELAY_MS, SESSION_EVENT, DEFAULT_HISTORY_LIMIT, STALLED_SESSION_KILL_MS, STALLED_WATCH_INTERVAL_MS, RUNAWAY_LOOP_MAX_SUBPROCS, RUNAWAY_LOOP_WINDOW_MS, } from '../constants.js';
|
|
115
|
+
import * as trajectory from '../lib/trajectory.js';
|
|
115
116
|
import { getGatewayUrl, getGatewayKey, getAnthropicApiKey, getOpenaiApiKey, getGeminiApiKey, getGeminiBin, getCodexBin, getCursorBin, } from '../lib/config.js';
|
|
116
117
|
// ─── SessionManager ──────────────────────────────────────────────────────────
|
|
117
118
|
export class SessionManager {
|
|
118
119
|
sessions = new Map();
|
|
119
120
|
_pendingSessions = new Map();
|
|
120
121
|
cleanupTimer = null;
|
|
122
|
+
stalledWatchTimer = null;
|
|
123
|
+
// v0.10.1: rolling spawn-timestamp log for the runaway-loop watchdog.
|
|
124
|
+
// Each new subprocess spawn pushes Date.now(); entries older than
|
|
125
|
+
// RUNAWAY_LOOP_WINDOW_MS are trimmed before each check.
|
|
126
|
+
_recentSpawns = [];
|
|
121
127
|
pluginConfig;
|
|
122
128
|
persistedSessions;
|
|
123
129
|
_debouncedSave;
|
|
@@ -149,6 +155,11 @@ export class SessionManager {
|
|
|
149
155
|
this._debouncedSave = makeDebounced(() => savePersistedSessionsAsync(this.persistedSessions, this.logger), DEBOUNCED_SAVE_MS);
|
|
150
156
|
// Start TTL cleanup timer
|
|
151
157
|
this.cleanupTimer = setInterval(() => this._cleanupIdleSessions(), CLEANUP_INTERVAL_MS);
|
|
158
|
+
// v0.10.0: Start runtime stalled-session watchdog. Mirrors the
|
|
159
|
+
// boot-time orphan reaper in gateway-pm2-wrapper.sh:53-60, but runs
|
|
160
|
+
// continuously. Catches subprocesses that hang at `model_call:started`
|
|
161
|
+
// without emitting any output for STALLED_SESSION_KILL_MS.
|
|
162
|
+
this.stalledWatchTimer = setInterval(() => this._watchStalledSessions(), STALLED_WATCH_INTERVAL_MS);
|
|
152
163
|
}
|
|
153
164
|
// ─── Session Lifecycle ─────────────────────────────────────────────────
|
|
154
165
|
async startSession(config) {
|
|
@@ -176,6 +187,24 @@ export class SessionManager {
|
|
|
176
187
|
if (this.sessions.size >= this.pluginConfig.maxConcurrentSessions) {
|
|
177
188
|
throw new Error(`Max concurrent sessions (${this.pluginConfig.maxConcurrentSessions}) reached`);
|
|
178
189
|
}
|
|
190
|
+
// v0.10.1: runaway-loop watchdog. Refuse the spawn if OpenClaw is
|
|
191
|
+
// hammering us with new-session requests faster than a real
|
|
192
|
+
// conversation justifies. See RUNAWAY_LOOP_MAX_SUBPROCS comment in
|
|
193
|
+
// constants.ts for the 2026-05-11 incident context.
|
|
194
|
+
if (this._isRunawayLoop()) {
|
|
195
|
+
const count = this._recentSpawns.length;
|
|
196
|
+
const thresholdMax = this._runawayThreshold();
|
|
197
|
+
const windowMs = RUNAWAY_LOOP_WINDOW_MS;
|
|
198
|
+
this.logger.warn(`[watchdog] refusing new session "${name}" — runaway loop detected (${count} spawns in last ${Math.round(windowMs / 1000)}s, threshold=${thresholdMax})`);
|
|
199
|
+
try {
|
|
200
|
+
trajectory.emit('runaway_loop_killed', { count, windowMs, thresholdMax, attemptedName: name }, name);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Trajectory is observability — must never block the refusal
|
|
204
|
+
}
|
|
205
|
+
throw new Error(`cc-openclaw: runaway loop — ${count} subprocess spawns in last ${Math.round(windowMs / 1000)}s exceeds threshold ${thresholdMax}; aborting to prevent gateway thrash`);
|
|
206
|
+
}
|
|
207
|
+
this._recordSpawn();
|
|
179
208
|
// Auto-resume: if we have a persisted claudeSessionId for this name, inject it.
|
|
180
209
|
// Skip when config.skipPersistence is set (e.g. openai-compat bridge sessions
|
|
181
210
|
// that must NOT resume stale CLI state from a previous server run).
|
|
@@ -738,6 +767,10 @@ export class SessionManager {
|
|
|
738
767
|
clearInterval(this.cleanupTimer);
|
|
739
768
|
this.cleanupTimer = null;
|
|
740
769
|
}
|
|
770
|
+
if (this.stalledWatchTimer) {
|
|
771
|
+
clearInterval(this.stalledWatchTimer);
|
|
772
|
+
this.stalledWatchTimer = null;
|
|
773
|
+
}
|
|
741
774
|
// Stop ultrareview pollers
|
|
742
775
|
for (const [, timer] of this.ultrareviewPollers)
|
|
743
776
|
clearInterval(timer);
|
|
@@ -1384,5 +1417,136 @@ export class SessionManager {
|
|
|
1384
1417
|
if (pruned)
|
|
1385
1418
|
savePersistedSessionsAsync(this.persistedSessions);
|
|
1386
1419
|
}
|
|
1420
|
+
/**
|
|
1421
|
+
* v0.10.0 — runtime stalled-session watchdog.
|
|
1422
|
+
*
|
|
1423
|
+
* Fires every STALLED_WATCH_INTERVAL_MS. For each session that is
|
|
1424
|
+
* currently `isBusy === true` (mid-turn) AND whose underlying
|
|
1425
|
+
* PersistentClaudeSession has not received any subprocess event for
|
|
1426
|
+
* STALLED_SESSION_KILL_MS, the watchdog:
|
|
1427
|
+
*
|
|
1428
|
+
* 1. Logs the stall
|
|
1429
|
+
* 2. Emits a `session_stalled_killed` trajectory event
|
|
1430
|
+
* 3. Calls session.stop() (SIGTERM, then SIGKILL after STOP_SIGKILL_DELAY_MS)
|
|
1431
|
+
* 4. Removes the entry from the in-memory `sessions` Map
|
|
1432
|
+
*
|
|
1433
|
+
* The in-flight `sendMessage()` promise will reject with the existing
|
|
1434
|
+
* `TURN_TIMEOUT_MS` error or a session-stop error. The outer agent-runner
|
|
1435
|
+
* then fast-fails to the cross-engine fallback (`openai-codex/gpt-5.4`)
|
|
1436
|
+
* rather than waiting the full provider envelope (900s).
|
|
1437
|
+
*
|
|
1438
|
+
* Threshold is overridable via `CC_OPENCLAW_STALLED_KILL_MS` env var.
|
|
1439
|
+
*
|
|
1440
|
+
* Mirrors `gateway-pm2-wrapper.sh:53-60` boot-time orphan reaper.
|
|
1441
|
+
*/
|
|
1442
|
+
_watchStalledSessions() {
|
|
1443
|
+
const thresholdMs = this._stalledThresholdMs();
|
|
1444
|
+
const now = Date.now();
|
|
1445
|
+
for (const [name, managed] of this.sessions) {
|
|
1446
|
+
// Skip sessions that aren't currently in-flight; idle TTL cleanup
|
|
1447
|
+
// handles those.
|
|
1448
|
+
if (!managed.session.isBusy)
|
|
1449
|
+
continue;
|
|
1450
|
+
// PersistentClaudeSession.stats.lastActivity is an ISO string updated
|
|
1451
|
+
// on every NDJSON event from the subprocess (persistent-session.ts:395).
|
|
1452
|
+
// If null, the session never received a single event — definitely stuck.
|
|
1453
|
+
const stats = managed.session.getStats();
|
|
1454
|
+
const lastActivityIso = stats.lastActivity;
|
|
1455
|
+
const lastEventMs = lastActivityIso
|
|
1456
|
+
? new Date(lastActivityIso).getTime()
|
|
1457
|
+
: managed.lastActivity;
|
|
1458
|
+
const ageMs = now - lastEventMs;
|
|
1459
|
+
if (ageMs <= thresholdMs)
|
|
1460
|
+
continue;
|
|
1461
|
+
this.logger.warn(`[watchdog] killing stalled session ${name} (busy, no subprocess event for ${Math.round(ageMs / 1000)}s, threshold=${Math.round(thresholdMs / 1000)}s)`);
|
|
1462
|
+
try {
|
|
1463
|
+
trajectory.emit('session_stalled_killed', {
|
|
1464
|
+
ageMs,
|
|
1465
|
+
lastActivity: lastActivityIso,
|
|
1466
|
+
thresholdMs,
|
|
1467
|
+
model: managed.config.model,
|
|
1468
|
+
cwd: managed.cwd,
|
|
1469
|
+
isBusy: true,
|
|
1470
|
+
}, managed.claudeSessionId ?? name);
|
|
1471
|
+
}
|
|
1472
|
+
catch {
|
|
1473
|
+
// Trajectory is observability — must never block recovery
|
|
1474
|
+
}
|
|
1475
|
+
try {
|
|
1476
|
+
managed.session.stop();
|
|
1477
|
+
}
|
|
1478
|
+
catch {
|
|
1479
|
+
// Best-effort — subprocess may already be dead
|
|
1480
|
+
}
|
|
1481
|
+
this.sessions.delete(name);
|
|
1482
|
+
// Don't touch persistedSessions: the disk record may still be
|
|
1483
|
+
// resumable if the stall was transient (e.g. network blip during
|
|
1484
|
+
// model call). PERSIST_DISK_TTL_MS will GC it eventually.
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Resolve the stalled-session kill threshold. Env var
|
|
1489
|
+
* `CC_OPENCLAW_STALLED_KILL_MS` overrides STALLED_SESSION_KILL_MS at
|
|
1490
|
+
* runtime so the value can be tuned without rebuild.
|
|
1491
|
+
*/
|
|
1492
|
+
_stalledThresholdMs() {
|
|
1493
|
+
const raw = process.env.CC_OPENCLAW_STALLED_KILL_MS;
|
|
1494
|
+
if (raw) {
|
|
1495
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1496
|
+
if (Number.isFinite(parsed) && parsed >= 10_000)
|
|
1497
|
+
return parsed;
|
|
1498
|
+
}
|
|
1499
|
+
return STALLED_SESSION_KILL_MS;
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* v0.10.1 — record a fresh subprocess spawn for the runaway-loop
|
|
1503
|
+
* watchdog. Trims entries older than RUNAWAY_LOOP_WINDOW_MS so the
|
|
1504
|
+
* array length is bounded.
|
|
1505
|
+
*/
|
|
1506
|
+
_recordSpawn() {
|
|
1507
|
+
const now = Date.now();
|
|
1508
|
+
this._recentSpawns.push(now);
|
|
1509
|
+
const cutoff = now - RUNAWAY_LOOP_WINDOW_MS;
|
|
1510
|
+
// Trim leading entries that have fallen out of the window. The array
|
|
1511
|
+
// stays sorted ascending since we only ever push Date.now().
|
|
1512
|
+
let drop = 0;
|
|
1513
|
+
while (drop < this._recentSpawns.length && this._recentSpawns[drop] < cutoff)
|
|
1514
|
+
drop++;
|
|
1515
|
+
if (drop > 0)
|
|
1516
|
+
this._recentSpawns.splice(0, drop);
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* v0.10.1 — true when the spawn rate over RUNAWAY_LOOP_WINDOW_MS
|
|
1520
|
+
* exceeds the configured threshold (env-overridable). Called by
|
|
1521
|
+
* `_doStartSession` BEFORE recording the new spawn — i.e. the check
|
|
1522
|
+
* fires when the (N+1)-th spawn attempt would push the count over.
|
|
1523
|
+
*/
|
|
1524
|
+
_isRunawayLoop() {
|
|
1525
|
+
// Trim stale entries before the rate decision so a long quiet period
|
|
1526
|
+
// followed by a burst doesn't carry stale counters.
|
|
1527
|
+
const now = Date.now();
|
|
1528
|
+
const cutoff = now - RUNAWAY_LOOP_WINDOW_MS;
|
|
1529
|
+
let drop = 0;
|
|
1530
|
+
while (drop < this._recentSpawns.length && this._recentSpawns[drop] < cutoff)
|
|
1531
|
+
drop++;
|
|
1532
|
+
if (drop > 0)
|
|
1533
|
+
this._recentSpawns.splice(0, drop);
|
|
1534
|
+
return this._recentSpawns.length >= this._runawayThreshold();
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* v0.10.1 — resolve the runaway-loop spawn threshold. Env var
|
|
1538
|
+
* `CC_OPENCLAW_LOOP_MAX_SUBPROCS` overrides RUNAWAY_LOOP_MAX_SUBPROCS
|
|
1539
|
+
* for runtime tuning without rebuild. Clamped to [2, 20] to prevent
|
|
1540
|
+
* accidental disable (0/1) or unbounded raise.
|
|
1541
|
+
*/
|
|
1542
|
+
_runawayThreshold() {
|
|
1543
|
+
const raw = process.env.CC_OPENCLAW_LOOP_MAX_SUBPROCS;
|
|
1544
|
+
if (raw) {
|
|
1545
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1546
|
+
if (Number.isFinite(parsed) && parsed >= 2 && parsed <= 20)
|
|
1547
|
+
return parsed;
|
|
1548
|
+
}
|
|
1549
|
+
return RUNAWAY_LOOP_MAX_SUBPROCS;
|
|
1550
|
+
}
|
|
1387
1551
|
}
|
|
1388
1552
|
//# sourceMappingURL=session-manager.js.map
|