@a1hvdy/cc-openclaw 0.27.13 → 0.29.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.
@@ -33,6 +33,16 @@ interface ParsedQuestion {
33
33
  multiSelect: boolean;
34
34
  options: QOption[];
35
35
  }
36
+ /**
37
+ * v0.28.0 — build a single inline button that, when tapped, resumes a Claude
38
+ * Code session. Reuses the proven askuser callback path: the payload is stashed
39
+ * in the shared (globalThis-anchored) CallbackMap and the callback_data is
40
+ * `ccmirror:<id>`, so taps route through the same before_dispatch interceptor.
41
+ */
42
+ export declare function buildResumeButton(uuid: string, label: string): {
43
+ text: string;
44
+ callback_data: string;
45
+ };
36
46
  /** Minimal subset of the registerInteractiveHandler ctx we use. */
37
47
  export interface InteractiveCtx {
38
48
  callback: {
@@ -27,6 +27,19 @@ import { probeInjectionEnqueued } from '../../lib/probes.js';
27
27
  /** Namespace prefix for callback_data so api.registerInteractiveHandler routes
28
28
  * taps here. Matched at the first ':' by the gateway (must be [A-Za-z0-9._-]+). */
29
29
  export const ASKUSER_NS = 'ccmirror';
30
+ function getResumeBridge() {
31
+ return globalThis[Symbol.for('cc-openclaw:resume-bridge')];
32
+ }
33
+ /**
34
+ * v0.28.0 — build a single inline button that, when tapped, resumes a Claude
35
+ * Code session. Reuses the proven askuser callback path: the payload is stashed
36
+ * in the shared (globalThis-anchored) CallbackMap and the callback_data is
37
+ * `ccmirror:<id>`, so taps route through the same before_dispatch interceptor.
38
+ */
39
+ export function buildResumeButton(uuid, label) {
40
+ const id = _cb.create({ kind: 'resume', uuid });
41
+ return { text: label, callback_data: `${ASKUSER_NS}:${id}` };
42
+ }
30
43
  // v0.26.5 — ALL askuser state is globalThis-anchored. captureAskUserQuestion is
31
44
  // reached via turn-bridge's module graph while handleTap is reached via the
32
45
  // inbound-handler graph; those can be SEPARATE module instances in the same
@@ -218,6 +231,37 @@ export async function handleTap(ctx, api) {
218
231
  await answerCb(ctx, 'This prompt expired.');
219
232
  return;
220
233
  }
234
+ // v0.28.0 — session-picker resume tap. Not tied to a question (no qid):
235
+ // resolve the chat from the tap ctx and fire the resume bridge immediately.
236
+ if (payload.kind === 'resume') {
237
+ await answerCb(ctx, 'Resuming…');
238
+ const conv = String(ctx.conversationId ?? (ctx.chatId ?? ''));
239
+ let chatPart = conv;
240
+ let threadId;
241
+ const ti = conv.indexOf(':topic:');
242
+ if (ti >= 0) {
243
+ chatPart = conv.slice(0, ti);
244
+ threadId = conv.slice(ti + ':topic:'.length);
245
+ }
246
+ if (!chatPart) {
247
+ process.stderr.write('[cc-openclaw/askuser] resume tap: no chat resolved\n');
248
+ return;
249
+ }
250
+ const bridge = getResumeBridge();
251
+ if (!bridge) {
252
+ await sendTg(chatPart, `Resume bridge unavailable — type <code>/cc resume ${escapeHtml(payload.uuid)}</code>`, threadId);
253
+ return;
254
+ }
255
+ try {
256
+ const text = bridge(chatPart, threadId, payload.uuid);
257
+ await sendTg(chatPart, escapeHtml(text), threadId);
258
+ process.stderr.write(`[cc-openclaw/askuser] resume fired uuid=${payload.uuid} chat=${chatPart}\n`);
259
+ }
260
+ catch (err) {
261
+ await sendTg(chatPart, `Resume failed: ${escapeHtml(err.message)}`, threadId);
262
+ }
263
+ return;
264
+ }
221
265
  const s = _questions.get(payload.qid);
222
266
  if (!s) {
223
267
  await answerCb(ctx, 'This prompt is no longer active.');
@@ -96,6 +96,18 @@ export interface ParsedSlash {
96
96
  args: string[];
97
97
  }
98
98
  export declare function parseSlash(text: string): ParsedSlash | undefined;
99
+ /**
100
+ * v0.28.0 — `/sessions` is now a `claude -r`-style picker over the REAL Claude
101
+ * Code sessions. It mirrors `~/.claude/projects/**` (the store every `claude`
102
+ * subprocess — terminal or cco-spawned — writes to), so one list spans both
103
+ * surfaces. Each row shows the session's own ai-title (name) + last-prompt
104
+ * (description); tapping resumes it immediately via the globalThis resume
105
+ * bridge. Buttons use the proven `ccmirror:` callback path (buildResumeButton).
106
+ *
107
+ * The previous registry-backed keyboard (buildSessionsKeyboard / enrichRows)
108
+ * is retired here: its "switch" callbacks were never wired (M4 stub), and the
109
+ * slug registry only ever saw cco-Telegram sessions, not terminal ones.
110
+ */
99
111
  export declare function handleSessions(ctx: CommandContext): CommandResult;
100
112
  export declare function handleNew(ctx: CommandContext): CommandResult;
101
113
  export declare function handleStop(ctx: CommandContext): CommandResult;
@@ -26,8 +26,12 @@
26
26
  * • Plan attachment via sendDocument (M9).
27
27
  */
28
28
  import { register, unregister, list, getBySlug, } from '../../lib/session-registry.js';
29
- import { buildSessionsKeyboard, formatLastActivity, } from './sessions-keyboard.js';
29
+ import { formatLastActivity, } from './sessions-keyboard.js';
30
30
  import { stubQuotaReader } from './quota-reader.js';
31
+ import { scanClaudeCliSessions } from '../../lib/cc-cli-scan.js';
32
+ import { buildResumeButton } from './askuser.js';
33
+ import { escapeHtml } from '../../lib/html-render.js';
34
+ import { basename } from 'node:path';
31
35
  export function parseSlash(text) {
32
36
  const trimmed = text.trim();
33
37
  if (!trimmed.startsWith('/'))
@@ -48,25 +52,59 @@ function enrichRows(entries, stateLookup) {
48
52
  lastUsedAt: e.lastUsedAt,
49
53
  }));
50
54
  }
55
+ /**
56
+ * v0.28.0 — `/sessions` is now a `claude -r`-style picker over the REAL Claude
57
+ * Code sessions. It mirrors `~/.claude/projects/**` (the store every `claude`
58
+ * subprocess — terminal or cco-spawned — writes to), so one list spans both
59
+ * surfaces. Each row shows the session's own ai-title (name) + last-prompt
60
+ * (description); tapping resumes it immediately via the globalThis resume
61
+ * bridge. Buttons use the proven `ccmirror:` callback path (buildResumeButton).
62
+ *
63
+ * The previous registry-backed keyboard (buildSessionsKeyboard / enrichRows)
64
+ * is retired here: its "switch" callbacks were never wired (M4 stub), and the
65
+ * slug registry only ever saw cco-Telegram sessions, not terminal ones.
66
+ */
51
67
  export function handleSessions(ctx) {
52
- const entries = list();
53
- const stateLookup = ctx.stateLookup ?? (() => 'idle');
54
- const rows = enrichRows(entries, stateLookup);
55
- const kb = buildSessionsKeyboard({
56
- rows,
57
- callbackMap: ctx.callbackMap,
58
- now: ctx.now,
68
+ const now = ctx.now ?? Date.now();
69
+ const all = scanClaudeCliSessions();
70
+ const shown = all.slice(0, 8);
71
+ if (shown.length === 0) {
72
+ return {
73
+ actions: [
74
+ {
75
+ type: 'sendMessage',
76
+ chat_id: ctx.chatId,
77
+ text: 'No resumable Claude Code sessions in the last 7 days.',
78
+ },
79
+ ],
80
+ };
81
+ }
82
+ const trunc = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
83
+ const bodyLines = ['🔵 <b>Resumable Claude Code sessions</b> — tap to resume:', ''];
84
+ const rows = [];
85
+ shown.forEach((s, i) => {
86
+ const n = i + 1;
87
+ const title = trunc(s.title || `session ${s.uuid.slice(0, 8)}`, 60);
88
+ const desc = trunc(s.desc || '', 70);
89
+ const rel = formatLastActivity(new Date(s.mtimeMs).toISOString(), now);
90
+ const where = s.cwd ? basename(s.cwd) : '';
91
+ const metaLine = [rel, where].filter(Boolean).join(' · ');
92
+ bodyLines.push(desc
93
+ ? `<b>${n}.</b> ${escapeHtml(title)} — <i>${escapeHtml(desc)}</i> <code>${escapeHtml(metaLine)}</code>`
94
+ : `<b>${n}.</b> ${escapeHtml(title)} <code>${escapeHtml(metaLine)}</code>`);
95
+ const btnLabel = `${n} · ${trunc(s.title || s.uuid.slice(0, 8), 28)} · ${rel}`;
96
+ rows.push([buildResumeButton(s.uuid, btnLabel)]);
59
97
  });
60
- const header = entries.length === 0
61
- ? 'No sessions registered yet. Tap New to create one.'
62
- : `Sessions (${entries.length})${kb.pageCount > 1 ? ` · page ${kb.page + 1}/${kb.pageCount}` : ''}`;
98
+ if (all.length > shown.length) {
99
+ bodyLines.push('', `<i>+${all.length - shown.length} older resume by id: /cc resume &lt;uuid&gt;</i>`);
100
+ }
63
101
  return {
64
102
  actions: [
65
103
  {
66
104
  type: 'sendMessage',
67
105
  chat_id: ctx.chatId,
68
- text: header,
69
- reply_markup: { inline_keyboard: kb.inline_keyboard },
106
+ text: bodyLines.join('\n'),
107
+ reply_markup: { inline_keyboard: rows },
70
108
  },
71
109
  ],
72
110
  };
@@ -77,6 +77,16 @@ export declare function pushAssistantText(text: string): void;
77
77
  * thinking-visibility setting rather than leaking reasoning unconditionally.
78
78
  */
79
79
  export declare function pushThinking(text: string): void;
80
+ /**
81
+ * #4 (dual-surface seam) — extract just the model's `★ Insight ─...─` block from
82
+ * the full answer text, for display as the finalized card's takeaway when the
83
+ * card no longer mirrors the whole answer (CC_OPENCLAW_CARD_ANSWER_MIRROR off,
84
+ * the default). Returns the block verbatim (renderTurn preserves ★ Insight
85
+ * inline), or '' when the answer carries no insight block → a clean
86
+ * status/tools/✓ Done card with no answer text. Matches the model's emitted
87
+ * shape: a `★ Insight` opener through the next box-drawing divider line.
88
+ */
89
+ export declare function extractInsightForCard(fullText: string): string;
80
90
  /**
81
91
  * Finalize every active card at the END of a model turn. Flips each card's
82
92
  * turn to 'done' (so renderTurn shows "✓ Done"), repaints once, and removes
@@ -259,6 +259,31 @@ export function pushThinking(text) {
259
259
  void repaint(chatId, /* force */ false);
260
260
  }
261
261
  }
262
+ /**
263
+ * #4 (dual-surface seam) — extract just the model's `★ Insight ─...─` block from
264
+ * the full answer text, for display as the finalized card's takeaway when the
265
+ * card no longer mirrors the whole answer (CC_OPENCLAW_CARD_ANSWER_MIRROR off,
266
+ * the default). Returns the block verbatim (renderTurn preserves ★ Insight
267
+ * inline), or '' when the answer carries no insight block → a clean
268
+ * status/tools/✓ Done card with no answer text. Matches the model's emitted
269
+ * shape: a `★ Insight` opener through the next box-drawing divider line.
270
+ */
271
+ export function extractInsightForCard(fullText) {
272
+ if (!fullText)
273
+ return '';
274
+ const start = fullText.indexOf('★ Insight');
275
+ if (start === -1)
276
+ return '';
277
+ const lines = fullText.slice(start).split('\n');
278
+ const out = [];
279
+ for (let i = 0; i < lines.length; i++) {
280
+ out.push(lines[i]);
281
+ // Closing divider: a line of ≥5 box-drawing / hyphen dashes after the opener.
282
+ if (i > 0 && /^[─-]{5,}\s*$/.test(lines[i].trim()))
283
+ break;
284
+ }
285
+ return out.join('\n').trim();
286
+ }
262
287
  /**
263
288
  * Finalize every active card at the END of a model turn. Flips each card's
264
289
  * turn to 'done' (so renderTurn shows "✓ Done"), repaints once, and removes
@@ -284,21 +309,25 @@ export async function finalizeActiveCards(deliveredText) {
284
309
  card.sm.end(chatId);
285
310
  const turn = card.sm.getTurn(chatId);
286
311
  if (turn) {
287
- // v0.26.4 dedupdrop the assistant text from the FINALIZED card. It
288
- // streamed live during the turn (good), but the OpenClaw gateway also
289
- // delivers the final reply as its own message; keeping it on the done
290
- // card too made the answer appear twice (A1's duplicate-response bug).
291
- // The plugin can't suppress the gateway reply (no suppressUserDelivery
292
- // knob), so the card yields the final text and finalizes to a clean
293
- // status/tools/✓ Done activity view. renderTurn itself is unchanged.
312
+ // #4 dual-surface seam — the FINALIZED card's answer text is whatever the
313
+ // caller hands in via `deliveredText`; this function no longer decides
314
+ // dedup policy, the openai-compat handlers do (they know the gateway's
315
+ // delivery state). The three cases the callers pass:
316
+ //
317
+ // happy path, default (CC_OPENCLAW_CARD_ANSWER_MIRROR off): the caller
318
+ // passes ONLY the extracted Insight block (extractInsightForCard).
319
+ // The card finalizes to status/tools/✓ Done + the short takeaway; the
320
+ // gateway's native draft message is the sole full-answer surface. No
321
+ // double-stream, no "answer vanishes then reappears" seam.
322
+ // • happy path, legacy (flag on): the caller passes '' — the old v0.26.4
323
+ // dedup, where the full answer streamed live on the card then blanked
324
+ // here so it wasn't duplicated by the gateway reply.
325
+ // • disconnect (v0.27.6 Killer #2): the gateway socket died mid-turn and
326
+ // delivers NOTHING separately, so the caller passes the FULL accumulated
327
+ // text and the card KEEPS the whole report as the sole delivery channel.
294
328
  //
295
- // v0.27.6 disconnect exception (Killer #2 report-drop) when the gateway
296
- // socket died mid-turn the gateway delivers NOTHING separately, so the
297
- // dedup assumption breaks and wiping the text yields total silence
298
- // ("✓ Done" then nothing). The caller passes the accumulated text in that
299
- // case (deliveredText); the finalized card then KEEPS the full report as
300
- // the sole delivery channel. On the happy path deliveredText is undefined
301
- // → '' → behavior unchanged (gateway delivers, no duplicate).
329
+ // There is still no upstream suppressUserDelivery knob, so the card never
330
+ // tries to be the answer pane on the happy path. renderTurn is unchanged.
302
331
  turn.assistantText = deliveredText ?? '';
303
332
  try {
304
333
  await editTg(chatId, card.messageId, renderTurn(turn, card.meta));
@@ -39,6 +39,7 @@ export interface CcCommand {
39
39
  prompt?: string;
40
40
  target?: string;
41
41
  }
42
+ export type ResumeBridge = (chatId: string, threadId: string | undefined, uuid: string) => string;
42
43
  /**
43
44
  * Parse a raw input string into a CcCommand.
44
45
  * Returns null if the input is not a /cc or /cc+ command.
@@ -38,6 +38,13 @@ const activeSessions = new Map();
38
38
  function _deps() {
39
39
  return { sessionManager: sessionManager, activeSessions, logger };
40
40
  }
41
+ const RESUME_BRIDGE_KEY = Symbol.for('cc-openclaw:resume-bridge');
42
+ globalThis[RESUME_BRIDGE_KEY] = ((chatId, threadId, uuid) => {
43
+ if (!sessionManager) {
44
+ return 'Claude Code handler not ready — try again in a few seconds.';
45
+ }
46
+ return handleResume(_deps(), chatId, threadId, uuid).text;
47
+ });
41
48
  // ── parseCcCommand — pure exported parser ─────────────────────────────────
42
49
  /**
43
50
  * Parse a raw input string into a CcCommand.
@@ -13,7 +13,38 @@
13
13
  */
14
14
  import { DEFAULT_CWD, sessionMapKey, saveSession, loadSession, loadSessionById, scanAllSessions, IDLE_TIMEOUT_MS, ACK_TIMEOUT_MS, scheduleIdle, _postTurnContextCheck, } from './launch-policy.js';
15
15
  import { sendDirectReply } from './turn-formatter.js';
16
+ import { findCliSession, isFullClaudeUuid } from '../lib/cc-cli-scan.js';
16
17
  const PLUGIN_TAG = '[cc-openclaw/resume-policy]';
18
+ /**
19
+ * Synthesize a SessionMeta for a Claude Code CLI session that cc-openclaw never
20
+ * created itself (a terminal `claude` session, or one started before this build).
21
+ * The transcript lives in `~/.claude/projects/<cwd>/<uuid>.jsonl`; we only need
22
+ * its uuid (the resumeSessionId) and the cwd it ran in. chatId is bound to the
23
+ * CURRENT chat so the resumed session belongs to whoever tapped/typed — this is
24
+ * the cross-surface bridge: start in the terminal, continue from Telegram.
25
+ */
26
+ function synthCliMeta(uuid, chatId, threadId) {
27
+ const hit = findCliSession(uuid);
28
+ const short = uuid.replace(/-/g, '').slice(0, 8);
29
+ return {
30
+ id: short,
31
+ slug: `cli-${short}`,
32
+ sessionName: `cc-cli-${short}`,
33
+ chatId,
34
+ threadId,
35
+ senderId: '',
36
+ state: 'idle',
37
+ instruction: hit?.title || 'resumed CLI session',
38
+ turns: 0,
39
+ startedAt: new Date().toISOString(),
40
+ completedAt: null,
41
+ lastContinuedAt: null,
42
+ claudeSessionId: uuid.toLowerCase(),
43
+ cwd: hit?.cwd || DEFAULT_CWD,
44
+ output: null,
45
+ error: null,
46
+ };
47
+ }
17
48
  // ── Continuation Handler ──────────────────────────────────────────────────
18
49
  export function handleContinuation(deps, prompt, chatId, threadId) {
19
50
  const { sessionManager, activeSessions, logger } = deps;
@@ -102,6 +133,11 @@ export function handleResume(deps, chatId, threadId, targetSlug) {
102
133
  if (!meta) {
103
134
  meta = loadSession(targetSlug);
104
135
  }
136
+ // Mirrored Claude Code CLI session — not in cco's own store, but resumable
137
+ // straight from its transcript via `claude --resume <uuid>`.
138
+ if (!meta && isFullClaudeUuid(targetSlug)) {
139
+ meta = synthCliMeta(targetSlug, chatId, threadId);
140
+ }
105
141
  if (!meta) {
106
142
  return { handled: true, text: `Session "${targetSlug}" not found.` };
107
143
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * src/lib/cc-cli-scan.ts — v0.28.0.
3
+ *
4
+ * Mirror (read-only) the actual Claude Code CLI sessions so they can be listed
5
+ * and resumed from Telegram, exactly like `claude -r` / `claude --resume`.
6
+ *
7
+ * Why "mirror, not migrate": cc-openclaw IS a `claude` subprocess wrapper, and
8
+ * both the terminal CLI and every cco-spawned session write their transcript to
9
+ * the SAME store — `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`. So
10
+ * `claude --resume <uuid>` already resumes any session in that tree regardless
11
+ * of who created it. The filesystem is the sync layer; copying into a parallel
12
+ * registry would only create drift. We read this tree at list-time, never write.
13
+ *
14
+ * Each jsonl already carries the metadata a picker needs:
15
+ * { "type":"ai-title", "aiTitle":"…", "sessionId":"<uuid>" } ← name
16
+ * { "type":"last-prompt", "lastPrompt":"…", "sessionId":"<uuid>" } ← description
17
+ * { "type":"user", "cwd":"/home/a1xai", … } ← resume cwd
18
+ * The filename (minus .jsonl) is the resumable sessionId (uuid).
19
+ *
20
+ * No fabrication: a session with no parseable title falls back to its first
21
+ * user message, then to the short uuid — never an invented label.
22
+ */
23
+ export interface CliSession {
24
+ /** jsonl filename (minus .jsonl) === Claude session id. Pass to --resume. */
25
+ uuid: string;
26
+ /** ai-title if present, else first user message, else ''. */
27
+ title: string;
28
+ /** last-prompt if present, else first user message, else ''. */
29
+ desc: string;
30
+ /** cwd recorded in the transcript — required to resume the right session. */
31
+ cwd: string;
32
+ /** File mtime in ms — proxy for last activity. */
33
+ mtimeMs: number;
34
+ }
35
+ export interface ScanOpts {
36
+ /** Only sessions touched within this many ms (default 7 days). 0 = no limit. */
37
+ maxAgeMs?: number;
38
+ /** Only sessions whose recorded cwd matches exactly. */
39
+ cwdFilter?: string;
40
+ /** Cap the number of results (after sort-by-recency). */
41
+ limit?: number;
42
+ }
43
+ /**
44
+ * Scan `~/.claude/projects` for resumable Claude Code sessions, newest first.
45
+ * Best-effort and side-effect-free: any unreadable dir/file is skipped, never
46
+ * thrown. Returns [] when the store is absent.
47
+ */
48
+ export declare function scanClaudeCliSessions(opts?: ScanOpts): CliSession[];
49
+ /** Look up a single CLI session by full uuid (case-insensitive). */
50
+ export declare function findCliSession(uuid: string): CliSession | undefined;
51
+ /** True if the string is a full Claude session uuid (not the 8-hex cco short id). */
52
+ export declare function isFullClaudeUuid(s: string): boolean;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * src/lib/cc-cli-scan.ts — v0.28.0.
3
+ *
4
+ * Mirror (read-only) the actual Claude Code CLI sessions so they can be listed
5
+ * and resumed from Telegram, exactly like `claude -r` / `claude --resume`.
6
+ *
7
+ * Why "mirror, not migrate": cc-openclaw IS a `claude` subprocess wrapper, and
8
+ * both the terminal CLI and every cco-spawned session write their transcript to
9
+ * the SAME store — `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`. So
10
+ * `claude --resume <uuid>` already resumes any session in that tree regardless
11
+ * of who created it. The filesystem is the sync layer; copying into a parallel
12
+ * registry would only create drift. We read this tree at list-time, never write.
13
+ *
14
+ * Each jsonl already carries the metadata a picker needs:
15
+ * { "type":"ai-title", "aiTitle":"…", "sessionId":"<uuid>" } ← name
16
+ * { "type":"last-prompt", "lastPrompt":"…", "sessionId":"<uuid>" } ← description
17
+ * { "type":"user", "cwd":"/home/a1xai", … } ← resume cwd
18
+ * The filename (minus .jsonl) is the resumable sessionId (uuid).
19
+ *
20
+ * No fabrication: a session with no parseable title falls back to its first
21
+ * user message, then to the short uuid — never an invented label.
22
+ */
23
+ import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ /**
27
+ * Resolved at call time (not import) so tests can point the scan at a fixture
28
+ * tree via CC_OPENCLAW_CLAUDE_PROJECTS_DIR without re-importing the module.
29
+ */
30
+ function claudeProjectsDir() {
31
+ return process.env.CC_OPENCLAW_CLAUDE_PROJECTS_DIR || join(homedir(), '.claude', 'projects');
32
+ }
33
+ /** 7-day window — matches cc-openclaw's session disk TTL. */
34
+ const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
35
+ /** Skip deep-parsing transcripts larger than this (memory safety on the live box). */
36
+ const MAX_PARSE_BYTES = 8 * 1024 * 1024;
37
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
38
+ /** Pull the plain text out of a Claude transcript user-message content field. */
39
+ function userText(d) {
40
+ const msg = d.message;
41
+ const c = msg?.content;
42
+ if (typeof c === 'string')
43
+ return c;
44
+ if (Array.isArray(c)) {
45
+ return c
46
+ .map((b) => (b && typeof b.text === 'string' ? b.text : ''))
47
+ .join(' ')
48
+ .trim();
49
+ }
50
+ return '';
51
+ }
52
+ /**
53
+ * cc-openclaw runs `claude` headless, so its sessions get no ai-title / last-prompt
54
+ * (those are interactive-CLI features). For those, the only signal is the user
55
+ * messages — but OpenClaw wraps each turn in a metadata envelope. The real prompts
56
+ * live as "#<id> <date> <sender>: <text>" context lines plus a trailing bare
57
+ * message. Pull the first (opening topic) and last (current focus) real prompts;
58
+ * for a non-enveloped message (terminal session), the text itself is the prompt.
59
+ */
60
+ /** True if a candidate line is envelope/JSON noise rather than a real prompt. */
61
+ function looksJunk(s) {
62
+ return (!s ||
63
+ /untrusted|metadata|inbound_meta|system-reminder/i.test(s) ||
64
+ /^[{}[\]"]/.test(s) || // JSON punctuation
65
+ /^"?[\w-]+"?\s*:/.test(s) // "key": value line
66
+ );
67
+ }
68
+ function parseEnvelope(raw) {
69
+ if (!raw)
70
+ return { first: '', last: '' };
71
+ const isEnvelope = /Conversation (info|context) \(untrusted/.test(raw) || raw.startsWith('<system');
72
+ if (!isEnvelope) {
73
+ const oneLine = raw.replace(/\s+/g, ' ').trim();
74
+ return { first: oneLine, last: oneLine };
75
+ }
76
+ const ctxLines = [...raw.matchAll(/^#\d+\s+.+?:\s+(.+)$/gm)]
77
+ .map((m) => m[1].trim())
78
+ .filter((l) => l && !looksJunk(l));
79
+ const afterFence = raw.split('```').pop() ?? '';
80
+ const trailing = afterFence
81
+ .split('\n')
82
+ .map((s) => s.trim())
83
+ .filter((l) => l && !/^#\d+/.test(l) && !l.startsWith('<') && !looksJunk(l))
84
+ .pop();
85
+ const first = ctxLines[0] || trailing || '';
86
+ const last = trailing || ctxLines[ctxLines.length - 1] || '';
87
+ return { first, last };
88
+ }
89
+ /**
90
+ * Extract title/desc/cwd from a single transcript. Cheap string pre-filter
91
+ * before JSON.parse keeps this fast even on large files. ai-title/last-prompt
92
+ * win when present (interactive sessions); otherwise we derive a name from the
93
+ * enveloped user prompts (headless cco sessions). cwd is taken from the first
94
+ * entry that records it.
95
+ */
96
+ function extractMeta(filePath, sizeBytes) {
97
+ let title = '';
98
+ let desc = '';
99
+ let cwd = '';
100
+ let firstUser = '';
101
+ let lastUser = '';
102
+ if (sizeBytes > MAX_PARSE_BYTES)
103
+ return { title, desc, cwd };
104
+ let content;
105
+ try {
106
+ content = readFileSync(filePath, 'utf8');
107
+ }
108
+ catch {
109
+ return { title, desc, cwd };
110
+ }
111
+ for (const line of content.split('\n')) {
112
+ if (!line)
113
+ continue;
114
+ const hasTitle = line.includes('"ai-title"');
115
+ const hasPrompt = line.includes('"last-prompt"');
116
+ const needCwd = !cwd && line.includes('"cwd"');
117
+ const isUser = line.includes('"type":"user"');
118
+ if (!hasTitle && !hasPrompt && !needCwd && !isUser)
119
+ continue;
120
+ let d;
121
+ try {
122
+ d = JSON.parse(line);
123
+ }
124
+ catch {
125
+ continue;
126
+ }
127
+ if (d.type === 'ai-title' && typeof d.aiTitle === 'string')
128
+ title = d.aiTitle;
129
+ else if (d.type === 'last-prompt' && typeof d.lastPrompt === 'string')
130
+ desc = d.lastPrompt;
131
+ if (!cwd && typeof d.cwd === 'string')
132
+ cwd = d.cwd;
133
+ if (d.type === 'user') {
134
+ const txt = userText(d);
135
+ if (txt) {
136
+ if (!firstUser)
137
+ firstUser = txt;
138
+ lastUser = txt;
139
+ }
140
+ }
141
+ }
142
+ const clean = (s) => s.replace(/\s+/g, ' ').trim();
143
+ const envFirst = parseEnvelope(firstUser);
144
+ const envLast = parseEnvelope(lastUser);
145
+ const finalTitle = clean(title || envFirst.first);
146
+ let finalDesc = clean(desc || envLast.last);
147
+ // Drop a description that's just noise or a duplicate of the title — the
148
+ // picker then shows the name alone rather than a redundant/garbage second line.
149
+ if (!finalDesc || looksJunk(finalDesc) || finalDesc === finalTitle)
150
+ finalDesc = '';
151
+ return { title: finalTitle, desc: finalDesc, cwd };
152
+ }
153
+ /**
154
+ * Scan `~/.claude/projects` for resumable Claude Code sessions, newest first.
155
+ * Best-effort and side-effect-free: any unreadable dir/file is skipped, never
156
+ * thrown. Returns [] when the store is absent.
157
+ */
158
+ export function scanClaudeCliSessions(opts = {}) {
159
+ const maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
160
+ const now = Date.now();
161
+ const results = [];
162
+ const baseDir = claudeProjectsDir();
163
+ if (!existsSync(baseDir))
164
+ return results;
165
+ let projectDirs;
166
+ try {
167
+ projectDirs = readdirSync(baseDir);
168
+ }
169
+ catch {
170
+ return results;
171
+ }
172
+ for (const proj of projectDirs) {
173
+ const projPath = join(baseDir, proj);
174
+ let files;
175
+ try {
176
+ if (!statSync(projPath).isDirectory())
177
+ continue;
178
+ files = readdirSync(projPath).filter((f) => f.endsWith('.jsonl'));
179
+ }
180
+ catch {
181
+ continue;
182
+ }
183
+ for (const file of files) {
184
+ const uuid = file.slice(0, -'.jsonl'.length);
185
+ if (!UUID_RE.test(uuid))
186
+ continue;
187
+ const fp = join(projPath, file);
188
+ let mtimeMs;
189
+ let sizeBytes;
190
+ try {
191
+ const st = statSync(fp);
192
+ mtimeMs = st.mtimeMs;
193
+ sizeBytes = st.size;
194
+ }
195
+ catch {
196
+ continue;
197
+ }
198
+ if (maxAgeMs > 0 && now - mtimeMs > maxAgeMs)
199
+ continue;
200
+ const meta = extractMeta(fp, sizeBytes);
201
+ if (opts.cwdFilter && meta.cwd !== opts.cwdFilter)
202
+ continue;
203
+ results.push({ uuid, mtimeMs, ...meta });
204
+ }
205
+ }
206
+ results.sort((a, b) => b.mtimeMs - a.mtimeMs);
207
+ return typeof opts.limit === 'number' ? results.slice(0, opts.limit) : results;
208
+ }
209
+ /** Look up a single CLI session by full uuid (case-insensitive). */
210
+ export function findCliSession(uuid) {
211
+ const target = uuid.toLowerCase();
212
+ return scanClaudeCliSessions({ maxAgeMs: 0 }).find((s) => s.uuid.toLowerCase() === target);
213
+ }
214
+ /** True if the string is a full Claude session uuid (not the 8-hex cco short id). */
215
+ export function isFullClaudeUuid(s) {
216
+ return UUID_RE.test(s);
217
+ }
@@ -61,6 +61,27 @@ export declare function getPerfAsyncCompactEnabled(): boolean;
61
61
  export declare function getPerfReadBatchEnabled(): boolean;
62
62
  /** M12 — direct claude-code SDK in-process. Default OFF (HIGH RISK, hard-gated). */
63
63
  export declare function getPerfDirectSdkEnabled(): boolean;
64
+ /**
65
+ * #4 (dual-surface seam / gap#2) — whether the live card mirrors the model's
66
+ * FULL answer text. Default OFF.
67
+ *
68
+ * The OpenClaw gateway already live-streams the answer to Telegram via its own
69
+ * native draft message. Mirroring the same text onto the card too produced
70
+ * (a) double live-streaming of the identical answer and (b) the finalize-blank
71
+ * "answer streams → vanishes → reappears below" seam, because the card had to
72
+ * wipe its copy on finalize to avoid a literal duplicate (no upstream
73
+ * suppressUserDelivery knob exists).
74
+ *
75
+ * With this OFF (default) the card is a pure ACTIVITY pane (status line · tools ·
76
+ * thinking · todos · ✓ Done · ★ Insight takeaway) and the gateway draft is the
77
+ * sole ANSWER pane — the Claude Code CLI split. The card still shows the short
78
+ * ★ Insight block on finalize (extracted from the accumulated text), so the
79
+ * signature "✓ Done + takeaway" card is preserved without the full-answer dup.
80
+ *
81
+ * Set CC_OPENCLAW_CARD_ANSWER_MIRROR=1 to restore the old in-card full-answer
82
+ * streaming (with the finalize-blank dedup) for comparison.
83
+ */
84
+ export declare function getCardAnswerMirrorEnabled(): boolean;
64
85
  export declare function getMaxConcurrentSessions(): number;
65
86
  export declare function getSessionTtlMinutes(): number;
66
87
  export declare function ensureUxBridgeAllSessionsDefault(): {
@@ -175,6 +175,29 @@ export function getPerfDirectSdkEnabled() {
175
175
  return cfg.config.perfDirectSdkEnabled;
176
176
  return process.env.CC_OPENCLAW_PERF_DIRECT_SDK === '1';
177
177
  }
178
+ /**
179
+ * #4 (dual-surface seam / gap#2) — whether the live card mirrors the model's
180
+ * FULL answer text. Default OFF.
181
+ *
182
+ * The OpenClaw gateway already live-streams the answer to Telegram via its own
183
+ * native draft message. Mirroring the same text onto the card too produced
184
+ * (a) double live-streaming of the identical answer and (b) the finalize-blank
185
+ * "answer streams → vanishes → reappears below" seam, because the card had to
186
+ * wipe its copy on finalize to avoid a literal duplicate (no upstream
187
+ * suppressUserDelivery knob exists).
188
+ *
189
+ * With this OFF (default) the card is a pure ACTIVITY pane (status line · tools ·
190
+ * thinking · todos · ✓ Done · ★ Insight takeaway) and the gateway draft is the
191
+ * sole ANSWER pane — the Claude Code CLI split. The card still shows the short
192
+ * ★ Insight block on finalize (extracted from the accumulated text), so the
193
+ * signature "✓ Done + takeaway" card is preserved without the full-answer dup.
194
+ *
195
+ * Set CC_OPENCLAW_CARD_ANSWER_MIRROR=1 to restore the old in-card full-answer
196
+ * streaming (with the finalize-blank dedup) for comparison.
197
+ */
198
+ export function getCardAnswerMirrorEnabled() {
199
+ return process.env.CC_OPENCLAW_CARD_ANSWER_MIRROR === '1';
200
+ }
178
201
  // ── SessionManager bootstrap caps (cwd-patch eager-init) ─────────────────
179
202
  // Defaults preserved from cwd-patch.ts:844-845.
180
203
  export function getMaxConcurrentSessions() {
@@ -23,11 +23,11 @@
23
23
  import { reportStatus, getToolDescription } from './status-reporter.js';
24
24
  import { parseToolCallsFromText } from './tool-calls-parser.js';
25
25
  import { formatCompletionResponse } from './response-formatter.js';
26
- import { getSurfaceThinkingEnabled, getTtsAutoMode } from '../lib/config.js';
26
+ import { getSurfaceThinkingEnabled, getTtsAutoMode, getCardAnswerMirrorEnabled } from '../lib/config.js';
27
27
  import { emit as emitTrajectory, emitTurnTrace } from '../lib/trajectory.js';
28
28
  import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
29
29
  import { applyVoiceRecovery, _logVoiceDebug, detectVoiceIntent, hasTtsMarkers } from './voice-recovery.js';
30
- 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';
30
+ import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, finalizeActiveCards as mirrorFinalizeActiveCards, extractInsightForCard as mirrorExtractInsightForCard, failActiveCards as mirrorFailActiveCards, classifyFailure, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
31
31
  import { cardStateDebug as mirrorCardStateDebug } from '../channels/telegram-mirror/card-state.js';
32
32
  /** Coerce a userMessage (string | UserMessageBlock[]) to a flat string
33
33
  * for voice-intent detection. Tool-result blocks aren't user prompts. */
@@ -72,6 +72,9 @@ slashCommand) {
72
72
  clientDisconnected = true;
73
73
  });
74
74
  let deliveredText = '';
75
+ // #4 dual-surface seam — default OFF: card is the activity pane, the gateway
76
+ // reply is the answer pane (see streaming-handler / config.ts for rationale).
77
+ const mirrorAnswerToCard = getCardAnswerMirrorEnabled();
75
78
  try {
76
79
  reportStatus('thinking', 'Processing request...');
77
80
  // v0.7.1: accumulate thinking-block content when surfaceThinking is on.
@@ -170,12 +173,14 @@ slashCommand) {
170
173
  emitTrajectory('tool_use', { name: '_voice_recovery', inputKeys: [recovery.via] }, sessionName);
171
174
  }
172
175
  }
173
- // v0.26.0 — push the final assistant text into the Telegram mirror
174
- // card. reply_dispatch in inbound-handler will fire shortly after and
175
- // re-render the card with state='done', picking up this text inline.
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).
176
+ // v0.26.0 — push the final assistant text into the Telegram mirror card.
177
+ // #4 dual-surface seam only when the legacy flag is on; by default the
178
+ // gateway reply is the answer pane and the card shows only the ★ Insight
179
+ // takeaway at finalize (see the finally block).
180
+ if (mirrorAnswerToCard)
181
+ mirrorPushAssistantText(outputText);
182
+ // v0.27.6 — remember the FULL text so the finally block can re-apply it as
183
+ // the sole channel if the socket died (see clientDisconnected note above).
179
184
  deliveredText = outputText;
180
185
  // Parse tool_calls from response text when caller provided tools
181
186
  let traceToolCount = 0;
@@ -270,10 +275,16 @@ slashCommand) {
270
275
  // v0.26.1 — finalize the Telegram mirror card at the true end of the model
271
276
  // turn (see handleStreaming counterpart). Best-effort.
272
277
  try {
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);
278
+ // v0.27.6 — report-drop fix (Killer #2): keep the FULL report on the card
279
+ // when the socket died (mirror of the streaming handler).
280
+ // #4 dual-surface seam happy path: Insight takeaway (default) or ''
281
+ // (legacy in-card answer streamed live, blank-on-finalize dedup).
282
+ const finalizeText = clientDisconnected
283
+ ? deliveredText
284
+ : mirrorAnswerToCard
285
+ ? ''
286
+ : mirrorExtractInsightForCard(deliveredText);
287
+ await mirrorFinalizeActiveCards(finalizeText);
277
288
  }
278
289
  catch {
279
290
  /* finalize is cosmetic; never propagate */
@@ -41,9 +41,9 @@ import { formatCompletionChunk } from './response-formatter.js';
41
41
  import { isToolStreamMode } from './mode-flags.js';
42
42
  import { emit as emitTrajectory, emitTurnTrace } from '../lib/trajectory.js';
43
43
  import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
44
- import { getSurfaceThinkingEnabled, getTtsAutoMode } from '../lib/config.js';
44
+ import { getSurfaceThinkingEnabled, getTtsAutoMode, getCardAnswerMirrorEnabled } 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, pushThinking as mirrorPushThinking, 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, extractInsightForCard as mirrorExtractInsightForCard, 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
@@ -84,6 +84,9 @@ onFinalText) {
84
84
  // runs permissionMode 'bypassPermissions' (openai-compat.ts) so we assert it.
85
85
  // Quota is read once here (real status-tee snapshot or omitted).
86
86
  mirrorSetCardMeta({ model, bypassPermissions: true, ...mirrorReadQuotaMeta() });
87
+ // #4 dual-surface seam — hoist once per turn (read off the hot delta loop).
88
+ // Default OFF: card is the activity pane, gateway draft is the answer pane.
89
+ const mirrorAnswerToCard = getCardAnswerMirrorEnabled();
87
90
  res.writeHead(200, {
88
91
  'Content-Type': 'text/event-stream',
89
92
  'Cache-Control': 'no-cache',
@@ -236,9 +239,13 @@ onFinalText) {
236
239
  markFirstByte();
237
240
  }
238
241
  accumulatedText += chunk;
239
- // v0.26.0feed the active Telegram mirror card with the cumulative
240
- // assistant text. Debounced inside the bridge to ~1 edit/sec.
241
- mirrorPushAssistantText(accumulatedText);
242
+ // #4 dual-surface seam only mirror the cumulative answer onto the
243
+ // card when the legacy flag is on. By default the card is NOT the
244
+ // answer pane (the gateway's native draft message already live-streams
245
+ // the same SSE content below), so mirroring here would double-stream
246
+ // and force the finalize-blank that made the answer vanish/reappear.
247
+ if (mirrorAnswerToCard)
248
+ mirrorPushAssistantText(accumulatedText);
242
249
  writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { content: chunk }, null)));
243
250
  }
244
251
  else {
@@ -629,10 +636,19 @@ onFinalText) {
629
636
  try {
630
637
  // v0.27.6 — report-drop fix (Killer #2): when the gateway socket died
631
638
  // 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);
639
+ // so pass the FULL accumulated text and the finalized card KEEPS it as the
640
+ // sole delivery channel.
641
+ //
642
+ // #4 dual-surface seam — happy path (connected): the gateway draft is the
643
+ // answer pane, so the card shows only the short ★ Insight takeaway
644
+ // (default) or '' when the legacy in-card answer streamed live (flag on,
645
+ // old v0.26.4 blank-on-finalize dedup).
646
+ const finalizeText = clientDisconnected
647
+ ? accumulatedText
648
+ : mirrorAnswerToCard
649
+ ? ''
650
+ : mirrorExtractInsightForCard(accumulatedText);
651
+ await mirrorFinalizeActiveCards(finalizeText);
636
652
  }
637
653
  catch {
638
654
  /* finalize is cosmetic; never propagate */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a1hvdy/cc-openclaw",
3
- "version": "0.27.13",
3
+ "version": "0.29.0",
4
4
  "description": "A1xAI's Anthropic CLI bridge plugin for OpenClaw",
5
5
  "author": "@a1cy",
6
6
  "license": "MIT",