@a1hvdy/cc-openclaw 0.27.9 → 0.27.11

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.
@@ -346,9 +346,12 @@ export function renderTurn(turn, meta) {
346
346
  push(`<b>${escapeHtml(status)}</b>`);
347
347
  // v0.26.2 M2 — meter row (context % + quota % + reset). Same no-fake-data
348
348
  // gating: omitted unless real values are present. HTML-escaped for safety.
349
+ // v0.27.10 — wrap the meter row in <code> so the bars read as one cohesive
350
+ // monospace status widget (ctx ▓▓░ 6% · use …) instead of loose plain text
351
+ // sitting under the bold header — part of the "solid HTML" cleanup.
349
352
  const meters = renderMeters(meta);
350
353
  if (meters)
351
- push(escapeHtml(meters));
354
+ push(`<code>${escapeHtml(meters)}</code>`);
352
355
  // v0.26.4 styling — divider between the status/telemetry block and the
353
356
  // activity block (only when a status block was actually rendered). The
354
357
  // heavy-bar glyph is not HTML-significant, so it needs no escaping.
@@ -362,7 +365,10 @@ export function renderTurn(turn, meta) {
362
365
  : turn.state === 'done'
363
366
  ? '✓ Done'
364
367
  : '▶ Working';
365
- push(header);
368
+ // v0.27.10 — bold the turn-status header so it anchors the activity block as a
369
+ // styled heading rather than a plain line (cohesion pass). failReason is already
370
+ // escapeHtml'd above; ✓ Done / ▶ Working are literal-safe.
371
+ push(`<b>${header}</b>`);
366
372
  if (turn.toolCalls.length > 0) {
367
373
  // Build tool ENTRIES (tool line + its optional <pre><code> result block)
368
374
  // newest-first, keeping what fits under budget; the older overflow collapses
@@ -611,10 +611,17 @@ export class PersistentClaudeSession extends EventEmitter {
611
611
  this.stats.tokensOut += usage.output_tokens || 0;
612
612
  this.stats.cachedTokens += usage.cache_read_input_tokens || 0;
613
613
  // v0.6.0: track actual per-turn context occupancy for the contextPercent
614
- // calc. cache_read_input_tokens DO occupy the window; input_tokens are
615
- // the new uncached prefix. Together they approximate real window usage.
614
+ // calc. v0.27.10: include cache_creation_input_tokens. The full prompt
615
+ // sent each turn = input (new uncached) + cache_read (cached prefix
616
+ // re-read) + cache_creation (newly written to cache). Summing ALL THREE
617
+ // yields the true current context-window occupancy, stable across cache
618
+ // hit/miss. Omitting cache_creation made the meter swing wildly (cold
619
+ // cache → everything is creation, uncounted → ~6%; warm cache → big
620
+ // read → 63%+) — the exact 6%→63%→100% jitter A1 flagged 2026-05-22.
616
621
  this.stats.lastTurnContextTokens =
617
- (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0);
622
+ (usage.input_tokens || 0) +
623
+ (usage.cache_read_input_tokens || 0) +
624
+ (usage.cache_creation_input_tokens || 0);
618
625
  this._updateCost();
619
626
  }
620
627
  this.emit(SESSION_EVENT.RESULT, event);
@@ -223,6 +223,19 @@ export function markdownToHtml(input) {
223
223
  // glyph isn't HTML-significant, so it survives the escape below. Uses [ \t]
224
224
  // (not \s) so it never consumes the line break.
225
225
  text = text.replace(/^([ \t]*)[-*+][ \t]+/gm, (_m, indent) => `${indent}• `);
226
+ // v0.27.10 — strip dangling inline-markdown delimiters left by PARTIAL
227
+ // streaming text. Every CLOSED span (```fence```, `code`, **bold**, ~~strike~~)
228
+ // was already lifted out to a NUL placeholder above; whatever **, ~~ or ` is
229
+ // still here is an UNTERMINATED marker at the live-stream frontier (e.g. the
230
+ // card edited mid-token while the model is still typing "…and the **bold"). Left
231
+ // in place it renders as a raw symbol next to formatted text — the "normal text
232
+ // mixed between HTML" A1 flagged 2026-05-22. Remove the markers, keep the words.
233
+ // Finalized (balanced) text has none left, so this is a no-op there. `*`/`_` are
234
+ // deliberately NOT stripped (too easily literal math / snake_case → false hits).
235
+ text = text
236
+ .replace(/\*\*/g, '')
237
+ .replace(/~~/g, '')
238
+ .replace(/`/g, '');
226
239
  text = escapeHtml(text);
227
240
  text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m, idx) => codeBlocks[Number(idx)]);
228
241
  text = text.replace(/\x00TABLE(\d+)\x00/g, (_m, idx) => tables[Number(idx)]);
@@ -219,7 +219,7 @@ export function resolveProvider(model) {
219
219
  }
220
220
  /** Get context window size for a model. Returns 200k default for unknown models. */
221
221
  export function getContextWindow(model) {
222
- const clean = model.replace(/^(anthropic|openai|openai-codex|google|gemini|cursor)\//g, '');
222
+ const clean = model.replace(/^(anthropic|openai|openai-codex|google|gemini|cursor|cc-openclaw)\//g, '');
223
223
  const known = lookupModel(clean);
224
224
  return known?.contextWindow ?? 200_000;
225
225
  }
@@ -227,7 +227,7 @@ export function getContextWindow(model) {
227
227
  export function getModelPricing(model, defaultModel = 'claude-sonnet-4-6') {
228
228
  if (!model)
229
229
  return lookupModel(defaultModel)?.pricing ?? { input: 0, output: 0 };
230
- const clean = model.replace(/^(anthropic|openai|openai-codex|google|gemini|cursor)\//g, '');
230
+ const clean = model.replace(/^(anthropic|openai|openai-codex|google|gemini|cursor|cc-openclaw)\//g, '');
231
231
  // Check overrides first
232
232
  const override = _pricingOverrides.get(clean);
233
233
  if (override)
@@ -0,0 +1,26 @@
1
+ /**
2
+ * AUTONOMY_RULE — "act, don't ask" posture injected into the Savvy chat path.
3
+ *
4
+ * Why: the openai-compat (Telegram) path does not reliably load the owner's
5
+ * CLAUDE.md "Execute, don't discuss" rules (tmpdir CWD), so the model reverts to
6
+ * Claude's base posture of asking on ambiguous forks. Combined with a Telegram
7
+ * AskUserQuestion round-trip that silently drops answers, this made Savvy stall
8
+ * and require many prods to finish one task. This rule restores the terminal-CLI
9
+ * posture: decide and execute, complete the whole task in one turn.
10
+ *
11
+ * Gated by CC_OPENCLAW_AUTONOMY_RULE (default on; set '0' to disable). Prepended
12
+ * at the same injection points as TTS_RULE in openai-compat.ts so it lands in
13
+ * both the REPLACE (--system-prompt) and APPEND (--append-system-prompt) paths.
14
+ *
15
+ * Pure-string constant — no I/O, no module state.
16
+ */
17
+ export declare const AUTONOMY_RULE: string;
18
+ /**
19
+ * Merge AskUserQuestion into a session's disallowedTools when suppression is on.
20
+ * The Telegram answer round-trip silently drops taps, so a question there stalls
21
+ * the turn — suppressing the tool is the hard guarantee behind AUTONOMY_RULE's
22
+ * "decide, do not ask" posture. Returns `prior` unchanged when suppression is
23
+ * off (so callers only set disallowedTools when there's something to set). Pure
24
+ * + dedup so it's unit-testable and idempotent across repeated session creates.
25
+ */
26
+ export declare function withAskUserSuppressed(prior: string[] | undefined, suppress: boolean): string[] | undefined;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * AUTONOMY_RULE — "act, don't ask" posture injected into the Savvy chat path.
3
+ *
4
+ * Why: the openai-compat (Telegram) path does not reliably load the owner's
5
+ * CLAUDE.md "Execute, don't discuss" rules (tmpdir CWD), so the model reverts to
6
+ * Claude's base posture of asking on ambiguous forks. Combined with a Telegram
7
+ * AskUserQuestion round-trip that silently drops answers, this made Savvy stall
8
+ * and require many prods to finish one task. This rule restores the terminal-CLI
9
+ * posture: decide and execute, complete the whole task in one turn.
10
+ *
11
+ * Gated by CC_OPENCLAW_AUTONOMY_RULE (default on; set '0' to disable). Prepended
12
+ * at the same injection points as TTS_RULE in openai-compat.ts so it lands in
13
+ * both the REPLACE (--system-prompt) and APPEND (--append-system-prompt) paths.
14
+ *
15
+ * Pure-string constant — no I/O, no module state.
16
+ */
17
+ export const AUTONOMY_RULE = [
18
+ '=== AUTONOMY RULE (cc-openclaw, takes precedence) ===',
19
+ '',
20
+ 'You are operating over a chat channel for the owner, who cannot watch you',
21
+ 'work and only sees your final reply. Default to ACTING, not asking.',
22
+ '',
23
+ '1. DECIDE, do not ask. For any reversible decision (layout, naming, which of',
24
+ ' several valid approaches), pick the best option, state your reasoning in',
25
+ ' ONE line, and proceed. Do NOT stop to ask "shall I proceed?", "A or B?",',
26
+ ' or "want me to continue?" for routine work. Only pause for genuinely',
27
+ ' destructive or irreversible actions (deleting data, force-push, sending',
28
+ ' external messages).',
29
+ '',
30
+ '2. FINISH THE WHOLE TASK in one turn. When a request implies multiple steps',
31
+ ' (e.g. fix -> build -> test -> commit -> ship), run ALL of them before',
32
+ ' yielding. Do not hand back a half-done task with "say go to continue" —',
33
+ ' that forces the owner to prod you repeatedly. Run it to completion.',
34
+ '',
35
+ '3. The ONE exception is restarting the gateway you run inside: never bounce it',
36
+ ' mid-reply (it kills this message). Do every other step first, then schedule',
37
+ ' the restart as a detached, delayed command so it lands AFTER your reply.',
38
+ '',
39
+ '4. Verify before claiming done. Run the build/test/probe and report what you',
40
+ ' actually observed ("ran X, it returned Y"), never "should work".',
41
+ '',
42
+ '=== END AUTONOMY RULE ===',
43
+ ].join('\n');
44
+ /**
45
+ * Merge AskUserQuestion into a session's disallowedTools when suppression is on.
46
+ * The Telegram answer round-trip silently drops taps, so a question there stalls
47
+ * the turn — suppressing the tool is the hard guarantee behind AUTONOMY_RULE's
48
+ * "decide, do not ask" posture. Returns `prior` unchanged when suppression is
49
+ * off (so callers only set disallowedTools when there's something to set). Pure
50
+ * + dedup so it's unit-testable and idempotent across repeated session creates.
51
+ */
52
+ export function withAskUserSuppressed(prior, suppress) {
53
+ if (!suppress)
54
+ return prior;
55
+ return [...new Set([...(prior ?? []), 'AskUserQuestion'])];
56
+ }
@@ -29,6 +29,7 @@ import { getTtsAutoMode } from '../lib/config.js';
29
29
  // suggestion. v0.10.3 explicitly forbids alternatives and gives an example.
30
30
  // `TTS_RULE` extracted to `./tts-rule.ts` 2026-05-13 — pure-string constant.
31
31
  import { TTS_RULE } from './tts-rule.js';
32
+ import { AUTONOMY_RULE, withAskUserSuppressed } from './autonomy-rule.js';
32
33
  import { extractUserMessage, } from './message-extractor.js';
33
34
  import { handleNonStreaming } from './non-streaming-handler.js';
34
35
  import { handleStreaming } from './streaming-handler.js';
@@ -332,6 +333,16 @@ export async function handleChatCompletion(manager, body, headers, res) {
332
333
  sessionConfig.tools = '';
333
334
  }
334
335
  }
336
+ // v0.27.11: suppress AskUserQuestion on the chat path (CC_OPENCLAW_SUPPRESS_ASKUSER,
337
+ // default on). The Telegram answer round-trip silently drops taps (askuser.ts
338
+ // injectAnswer → "SKIPPED sessionKey=none"), so a question there stalls the turn.
339
+ // This is the hard guarantee behind the AUTONOMY_RULE posture: the model decides
340
+ // instead of asking. Reversible via the env flag.
341
+ if (engine === 'claude') {
342
+ const suppressed = withAskUserSuppressed(sessionConfig.disallowedTools, process.env.CC_OPENCLAW_SUPPRESS_ASKUSER !== '0');
343
+ if (suppressed)
344
+ sessionConfig.disallowedTools = suppressed;
345
+ }
335
346
  // Claude Code CLI supports --system-prompt (replace) and --append-system-prompt (append).
336
347
  // When the caller provides tools, use --system-prompt to REPLACE the CLI's entire
337
348
  // system prompt via buildSessionSystemPrompt(). See that function's doc for details
@@ -343,16 +354,21 @@ export async function handleChatCompletion(manager, body, headers, res) {
343
354
  // [[tts:text]] syntax regardless of which CLI flag is used.
344
355
  const ttsAuto = getTtsAutoMode();
345
356
  const ttsPrefix = ttsAuto !== 'off' ? `${TTS_RULE}\n\n` : '';
357
+ // v0.27.11: "act, don't ask" posture (CC_OPENCLAW_AUTONOMY_RULE, default on).
358
+ // Restores the terminal-CLI execution posture the tmpdir-CWD chat path
359
+ // otherwise loses, so Savvy finishes the whole task instead of stalling.
360
+ const autonomyPrefix = process.env.CC_OPENCLAW_AUTONOMY_RULE !== '0' ? `${AUTONOMY_RULE}\n\n` : '';
361
+ const prefix = autonomyPrefix + ttsPrefix;
346
362
  if (request.tools?.length) {
347
363
  sessionConfig.systemPrompt =
348
- ttsPrefix + buildSessionSystemPrompt(request.tools, extracted.systemPrompt);
364
+ prefix + buildSessionSystemPrompt(request.tools, extracted.systemPrompt);
349
365
  }
350
366
  else if (extracted.systemPrompt) {
351
- sessionConfig.appendSystemPrompt = ttsPrefix + extracted.systemPrompt;
367
+ sessionConfig.appendSystemPrompt = prefix + extracted.systemPrompt;
352
368
  }
353
- else if (ttsPrefix) {
354
- // No upstream system prompt but TTS is on → inject just the rule
355
- sessionConfig.appendSystemPrompt = ttsPrefix.trim();
369
+ else if (prefix) {
370
+ // No upstream system prompt but a rule prefix is on → inject just the rules
371
+ sessionConfig.appendSystemPrompt = prefix.trim();
356
372
  }
357
373
  }
358
374
  try {
@@ -30,6 +30,21 @@ export interface PersistedSession {
30
30
  * the decision is unit-testable independent of the disk layer.
31
31
  */
32
32
  export declare function isPersistedSessionFresh(persisted: Pick<PersistedSession, 'lastActivity'> | undefined, now: number, freshnessMs: number): boolean;
33
+ /**
34
+ * v0.27.11 — write-through decision for the resume id. The debounced disk save
35
+ * loses an unflushed claudeSessionId on a hard kill (cc-install / watchdog
36
+ * SIGTERM / detached pm2 restart) — exactly when post-restart resume matters.
37
+ * So a freshness-resume session flushes its id to disk synchronously the first
38
+ * time that id is seen. Returns true → caller does a synchronous save and
39
+ * records the id as flushed; false → caller uses the debounced save (fine for
40
+ * frequent same-id lastActivity bumps). Pure + side-effect-free for unit-testing.
41
+ */
42
+ export declare function shouldWriteThroughResumeId(opts: {
43
+ enabled: boolean;
44
+ optedFreshResume: boolean;
45
+ lastFlushedId: string | undefined;
46
+ newId: string | undefined;
47
+ }): boolean;
33
48
  export declare function loadPersistedSessions(): Map<string, PersistedSession>;
34
49
  export declare function savePersistedSessions(sessions: Map<string, PersistedSession>, logger?: Logger): void;
35
50
  export declare function savePersistedSessionsAsync(sessions: Map<string, PersistedSession>, logger?: Logger): void;
@@ -31,6 +31,22 @@ export function isPersistedSessionFresh(persisted, now, freshnessMs) {
31
31
  return false;
32
32
  return now - persisted.lastActivity <= freshnessMs;
33
33
  }
34
+ /**
35
+ * v0.27.11 — write-through decision for the resume id. The debounced disk save
36
+ * loses an unflushed claudeSessionId on a hard kill (cc-install / watchdog
37
+ * SIGTERM / detached pm2 restart) — exactly when post-restart resume matters.
38
+ * So a freshness-resume session flushes its id to disk synchronously the first
39
+ * time that id is seen. Returns true → caller does a synchronous save and
40
+ * records the id as flushed; false → caller uses the debounced save (fine for
41
+ * frequent same-id lastActivity bumps). Pure + side-effect-free for unit-testing.
42
+ */
43
+ export function shouldWriteThroughResumeId(opts) {
44
+ if (!opts.enabled || !opts.optedFreshResume)
45
+ return false;
46
+ if (!opts.newId)
47
+ return false;
48
+ return opts.lastFlushedId !== opts.newId;
49
+ }
34
50
  export function loadPersistedSessions() {
35
51
  try {
36
52
  if (!fs.existsSync(PERSIST_FILE))
@@ -24,6 +24,7 @@ export declare class SessionManager {
24
24
  private pluginConfig;
25
25
  private persistedSessions;
26
26
  private _debouncedSave;
27
+ private _lastFlushedIds;
27
28
  private _proxyServer;
28
29
  private _proxyPort;
29
30
  private _activePids;
@@ -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, isPersistedSessionFresh, } from './persisted-sessions.js';
36
+ import { loadPersistedSessions, savePersistedSessions, savePersistedSessionsAsync, isPersistedSessionFresh, shouldWriteThroughResumeId, } 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.
@@ -72,6 +72,10 @@ export class SessionManager {
72
72
  pluginConfig;
73
73
  persistedSessions;
74
74
  _debouncedSave;
75
+ // v0.27.11: tracks the claudeSessionId last DURABLY (synchronously) written to
76
+ // disk per session name, so write-through fires once per new id rather than on
77
+ // every send. See _persistSession.
78
+ _lastFlushedIds = new Map();
75
79
  _proxyServer = null;
76
80
  _proxyPort = null;
77
81
  _activePids = new Map();
@@ -429,6 +433,7 @@ export class SessionManager {
429
433
  this._savePids();
430
434
  // Explicit stop = user intent to end session — remove from disk too
431
435
  this.persistedSessions.delete(name);
436
+ this._lastFlushedIds.delete(name);
432
437
  savePersistedSessions(this.persistedSessions, this.logger);
433
438
  }
434
439
  listSessions() {
@@ -943,7 +948,26 @@ export class SessionManager {
943
948
  lastResumed: new Date().toISOString(),
944
949
  lastActivity: managed.lastActivity,
945
950
  });
946
- this._debouncedSave();
951
+ // v0.27.11: write-through the resume id. The debounced save (used for
952
+ // frequent lastActivity bumps) loses the claudeSessionId when the gateway is
953
+ // hard-killed before the timer fires — and hard restarts are common
954
+ // (cc-install, watchdog SIGTERM, detached pm2 restart), which is exactly when
955
+ // resume matters most. So when a freshness-resume session sees a NEW
956
+ // claudeSessionId, flush it to disk synchronously, once, immediately. Frequent
957
+ // same-id bumps stay debounced. Gated by CC_OPENCLAW_RESUME_WRITETHROUGH.
958
+ const optedFreshResume = typeof managed.config.resumeFreshnessMs === 'number' && managed.config.resumeFreshnessMs > 0;
959
+ if (shouldWriteThroughResumeId({
960
+ enabled: process.env.CC_OPENCLAW_RESUME_WRITETHROUGH !== '0',
961
+ optedFreshResume,
962
+ lastFlushedId: this._lastFlushedIds.get(name),
963
+ newId: managed.claudeSessionId,
964
+ })) {
965
+ savePersistedSessions(this.persistedSessions, this.logger);
966
+ this._lastFlushedIds.set(name, managed.claudeSessionId);
967
+ }
968
+ else {
969
+ this._debouncedSave();
970
+ }
947
971
  }
948
972
  // ─── PID Tracking ──────────────────────────────────────────────────────
949
973
  static PID_FILE = path.join(os.homedir(), '.openclaw', 'session-pids.json');
@@ -1523,6 +1547,7 @@ export class SessionManager {
1523
1547
  for (const [name, entry] of this.persistedSessions) {
1524
1548
  if (now - entry.lastActivity > PERSIST_DISK_TTL_MS) {
1525
1549
  this.persistedSessions.delete(name);
1550
+ this._lastFlushedIds.delete(name);
1526
1551
  pruned = true;
1527
1552
  }
1528
1553
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a1hvdy/cc-openclaw",
3
- "version": "0.27.9",
3
+ "version": "0.27.11",
4
4
  "description": "A1xAI's Anthropic CLI bridge plugin for OpenClaw",
5
5
  "author": "@a1cy",
6
6
  "license": "MIT",