@a1hvdy/cc-openclaw 0.27.1 → 0.27.4

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 (30) hide show
  1. package/dist/src/channels/telegram-mirror/askuser.js +2 -0
  2. package/dist/src/channels/telegram-mirror/card-renderer.d.ts +1 -22
  3. package/dist/src/channels/telegram-mirror/card-renderer.js +172 -20
  4. package/dist/src/channels/telegram-mirror/commands.d.ts +16 -0
  5. package/dist/src/channels/telegram-mirror/commands.js +53 -12
  6. package/dist/src/channels/telegram-mirror/inbound-handler.d.ts +18 -0
  7. package/dist/src/channels/telegram-mirror/inbound-handler.js +21 -8
  8. package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +11 -0
  9. package/dist/src/channels/telegram-mirror/turn-bridge.js +31 -0
  10. package/dist/src/constants.d.ts +10 -0
  11. package/dist/src/constants.js +10 -0
  12. package/dist/src/lib/error-formatter.d.ts +14 -2
  13. package/dist/src/lib/error-formatter.js +23 -11
  14. package/dist/src/lib/error-renderer.js +3 -1
  15. package/dist/src/lib/html-render.d.ts +8 -16
  16. package/dist/src/lib/html-render.js +91 -1
  17. package/dist/src/lib/markdown-to-mdv2.js +2 -1
  18. package/dist/src/lib/probes.d.ts +50 -0
  19. package/dist/src/lib/probes.js +96 -0
  20. package/dist/src/lib/telegram-bot-api.d.ts +52 -6
  21. package/dist/src/lib/telegram-bot-api.js +180 -13
  22. package/dist/src/openai-compat/message-extractor.js +4 -0
  23. package/dist/src/openai-compat/openai-compat.js +12 -1
  24. package/dist/src/openai-compat/streaming-handler.js +7 -1
  25. package/dist/src/session/persisted-sessions.d.ts +11 -0
  26. package/dist/src/session/persisted-sessions.js +17 -0
  27. package/dist/src/session/session-manager.js +22 -6
  28. package/dist/src/session-bootstrap/cwd-patch.js +1 -2
  29. package/dist/src/types.d.ts +7 -0
  30. package/package.json +1 -1
@@ -27,6 +27,10 @@ import { request as httpsRequest } from 'node:https';
27
27
  import { readFileSync } from 'node:fs';
28
28
  import { homedir } from 'node:os';
29
29
  import { join } from 'node:path';
30
+ import { randomBytes } from 'node:crypto';
31
+ import { stripHtml } from './html-render.js';
32
+ /** Telegram's hard per-message character cap. */
33
+ const TG_MAX_CHARS = 4096;
30
34
  export const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
31
35
  const PLUGIN_TAG = '[cc-openclaw/telegram-bot-api]';
32
36
  // ─── Bot token state ───────────────────────────────────────────────────────
@@ -138,32 +142,96 @@ export function telegramApi(method, params) {
138
142
  });
139
143
  }
140
144
  /**
141
- * sendMessage with MarkdownV2 first + plain-text fallback. The fallback
142
- * is the v0.20.1 fix: prior implementation stripped punctuation on
143
- * MarkdownV2 parse errors; current behaviour retries with parse_mode
144
- * omitted so all content survives.
145
+ * v0.27.4 M6 CLI-parity gap #8: split a message that exceeds Telegram's 4096
146
+ * char cap into ≤max-char chunks so long cc-openclaw-originated content sends as
147
+ * sequential messages instead of being rejected outright. Splits on newline
148
+ * boundaries (never mid-line) so HTML constructs mostly stay intact; a single
149
+ * line longer than max is hard-split. Returns [text] unchanged when ≤max (the
150
+ * common path — no behavior change for normal messages).
151
+ *
152
+ * Scope note: this covers cc-openclaw's OWN sends (slash/error responses). The
153
+ * live card is one edited message (truncated by design) and the model's final
154
+ * answer is delivered by the OpenClaw gateway — neither flows through here.
155
+ */
156
+ export function splitForTelegram(text, max = TG_MAX_CHARS) {
157
+ if (text.length <= max)
158
+ return [text];
159
+ const chunks = [];
160
+ let current = '';
161
+ for (const line of text.split('\n')) {
162
+ if (line.length > max) {
163
+ if (current) {
164
+ chunks.push(current);
165
+ current = '';
166
+ }
167
+ for (let i = 0; i < line.length; i += max)
168
+ chunks.push(line.slice(i, i + max));
169
+ continue;
170
+ }
171
+ const candidate = current ? `${current}\n${line}` : line;
172
+ if (candidate.length > max) {
173
+ if (current)
174
+ chunks.push(current);
175
+ current = line;
176
+ }
177
+ else {
178
+ current = candidate;
179
+ }
180
+ }
181
+ if (current)
182
+ chunks.push(current);
183
+ return chunks;
184
+ }
185
+ /**
186
+ * sendMessage with HTML parse_mode first + plain-text fallback. The fallback
187
+ * is the v0.20.1 fix: prior implementation stripped punctuation on parse
188
+ * errors; current behaviour retries with parse_mode omitted so all content
189
+ * survives. (v0.27.0 switched the live mirror MarkdownV2 → HTML; v0.27.3
190
+ * converted the last MarkdownV2 emitter, error-formatter, so the whole
191
+ * Telegram surface is now one HTML render path.) v0.27.4 M6 — auto-chunks
192
+ * over-cap text and the plain fallback now strips HTML (was: re-sent raw tags).
145
193
  */
146
194
  export async function sendTg(chatId, text, threadId, replyMarkup, replyToMessageId) {
147
195
  try {
148
196
  const base = { chat_id: chatId, disable_web_page_preview: true };
149
197
  if (threadId)
150
198
  base.message_thread_id = Number(threadId);
151
- if (replyMarkup)
152
- base.reply_markup = replyMarkup;
153
199
  if (replyToMessageId)
154
200
  base.reply_to_message_id = Number(replyToMessageId);
155
- const res = await telegramApi('sendMessage', { ...base, text, parse_mode: 'HTML' });
156
- if (res.ok)
157
- return res;
158
- return telegramApi('sendMessage', { ...base, text: text || 'Session update' });
201
+ // Send one body with HTML first, then a stripped plain-text fallback so a
202
+ // chunk that split an HTML tag still lands legibly (mirrors editTg v0.27.3).
203
+ const sendOne = async (body, markup) => {
204
+ const params = { ...base, text: body, parse_mode: 'HTML' };
205
+ if (markup)
206
+ params.reply_markup = markup;
207
+ const res = await telegramApi('sendMessage', params);
208
+ if (res.ok)
209
+ return res;
210
+ const fb = { ...base, text: stripHtml(body) || 'Session update' };
211
+ if (markup)
212
+ fb.reply_markup = markup;
213
+ return telegramApi('sendMessage', fb);
214
+ };
215
+ if (text.length > TG_MAX_CHARS) {
216
+ const chunks = splitForTelegram(text, TG_MAX_CHARS);
217
+ let first = { ok: false };
218
+ for (let i = 0; i < chunks.length; i++) {
219
+ // Keyboard rides only the LAST chunk (actions belong with the tail).
220
+ const res = await sendOne(chunks[i], i === chunks.length - 1 ? replyMarkup : undefined);
221
+ if (i === 0)
222
+ first = res;
223
+ }
224
+ return first;
225
+ }
226
+ return await sendOne(text, replyMarkup);
159
227
  }
160
228
  catch {
161
229
  return { ok: false };
162
230
  }
163
231
  }
164
232
  /**
165
- * editMessageText with MarkdownV2-first + 429 retry-after handling +
166
- * plain-text fallback.
233
+ * editMessageText with HTML parse_mode + 429 retry-after handling +
234
+ * plain-text fallback (v0.27.3 stripHtml fallback on rejection).
167
235
  */
168
236
  export async function editTg(chatId, messageId, text, replyMarkup) {
169
237
  try {
@@ -187,16 +255,115 @@ export async function editTg(chatId, messageId, text, replyMarkup) {
187
255
  }
188
256
  if (res.ok)
189
257
  return res;
258
+ // v0.27.3 — the HTML edit was rejected (commonly "can't parse
259
+ // entities" or "message is too long"). Re-sending the SAME string without
260
+ // parse_mode would dump literal <b>/<pre> tags into the chat AND, if the
261
+ // reason was length, fail identically. Strip the markup back to plain text
262
+ // and hard-truncate to Telegram's cap so the fallback can actually land.
263
+ // Log the original failure so a broken live card is never silent again
264
+ // (the prior swallow is exactly why the 4096-overflow regression hid).
265
+ process.stderr.write(`[cc-openclaw/telegram-bot-api] editTg HTML edit rejected (len=${text.length}) ` +
266
+ `code=${res.error_code ?? '?'} desc=${JSON.stringify(res.description ?? '')} — plain-text fallback\n`);
267
+ let plain = stripHtml(text) || 'Session update';
268
+ if (plain.length > TG_MAX_CHARS)
269
+ plain = plain.slice(0, TG_MAX_CHARS - 1) + '…';
190
270
  const fallback = {
191
271
  chat_id: chatId,
192
272
  message_id: messageId,
193
- text: text || 'Session update',
273
+ text: plain,
194
274
  disable_web_page_preview: true,
195
275
  };
196
276
  if (replyMarkup)
197
277
  fallback.reply_markup = replyMarkup;
198
278
  return telegramApi('editMessageText', fallback);
199
279
  }
280
+ catch (err) {
281
+ process.stderr.write(`[cc-openclaw/telegram-bot-api] editTg threw: ${err.message}\n`);
282
+ return { ok: false };
283
+ }
284
+ }
285
+ /**
286
+ * Build a multipart/form-data body for sendDocument. PURE — no I/O — so the
287
+ * encoding (the R-3 risk) is unit-testable without a network round-trip.
288
+ *
289
+ * The document is sent as an inline InputFile (Content-Type text/markdown). The
290
+ * boundary MUST NOT appear in any field value or the file content; callers use a
291
+ * random 16-byte boundary (sendDocumentTg) so collision is astronomically
292
+ * unlikely against Markdown plan bodies.
293
+ */
294
+ export function buildDocumentMultipart(opts) {
295
+ const { boundary } = opts;
296
+ const parts = [];
297
+ const textField = (name, value) => {
298
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`, 'utf8'));
299
+ };
300
+ textField('chat_id', String(opts.chatId));
301
+ if (opts.caption)
302
+ textField('caption', opts.caption);
303
+ if (opts.parseMode)
304
+ textField('parse_mode', opts.parseMode);
305
+ if (opts.threadId !== undefined)
306
+ textField('message_thread_id', String(opts.threadId));
307
+ if (opts.replyMarkup)
308
+ textField('reply_markup', JSON.stringify(opts.replyMarkup));
309
+ // The document file part — header, then raw content, then CRLF.
310
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${opts.filename}"\r\nContent-Type: text/markdown\r\n\r\n`, 'utf8'));
311
+ parts.push(Buffer.from(opts.content, 'utf8'));
312
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'));
313
+ return Buffer.concat(parts);
314
+ }
315
+ /** Low-level multipart POST. Mirrors `telegramApi` but sets a multipart
316
+ * Content-Type + a Buffer body. */
317
+ function telegramApiMultipart(method, boundary, body) {
318
+ return new Promise((resolve, reject) => {
319
+ const options = {
320
+ hostname: 'api.telegram.org',
321
+ path: `/bot${_botToken}/${method}`,
322
+ method: 'POST',
323
+ headers: {
324
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
325
+ 'Content-Length': body.length,
326
+ },
327
+ };
328
+ const req = httpsRequest(options, (res) => {
329
+ let data = '';
330
+ res.on('data', (chunk) => (data += chunk));
331
+ res.on('end', () => {
332
+ try {
333
+ resolve(JSON.parse(data));
334
+ }
335
+ catch {
336
+ resolve({ ok: false, description: 'JSON parse error' });
337
+ }
338
+ });
339
+ });
340
+ req.on('error', (err) => reject(err));
341
+ req.setTimeout(15_000, () => {
342
+ req.destroy(new Error('Telegram API timeout'));
343
+ });
344
+ req.write(body);
345
+ req.end();
346
+ });
347
+ }
348
+ /**
349
+ * Upload a text document (e.g. a plan .md) to a chat via sendDocument. Returns
350
+ * the API response, or {ok:false} on network/encoding failure (never throws).
351
+ */
352
+ export async function sendDocumentTg(chatId, filename, content, opts = {}) {
353
+ try {
354
+ const boundary = `----ccopenclaw${randomBytes(16).toString('hex')}`;
355
+ const body = buildDocumentMultipart({
356
+ boundary,
357
+ chatId,
358
+ filename,
359
+ content,
360
+ caption: opts.caption,
361
+ parseMode: opts.parseMode,
362
+ threadId: opts.threadId,
363
+ replyMarkup: opts.replyMarkup,
364
+ });
365
+ return await telegramApiMultipart('sendDocument', boundary, body);
366
+ }
200
367
  catch {
201
368
  return { ok: false };
202
369
  }
@@ -32,6 +32,7 @@ import { serializeToolResults, serializeToolResultsAsBlocks, } from './tool-resu
32
32
  import { isToolStreamMode } from './mode-flags.js';
33
33
  import { detectSlashCommand, maybeInlineSkill } from './skill-resolver.js';
34
34
  import { isOpenaiCompatNewConvoHeuristic } from '../lib/config.js';
35
+ import { probeMultimodalContent } from '../lib/probes.js';
35
36
  /**
36
37
  * Extract the relevant parts from an OpenAI messages array.
37
38
  *
@@ -59,6 +60,9 @@ export function extractUserMessage(messages, headers) {
59
60
  if (!messages || messages.length === 0) {
60
61
  throw new Error('messages array is empty');
61
62
  }
63
+ // P0-C openai-body probe (observe-only, gated): does an image block survive to
64
+ // the request body before textOf() below strips non-text parts? See lib/probes.ts.
65
+ probeMultimodalContent(messages);
62
66
  // Normalize content from any message: OpenAI API allows content as a string
63
67
  // OR an array of content parts (e.g. multimodal messages with text + images).
64
68
  // We need a string for the CLI, so arrays are joined.
@@ -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';
@@ -272,6 +272,17 @@ export async function handleChatCompletion(manager, body, headers, res) {
272
272
  // Note: noSessionPersistence (--no-session-persistence) is NOT set
273
273
  // because some CLI forks don't support this flag.
274
274
  skipPersistence: true,
275
+ // v0.27.4 (M4/M6): opt this session into freshness-windowed --resume so a
276
+ // gateway restart or stalled-session watchdog SIGTERM doesn't wipe the
277
+ // conversation. Persists the claudeSessionId (despite skipPersistence) and
278
+ // the next turn for this chat resumes it iff it was active within the
279
+ // window — older sessions still start fresh (anti-stale). Env override
280
+ // CC_OPENCLAW_RESUME_FRESHNESS_MS; default RESUME_FRESHNESS_MS (30 min).
281
+ resumeFreshnessMs: (() => {
282
+ const v = process.env.CC_OPENCLAW_RESUME_FRESHNESS_MS;
283
+ const n = v !== undefined ? parseInt(v, 10) : NaN;
284
+ return Number.isFinite(n) && n > 0 ? n : RESUME_FRESHNESS_MS;
285
+ })(),
275
286
  // v0.7.4 EMERGENCY RESTORE: re-enable --include-partial-messages for
276
287
  // openai-compat sessions. v0.6.0 made this opt-in (default OFF) for
277
288
  // a 10-100× JSON overhead drop, but the engine never grew the
@@ -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
@@ -327,6 +327,12 @@ slashCommand) {
327
327
  if (!text)
328
328
  return;
329
329
  thinkingBuffer += text;
330
+ // v0.27.4 M1 — surface thinking on the live Telegram card too (gap
331
+ // #1). Pass the cumulative buffer; pushThinking overwrites
332
+ // turn.thinkingText so the 💭 block grows in place. Same surfacing
333
+ // gate as the SSE reasoning emit below (this callback only exists
334
+ // when surfaceThinking is on).
335
+ mirrorPushThinking(thinkingBuffer);
330
336
  const chunk = {
331
337
  id: completionId,
332
338
  object: 'chat.completion.chunk',
@@ -21,6 +21,17 @@ export interface PersistedSession {
21
21
  lastResumed: string;
22
22
  lastActivity: number;
23
23
  }
24
+ /**
25
+ * v0.27.4 (M4/M6) — resume-freshness gate. A persisted Claude session is
26
+ * eligible for --resume only if its last activity is within `freshnessMs`.
27
+ * This restores cross-restart / post-watchdog-kill conversation continuity for
28
+ * openai-compat sessions WITHOUT reintroducing the stale-resume hazard that
29
+ * motivated skipPersistence: a session idle longer than the window starts
30
+ * fresh. Returns false for a missing entry, a non-numeric lastActivity, or a
31
+ * non-positive/non-finite window (resume disabled). Pure + side-effect-free so
32
+ * the decision is unit-testable independent of the disk layer.
33
+ */
34
+ export declare function isPersistedSessionFresh(persisted: Pick<PersistedSession, 'lastActivity'> | undefined, now: number, freshnessMs: number): boolean;
24
35
  export declare function loadPersistedSessions(): Map<string, PersistedSession>;
25
36
  export declare function savePersistedSessions(sessions: Map<string, PersistedSession>, logger?: Logger): void;
26
37
  export declare function savePersistedSessionsAsync(sessions: Map<string, PersistedSession>, logger?: Logger): void;
@@ -14,6 +14,23 @@ import { createConsoleLogger } from '../logger.js';
14
14
  import { PERSIST_DISK_TTL_MS } from '../constants.js';
15
15
  export const PERSIST_DIR = path.join(os.homedir(), '.openclaw');
16
16
  export const PERSIST_FILE = path.join(PERSIST_DIR, 'claude-sessions.json');
17
+ /**
18
+ * v0.27.4 (M4/M6) — resume-freshness gate. A persisted Claude session is
19
+ * eligible for --resume only if its last activity is within `freshnessMs`.
20
+ * This restores cross-restart / post-watchdog-kill conversation continuity for
21
+ * openai-compat sessions WITHOUT reintroducing the stale-resume hazard that
22
+ * motivated skipPersistence: a session idle longer than the window starts
23
+ * fresh. Returns false for a missing entry, a non-numeric lastActivity, or a
24
+ * non-positive/non-finite window (resume disabled). Pure + side-effect-free so
25
+ * the decision is unit-testable independent of the disk layer.
26
+ */
27
+ export function isPersistedSessionFresh(persisted, now, freshnessMs) {
28
+ if (!persisted || typeof persisted.lastActivity !== 'number')
29
+ return false;
30
+ if (!Number.isFinite(freshnessMs) || freshnessMs <= 0)
31
+ return false;
32
+ return now - persisted.lastActivity <= freshnessMs;
33
+ }
17
34
  export function loadPersistedSessions() {
18
35
  try {
19
36
  if (!fs.existsSync(PERSIST_FILE))
@@ -33,7 +33,7 @@ function getPluginVersion() {
33
33
  // ─── Persistence ─────────────────────────────────────────────────────────────
34
34
  // Extracted to `./persisted-sessions.ts` 2026-05-13 — coherent persistence
35
35
  // layer (load + sync atomic-write + async-write + types + constants).
36
- import { loadPersistedSessions, savePersistedSessions, savePersistedSessionsAsync, } from './persisted-sessions.js';
36
+ import { loadPersistedSessions, savePersistedSessions, savePersistedSessionsAsync, isPersistedSessionFresh, } from './persisted-sessions.js';
37
37
  // Debounce helper — coalesces rapid writes into one
38
38
  // `makeDebounced` extracted to `../lib/debounce.ts` 2026-05-13 —
39
39
  // pure-function hot-path decomposition.
@@ -158,10 +158,21 @@ export class SessionManager {
158
158
  }
159
159
  this._recordSpawn();
160
160
  // Auto-resume: if we have a persisted claudeSessionId for this name, inject it.
161
- // Skip when config.skipPersistence is set (e.g. openai-compat bridge sessions
162
- // that must NOT resume stale CLI state from a previous server run).
161
+ // Normal (non-skipPersistence) sessions resume unconditionally as before.
162
+ // skipPersistence sessions normally must NOT resume stale CLI state from a
163
+ // previous server run — EXCEPT v0.27.4 (M4/M6): when they opt into
164
+ // freshness-windowed resume (config.resumeFreshnessMs, set by the
165
+ // openai-compat bridge), resume the prior session iff it's still fresh, so
166
+ // Savvy keeps context across a gateway restart / watchdog-kill while a
167
+ // long-idle session still starts fresh.
163
168
  const skipPersist = !!config.skipPersistence;
164
- const persisted = skipPersist ? undefined : this.persistedSessions.get(name);
169
+ let persisted = skipPersist ? undefined : this.persistedSessions.get(name);
170
+ if (skipPersist && typeof config.resumeFreshnessMs === 'number') {
171
+ const candidate = this.persistedSessions.get(name);
172
+ if (isPersistedSessionFresh(candidate, Date.now(), config.resumeFreshnessMs)) {
173
+ persisted = candidate;
174
+ }
175
+ }
165
176
  // Unified: only use resumeSessionId (claudeResumeId is an internal alias, not exposed)
166
177
  const resumeId = config.resumeSessionId ?? persisted?.claudeSessionId;
167
178
  const fullConfig = {
@@ -349,10 +360,15 @@ export class SessionManager {
349
360
  }
350
361
  const result = await managed.session.send(message, sendOpts);
351
362
  // Update session ID if available (skip disk persist for ephemeral
352
- // sessions that were started with skipPersistence)
363
+ // sessions that were started with skipPersistence) — EXCEPT v0.27.4
364
+ // (M4/M6): a session that opted into freshness-windowed resume must be
365
+ // persisted (even though skipPersistence is true) so its claudeSessionId
366
+ // is on disk for the next turn / a post-restart resume.
353
367
  if (managed.session.sessionId) {
354
368
  managed.claudeSessionId = managed.session.sessionId;
355
- if (this.persistedSessions.has(name)) {
369
+ const optedIntoFreshResume = typeof managed.config.resumeFreshnessMs === 'number' &&
370
+ managed.config.resumeFreshnessMs > 0;
371
+ if (this.persistedSessions.has(name) || optedIntoFreshResume) {
356
372
  this._persistSession(name, managed);
357
373
  }
358
374
  }
@@ -84,8 +84,6 @@ const PATHS = {
84
84
  const DEPS_STUB_PATH = join(PATHS.openclawDist, 'commands-status-deps.runtime.js');
85
85
  const STATUS_STUB_PATH = join(PATHS.openclawRoot, 'status.runtime.js');
86
86
  const AUTO_REPLY_STATUS_PATH = join(PATHS.autoReplyDir, 'commands-status.runtime.js');
87
- const SAVVY_REGISTRY_PATH = join(HOME, '.openclaw/savvy-resume-registry.json');
88
- const CLAUDE_SESSIONS_PATH = join(HOME, '.openclaw/claude-sessions.json');
89
87
  const CACHE_PARITY_REGISTRY_PATH = join(HOME, '.openclaw/openclaw-cache-parity-registry.json');
90
88
  // Patch identity symbols (module-scoped, stable across re-imports within a process)
91
89
  const PATCH_MARKER = Symbol.for('claude-local-enhancer:patched');
@@ -166,6 +164,7 @@ function _setToolDumpCacheEntry(key, val) {
166
164
  let _lastToolDumpHash = null;
167
165
  // ── sessionId capture state ───────────────────────────────────────────────
168
166
  let _lastCapturedJson = '';
167
+ // ── Resume registry helpers ───────────────────────────────────────────────
169
168
  // `restoreClaudeSessionsFromBackup` + `writeBackupRegistry` extracted to
170
169
  // `./resume-registry.ts` 2026-05-13. The wrapper preserves the caller-less
171
170
  // call signature locally by closing over the module `logger`.
@@ -137,6 +137,13 @@ export interface SessionConfig {
137
137
  sessionName?: string;
138
138
  claudeResumeId?: string;
139
139
  resumeSessionId?: string;
140
+ /** v0.27.4 (M4/M6) — opt a skipPersistence session into freshness-windowed
141
+ * --resume across restart / watchdog-kill. When set (ms), the SessionManager
142
+ * persists this session's claudeSessionId and, on the next start for the same
143
+ * name, resumes it iff its last activity is within this window. Used by the
144
+ * openai-compat bridge so Savvy keeps context across a gateway restart while
145
+ * still starting fresh after a long idle gap. */
146
+ resumeFreshnessMs?: number;
140
147
  forkSession?: boolean;
141
148
  addDir?: string[];
142
149
  effort?: EffortLevel;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a1hvdy/cc-openclaw",
3
- "version": "0.27.1",
3
+ "version": "0.27.4",
4
4
  "description": "A1xAI's Anthropic CLI bridge plugin for OpenClaw",
5
5
  "author": "@a1cy",
6
6
  "license": "MIT",